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/user-event": "^13.5.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lucide-react": "^0.539.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -11186,6 +11187,15 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"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/user-event": "^13.5.0",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lucide-react": "^0.539.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
75
src/App.js
75
src/App.js
@@ -1,11 +1,24 @@
|
||||
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 SuccessPage from "./SuccessPage";
|
||||
import Dashboard from "./Dashboard";
|
||||
import Login from "./Login";
|
||||
import LoginPage from "./Login";
|
||||
import Expetation from "./DataTypePage";
|
||||
import CameraKtp from "./KTPScanner";
|
||||
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
|
||||
const ProtectedRoute = ({ element }) => {
|
||||
@@ -14,21 +27,67 @@ const ProtectedRoute = ({ element }) => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="App">
|
||||
<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="/success" element={<SuccessPage />} />
|
||||
|
||||
{/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<ProtectedRoute element={<Navigate to="/pickorganization" />} />}
|
||||
/>
|
||||
|
||||
{/* Dashboard spesifik organisasi */}
|
||||
<Route
|
||||
path="/dashboard/:organization"
|
||||
element={<ProtectedRoute element={<Dashboard />} />}
|
||||
/>
|
||||
<Route path="/" element={<ProtectedRoute element={<Dashboard />} />} />
|
||||
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<ProtectedRoute element={<Profile />} />}
|
||||
path="/dashboard/:organization/scan"
|
||||
element={<ProtectedRoute element={<Expetation />} />}
|
||||
/>
|
||||
<Route path="/:nik" element={<ShowImage />} />
|
||||
|
||||
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
253
src/Dashboard.js
253
src/Dashboard.js
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom"; // ***
|
||||
import FileListComponent from "./FileListComponent";
|
||||
import {
|
||||
BarChart,
|
||||
@@ -11,14 +11,19 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
const API_BASE = "https://bot.kediritechnopark.com/webhook/solid-data";
|
||||
|
||||
const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const { organization_id: orgIdFromRoute } = useParams(); // ***
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const [user, setUser] = useState({});
|
||||
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
||||
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
||||
@@ -26,107 +31,135 @@ const Dashboard = () => {
|
||||
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
|
||||
const [officers, setOfficers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, []);
|
||||
// Helper: ambil orgId yang valid dari route atau localStorage
|
||||
const getActiveOrg = () => {
|
||||
const selected = JSON.parse(localStorage.getItem("selected_organization") || "null");
|
||||
// prioritas: URL param, fallback ke localStorage
|
||||
const orgId = orgIdFromRoute || selected?.organization_id;
|
||||
const orgName = selected?.nama_organization || "";
|
||||
return { orgId, orgName };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const verifyTokenAndFetchData = async () => {
|
||||
// Helper: header standar, opsional kirim X-Organization-Id
|
||||
const authHeaders = (extra = {}) => {
|
||||
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) {
|
||||
window.location.href = "/login";
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
if (!orgId) {
|
||||
navigate("/pick-organization");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/solid-data/dashboard",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
// Sinkronkan URL dengan orgId dari localStorage kalau user buka /dashboard tanpa param
|
||||
if (!orgIdFromRoute) {
|
||||
navigate(`/dashboard/${orgId}`, { replace: true });
|
||||
}
|
||||
}, [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) {
|
||||
throw new Error("Unauthorized");
|
||||
console.log(response);
|
||||
if (!res.ok) {
|
||||
console.error("Dashboard error:", data);
|
||||
}
|
||||
|
||||
setUser(data[0]);
|
||||
} catch (error) {
|
||||
console.error("Token tidak valid:", error.message);
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
// Contoh normalisasi struktur user dari backend
|
||||
// Pakai apa yang ada: data.user atau data[0] atau langsung isi metrik
|
||||
if (data?.user) setUser(data.user);
|
||||
else if (Array.isArray(data) && data.length) setUser(data[0]);
|
||||
|
||||
// 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();
|
||||
}, []);
|
||||
}, [orgIdFromRoute]);
|
||||
|
||||
// Memisahkan fungsi fetchOfficers agar dapat dipanggil ulang
|
||||
// Ambil daftar officer (khusus admin) untuk org terpilih
|
||||
const fetchOfficers = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/solid-data/list-user",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const { orgId } = getActiveOrg();
|
||||
if (!orgId) return;
|
||||
|
||||
const data = await response.json();
|
||||
setOfficers(data);
|
||||
} catch (error) {
|
||||
console.error("Gagal memuat daftar officer:", error.message);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/list-user?organization_id=${encodeURIComponent(orgId)}`,
|
||||
{ method: "GET", headers: authHeaders() }
|
||||
);
|
||||
const data = await res.json();
|
||||
setOfficers(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error("Gagal memuat daftar officer:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user.role == "admin") {
|
||||
if (user?.role === "admin") {
|
||||
fetchOfficers();
|
||||
}
|
||||
}, [user.role]);
|
||||
}, [user?.role]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
// jangan hapus selected_organization kalau mau balik lagi ke org sebelumnya
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleAddOfficer = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const { orgId } = getActiveOrg();
|
||||
if (!orgId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://bot.kediritechnopark.com/webhook/solid-data/add-officer",
|
||||
{
|
||||
const res = await fetch(`${API_BASE}/add-officer`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -135,14 +168,43 @@ const Dashboard = () => {
|
||||
setPassword("");
|
||||
setErrorMessage("");
|
||||
|
||||
// Refresh daftar officer setelah berhasil menambahkan
|
||||
await fetchOfficers();
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || "Gagal menambahkan officer");
|
||||
} catch (err) {
|
||||
setErrorMessage(err.message || "Gagal menambahkan officer");
|
||||
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(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
@@ -153,41 +215,7 @@ const Dashboard = () => {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleDeleteOfficer = async (id) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
const { orgName } = getActiveOrg();
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
@@ -195,9 +223,9 @@ const Dashboard = () => {
|
||||
<div className={styles.logoAndTitle}>
|
||||
<img src="/ikasapta.png" alt="Bot Avatar" />
|
||||
<h1 className={styles.h1}>SOLID</h1>
|
||||
<h1 className={styles.h1} styles="color: #43a0a7;">
|
||||
DATA
|
||||
</h1>
|
||||
<h1 className={styles.h1} styles="color: #43a0a7;">DATA</h1>
|
||||
{/* *** tampilkan nama org aktif */}
|
||||
{orgName && <span className={styles.orgBadge}>Org: {orgName}</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.dropdownContainer} ref={menuRef}>
|
||||
@@ -207,16 +235,8 @@ const Dashboard = () => {
|
||||
aria-expanded={isMenuOpen ? "true" : "false"}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg width="15" 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="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
@@ -224,15 +244,6 @@ const Dashboard = () => {
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<div className={styles.dropdownMenu}>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/profile");
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className={styles.dropdownItem}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/scan");
|
||||
@@ -273,7 +284,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
<div className={styles.dashboardGrid}>
|
||||
{user.role === "admin" && (
|
||||
{user?.role === "admin" && (
|
||||
<div className={styles.formSection}>
|
||||
<h2>Daftar Petugas</h2>
|
||||
<div className={styles.officerListContainer}>
|
||||
@@ -364,7 +375,9 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */}
|
||||
<FileListComponent
|
||||
organizationId={getActiveOrg().orgId} // ***
|
||||
setTotalFilesSentToday={setTotalFilesSentToday}
|
||||
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
||||
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(() => {
|
||||
|
||||
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";
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
/* ===========================================================
|
||||
LOGIN PAGE
|
||||
=========================================================== */
|
||||
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) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
const authorizedUri = "http://localhost:3000/dashboard?token=";
|
||||
const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const url =
|
||||
`${baseUrl}?modal=${modal}&product_id=${productId}` +
|
||||
`&authorized_uri=${encodeURIComponent(authorizedUri)}` +
|
||||
`&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
|
||||
|
||||
try {
|
||||
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");
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.loginContainer}>
|
||||
<div className={styles.loginBox}>
|
||||
<img src="/ikasapta.png" alt="Logo" className={styles.logo} />
|
||||
<h1 className={styles.h1}>SOLID DATA</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Silakan masuk untuk melanjutkan ke dashboard
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={styles.input}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={styles.input}
|
||||
/>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<button type="submit" className={styles.button}>
|
||||
Login
|
||||
<div className={styles.loginCard}>
|
||||
{/* Logo/Brand */}
|
||||
|
||||
{/* Login Form */}
|
||||
<div className={styles.loginForm}>
|
||||
|
||||
<div className={styles.brandSection}>
|
||||
<div className={styles.logoIcon}>
|
||||
<FileText className={styles.logoIconSvg} />
|
||||
</div>
|
||||
<h1 className={styles.brandTitle}>SOLID DATA</h1>
|
||||
<p className={styles.brandSubtitle}>Kelola data dokumen Anda dengan mudah</p>
|
||||
</div>
|
||||
<button
|
||||
className={styles.loginButton}
|
||||
onClick={login}
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
</form>
|
||||
<div className={styles.footer}>© 2025 Kediri Technopark</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