ok
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -11186,6 +11187,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.539.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz",
|
||||||
|
"integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|||||||
75
src/App.js
75
src/App.js
@@ -1,11 +1,24 @@
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useEffect } from "react";
|
||||||
import ShowImage from "./ShowImage";
|
import ShowImage from "./ShowImage";
|
||||||
|
import SuccessPage from "./SuccessPage";
|
||||||
import Dashboard from "./Dashboard";
|
import Dashboard from "./Dashboard";
|
||||||
import Login from "./Login";
|
import LoginPage from "./Login";
|
||||||
|
import Expetation from "./DataTypePage";
|
||||||
import CameraKtp from "./KTPScanner";
|
import CameraKtp from "./KTPScanner";
|
||||||
import Profile from "./ProfileTab";
|
import Profile from "./ProfileTab";
|
||||||
|
import PickOrganization from "./PickOrganization"; // <-- import baru
|
||||||
|
|
||||||
|
// LandingPage.js
|
||||||
|
const LandingPage = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Selamat datang di Aplikasi Kami</h1>
|
||||||
|
{/* Tambahkan konten lainnya sesuai kebutuhan */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Komponen untuk melindungi route dengan token
|
// Komponen untuk melindungi route dengan token
|
||||||
const ProtectedRoute = ({ element }) => {
|
const ProtectedRoute = ({ element }) => {
|
||||||
@@ -14,21 +27,67 @@ const ProtectedRoute = ({ element }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Simpan token dari query parameter ke localStorage (jika ada)
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const token = params.get("token");
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
|
||||||
|
// Bersihkan token dari URL setelah disimpan
|
||||||
|
const newSearch = new URLSearchParams(location.search);
|
||||||
|
newSearch.delete("token");
|
||||||
|
|
||||||
|
// Replace URL tanpa query token
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: newSearch.toString(),
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [location, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Halaman pilih organisasi (wajib setelah login) */}
|
||||||
|
<Route
|
||||||
|
path="/pickorganization"
|
||||||
|
element={<ProtectedRoute element={<PickOrganization />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/scan" element={<CameraKtp />} />
|
<Route path="/scan" element={<CameraKtp />} />
|
||||||
|
<Route path="/success" element={<SuccessPage />} />
|
||||||
|
|
||||||
|
{/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
|
element={<ProtectedRoute element={<Navigate to="/pickorganization" />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dashboard spesifik organisasi */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:organization"
|
||||||
element={<ProtectedRoute element={<Dashboard />} />}
|
element={<ProtectedRoute element={<Dashboard />} />}
|
||||||
/>
|
/>
|
||||||
<Route path="/" element={<ProtectedRoute element={<Dashboard />} />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/dashboard/:organization/scan"
|
||||||
element={<ProtectedRoute element={<Profile />} />}
|
element={<ProtectedRoute element={<Expetation />} />}
|
||||||
/>
|
/>
|
||||||
<Route path="/:nik" element={<ShowImage />} />
|
|
||||||
|
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
253
src/Dashboard.js
253
src/Dashboard.js
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
import styles from "./Dashboard.module.css";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom"; // ***
|
||||||
import FileListComponent from "./FileListComponent";
|
import FileListComponent from "./FileListComponent";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -11,14 +11,19 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
|
const API_BASE = "https://bot.kediritechnopark.com/webhook/solid-data";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { organization_id: orgIdFromRoute } = useParams(); // ***
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
const [successMessage, setSuccessMessage] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const [user, setUser] = useState({});
|
const [user, setUser] = useState({});
|
||||||
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
||||||
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
||||||
@@ -26,107 +31,135 @@ const Dashboard = () => {
|
|||||||
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
|
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
|
||||||
const [officers, setOfficers] = useState([]);
|
const [officers, setOfficers] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Helper: ambil orgId yang valid dari route atau localStorage
|
||||||
const token = localStorage.getItem("token");
|
const getActiveOrg = () => {
|
||||||
if (!token) {
|
const selected = JSON.parse(localStorage.getItem("selected_organization") || "null");
|
||||||
window.location.href = "/login";
|
// prioritas: URL param, fallback ke localStorage
|
||||||
}
|
const orgId = orgIdFromRoute || selected?.organization_id;
|
||||||
}, []);
|
const orgName = selected?.nama_organization || "";
|
||||||
|
return { orgId, orgName };
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// Helper: header standar, opsional kirim X-Organization-Id
|
||||||
const verifyTokenAndFetchData = async () => {
|
const authHeaders = (extra = {}) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
const { orgId } = getActiveOrg();
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Organization-Id": orgId ? String(orgId) : undefined, // backend boleh pakai header ini jika mau
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pastikan sudah login & punya org yang dipilih
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const { orgId } = getActiveOrg();
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
window.location.href = "/login";
|
navigate("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!orgId) {
|
||||||
|
navigate("/pick-organization");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Sinkronkan URL dengan orgId dari localStorage kalau user buka /dashboard tanpa param
|
||||||
const response = await fetch(
|
if (!orgIdFromRoute) {
|
||||||
"https://bot.kediritechnopark.com/webhook/solid-data/dashboard",
|
navigate(`/dashboard/${orgId}`, { replace: true });
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}, [orgIdFromRoute, navigate]);
|
||||||
|
|
||||||
|
// Verifikasi token & ambil ringkasan dashboard untuk org terpilih
|
||||||
|
useEffect(() => {
|
||||||
|
const verifyTokenAndFetchData = async () => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const { orgId } = getActiveOrg();
|
||||||
|
if (!token || !orgId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GET -> kirim orgId lewat query string
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(orgId)}`,
|
||||||
|
{ method: "GET", headers: authHeaders() }
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!response.ok || !data[0].username) {
|
if (!res.ok) {
|
||||||
throw new Error("Unauthorized");
|
console.error("Dashboard error:", data);
|
||||||
console.log(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(data[0]);
|
// Contoh normalisasi struktur user dari backend
|
||||||
} catch (error) {
|
// Pakai apa yang ada: data.user atau data[0] atau langsung isi metrik
|
||||||
console.error("Token tidak valid:", error.message);
|
if (data?.user) setUser(data.user);
|
||||||
localStorage.removeItem("token");
|
else if (Array.isArray(data) && data.length) setUser(data[0]);
|
||||||
window.location.href = "/login";
|
|
||||||
|
// Jika backend mengembalikan metrik-metrik ini, set di sini.
|
||||||
|
if (typeof data?.total_today === "number") setTotalFilesSentToday(data.total_today);
|
||||||
|
if (typeof data?.total_month === "number") setTotalFilesSentMonth(data.total_month);
|
||||||
|
if (typeof data?.total_overall === "number") setTotalFilesSentOverall(data.total_overall);
|
||||||
|
if (Array.isArray(data?.officerPerformance))
|
||||||
|
setOfficerPerformanceData(data.officerPerformance);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Token/Fetch dashboard gagal:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
verifyTokenAndFetchData();
|
verifyTokenAndFetchData();
|
||||||
}, []);
|
}, [orgIdFromRoute]);
|
||||||
|
|
||||||
// Memisahkan fungsi fetchOfficers agar dapat dipanggil ulang
|
// Ambil daftar officer (khusus admin) untuk org terpilih
|
||||||
const fetchOfficers = async () => {
|
const fetchOfficers = async () => {
|
||||||
const token = localStorage.getItem("token");
|
const { orgId } = getActiveOrg();
|
||||||
try {
|
if (!orgId) return;
|
||||||
const response = await fetch(
|
|
||||||
"https://bot.kediritechnopark.com/webhook/solid-data/list-user",
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
try {
|
||||||
setOfficers(data);
|
const res = await fetch(
|
||||||
} catch (error) {
|
`${API_BASE}/list-user?organization_id=${encodeURIComponent(orgId)}`,
|
||||||
console.error("Gagal memuat daftar officer:", error.message);
|
{ method: "GET", headers: authHeaders() }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setOfficers(Array.isArray(data) ? data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal memuat daftar officer:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.role == "admin") {
|
if (user?.role === "admin") {
|
||||||
fetchOfficers();
|
fetchOfficers();
|
||||||
}
|
}
|
||||||
}, [user.role]);
|
}, [user?.role]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
|
// jangan hapus selected_organization kalau mau balik lagi ke org sebelumnya
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOfficer = async (e) => {
|
const handleAddOfficer = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const { orgId } = getActiveOrg();
|
||||||
const token = localStorage.getItem("token");
|
if (!orgId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const res = await fetch(`${API_BASE}/add-officer`, {
|
||||||
"https://bot.kediritechnopark.com/webhook/solid-data/add-officer",
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
organization_id: orgId, // *** kirim org pada body
|
||||||
}),
|
}),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!response.ok || data.success === false) {
|
if (!res.ok || data.success === false) {
|
||||||
throw new Error(data.message || "Gagal menambahkan officer");
|
throw new Error(data.message || "Gagal menambahkan officer");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +168,43 @@ const Dashboard = () => {
|
|||||||
setPassword("");
|
setPassword("");
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
|
||||||
// Refresh daftar officer setelah berhasil menambahkan
|
|
||||||
await fetchOfficers();
|
await fetchOfficers();
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
setErrorMessage(error.message || "Gagal menambahkan officer");
|
setErrorMessage(err.message || "Gagal menambahkan officer");
|
||||||
setSuccessMessage("");
|
setSuccessMessage("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteOfficer = async (id) => {
|
||||||
|
const confirmDelete = window.confirm("Apakah Anda yakin ingin menghapus petugas ini?");
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
const { orgId } = getActiveOrg();
|
||||||
|
if (!orgId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/delete-officer`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
organization_id: orgId, // *** kirim org pada body
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.success === false) {
|
||||||
|
throw new Error(data.message || "Gagal menghapus officer");
|
||||||
|
}
|
||||||
|
|
||||||
|
setOfficers((prev) => prev.filter((o) => o.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
alert("Gagal menghapus petugas: " + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tutup menu bila klik di luar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@@ -153,41 +215,7 @@ const Dashboard = () => {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteOfficer = async (id) => {
|
const { orgName } = getActiveOrg();
|
||||||
const confirmDelete = window.confirm(
|
|
||||||
"Apakah Anda yakin ingin menghapus petugas ini?"
|
|
||||||
);
|
|
||||||
if (!confirmDelete) return;
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`https://bot.kediritechnopark.com/webhook/solid-data/delete-officer`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
id,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok || data.success === false) {
|
|
||||||
throw new Error(data.message || "Gagal menghapus officer");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hapus dari daftar tampilan
|
|
||||||
setOfficers((prev) => prev.filter((officer) => officer.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
alert("Gagal menghapus petugas: " + error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboardContainer}>
|
<div className={styles.dashboardContainer}>
|
||||||
@@ -195,9 +223,9 @@ const Dashboard = () => {
|
|||||||
<div className={styles.logoAndTitle}>
|
<div className={styles.logoAndTitle}>
|
||||||
<img src="/ikasapta.png" alt="Bot Avatar" />
|
<img src="/ikasapta.png" alt="Bot Avatar" />
|
||||||
<h1 className={styles.h1}>SOLID</h1>
|
<h1 className={styles.h1}>SOLID</h1>
|
||||||
<h1 className={styles.h1} styles="color: #43a0a7;">
|
<h1 className={styles.h1} styles="color: #43a0a7;">DATA</h1>
|
||||||
DATA
|
{/* *** tampilkan nama org aktif */}
|
||||||
</h1>
|
{orgName && <span className={styles.orgBadge}>Org: {orgName}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.dropdownContainer} ref={menuRef}>
|
<div className={styles.dropdownContainer} ref={menuRef}>
|
||||||
@@ -207,16 +235,8 @@ const Dashboard = () => {
|
|||||||
aria-expanded={isMenuOpen ? "true" : "false"}
|
aria-expanded={isMenuOpen ? "true" : "false"}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="15" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
width="15"
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
@@ -224,15 +244,6 @@ const Dashboard = () => {
|
|||||||
</button>
|
</button>
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<div className={styles.dropdownMenu}>
|
<div className={styles.dropdownMenu}>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigate("/profile");
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className={styles.dropdownItem}
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("/scan");
|
navigate("/scan");
|
||||||
@@ -273,7 +284,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.dashboardGrid}>
|
<div className={styles.dashboardGrid}>
|
||||||
{user.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<div className={styles.formSection}>
|
<div className={styles.formSection}>
|
||||||
<h2>Daftar Petugas</h2>
|
<h2>Daftar Petugas</h2>
|
||||||
<div className={styles.officerListContainer}>
|
<div className={styles.officerListContainer}>
|
||||||
@@ -364,7 +375,9 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */}
|
||||||
<FileListComponent
|
<FileListComponent
|
||||||
|
organizationId={getActiveOrg().orgId} // ***
|
||||||
setTotalFilesSentToday={setTotalFilesSentToday}
|
setTotalFilesSentToday={setTotalFilesSentToday}
|
||||||
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
||||||
setTotalFilesSentOverall={setTotalFilesSentOverall}
|
setTotalFilesSentOverall={setTotalFilesSentOverall}
|
||||||
|
|||||||
441
src/DataTypePage.js
Normal file
441
src/DataTypePage.js
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
User, Eye, EyeOff, Plus, X, RefreshCw, FileText, Users, Baby, Settings, LogOut, Camera
|
||||||
|
} from "lucide-react";
|
||||||
|
import styles from "./Login.module.css";
|
||||||
|
|
||||||
|
/* ===========================================================
|
||||||
|
TEMPLATE DATA
|
||||||
|
=========================================================== */
|
||||||
|
const templates = {
|
||||||
|
KTP: {
|
||||||
|
icon: <User className={styles.templateIcon} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nik", value: "number" },
|
||||||
|
{ key: "nama", value: "text" },
|
||||||
|
{ key: "tempat_lahir", value: "text" },
|
||||||
|
{ key: "tanggal_lahir", value: "date" },
|
||||||
|
{ key: "jenis_kelamin", value: "selection" },
|
||||||
|
{ key: "alamat", value: "text" },
|
||||||
|
{ key: "agama", value: "selection" },
|
||||||
|
{ key: "status_perkawinan", value: "selection" },
|
||||||
|
{ key: "pekerjaan", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
KK: {
|
||||||
|
icon: <Users className={styles.templateIcon} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nomor_kk", value: "number" },
|
||||||
|
{ key: "kepala_keluarga", value: "text" },
|
||||||
|
{ key: "istri", value: "list" },
|
||||||
|
{ key: "anak", value: "list" },
|
||||||
|
{ key: "orang_tua", value: "list" },
|
||||||
|
{ key: "alamat", value: "text" },
|
||||||
|
{ key: "rt_rw", value: "text" },
|
||||||
|
{ key: "kelurahan", value: "text" },
|
||||||
|
{ key: "kecamatan", value: "text" },
|
||||||
|
{ key: "kabupaten_kota", value: "text" },
|
||||||
|
{ key: "provinsi", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Akta Kelahiran": {
|
||||||
|
icon: <Baby className={styles.templateIcon} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nomor_akta", value: "text" },
|
||||||
|
{ key: "nama_anak", value: "text" },
|
||||||
|
{ key: "jenis_kelamin", value: "selection" },
|
||||||
|
{ key: "tempat_lahir", value: "text" },
|
||||||
|
{ key: "tanggal_lahir", value: "date" },
|
||||||
|
{ key: "nama_ayah", value: "text" },
|
||||||
|
{ key: "nama_ibu", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===========================================================
|
||||||
|
EXPECTATION FORM (Controlled Component)
|
||||||
|
- Tidak memakai state internal; parent (DataTypePage) sebagai sumber kebenaran
|
||||||
|
=========================================================== */
|
||||||
|
function ExpectationForm({ fields, setFields }) {
|
||||||
|
const safeFields = fields?.length ? fields : [{ key: "", value: "" }];
|
||||||
|
|
||||||
|
const updateField = (index, key, value) => {
|
||||||
|
const next = safeFields.map((f, i) => (i === index ? { ...f, [key]: value } : f));
|
||||||
|
setFields(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addField = () =>
|
||||||
|
setFields([...(safeFields || []), { key: "", value: "" }]);
|
||||||
|
|
||||||
|
const removeField = (index) => {
|
||||||
|
const next = safeFields.filter((_, i) => i !== index);
|
||||||
|
setFields(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.expectationForm}>
|
||||||
|
{safeFields.map((f, i) => (
|
||||||
|
<div key={i} className={styles.fieldRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Field name"
|
||||||
|
value={f.key}
|
||||||
|
onChange={(e) => updateField(i, "key", e.target.value)}
|
||||||
|
className={styles.fieldInput}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={f.value}
|
||||||
|
onChange={(e) => updateField(i, "value", e.target.value)}
|
||||||
|
className={styles.fieldSelect}
|
||||||
|
>
|
||||||
|
<option value="">Pilih Type</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="boolean">Boolean</option>
|
||||||
|
<option value="selection">Selection</option>
|
||||||
|
<option value="list">List</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
className={styles.removeFieldButton}
|
||||||
|
title="Hapus field"
|
||||||
|
>
|
||||||
|
<X className={styles.removeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addField}
|
||||||
|
className={styles.addFieldButton}
|
||||||
|
>
|
||||||
|
<Plus className={styles.addIcon} />
|
||||||
|
Tambah Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================
|
||||||
|
DATA TYPE PAGE
|
||||||
|
=========================================================== */
|
||||||
|
export default function DataTypePage() {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||||
|
const [isFormSectionOpen, setIsFormSectionOpen] = useState(false);
|
||||||
|
|
||||||
|
const [namaTipe, setNamaTipe] = useState("");
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [expectation, setExpectation] = useState({});
|
||||||
|
|
||||||
|
const [scanned, setScanned] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const LIST_SCANNED_URL = "https://bot.kediritechnopark.com/webhook/list-scanned";
|
||||||
|
|
||||||
|
const resolveNama = (row) =>
|
||||||
|
row?.nama ??
|
||||||
|
row?.data?.nama ??
|
||||||
|
row?.fields?.nama ??
|
||||||
|
row?.payload?.nama ??
|
||||||
|
row?.kepala_keluarga ??
|
||||||
|
row?.nama_anak ??
|
||||||
|
row?.name ??
|
||||||
|
"-";
|
||||||
|
|
||||||
|
const resolveType = (row) =>
|
||||||
|
row?.type ??
|
||||||
|
row?.type_data ??
|
||||||
|
row?.nama_tipe ??
|
||||||
|
row?.data_type ??
|
||||||
|
row?.template_name ??
|
||||||
|
"-";
|
||||||
|
|
||||||
|
const fetchScanned = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await fetch(LIST_SCANNED_URL, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const rows = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
||||||
|
setScanned(rows);
|
||||||
|
} catch (e) {
|
||||||
|
setError("Gagal memuat daftar hasil scan");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchScanned();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-bangun expectation dari fields (single source of truth)
|
||||||
|
useEffect(() => {
|
||||||
|
const obj = Object.fromEntries(
|
||||||
|
(fields || [])
|
||||||
|
.map((f) => [f?.key ?? "", f?.value ?? ""])
|
||||||
|
.filter(([k]) => k !== "")
|
||||||
|
);
|
||||||
|
setExpectation(obj);
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
const handleTemplateSelect = (templateName) => {
|
||||||
|
if (selectedTemplate === templateName && isFormSectionOpen) {
|
||||||
|
// klik ulang => tutup form & reset
|
||||||
|
setIsFormSectionOpen(false);
|
||||||
|
setSelectedTemplate("");
|
||||||
|
setNamaTipe("");
|
||||||
|
setFields([]);
|
||||||
|
setExpectation({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pilih dan buka form
|
||||||
|
setIsFormSectionOpen(true);
|
||||||
|
setSelectedTemplate(templateName);
|
||||||
|
|
||||||
|
if (templateName === "Custom") {
|
||||||
|
setNamaTipe("");
|
||||||
|
setFields([]);
|
||||||
|
setExpectation({});
|
||||||
|
} else {
|
||||||
|
const tpl = templates[templateName]?.fields || [];
|
||||||
|
setNamaTipe(templateName);
|
||||||
|
setFields(tpl); // expectation otomatis lewat useEffect
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!namaTipe.trim()) {
|
||||||
|
alert("Nama Tipe harus diisi!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
"https://bot.kediritechnopark.com/webhook/create-data-type",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nama_tipe: namaTipe, expectation }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
alert("Data Type created!");
|
||||||
|
setSelectedTemplate("");
|
||||||
|
setNamaTipe("");
|
||||||
|
setFields([]);
|
||||||
|
setExpectation({});
|
||||||
|
setIsFormSectionOpen(false);
|
||||||
|
} else {
|
||||||
|
alert("Gagal membuat data type");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("Gagal membuat data type");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dataTypePage}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.headerBrand}>
|
||||||
|
<div className={styles.headerLogo}>
|
||||||
|
<FileText className={styles.headerLogoIcon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerInfo}>
|
||||||
|
<h1 className={styles.headerTitle}>DataScan</h1>
|
||||||
|
<p className={styles.headerSubtitle}>Data Management System</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tombol Scans + Logout */}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { window.location.href = "/scan"; }}
|
||||||
|
className={styles.submitButton}
|
||||||
|
title="Buka pemindaian KTP"
|
||||||
|
>
|
||||||
|
<Camera style={{ marginRight: 6 }} />
|
||||||
|
Scans
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.logoutButton}
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className={styles.logoutIcon} />
|
||||||
|
<span className={styles.logoutText}>Keluar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div className={styles.mainContent}>
|
||||||
|
{/* Create Data Type Section */}
|
||||||
|
<div className={styles.createSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2 className={styles.sectionTitle}>Buat Tipe Data Baru</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>Pilih template atau buat tipe data custom sesuai kebutuhan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Selection */}
|
||||||
|
<div className={styles.templateSection}>
|
||||||
|
<h3 className={styles.templateTitle}>Pilih Template</h3>
|
||||||
|
<div className={styles.templateGrid}>
|
||||||
|
{Object.entries(templates).map(([templateName, template]) => (
|
||||||
|
<button
|
||||||
|
key={templateName}
|
||||||
|
onClick={() => handleTemplateSelect(templateName)}
|
||||||
|
className={`${styles.templateCard} ${
|
||||||
|
selectedTemplate === templateName ? styles.templateCardActive : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={styles.templateContent}>
|
||||||
|
<div className={`${styles.templateIconContainer} ${
|
||||||
|
selectedTemplate === templateName ? styles.templateIconActive : ""
|
||||||
|
}`}>
|
||||||
|
{template.icon}
|
||||||
|
</div>
|
||||||
|
<span className={styles.templateName}>{templateName}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom Template */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTemplateSelect("Custom")}
|
||||||
|
className={`${styles.templateCard} ${styles.customTemplateCard} ${
|
||||||
|
selectedTemplate === "Custom" ? styles.customTemplateActive : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={styles.templateContent}>
|
||||||
|
<div className={`${styles.templateIconContainer} ${
|
||||||
|
selectedTemplate === "Custom" ? styles.customIconActive : ""
|
||||||
|
}`}>
|
||||||
|
<Settings className={styles.templateIcon} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.templateName}>Custom</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Section */}
|
||||||
|
{isFormSectionOpen && (
|
||||||
|
<div className={styles.formSection}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.formLabel}>Nama Tipe Data</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Masukkan nama tipe data"
|
||||||
|
className={styles.inputField}
|
||||||
|
value={namaTipe}
|
||||||
|
onChange={(e) => setNamaTipe(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields Section */}
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.formLabel}>Fields</label>
|
||||||
|
<ExpectationForm
|
||||||
|
fields={fields}
|
||||||
|
setFields={setFields}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
Simpan Tipe Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scanned Data List */}
|
||||||
|
<div className={styles.dataSection}>
|
||||||
|
<div className={styles.dataHeader}>
|
||||||
|
<div className={styles.dataHeaderInfo}>
|
||||||
|
<h2 className={styles.dataTitle}>Data Hasil Scan</h2>
|
||||||
|
<p className={styles.dataSubtitle}>Daftar semua data yang telah di-scan</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchScanned}
|
||||||
|
disabled={loading}
|
||||||
|
className={styles.refreshButton}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`${styles.refreshIcon} ${loading ? styles.spinning : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorMessage}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<table className={styles.dataTable}>
|
||||||
|
<thead className={styles.tableHeader}>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.tableHeaderCell}>No</th>
|
||||||
|
<th className={styles.tableHeaderCell}>Tipe Data</th>
|
||||||
|
<th className={styles.tableHeaderCell}>Nama</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={styles.tableBody}>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className={styles.loadingCell}>
|
||||||
|
<div className={styles.loadingContent}>
|
||||||
|
<RefreshCw className={`${styles.loadingIcon} ${styles.spinning}`} />
|
||||||
|
Memuat data...
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : scanned.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className={styles.emptyCell}>
|
||||||
|
Belum ada data hasil scan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
scanned.map((row, idx) => (
|
||||||
|
<tr key={row.id || row.nik || idx} className={styles.tableRow}>
|
||||||
|
<td className={styles.tableCell}>{idx + 1}</td>
|
||||||
|
<td className={styles.tableCell}>
|
||||||
|
<span className={styles.typeBadge}>
|
||||||
|
{resolveType(row)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.tableCell}>{resolveNama(row)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
725
src/Expetation.js
Normal file
725
src/Expetation.js
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { User, Users, Baby, Settings, Plus, X } from "lucide-react";
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Helpers
|
||||||
|
============================ */
|
||||||
|
const getCleanToken = () => {
|
||||||
|
let raw = localStorage.getItem("token") || "";
|
||||||
|
try { raw = JSON.parse(raw); } catch {}
|
||||||
|
return String(raw).replace(/^"+|"+$/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// BACA org dari localStorage: utamakan 'selected_organization', fallback 'select_organization'
|
||||||
|
const getSelectedOrganization = () => {
|
||||||
|
let raw =
|
||||||
|
localStorage.getItem("selected_organization") ??
|
||||||
|
localStorage.getItem("select_organization");
|
||||||
|
if (!raw) return null;
|
||||||
|
try { return JSON.parse(raw); } catch { return raw; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ambil organization_id aktif (string atau object)
|
||||||
|
const getActiveOrgId = () => {
|
||||||
|
const sel = getSelectedOrganization();
|
||||||
|
if (!sel) return "";
|
||||||
|
if (typeof sel === "object" && sel?.organization_id) return String(sel.organization_id);
|
||||||
|
return String(sel);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header auth standar (ikutkan X-Organization-Id juga)
|
||||||
|
const authHeaders = () => {
|
||||||
|
const token = getCleanToken();
|
||||||
|
const orgId = getActiveOrgId();
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(orgId ? { "X-Organization-Id": orgId } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ID generator aman
|
||||||
|
const safeUUID = () => {
|
||||||
|
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1);
|
||||||
|
return `${Date.now().toString(16)}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ubah array fields -> object expectation dengan menjaga urutan
|
||||||
|
const fieldsToExpectationObject = (fields, forcedOrder = []) => {
|
||||||
|
if (!Array.isArray(fields)) return {};
|
||||||
|
const base = {};
|
||||||
|
fields.forEach((f) => {
|
||||||
|
const k = (f?.key || f?.label || "").toString().trim();
|
||||||
|
const v = (f?.value || "text").toString().trim();
|
||||||
|
if (k) base[k] = v || "text";
|
||||||
|
});
|
||||||
|
if (!forcedOrder?.length) return base;
|
||||||
|
|
||||||
|
const ordered = {};
|
||||||
|
forcedOrder.forEach((k) => {
|
||||||
|
if (k in base) ordered[k] = base[k];
|
||||||
|
});
|
||||||
|
Object.keys(base).forEach((k) => {
|
||||||
|
if (!(k in ordered)) ordered[k] = base[k];
|
||||||
|
});
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSlug = (name) =>
|
||||||
|
(name || "")
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Template Data (Default)
|
||||||
|
============================ */
|
||||||
|
const templates = {
|
||||||
|
KTP: {
|
||||||
|
icon: <User style={{ width: 24, height: 24 }} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nik", value: "number" },
|
||||||
|
{ key: "nama", value: "text" },
|
||||||
|
{ key: "tempat_lahir", value: "text" },
|
||||||
|
{ key: "tanggal_lahir", value: "date" },
|
||||||
|
{ key: "jenis_kelamin", value: "selection" },
|
||||||
|
{ key: "alamat", value: "text" },
|
||||||
|
{ key: "agama", value: "selection" },
|
||||||
|
{ key: "status_perkawinan", value: "selection" },
|
||||||
|
{ key: "pekerjaan", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
KK: {
|
||||||
|
icon: <Users style={{ width: 24, height: 24 }} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nomor_kk", value: "number" },
|
||||||
|
{ key: "kepala_keluarga", value: "text" },
|
||||||
|
{ key: "istri", value: "list" },
|
||||||
|
{ key: "anak", value: "list" },
|
||||||
|
{ key: "orang_tua", value: "list" },
|
||||||
|
{ key: "alamat", value: "text" },
|
||||||
|
{ key: "rt_rw", value: "text" },
|
||||||
|
{ key: "kelurahan", value: "text" },
|
||||||
|
{ key: "kecamatan", value: "text" },
|
||||||
|
{ key: "kabupaten_kota", value: "text" },
|
||||||
|
{ key: "provinsi", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Akta Kelahiran": {
|
||||||
|
icon: <Baby style={{ width: 24, height: 24 }} />,
|
||||||
|
fields: [
|
||||||
|
{ key: "nomor_akta", value: "text" },
|
||||||
|
{ key: "nama_anak", value: "text" },
|
||||||
|
{ key: "jenis_kelamin", value: "selection" },
|
||||||
|
{ key: "tempat_lahir", value: "text" },
|
||||||
|
{ key: "tanggal_lahir", value: "date" },
|
||||||
|
{ key: "nama_ayah", value: "text" },
|
||||||
|
{ key: "nama_ibu", value: "text" },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Urutan paksa untuk payload "Akta Kelahiran"
|
||||||
|
const AKTA_KELAHIRAN_FORCED_ORDER = [
|
||||||
|
"nomor_akta",
|
||||||
|
"nama_anak",
|
||||||
|
"jenis_kelamin",
|
||||||
|
"tempat_lahir",
|
||||||
|
"tanggal_lahir",
|
||||||
|
"nama_ayah",
|
||||||
|
"nama_ibu",
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
ExpectationForm
|
||||||
|
============================ */
|
||||||
|
const ExpectationForm = ({ fields, setFields }) => {
|
||||||
|
const safeFields = fields?.length ? fields : [{ key: "", value: "" }];
|
||||||
|
|
||||||
|
const updateField = (index, key, value) => {
|
||||||
|
const next = safeFields.map((f, i) => (i === index ? { ...f, [key]: value } : f));
|
||||||
|
setFields(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addField = () =>
|
||||||
|
setFields([...(safeFields || []), { key: "", value: "" }]);
|
||||||
|
|
||||||
|
const removeField = (index) => {
|
||||||
|
const next = safeFields.filter((_, i) => i !== index);
|
||||||
|
setFields(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={expectationFormStyles.container}>
|
||||||
|
{safeFields.map((f, i) => (
|
||||||
|
<div key={i} style={expectationFormStyles.fieldRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Field name"
|
||||||
|
value={f.key}
|
||||||
|
onChange={(e) => updateField(i, "key", e.target.value)}
|
||||||
|
style={expectationFormStyles.fieldInput}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={f.value}
|
||||||
|
onChange={(e) => updateField(i, "value", e.target.value)}
|
||||||
|
style={expectationFormStyles.fieldSelect}
|
||||||
|
>
|
||||||
|
<option value="">Pilih Type</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="boolean">Boolean</option>
|
||||||
|
<option value="selection">Selection</option>
|
||||||
|
<option value="list">List</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
style={expectationFormStyles.removeFieldButton}
|
||||||
|
title="Hapus field"
|
||||||
|
>
|
||||||
|
<X style={{ width: 16, height: 16 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addField}
|
||||||
|
style={expectationFormStyles.addFieldButton}
|
||||||
|
>
|
||||||
|
<Plus style={{ width: 16, height: 16, marginRight: 8 }} />
|
||||||
|
Tambah Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Modal: New Document
|
||||||
|
============================ */
|
||||||
|
const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||||
|
const [documentName, setDocumentName] = useState("");
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedTemplate("");
|
||||||
|
setDocumentName("");
|
||||||
|
setFields([]);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleTemplateSelect = (templateName) => {
|
||||||
|
setSelectedTemplate(templateName);
|
||||||
|
|
||||||
|
if (templateName === "Custom") {
|
||||||
|
setDocumentName("");
|
||||||
|
setFields([{ key: "", value: "" }]);
|
||||||
|
} else {
|
||||||
|
const tpl = templates[templateName]?.fields || [];
|
||||||
|
setDocumentName(templateName);
|
||||||
|
setFields(tpl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!documentName.trim()) return;
|
||||||
|
|
||||||
|
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
|
||||||
|
if (validFields.length === 0) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const forcedOrder = documentName.trim() === "Akta Kelahiran"
|
||||||
|
? AKTA_KELAHIRAN_FORCED_ORDER
|
||||||
|
: [];
|
||||||
|
const expectationObj = fieldsToExpectationObject(validFields, forcedOrder);
|
||||||
|
await onSubmit(documentName.trim(), expectationObj);
|
||||||
|
|
||||||
|
setSelectedTemplate("");
|
||||||
|
setDocumentName("");
|
||||||
|
setFields([]);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error submit new document type:", err);
|
||||||
|
alert("Terjadi kesalahan saat membuat tipe dokumen baru.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
|
||||||
|
const isFormValid = documentName.trim() && validFields.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={modalStyles.overlay}>
|
||||||
|
<div style={modalStyles.modal}>
|
||||||
|
<div style={modalStyles.header}>
|
||||||
|
<h3 style={modalStyles.title}>Tambah Jenis Dokumen Baru</h3>
|
||||||
|
<button onClick={onClose} style={modalStyles.closeButton} disabled={isSubmitting}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={modalStyles.content}>
|
||||||
|
{/* Template Selection */}
|
||||||
|
<div style={modalStyles.section}>
|
||||||
|
<label style={modalStyles.sectionLabel}>Pilih Template</label>
|
||||||
|
<div style={modalStyles.templateGrid}>
|
||||||
|
{Object.entries(templates).map(([templateName, template]) => (
|
||||||
|
<button
|
||||||
|
key={templateName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTemplateSelect(templateName)}
|
||||||
|
style={{
|
||||||
|
...modalStyles.templateCard,
|
||||||
|
...(selectedTemplate === templateName ? modalStyles.templateCardActive : {})
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<div style={modalStyles.templateContent}>
|
||||||
|
<div style={{
|
||||||
|
...modalStyles.templateIconContainer,
|
||||||
|
...(selectedTemplate === templateName ? modalStyles.templateIconActive : {})
|
||||||
|
}}>
|
||||||
|
{template.icon}
|
||||||
|
</div>
|
||||||
|
<span style={modalStyles.templateName}>{templateName}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom Template */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTemplateSelect("Custom")}
|
||||||
|
style={{
|
||||||
|
...modalStyles.templateCard,
|
||||||
|
...modalStyles.customTemplateCard,
|
||||||
|
...(selectedTemplate === "Custom" ? modalStyles.customTemplateActive : {})
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<div style={modalStyles.templateContent}>
|
||||||
|
<div style={{
|
||||||
|
...modalStyles.templateIconContainer,
|
||||||
|
...(selectedTemplate === "Custom" ? modalStyles.customIconActive : {})
|
||||||
|
}}>
|
||||||
|
<Settings style={{ width: 24, height: 24 }} />
|
||||||
|
</div>
|
||||||
|
<span style={modalStyles.templateName}>Custom</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Section - hanya muncul jika template dipilih */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<>
|
||||||
|
<div style={modalStyles.section}>
|
||||||
|
<label style={modalStyles.label}>Nama Document Type</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={documentName}
|
||||||
|
onChange={(e) => setDocumentName(e.target.value)}
|
||||||
|
placeholder="Data yang ingin di tambahkan"
|
||||||
|
style={modalStyles.input}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={modalStyles.section}>
|
||||||
|
<label style={modalStyles.label}>Fields</label>
|
||||||
|
<ExpectationForm
|
||||||
|
fields={fields}
|
||||||
|
setFields={setFields}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTemplate && (
|
||||||
|
<div style={modalStyles.footer}>
|
||||||
|
<button type="button" onClick={onClose} style={modalStyles.cancelButton} disabled={isSubmitting}>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={modalStyles.submitButton}
|
||||||
|
disabled={isSubmitting || !isFormValid}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Mengirim..." : "Tambah"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Komponen Utama: Expetation
|
||||||
|
============================ */
|
||||||
|
const Expetation = ({ onSelect }) => {
|
||||||
|
const [documentTypes, setDocumentTypes] = useState([]);
|
||||||
|
const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [showNewDocumentModal, setShowNewDocumentModal] = useState(false);
|
||||||
|
|
||||||
|
const getDocumentDisplayInfo = (doc) => {
|
||||||
|
const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString();
|
||||||
|
const pretty = base
|
||||||
|
? base.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())
|
||||||
|
: "Tanpa Nama";
|
||||||
|
return { icon: "📄", name: pretty, fullName: pretty };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalisasi data dari server "show"
|
||||||
|
const normalizeItem = (doc) => {
|
||||||
|
const humanName = doc.display_name ?? doc.nama_tipe ?? doc.document_type ?? "";
|
||||||
|
const slug = toSlug(humanName);
|
||||||
|
|
||||||
|
let expectationObj = {};
|
||||||
|
if (doc.expectation && typeof doc.expectation === "object" && !Array.isArray(doc.expectation)) {
|
||||||
|
expectationObj = { ...doc.expectation };
|
||||||
|
} else if (Array.isArray(doc.expectation)) {
|
||||||
|
expectationObj = fieldsToExpectationObject(doc.expectation);
|
||||||
|
} else if (Array.isArray(doc.fields)) {
|
||||||
|
expectationObj = fieldsToExpectationObject(doc.fields);
|
||||||
|
} else if (templates[humanName]) {
|
||||||
|
expectationObj = fieldsToExpectationObject(templates[humanName].fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: doc.id ?? doc.data_type_id ?? safeUUID(),
|
||||||
|
nama_tipe: slug,
|
||||||
|
display_name: humanName,
|
||||||
|
expectation: expectationObj,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Komunikasi dengan webhook
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
// Kirim org ke /solid-data/show (GET + query + header)
|
||||||
|
const sendSelectedOrgToWebhook = async () => {
|
||||||
|
try {
|
||||||
|
const orgId = getActiveOrgId();
|
||||||
|
const url = new URL("https://bot.kediritechnopark.com/webhook/solid-data/show");
|
||||||
|
if (orgId) url.searchParams.set("organization_id", orgId);
|
||||||
|
|
||||||
|
await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal mengirim organization_id ke /solid-data/show:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ambil daftar tipe dokumen (ikutkan organization_id)
|
||||||
|
const fetchDocumentTypes = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingDocumentTypes(true);
|
||||||
|
|
||||||
|
const orgId = getActiveOrgId();
|
||||||
|
const url = new URL("https://bot.kediritechnopark.com/webhook/solid-data/show");
|
||||||
|
if (orgId) url.searchParams.set("organization_id", orgId);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const normalized = (Array.isArray(data) ? data : [])
|
||||||
|
.filter((doc) => (doc.nama_tipe ?? doc.document_type) !== "INACTIVE")
|
||||||
|
.map(normalizeItem);
|
||||||
|
|
||||||
|
setDocumentTypes(normalized);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching document types:", error);
|
||||||
|
// fallback dari templates lokal
|
||||||
|
const fallback = Object.keys(templates).map((name) =>
|
||||||
|
normalizeItem({
|
||||||
|
id: safeUUID(),
|
||||||
|
nama_tipe: toSlug(name),
|
||||||
|
display_name: name,
|
||||||
|
fields: templates[name].fields
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setDocumentTypes(fallback);
|
||||||
|
} finally {
|
||||||
|
setLoadingDocumentTypes(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saat mount: 1) kirim organization_id 2) ambil list tipe dokumen
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await sendSelectedOrgToWebhook();
|
||||||
|
await fetchDocumentTypes();
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Hapus tipe dokumen (POST body + header X-Organization-Id)
|
||||||
|
const handleDeleteDocumentType = async (id, namaTipe) => {
|
||||||
|
if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${namaTipe}"?`)) {
|
||||||
|
try {
|
||||||
|
const orgId = getActiveOrgId();
|
||||||
|
const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-document-type", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
nama_tipe: namaTipe,
|
||||||
|
...(orgId ? { organization_id: orgId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setDocumentTypes((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
alert(`Dokumen tipe "${namaTipe}" berhasil dihapus.`);
|
||||||
|
} else {
|
||||||
|
console.error("Server reported failure:", result);
|
||||||
|
alert(`Gagal menghapus dokumen tipe "${namaTipe}": ${result.message || "Respon tidak menunjukkan keberhasilan."}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting document type:", error);
|
||||||
|
alert(`Terjadi kesalahan saat menghapus dokumen tipe "${namaTipe}". Detail: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsEditMode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buat tipe dokumen baru (POST body + header X-Organization-Id)
|
||||||
|
const handleNewDocumentSubmit = async (documentName, expectationObj) => {
|
||||||
|
try {
|
||||||
|
const orgId = getActiveOrgId();
|
||||||
|
|
||||||
|
const resp = await fetch("https://bot.kediritechnopark.com/webhook/create-data-type", {
|
||||||
|
method: "POST",
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
nama_tipe: documentName, // EXACT seperti input
|
||||||
|
expectation: expectationObj,
|
||||||
|
...(orgId ? { organization_id: orgId } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
throw new Error(`HTTP ${resp.status} ${resp.statusText} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchDocumentTypes();
|
||||||
|
alert(`Dokumen tipe "${documentName}" berhasil dibuat.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting new document type:", error);
|
||||||
|
alert(`Terjadi kesalahan saat membuat dokumen tipe "${documentName}".`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentTypeSelection = (item) => {
|
||||||
|
if (!item) return;
|
||||||
|
if (item === "new") {
|
||||||
|
setShowNewDocumentModal(true);
|
||||||
|
} else {
|
||||||
|
onSelect?.(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={selectionStyles.selectionContainer}>
|
||||||
|
<div style={selectionStyles.selectionContent}>
|
||||||
|
<div style={selectionStyles.selectionHeader}>
|
||||||
|
<h2 style={selectionStyles.selectionTitle}>Pilih Jenis Dokumen</h2>
|
||||||
|
<button onClick={() => setIsEditMode(!isEditMode)} style={selectionStyles.editButton}>
|
||||||
|
{isEditMode ? "Selesai" : "Edit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={selectionStyles.selectionSubtitle}>Silakan pilih jenis dokumen yang akan Anda scan</p>
|
||||||
|
|
||||||
|
<div style={selectionStyles.documentGrid}>
|
||||||
|
{loadingDocumentTypes ? (
|
||||||
|
<div style={selectionStyles.spinnerContainer}>
|
||||||
|
<div style={selectionStyles.spinner} />
|
||||||
|
<style>{spinnerStyle}</style>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleDocumentTypeSelection("new")} style={selectionStyles.documentCard}>
|
||||||
|
<div style={selectionStyles.documentIconContainer}>
|
||||||
|
<div style={selectionStyles.plusIcon}>+</div>
|
||||||
|
</div>
|
||||||
|
<div style={selectionStyles.documentLabel}>new</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{documentTypes.map((doc) => {
|
||||||
|
const displayInfo = getDocumentDisplayInfo(doc);
|
||||||
|
return (
|
||||||
|
<div key={doc.id} style={selectionStyles.documentCardWrapper}>
|
||||||
|
<button onClick={() => handleDocumentTypeSelection(doc)} style={selectionStyles.documentCard}>
|
||||||
|
<div style={{ ...selectionStyles.documentIconContainer, backgroundColor: "#f0f0f0" }}>
|
||||||
|
<div style={selectionStyles.documentIcon}>{displayInfo.icon}</div>
|
||||||
|
</div>
|
||||||
|
<div style={selectionStyles.documentLabel}>{displayInfo.name}</div>
|
||||||
|
</button>
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
style={selectionStyles.deleteIcon}
|
||||||
|
onClick={() => handleDeleteDocumentType(doc.id, doc.nama_tipe)}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewDocumentModal
|
||||||
|
isOpen={showNewDocumentModal}
|
||||||
|
onClose={() => setShowNewDocumentModal(false)}
|
||||||
|
onSubmit={handleNewDocumentSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Styles
|
||||||
|
============================ */
|
||||||
|
const spinnerStyle = `
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const expectationFormStyles = {
|
||||||
|
container: { marginTop: "10px" },
|
||||||
|
fieldRow: { display: "flex", alignItems: "center", marginBottom: "12px", gap: "8px" },
|
||||||
|
fieldInput: {
|
||||||
|
flex: "2",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "14px",
|
||||||
|
outline: "none",
|
||||||
|
transition: "border-color 0.3s ease",
|
||||||
|
},
|
||||||
|
fieldSelect: {
|
||||||
|
flex: "1",
|
||||||
|
padding: "10px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "14px",
|
||||||
|
outline: "none",
|
||||||
|
backgroundColor: "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
removeFieldButton: {
|
||||||
|
backgroundColor: "#dc3545",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
addFieldButton: {
|
||||||
|
backgroundColor: "#007bff",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 15px",
|
||||||
|
fontSize: "14px",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "10px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectionStyles = {
|
||||||
|
selectionContainer: {
|
||||||
|
display: "flex", justifyContent: "center", alignItems: "center",
|
||||||
|
minHeight: "calc(100vh - 70px)", padding: "20px", boxSizing: "border-box", backgroundColor: "#f0f2f5",
|
||||||
|
},
|
||||||
|
selectionContent: {
|
||||||
|
backgroundColor: "white", borderRadius: "16px", padding: "30px", textAlign: "center",
|
||||||
|
boxShadow: "0 8px 20px rgba(0,0,0,0.1)", maxWidth: "600px", width: "100%",
|
||||||
|
},
|
||||||
|
selectionHeader: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" },
|
||||||
|
selectionTitle: { fontSize: "28px", fontWeight: "bold", marginBottom: "10px", color: "#333" },
|
||||||
|
selectionSubtitle: { fontSize: "16px", color: "#666", marginBottom: "30px" },
|
||||||
|
documentGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))", gap: "20px", justifyContent: "center" },
|
||||||
|
documentCard: {
|
||||||
|
backgroundColor: "#f8f9fa", borderRadius: "12px", padding: "20px", display: "flex", flexDirection: "column",
|
||||||
|
alignItems: "center", justifyContent: "center", gap: "10px", cursor: "pointer", border: "1px solid #e9ecef",
|
||||||
|
transition: "transform 0.2s, box-shadow 0.2s",
|
||||||
|
},
|
||||||
|
documentIconContainer: { width: "60px", height: "60px", borderRadius: "50%", backgroundColor: "#e0f7fa", display: "flex", justifyContent: "center", alignItems: "center" },
|
||||||
|
documentIcon: { fontSize: "30px" },
|
||||||
|
plusIcon: { fontSize: "40px", color: "#43a0a7", fontWeight: "200" },
|
||||||
|
documentLabel: { fontSize: "15px", fontWeight: "bold", color: "#333", textTransform: "capitalize" },
|
||||||
|
spinnerContainer: { display: "flex", justifyContent: "center", alignItems: "center", height: "100px" },
|
||||||
|
spinner: { border: "4px solid #f3f3f3", borderTop: "4px solid #429241", borderRadius: "50%", width: "40px", height: "40px", animation: "spin 1s linear infinite" },
|
||||||
|
editButton: { backgroundColor: "#007bff", color: "white", padding: "8px 15px", borderRadius: "8px", border: "none", fontSize: "14px", fontWeight: "bold", cursor: "pointer" },
|
||||||
|
documentCardWrapper: { position: "relative", display: "flex", flexDirection: "column", alignItems: "center" },
|
||||||
|
deleteIcon: {
|
||||||
|
position: "absolute", top: "-10px", right: "-10px", backgroundColor: "#dc3545", color: "white", borderRadius: "50%",
|
||||||
|
width: "28px", height: "28px", fontSize: "20px", display: "flex", justifyContent: "center", alignItems: "center",
|
||||||
|
cursor: "pointer", border: "2px solid white", boxShadow: "0 2px 5px rgba(0,0,0,0.2)", zIndex: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalStyles = {
|
||||||
|
overlay: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 },
|
||||||
|
modal: { backgroundColor: "white", borderRadius: "16px", width: "90%", maxWidth: "600px", boxShadow: "0 10px 25px rgba(0, 0, 0, 0.2)", maxHeight: "85vh", overflowY: "auto" },
|
||||||
|
header: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "20px 20px 0 20px", borderBottom: "1px solid #e9ecef", marginBottom: "20px" },
|
||||||
|
title: { margin: 0, fontSize: "18px", fontWeight: "bold", color: "#333" },
|
||||||
|
closeButton: { background: "none", border: "none", fontSize: "24px", cursor: "pointer", color: "gray", padding: 0, width: "30px", height: "30px", display: "flex", alignItems: "center", justifyContent: "center" },
|
||||||
|
content: { padding: "0 20px 20px 20px" },
|
||||||
|
section: { marginBottom: "25px" },
|
||||||
|
sectionLabel: { display: "block", marginBottom: "15px", fontWeight: "bold", color: "#333", fontSize: "16px" },
|
||||||
|
label: { display: "block", marginBottom: "8px", fontWeight: "bold", color: "#333", fontSize: "14px" },
|
||||||
|
input: { width: "100%", padding: "12px", border: "2px solid #e9ecef", borderRadius: "8px", fontSize: "16px", outline: "none", transition: "border-color 0.3s ease", boxSizing: "border-box" },
|
||||||
|
templateGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "12px" },
|
||||||
|
templateCard: { backgroundColor: "#f8f9fa", borderRadius: "12px", padding: "15px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", border: "2px solid transparent", transition: "all 0.2s ease" },
|
||||||
|
templateCardActive: { borderColor: "#007bff", backgroundColor: "#e3f2fd" },
|
||||||
|
customTemplateCard: { backgroundColor: "#fff3cd" },
|
||||||
|
customTemplateActive: { borderColor: "#ffc107", backgroundColor: "#fff3cd" },
|
||||||
|
templateContent: { display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" },
|
||||||
|
templateIconContainer: { width: "40px", height: "40px", borderRadius: "50%", backgroundColor: "#e0f7fa", display: "flex", justifyContent: "center", alignItems: "center", transition: "background-color 0.2s ease" },
|
||||||
|
templateIconActive: { backgroundColor: "#007bff", color: "white" },
|
||||||
|
customIconActive: { backgroundColor: "#ffc107", color: "white" },
|
||||||
|
templateName: { fontSize: "12px", fontWeight: "bold", color: "#333", textAlign: "center" },
|
||||||
|
footer: { display: "flex", gap: "10px", padding: "20px", borderTop: "1px solid #e9ecef" },
|
||||||
|
cancelButton: { flex: 1, padding: "12px", border: "2px solid #e9ecef", borderRadius: "8px", backgroundColor: "white", cursor: "pointer", fontSize: "16px", fontWeight: "bold", color: "#666" },
|
||||||
|
submitButton: { flex: 1, padding: "12px", border: "none", borderRadius: "8px", backgroundColor: "#429241", color: "white", cursor: "pointer", fontSize: "16px", fontWeight: "bold" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Expetation;
|
||||||
@@ -179,7 +179,7 @@ const FileListComponent = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFiles();
|
// fetchFiles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
1246
src/KTPScanner.js
1246
src/KTPScanner.js
File diff suppressed because it is too large
Load Diff
109
src/Login.js
109
src/Login.js
@@ -1,84 +1,53 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
User, Eye, EyeOff, Plus, X, RefreshCw, FileText, Users, Baby, Settings, LogOut, Camera
|
||||||
|
} from "lucide-react";
|
||||||
import styles from "./Login.module.css";
|
import styles from "./Login.module.css";
|
||||||
|
|
||||||
const Login = () => {
|
/* ===========================================================
|
||||||
const [formData, setFormData] = useState({
|
LOGIN PAGE
|
||||||
username: "",
|
=========================================================== */
|
||||||
password: "",
|
export default function LoginPage({ onLoggedIn }) {
|
||||||
});
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
const login = () => {
|
||||||
|
const baseUrl = "http://localhost:3001/";
|
||||||
|
const modal = "product";
|
||||||
|
const productId = 9;
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const authorizedUri = "http://localhost:3000/dashboard?token=";
|
||||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const url =
|
||||||
e.preventDefault();
|
`${baseUrl}?modal=${modal}&product_id=${productId}` +
|
||||||
|
`&authorized_uri=${encodeURIComponent(authorizedUri)}` +
|
||||||
|
`&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
|
||||||
|
|
||||||
try {
|
window.location.href = url;
|
||||||
const loginResponse = await fetch(
|
|
||||||
"https://bot.kediritechnopark.com/webhook/solid-data/login",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginDataRaw = await loginResponse.json();
|
|
||||||
const loginData = Array.isArray(loginDataRaw)
|
|
||||||
? loginDataRaw[0]
|
|
||||||
: loginDataRaw;
|
|
||||||
|
|
||||||
if (loginData?.success && loginData?.token) {
|
|
||||||
localStorage.setItem("token", loginData.token);
|
|
||||||
window.location.href = "/";
|
|
||||||
} else {
|
|
||||||
setError(loginData?.message || "Username atau password salah");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Login Error:", err);
|
|
||||||
setError("Gagal terhubung ke server");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.loginContainer}>
|
<div className={styles.loginContainer}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginCard}>
|
||||||
<img src="/ikasapta.png" alt="Logo" className={styles.logo} />
|
{/* Logo/Brand */}
|
||||||
<h1 className={styles.h1}>SOLID DATA</h1>
|
|
||||||
<p className={styles.subtitle}>
|
{/* Login Form */}
|
||||||
Silakan masuk untuk melanjutkan ke dashboard
|
<div className={styles.loginForm}>
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className={styles.form}>
|
<div className={styles.brandSection}>
|
||||||
<input
|
<div className={styles.logoIcon}>
|
||||||
type="text"
|
<FileText className={styles.logoIconSvg} />
|
||||||
name="username"
|
</div>
|
||||||
placeholder="Username"
|
<h1 className={styles.brandTitle}>SOLID DATA</h1>
|
||||||
value={formData.username}
|
<p className={styles.brandSubtitle}>Kelola data dokumen Anda dengan mudah</p>
|
||||||
onChange={handleChange}
|
</div>
|
||||||
className={styles.input}
|
<button
|
||||||
/>
|
className={styles.loginButton}
|
||||||
<input
|
onClick={login}
|
||||||
type="password"
|
>
|
||||||
name="password"
|
Masuk
|
||||||
placeholder="Password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
className={styles.input}
|
|
||||||
/>
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
|
||||||
<button type="submit" className={styles.button}>
|
|
||||||
Login
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
<div className={styles.footer}>© 2025 Kediri Technopark</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Login;
|
|
||||||
1020
src/Login.module.css
1020
src/Login.module.css
File diff suppressed because it is too large
Load Diff
163
src/PickOrganization.js
Normal file
163
src/PickOrganization.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// PickOrganization.js
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Building2, ArrowRight, Loader2, AlertCircle } from "lucide-react";
|
||||||
|
import styles from "./PickOrganization.module.css";
|
||||||
|
|
||||||
|
// ====== KONFIG BACKEND ======
|
||||||
|
// Webhook n8n untuk mengambil daftar organisasi berdasarkan token JWT
|
||||||
|
const LIST_ENDPOINT = "https://bot.kediritechnopark.com/webhook/soliddata/get-organization";
|
||||||
|
|
||||||
|
// Webhook n8n untuk memilih organisasi
|
||||||
|
const SELECT_ENDPOINT = "https://bot.kediritechnopark.com/webhook/soliddata/pick-organization";
|
||||||
|
|
||||||
|
// Fungsi GET organisasi dari backend N8N
|
||||||
|
async function getOrganizationsFromBackend() {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Token tidak ditemukan. Silakan login.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(LIST_ENDPOINT, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || `Gagal mengambil data organisasi. Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error("Respon bukan array. Format data organisasi tidak valid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PickOrganization() {
|
||||||
|
const [orgs, setOrgs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Load daftar organisasi dari backend menggunakan JWT
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
navigate("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data = await getOrganizationsFromBackend();
|
||||||
|
setOrgs(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e.message || "Terjadi kesalahan saat memuat organisasi.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Saat user memilih salah satu organisasi
|
||||||
|
const handleSelect = async (org) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
navigate("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = {
|
||||||
|
organization_id: org.organization_id,
|
||||||
|
nama_organization: org.nama_organization,
|
||||||
|
};
|
||||||
|
|
||||||
|
// simpan lokal untuk dipakai di halaman lain
|
||||||
|
localStorage.setItem("selected_organization", JSON.stringify(chosen));
|
||||||
|
|
||||||
|
setPosting(true);
|
||||||
|
try {
|
||||||
|
await fetch(SELECT_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(chosen),
|
||||||
|
}).catch(() => {}); // abaikan error jaringan/timeout, tetap navigate
|
||||||
|
|
||||||
|
// Lanjut ke dashboard spesifik org
|
||||||
|
navigate(`/dashboard/${org.organization_id}`);
|
||||||
|
} finally {
|
||||||
|
setPosting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Loader2 className={styles.spin} />
|
||||||
|
<span>Memuat organisasi…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.errorWrap}>
|
||||||
|
<AlertCircle />
|
||||||
|
<div>
|
||||||
|
<h3>Gagal memuat organisasi</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button className={styles.retryBtn} onClick={() => window.location.reload()}>
|
||||||
|
Coba lagi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<h1 className={styles.title}>Pilih Organisasi</h1>
|
||||||
|
<p className={styles.subtitle}>Silakan pilih organisasi yang ingin Anda kelola.</p>
|
||||||
|
|
||||||
|
{orgs.length === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<Building2 />
|
||||||
|
<p>Tidak ada organisasi untuk akun ini.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{orgs.map((org) => (
|
||||||
|
<button
|
||||||
|
key={org.organization_id}
|
||||||
|
className={styles.card}
|
||||||
|
onClick={() => handleSelect(org)}
|
||||||
|
disabled={posting}
|
||||||
|
aria-label={`Pilih organisasi ${org.nama_organization}`}
|
||||||
|
>
|
||||||
|
<div className={styles.cardIcon}><Building2 /></div>
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
<div className={styles.cardTitle}>{org.nama_organization}</div>
|
||||||
|
<div className={styles.cardMeta}>ID: {org.organization_id}</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className={styles.cardArrow} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/PickOrganization.module.css
Normal file
18
src/PickOrganization.module.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.wrap { max-width: 840px; margin: 48px auto; padding: 0 16px; }
|
||||||
|
.title { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.subtitle { color: #666; margin-bottom: 24px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
|
||||||
|
.card { display: flex; align-items: center; gap: 12px; border: 1px solid #eaeaea; border-radius: 14px; padding: 14px; background: #fff; cursor: pointer; transition: transform .08s ease, box-shadow .08s ease; }
|
||||||
|
.card:hover { box-shadow: 0 6px 18px rgba(0,0,0,0.06); transform: translateY(-1px); }
|
||||||
|
.card:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||||
|
.cardIcon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; background: #f5f7fb; }
|
||||||
|
.cardBody { text-align: left; flex: 1; }
|
||||||
|
.cardTitle { font-weight: 600; }
|
||||||
|
.cardMeta { font-size: 12px; color: #777; margin-top: 4px; }
|
||||||
|
.cardArrow { opacity: 0.7; }
|
||||||
|
.center { display: flex; gap: 8px; align-items: center; justify-content: center; height: 50vh; color: #333; }
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.errorWrap { max-width: 560px; margin: 80px auto; border: 1px solid #ffd7d7; background: #fff5f5; color: #7a1111; padding: 16px; border-radius: 12px; display: flex; gap: 12px; }
|
||||||
|
.retryBtn { margin-top: 8px; background:#111827; color:#fff; border:none; padding:8px 12px; border-radius:8px; cursor:pointer; }
|
||||||
|
.empty { display: grid; place-items: center; gap: 8px; height: 40vh; color: #555; }
|
||||||
0
src/QWEN.md
Normal file
0
src/QWEN.md
Normal file
30
src/SuccessPage.js
Normal file
30
src/SuccessPage.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const SuccessPage = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
// Redirect to dashboard or another protected route after setting the token
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
// Handle case where no token is present, maybe redirect to login
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
}, [location, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Processing your request...</h1>
|
||||||
|
<p>If you are not redirected automatically, please check your URL.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SuccessPage;
|
||||||
Reference in New Issue
Block a user