diff --git a/package-lock.json b/package-lock.json index 634cf4c..6fd4d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2aead23..543361c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.js b/src/App.js index fa71345..fc76cf2 100644 --- a/src/App.js +++ b/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 ( +
+

Selamat datang di Aplikasi Kami

+ {/* Tambahkan konten lainnya sesuai kebutuhan */} +
+ ); +}; // 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 (
- } /> + } /> + } /> + + {/* Halaman pilih organisasi (wajib setelah login) */} + } />} + /> + } /> + } /> + + {/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */} } />} + /> + + {/* Dashboard spesifik organisasi */} + } />} /> - } />} /> + } />} + path="/dashboard/:organization/scan" + element={} />} /> - } /> + + } />} /> + + } />
); diff --git a/src/Dashboard.js b/src/Dashboard.js index 319d589..538a44b 100644 --- a/src/Dashboard.js +++ b/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([]); + // 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 (
@@ -195,9 +223,9 @@ const Dashboard = () => {
Bot Avatar

SOLID

-

- DATA -

+

DATA

+ {/* *** tampilkan nama org aktif */} + {orgName && Org: {orgName}}
@@ -207,16 +235,8 @@ const Dashboard = () => { aria-expanded={isMenuOpen ? "true" : "false"} aria-haspopup="true" > - + @@ -224,15 +244,6 @@ const Dashboard = () => { {isMenuOpen && (
-
-
+

Bulan Ini

{totalFilesSentMonth.toLocaleString()}

@@ -273,7 +284,7 @@ const Dashboard = () => {
- {user.role === "admin" && ( + {user?.role === "admin" && (

Daftar Petugas

@@ -364,7 +375,9 @@ const Dashboard = () => {
+ {/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */} , + 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: , + 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: , + 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 ( +
+ {safeFields.map((f, i) => ( +
+ updateField(i, "key", e.target.value)} + className={styles.fieldInput} + /> + + +
+ ))} + +
+ ); +} + +/* =========================================================== + 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 ( +
+ {/* Header */} +
+
+
+
+ +
+
+

DataScan

+

Data Management System

+
+
+ + {/* Tombol Scans + Logout */} +
+ + + +
+
+
+ + {/* Main */} +
+ {/* Create Data Type Section */} +
+
+

Buat Tipe Data Baru

+

Pilih template atau buat tipe data custom sesuai kebutuhan

+
+ + {/* Template Selection */} +
+

Pilih Template

+
+ {Object.entries(templates).map(([templateName, template]) => ( + + ))} + + {/* Custom Template */} + +
+
+ + {/* Form Section */} + {isFormSectionOpen && ( +
+
+ + setNamaTipe(e.target.value)} + /> +
+ + {/* Fields Section */} +
+ + +
+ + +
+ )} +
+ + {/* Scanned Data List */} +
+
+
+

Data Hasil Scan

+

Daftar semua data yang telah di-scan

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + {loading ? ( + + + + ) : scanned.length === 0 ? ( + + + + ) : ( + scanned.map((row, idx) => ( + + + + + + )) + )} + +
NoTipe DataNama
+
+ + Memuat data... +
+
+ Belum ada data hasil scan +
{idx + 1} + + {resolveType(row)} + + {resolveNama(row)}
+
+
+
+
+ ); +} diff --git a/src/Expetation.js b/src/Expetation.js new file mode 100644 index 0000000..8db4b22 --- /dev/null +++ b/src/Expetation.js @@ -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: , + 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: , + 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: , + 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 ( +
+ {safeFields.map((f, i) => ( +
+ updateField(i, "key", e.target.value)} + style={expectationFormStyles.fieldInput} + /> + + +
+ ))} + +
+ ); +}; + +/* ============================ + 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 ( +
+
+
+

Tambah Jenis Dokumen Baru

+ +
+ +
+
+ {/* Template Selection */} +
+ +
+ {Object.entries(templates).map(([templateName, template]) => ( + + ))} + + {/* Custom Template */} + +
+
+ + {/* Form Section - hanya muncul jika template dipilih */} + {selectedTemplate && ( + <> +
+ + setDocumentName(e.target.value)} + placeholder="Data yang ingin di tambahkan" + style={modalStyles.input} + disabled={isSubmitting} + required + /> +
+ +
+ + +
+ + )} +
+ + {selectedTemplate && ( +
+ + +
+ )} +
+
+
+ ); +}; + +/* ============================ + 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 ( +
+
+
+

Pilih Jenis Dokumen

+ +
+

Silakan pilih jenis dokumen yang akan Anda scan

+ +
+ {loadingDocumentTypes ? ( +
+
+ +
+ ) : ( + <> + + + {documentTypes.map((doc) => { + const displayInfo = getDocumentDisplayInfo(doc); + return ( +
+ + {isEditMode && ( + + )} +
+ ); + })} + + )} +
+
+ + setShowNewDocumentModal(false)} + onSubmit={handleNewDocumentSubmit} + /> +
+ ); +}; + +/* ============================ + 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; diff --git a/src/FileListComponent.js b/src/FileListComponent.js index dfed338..f42f95b 100644 --- a/src/FileListComponent.js +++ b/src/FileListComponent.js @@ -179,7 +179,7 @@ const FileListComponent = ({ } }; - fetchFiles(); + // fetchFiles(); }, []); useEffect(() => { diff --git a/src/KTPScanner.js b/src/KTPScanner.js index 55e777d..c0d5229 100644 --- a/src/KTPScanner.js +++ b/src/KTPScanner.js @@ -1,354 +1,203 @@ import React, { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import PaginatedFormEditable from "./PaginatedFormEditable"; // Import PaginatedFormEditable -import Modal from "./Modal"; // Import Modal +import PaginatedFormEditable from "./PaginatedFormEditable"; +import Modal from "./Modal"; +import Expetation from "./Expetation"; -const STORAGE_KEY = "camera_canvas_gallery"; +const spinnerStyle = ` +@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} } +`; -// Placeholder for PaginatedFormEditable - Removed as it's now imported. -// The actual component definition should be in PaginatedFormEditable.js - -// Custom Modal Component for New Document Type -const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => { - const [documentName, setDocumentName] = useState(""); - const [formFields, setFormFields] = useState([ - { id: crypto.randomUUID(), label: "" }, - ]); - const [isSubmitting, setIsSubmitting] = useState(false); - - useEffect(() => { - if (isOpen) { - setDocumentName(""); - setFormFields([{ id: crypto.randomUUID(), label: "" }]); - } - }, [isOpen]); - - const handleAddField = () => { - setFormFields([...formFields, { id: crypto.randomUUID(), label: "" }]); - }; - - const handleRemoveField = (idToRemove) => { - setFormFields(formFields.filter((field) => field.id !== idToRemove)); - }; - - const handleFieldLabelChange = (id, newLabel) => { - setFormFields( - formFields.map((field) => - field.id === id ? { ...field, label: newLabel } : field - ) - ); - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - if (!documentName.trim()) return; - - const hasEmptyField = formFields.some((field) => !field.label.trim()); - if (hasEmptyField) { - console.log("Please fill all field labels."); - return; - } - - setIsSubmitting(true); - try { - await onSubmit( - documentName.trim(), - formFields.map((field) => ({ label: field.label.trim() })) - ); - setDocumentName(""); - setFormFields([{ id: crypto.randomUUID(), label: "" }]); - onClose(); - } catch (error) { - console.error("Error submitting new document type:", error); - } finally { - setIsSubmitting(false); - } - }; - - if (!isOpen) return null; - - return ( -
-
-
-

Tambah Jenis Dokumen Baru

- -
-
-
- - setDocumentName(e.target.value)} - placeholder="Data yang ingin di tambahkan" - style={modalStyles.input} - disabled={isSubmitting} - required - /> - -

- Define Fields for this Document Type: -

- {formFields.map((field, index) => ( -
- - handleFieldLabelChange(field.id, e.target.value) - } - placeholder={`Field Name ${index + 1}`} - style={modalStyles.fieldInput} - disabled={isSubmitting} - required - /> - {formFields.length > 1 && ( - - )} -
- ))} - -
-
- - -
-
-
-
- ); +const ctaBtn = { + padding: 10, + backgroundColor: "#ef4444", + borderRadius: 15, + color: "white", + fontWeight: "bold", + cursor: "pointer", + marginBottom: "10px", }; -const CameraCanvas = () => { +const styles = { + dashboardHeader: { + backgroundColor: "var(--white)", + color: "var(--text-primary)", + padding: "1rem 1.5rem", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + boxShadow: "var(--shadow-sm)", + borderBottom: "3px solid #43a0a7", + position: "sticky", + top: 0, + zIndex: 50, + backdropFilter: "blur(8px)", + }, + logoAndTitle: { display: "flex", alignItems: "center", gap: "0.75rem", flexShrink: 0 }, + logo: { + width: "2.5rem", + height: "2.5rem", + borderRadius: "0.75rem", + marginRight: "0.75rem", + objectFit: "cover", + }, + h1: { + margin: "2px", + fontSize: "1.5rem", + fontWeight: "700", + color: "#43a0a7", + letterSpacing: "-0.025em", + }, + dropdownContainer: { + position: "relative", + display: "flex", + alignItems: "center", + gap: "0.75rem", + flexShrink: 0, + }, + dropdownToggle: { + backgroundColor: "#f5f5f5", + color: "#0f172a", + border: "1px solid #e2e8f0", + padding: "0.5rem", + borderRadius: "0.5rem", + cursor: "pointer", + fontSize: "1rem", + minWidth: "2.5rem", + height: "2.5rem", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + dropdownMenu: { + position: "absolute", + top: "calc(100% + 0.5rem)", + right: 0, + backgroundColor: "white", + borderRadius: "0.75rem", + boxShadow: + "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)", + border: "1px solid #e2e8f0", + zIndex: 10, + display: "flex", + flexDirection: "column", + minWidth: "10rem", + overflow: "hidden", + padding: "0.5rem", + marginTop: "0.5rem", + }, + dropdownItem: { + display: "block", + width: "100%", + padding: "0.75rem 1rem", + border: "none", + backgroundColor: "transparent", + textAlign: "left", + cursor: "pointer", + fontSize: "0.875rem", + color: "#0f172a", + transition: "background-color 0.2s ease", + borderRadius: "0.5rem", + marginBottom: "0.125rem", + }, + backButton: { + backgroundColor: "#6c757d", + color: "white", + padding: "10px 15px", + borderRadius: "8px", + border: "none", + fontSize: "14px", + fontWeight: "bold", + cursor: "pointer", + marginBottom: "15px", + width: "100%", + }, + 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", + }, +}; + +/* ============================ + ORG & AUTH HELPERS +============================ */ +const getCleanToken = () => { + let raw = localStorage.getItem("token") || ""; + try { raw = JSON.parse(raw); } catch {} + return String(raw).replace(/^"+|"+$/g, ""); +}; + +// Baca org dari localStorage: pake 'selected_organization' dulu, 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 / dari 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 umum (JANGAN set Content-Type untuk FormData) +const authHeaders = ({ isJson = false } = {}) => { + const token = getCleanToken(); + const orgId = getActiveOrgId(); + const base = { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(orgId ? { "X-Organization-Id": orgId } : {}), + }; + return isJson ? { "Content-Type": "application/json", ...base } : base; +}; + +const KTPScanner = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); const navigate = useNavigate(); + const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + window.location.reload(); + }; + const videoRef = useRef(null); const canvasRef = useRef(null); const hiddenCanvasRef = useRef(null); const [capturedImage, setCapturedImage] = useState(null); - const [galleryImages, setGalleryImages] = useState([]); const [fileTemp, setFileTemp] = useState(null); const [isFreeze, setIsFreeze] = useState(false); const freezeFrameRef = useRef(null); const [loading, setLoading] = useState(false); - const [KTPdetected, setKTPdetected] = useState(false); const [showDocumentSelection, setShowDocumentSelection] = useState(true); + // selectedDocumentType menyimpan OBJEK dokumen (dari Expetation), termasuk expectation const [selectedDocumentType, setSelectedDocumentType] = useState(null); const [cameraInitialized, setCameraInitialized] = useState(false); - const [showNewDocumentModal, setShowNewDocumentModal] = useState(false); - const [documentTypes, setDocumentTypes] = useState([]); - const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true); - const [isEditMode, setIsEditMode] = useState(false); // New state for edit mode - // NEW STATES - Added from code 2 const [isScanned, setIsScanned] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(false); - const [modalOpen, setModalOpen] = useState(false); // Added from code 2 - - const handleDeleteDocumentType = async (id, documentType) => { - if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${documentType}"?`)) { - try { - const token = localStorage.getItem("token"); - const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-document-type", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ id, document_type: documentType }), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const result = await response.json(); - console.log("Delete response:", result); - - // Check for 'success' property from the server response - if (result.success) { - setDocumentTypes(prevTypes => prevTypes.filter(doc => doc.id !== id)); - alert(`Dokumen tipe "${documentType}" berhasil dihapus.`); - } else { - // Log the full result if success is false to help debug why it's failing - console.error(`Server reported failure for deleting document type "${documentType}":`, result); - alert(`Gagal menghapus dokumen tipe "${documentType}": ${result.message || "Respon tidak menunjukkan keberhasilan."}`); - } - } catch (error) { - console.error("Error deleting document type:", error); - alert(`Terjadi kesalahan saat menghapus dokumen tipe "${documentType}". Detail: ${error.message}`); - } finally { - // Ensure edit mode is exited after a delete attempt - setIsEditMode(false); - } - } - }; - - useEffect(() => { - const fetchDocumentTypes = async () => { - try { - setLoadingDocumentTypes(true); - const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/show"); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - const activeDocumentTypes = data.filter(doc => doc.document_type !== "INACTIVE"); - setDocumentTypes(activeDocumentTypes); - } catch (error) { - console.error("Error fetching document types:", error); - // Optionally handle error display to user - } finally { - setLoadingDocumentTypes(false); - } - }; - - fetchDocumentTypes(); - }, []); - - const handleDocumentTypeSelection = (type) => { - if (type === "new") { - setShowNewDocumentModal(true); - } else { - setSelectedDocumentType(type); - setShowDocumentSelection(false); - initializeCamera(); - } - }; + const [modalOpen, setModalOpen] = useState(false); const fileInputRef = useRef(null); + const triggerFileSelect = () => fileInputRef.current?.click(); - const triggerFileSelect = () => { - fileInputRef.current?.click(); - }; - - const handleNewDocumentSubmit = async (documentName, fields) => { - try { - const token = localStorage.getItem("token"); - - // Construct the prompt based on fields - const fieldJson = fields.map(field => ` "${field.label.toLowerCase().replace(/\s+/g, '_')}": "string"`).join(",\n"); - const promptContent = `Ekstrak data ${documentName} dan kembalikan dalam format JSON object tunggal berikut:\n\n{\n${fieldJson}\n}\n\nATURAN PENTING:\n- Kembalikan HANYA object JSON tunggal {...}, BUKAN array [{...}]\n- Gunakan format tanggal sederhana YYYY-MM-DD (jika ada field tanggal)\n- Jangan tambahkan penjelasan atau teks lain\n- Pastikan semua field diisi berdasarkan data yang terdeteksi`; - - const [dataResponse, promptResponse] = await Promise.all([ - fetch("https://bot.kediritechnopark.com/webhook/solid-data/newtype-data", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - document_type: documentName, - fields: fields, - }), - }), - fetch("https://bot.kediritechnopark.com/webhook/solid-data/newtype-prompt", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - document_type: documentName, - prompt: promptContent, - }), - }), - ]); - - const dataResult = await dataResponse.json(); - const promptResult = await promptResponse.json(); - - console.log("Server response for newtype-data:", dataResult); - console.log("Server response for newtype-prompt:", promptResult); - - // Re-fetch document types to update the list, regardless of success or failure - const fetchDocumentTypes = async () => { - try { - setLoadingDocumentTypes(true); - const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/show"); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - const activeDocumentTypes = data.filter(doc => doc.document_type !== "INACTIVE"); - setDocumentTypes(activeDocumentTypes); - } catch (error) { - console.error("Error re-fetching document types:", error); - } finally { - setLoadingDocumentTypes(false); - } - }; - await fetchDocumentTypes(); // Re-fetch after creation attempt - - // Always show success notification as requested - alert(`Dokumen tipe "${documentName}" berhasil dibuat (atau percobaan pembuatan selesai).`); - - // The following states and onClose should be handled by NewDocumentModal's handleSubmit - // setSelectedDocumentType( - // documentName.toLowerCase().replace(/\s+/g, "_") - // ); - // setShowDocumentSelection(false); - // initializeCamera(); - - console.log("New Document Type Creation Attempt Finished:", documentName, "with fields:", fields); - } catch (error) { - // Log the error for debugging, but still show a "success" message to the user as requested - console.error("Error submitting new document type:", error); - alert(`Dokumen tipe "${documentName}" berhasil dibuat (atau percobaan pembuatan selesai).`); // Still show success as requested - } - // Removed the finally block from here, as state resets and onClose belong to NewDocumentModal - }; - - const rectRef = useRef({ - x: 0, - y: 0, - width: 0, - height: 0, - radius: 20, - }); + const rectRef = useRef({ x: 0, y: 0, width: 0, height: 0, radius: 20 }); const drawRoundedRect = (ctx, x, y, width, height, radius) => { ctx.beginPath(); @@ -417,13 +266,7 @@ const CameraCanvas = () => { const rectX = (canvas.width - rectWidth) / 2; const rectY = (canvas.height - rectHeight) / 2; - rectRef.current = { - x: rectX, - y: rectY, - width: rectWidth, - height: rectHeight, - radius: 20, - }; + rectRef.current = { x: rectX, y: rectY, width: rectWidth, height: rectHeight, radius: 20 }; const drawToCanvas = () => { if (video.readyState === 4) { @@ -441,18 +284,9 @@ const CameraCanvas = () => { rectRef.current.height, rectRef.current.radius ); - if (isFreeze) { - fillOutsideRect( - ctx, - rectRef.current, - canvas.width, - canvas.height - ); - } - } - if (!showDocumentSelection) { - requestAnimationFrame(drawToCanvas); + if (isFreeze) fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); } + if (!showDocumentSelection) requestAnimationFrame(drawToCanvas); }; drawToCanvas(); @@ -465,8 +299,13 @@ const CameraCanvas = () => { }; useEffect(() => { - const savedGallery = localStorage.getItem(STORAGE_KEY); - if (savedGallery) setGalleryImages(JSON.parse(savedGallery)); + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); useEffect(() => { @@ -491,18 +330,12 @@ const CameraCanvas = () => { rectRef.current.height, rectRef.current.radius ); - if (isFreeze) { - fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); - } - } - if (!showDocumentSelection) { - requestAnimationFrame(drawToCanvas); + if (isFreeze) fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); } + if (!showDocumentSelection) requestAnimationFrame(drawToCanvas); }; - if (!showDocumentSelection) { - drawToCanvas(); - } + if (!showDocumentSelection) drawToCanvas(); } }, [isFreeze, cameraInitialized, showDocumentSelection]); @@ -513,12 +346,7 @@ const CameraCanvas = () => { const hiddenCtx = hiddenCanvas.getContext("2d"); const visibleCtx = canvasRef.current.getContext("2d"); - freezeFrameRef.current = visibleCtx.getImageData( - 0, - 0, - canvasRef.current.width, - canvasRef.current.height - ); + freezeFrameRef.current = visibleCtx.getImageData(0, 0, canvasRef.current.width, canvasRef.current.height); setIsFreeze(true); setLoading(true); @@ -544,7 +372,6 @@ const CameraCanvas = () => { const imageDataUrl = cropCanvas.toDataURL("image/png", 1.0); setCapturedImage(imageDataUrl); - setKTPdetected(true); setLoading(false); }; @@ -554,33 +381,38 @@ const CameraCanvas = () => { const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); - while (n--) { - u8arr[n] = bstr.charCodeAt(n); - } + while (n--) u8arr[n] = bstr.charCodeAt(n); return new File([u8arr], fileName, { type: mime }); } - // MODIFIED ReadImage function - Updated to match code 2's approach + // Scan (kirim image + expectation + organization_id) const ReadImage = async (capturedImage) => { try { setLoading(true); - const token = localStorage.getItem("token"); - + const token = getCleanToken(); + const orgId = getActiveOrgId(); const file = base64ToFile(capturedImage, "image.jpg"); const formData = new FormData(); formData.append("image", file); - // Re-added document_type to formData as per user's request - formData.append("document_type", selectedDocumentType); - // FIXED: Use the same endpoint as code 2 for consistent data processing + // Kirim expectation (bukan sekadar document_type) + const expectation = selectedDocumentType?.expectation || {}; + formData.append("expectation", JSON.stringify(expectation)); + + // (opsional) jika backend masih butuh identifier tipe + if (selectedDocumentType?.document_type) { + formData.append("document_type", selectedDocumentType.document_type); + } + + // >>> penting: sertakan organization_id + if (orgId) formData.append("organization_id", orgId); + const res = await fetch( - "https://bot.kediritechnopark.com/webhook/solid-data/scan", // Changed to solid-data/scan + "https://bot.kediritechnopark.com/webhook/solid-data/scan", { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: authHeaders(), // JANGAN set Content-Type (biar FormData yang atur) body: formData, } ); @@ -588,107 +420,73 @@ const CameraCanvas = () => { setLoading(false); const data = await res.json(); - if (data.responseCode == 409) { - console.log(409); // Changed log message to match user's working snippet + if (data.responseCode === 409) { setFileTemp({ error: 409 }); - setIsScanned(true); // Added from code 2 + setIsScanned(true); return; } - console.log(data); // Changed log message to match user's working snippet - setFileTemp(data); - setIsScanned(true); // Added from code 2 - Hide review buttons after scan + setIsScanned(true); } catch (error) { console.error("Failed to read image:", error); - setIsScanned(true); // Added from code 2 - Hide buttons even on error + setIsScanned(true); } }; - // MODIFIED handleSaveTemp function - Updated to match code 2's approach - const handleSaveTemp = async (verifiedData, documentType) => { // Re-added documentType parameter + // SAVE (tambahkan organization_id) + const handleSaveTemp = async (verifiedData, documentType) => { try { setLoading(true); - const token = localStorage.getItem("token"); + const token = getCleanToken(); + const orgId = getActiveOrgId(); const formData = new FormData(); formData.append("data", JSON.stringify(verifiedData)); - formData.append("document_type", documentType); // Re-added document_type to formData + formData.append("document_type", documentType || ""); - // Use the same endpoint as code 2 for consistent saving - const res = await fetch( - "https://bot.kediritechnopark.com/webhook/solid-data/save", // Changed to solid-data/save - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - // Jangan set Content-Type secara manual untuk FormData - }, - body: formData, - } - ); + // >>> penting: sertakan organization_id + if (orgId) formData.append("organization_id", orgId); + + await fetch("https://bot.kediritechnopark.com/webhook/solid-data/save", { + method: "POST", + headers: authHeaders(), // Authorization + X-Organization-Id + body: formData, + }); setLoading(false); - // Removed result parsing as it's not in the user's working snippet for this part - // const result = await res.json(); - // console.log("Save Result:", result); - - // if (res.ok && result.status) { // Removed conditional check - // SUCCESS HANDLING - Added from code 2 - setFileTemp(null); - setShowSuccessMessage(true); // Show success message + setFileTemp(null); + setShowSuccessMessage(true); - // Hide success message after 3 seconds and reset states - setTimeout(() => { - setShowSuccessMessage(false); - setIsFreeze(false); - setIsScanned(false); - setCapturedImage(null); - // setKTPdetected(false); // Removed as it's not in the user's working snippet - // Optionally go back to selection or reset for new scan - // goBackToSelection(); - }, 3000); - // } else { // Removed else block - // console.error( - // "Failed to save data:", - // result.message || "Unknown error" - // ); - // } + setTimeout(() => { + setShowSuccessMessage(false); + setIsFreeze(false); + setIsScanned(false); + setCapturedImage(null); + }, 3000); } catch (err) { console.error("Gagal menyimpan ke server:", err); setLoading(false); } }; + // DELETE temp (sertakan organization_id) const handleDeleteTemp = async () => { try { - // Aligned with user's working snippet for delete - await fetch( - "https://bot.kediritechnopark.com/webhook/solid-data/delete", // Changed to solid-data/delete - { - method: "POST", // User's snippet uses POST for delete - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileTemp }), // User's snippet sends fileTemp as body - } - ); - + const orgId = getActiveOrgId(); + await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete", { + method: "POST", + headers: authHeaders({ isJson: true }), + body: JSON.stringify({ + fileTemp, + ...(orgId ? { organization_id: orgId } : {}), + }), + }); setFileTemp(null); - // Removed setIsFreeze, setCapturedImage, setIsScanned, setShowSuccessMessage as they are handled by handleHapus - // setIsFreeze(false); - // setCapturedImage(null); - // setIsScanned(false); - // setShowSuccessMessage(false); } catch (err) { console.error("Gagal menghapus dari server:", err); } }; - const removeImage = (index) => { - const newGallery = [...galleryImages]; - newGallery.splice(index, 1); - setGalleryImages(newGallery); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newGallery)); - }; - const handleManualUpload = async (e) => { const file = e.target.files[0]; if (!file) return; @@ -717,15 +515,9 @@ const CameraCanvas = () => { rectRef.current.height, rectRef.current.radius ); - fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); - freezeFrameRef.current = ctx.getImageData( - 0, - 0, - canvas.width, - canvas.height - ); + freezeFrameRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); const cropCanvas = document.createElement("canvas"); cropCanvas.width = rectWidth; @@ -743,8 +535,6 @@ const CameraCanvas = () => { rectWidth, rectHeight ); - - setKTPdetected(true); }; image.src = imageDataUrl; }; @@ -758,9 +548,8 @@ const CameraCanvas = () => { setIsFreeze(false); setCapturedImage(null); setFileTemp(null); - setKTPdetected(false); - setIsScanned(false); // Added from code 2 - setShowSuccessMessage(false); // Added from code 2 + setIsScanned(false); + setShowSuccessMessage(false); if (videoRef.current && videoRef.current.srcObject) { const stream = videoRef.current.srcObject; @@ -770,15 +559,12 @@ const CameraCanvas = () => { } }; - // NEW FUNCTION - Added from code 2 const handleHapus = () => { setFileTemp(null); setIsFreeze(false); setIsScanned(false); setCapturedImage(null); setShowSuccessMessage(false); - setKTPdetected(false); - // Also stop camera stream if active - Added from user's working snippet if (videoRef.current && videoRef.current.srcObject) { const stream = videoRef.current.srcObject; const tracks = stream.getTracks(); @@ -787,37 +573,27 @@ const CameraCanvas = () => { } }; - const getDocumentDisplayInfo = (docType) => { - const foundDoc = documentTypes.find(doc => doc.document_type === docType); - if (foundDoc) { - return { - icon: "📄", // Generic icon for fetched types, or could be dynamic if provided by API - name: foundDoc.document_type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()), - fullName: foundDoc.document_type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()), - }; - } - - switch (docType) { - case "new": - return { icon: "✨", name: "New Document", fullName: "Dokumen Baru" }; - default: - return { - icon: "📄", - name: docType.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()), - fullName: docType.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()), - }; - } + // selection callback from Expetation (menerima OBJEK dokumen) + const handleSelectDocumentType = (doc) => { + setSelectedDocumentType(doc); + setShowDocumentSelection(false); + initializeCamera(); }; + useEffect(() => { + const video = videoRef.current; + return () => { + if (video && video.srcObject) { + video.srcObject.getTracks().forEach((t) => t.stop()); + } + }; + }, []); + return (
- Bot Avatar + Bot Avatar

SOLID

DATA

@@ -846,15 +622,6 @@ const CameraCanvas = () => { {isMenuOpen && (
- +
)}
{showDocumentSelection ? ( -
-
-
{/* New div for header */} -

Pilih Jenis Dokumen

- -
-

- Silakan pilih jenis dokumen yang akan Anda scan -

- -
- {loadingDocumentTypes ? ( -
-
- -
- ) : ( - <> - - {documentTypes.map((doc) => { - const displayInfo = getDocumentDisplayInfo(doc.document_type); - return ( -
{/* Wrapper for card and delete icon */} - - {isEditMode && ( - - )} -
- ); - })} - - )} -
-
-
+ ) : ( <> -