This commit is contained in:
Vassshhh
2025-08-21 11:03:40 +07:00
parent e30b1a8de8
commit 28c4c4d66b
14 changed files with 2863 additions and 1282 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);

View File

@@ -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([]);
// 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 };
};
// 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");
if (!token) {
window.location.href = "/login";
}
}, []);
const { orgId } = getActiveOrg();
if (!token) {
navigate("/login");
return;
}
if (!orgId) {
navigate("/pick-organization");
return;
}
// 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");
if (!token) {
window.location.href = "/login";
return;
}
const { orgId } = getActiveOrg();
if (!token || !orgId) return;
try {
const response = await fetch(
"https://bot.kediritechnopark.com/webhook/solid-data/dashboard",
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}
// 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",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
username,
password,
}),
}
);
const res = await fetch(`${API_BASE}/add-officer`, {
method: "POST",
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");
@@ -262,7 +273,7 @@ const Dashboard = () => {
<h3>Hari Ini</h3>
<p>{totalFilesSentToday.toLocaleString()}</p>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryCard}>
<h3>Bulan Ini</h3>
<p>{totalFilesSentMonth.toLocaleString()}</p>
</div>
@@ -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
View 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
View 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;

View File

@@ -179,7 +179,7 @@ const FileListComponent = ({
}
};
fetchFiles();
// fetchFiles();
}, []);
useEffect(() => {

File diff suppressed because it is too large Load Diff

View File

@@ -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}>&copy; 2025 Kediri Technopark</div>
</div>
</div>
</div>
);
};
export default Login;
}

File diff suppressed because it is too large Load Diff

163
src/PickOrganization.js Normal file
View 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>
);
}

View 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
View File

30
src/SuccessPage.js Normal file
View 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;