- {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}
+
+ )}
+
+
+
+
+
+ | No |
+ Tipe Data |
+ Nama |
+
+
+
+ {loading ? (
+
+ |
+
+
+ Memuat data...
+
+ |
+
+ ) : scanned.length === 0 ? (
+
+ |
+ Belum ada data hasil scan
+ |
+
+ ) : (
+ scanned.map((row, idx) => (
+
+ | {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
+
+
+
+
+
+
+ );
+};
+
+/* ============================
+ 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
-
-
-
-
-
- );
+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 (
-

+
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 && (
-
- )}
-
- );
- })}
- >
- )}
-
-
-
+
) : (
<>
-
-
+
+
{
← Kembali ke Pilihan Dokumen
- {/* SUCCESS MESSAGE - Added from code 2 */}
{showSuccessMessage ? (
{
) : !isFreeze ? (
<>
-
-
+
+
Ambil Gambar
-
@@ -1025,50 +704,32 @@ const CameraCanvas = () => {
) : (
- capturedImage &&
- (!fileTemp || fileTemp.error == undefined) &&
- !isScanned && ( // MODIFIED: Hide when isScanned is true (from code 2)
+ capturedImage && (!fileTemp || fileTemp.error === undefined) && !isScanned && (
Tinjau Gambar
-
ReadImage(capturedImage)}
- >
+
ReadImage(capturedImage)}>
Scan
-
+
Hapus
)
)}
-
- {/* DATA DISPLAY SECTION - Updated to match code 2's approach */}
- {fileTemp && fileTemp.error != "409" ? (
+
+ {fileTemp && fileTemp.error !== "409" ? (
handleSaveTemp(data, selectedDocumentType)} // Re-added selectedDocumentType
+ handleSimpan={(data) =>
+ handleSaveTemp(data, selectedDocumentType?.document_type || "")
+ }
/>
) : (
fileTemp && (
<>
- KTP Sudah Terdaftar
{/* Changed text to match user's working snippet */}
-
+ KTP Sudah Terdaftar
+
Hapus
>
@@ -1078,13 +739,6 @@ const CameraCanvas = () => {
>
)}
- setShowNewDocumentModal(false)}
- onSubmit={handleNewDocumentSubmit}
- />
-
- {/* Modal component from user's working snippet */}
setModalOpen(false)}
@@ -1097,380 +751,4 @@ const CameraCanvas = () => {
);
};
-const spinnerStyle = `
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-`;
-
-// Modal styles
-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: "400px",
- boxShadow: "0 10px 25px rgba(0, 0, 0, 0.2)",
- maxHeight: "80vh",
- 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: "#666",
- padding: "0",
- width: "30px",
- height: "30px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- },
- content: {
- padding: "0 20px 20px 20px",
- },
- 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",
- },
- formFieldRow: {
- display: "flex",
- alignItems: "center",
- marginBottom: "10px",
- gap: "10px",
- },
- fieldInput: {
- flexGrow: 1,
- padding: "10px",
- border: "1px solid #ddd",
- borderRadius: "8px",
- fontSize: "15px",
- boxSizing: "border-box",
- },
- removeFieldButton: {
- background: "#dc3545",
- color: "white",
- border: "none",
- borderRadius: "50%",
- width: "30px",
- height: "30px",
- fontSize: "20px",
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- cursor: "pointer",
- flexShrink: 0,
- },
- addFieldButton: {
- background: "#007bff",
- color: "white",
- border: "none",
- borderRadius: "8px",
- padding: "10px 15px",
- fontSize: "15px",
- cursor: "pointer",
- marginTop: "10px",
- width: "100%",
- },
- footer: {
- display: "flex",
- gap: "10px",
- padding: "20px",
- borderTop: "1px solid #e9ecef",
- },
- cancelButton: {
- flex: 1,
- padding: "12px",
- border: "2px solid #e9ecef",
- borderRadius: "8px",
- backgroundColor: "white",
- color: "#666",
- cursor: "pointer",
- fontSize: "16px",
- fontWeight: "bold",
- },
- submitButton: {
- flex: 1,
- padding: "12px",
- border: "none",
- borderRadius: "8px",
- backgroundColor: "#429241",
- color: "white",
- cursor: "pointer",
- fontSize: "16px",
- fontWeight: "bold",
- },
-};
-
-const styles = {
- dashboardHeader: {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- padding: "15px 20px",
- backgroundColor: "#f8f9fa",
- borderBottom: "1px solid #e9ecef",
- boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
- },
- logoAndTitle: {
- display: "flex",
- alignItems: "center",
- gap: "10px",
- },
- logo: {
- width: "40px",
- height: "40px",
- borderRadius: "8px",
- },
- h1: {
- margin: 0,
- fontSize: "24px",
- fontWeight: "bold",
- color: "#333",
- },
- dropdownContainer: {
- position: "relative",
- },
- dropdownToggle: {
- background: "none",
- border: "none",
- cursor: "pointer",
- padding: "8px",
- borderRadius: "8px",
- transition: "background-color 0.2s",
- },
- dropdownMenu: {
- position: "absolute",
- top: "100%",
- right: 0,
- backgroundColor: "white",
- borderRadius: "8px",
- boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
- minWidth: "120px",
- zIndex: 100,
- marginTop: "10px",
- overflow: "hidden",
- },
- dropdownItem: {
- display: "block",
- width: "100%",
- padding: "10px 15px",
- border: "none",
- backgroundColor: "transparent",
- textAlign: "left",
- cursor: "pointer",
- fontSize: "16px",
- color: "#333",
- transition: "background-color 0.2s",
- },
- 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%",
- },
- 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",
- },
- 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",
- },
- saveHeader: {
- backgroundColor: "#e0f7fa",
- borderRadius: "12px",
- padding: "15px",
- marginBottom: "20px",
- display: "flex",
- alignItems: "center",
- gap: "15px",
- },
- saveHeaderContent: {
- display: "flex",
- alignItems: "center",
- gap: "15px",
- },
- saveHeaderIcon: {
- fontSize: "30px",
- },
- saveHeaderText: {
- textAlign: "left",
- },
- saveHeaderTitle: {
- fontSize: "18px",
- fontWeight: "bold",
- color: "#212529",
- },
- saveHeaderSubtitle: {
- fontSize: "14px",
- color: "#495057",
- },
- selectionHeader: {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "10px",
- },
- editButton: {
- backgroundColor: "#007bff",
- color: "white",
- padding: "8px 15px",
- borderRadius: "8px",
- border: "none",
- fontSize: "14px",
- fontWeight: "bold",
- cursor: "pointer",
- transition: "background-color 0.2s",
- },
- 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,
- },
-};
-
-export default CameraCanvas;
+export default KTPScanner;
diff --git a/src/Login.js b/src/Login.js
index 80914da..544f9f9 100644
--- a/src/Login.js
+++ b/src/Login.js
@@ -1,84 +1,53 @@
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
+import {
+ User, Eye, EyeOff, Plus, X, RefreshCw, FileText, Users, Baby, Settings, LogOut, Camera
+} from "lucide-react";
import styles from "./Login.module.css";
-const Login = () => {
- const [formData, setFormData] = useState({
- username: "",
- password: "",
- });
+/* ===========================================================
+ LOGIN PAGE
+ =========================================================== */
+export default function LoginPage({ onLoggedIn }) {
- const [error, setError] = useState("");
+ const login = () => {
+ const baseUrl = "http://localhost:3001/";
+ const modal = "product";
+ const productId = 9;
- const handleChange = (e) => {
- setFormData({ ...formData, [e.target.name]: e.target.value });
- };
+ const authorizedUri = "http://localhost:3000/dashboard?token=";
+ const unauthorizedUri = `${baseUrl}?modal=${modal}&product_id=${productId}`;
- const handleSubmit = async (e) => {
- e.preventDefault();
+ const url =
+ `${baseUrl}?modal=${modal}&product_id=${productId}` +
+ `&authorized_uri=${encodeURIComponent(authorizedUri)}` +
+ `&unauthorized_uri=${encodeURIComponent(unauthorizedUri)}`;
- try {
- const loginResponse = await fetch(
- "https://bot.kediritechnopark.com/webhook/solid-data/login",
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(formData),
- }
- );
-
- const loginDataRaw = await loginResponse.json();
- const loginData = Array.isArray(loginDataRaw)
- ? loginDataRaw[0]
- : loginDataRaw;
-
- if (loginData?.success && loginData?.token) {
- localStorage.setItem("token", loginData.token);
- window.location.href = "/";
- } else {
- setError(loginData?.message || "Username atau password salah");
- }
- } catch (err) {
- console.error("Login Error:", err);
- setError("Gagal terhubung ke server");
- }
+ window.location.href = url;
};
return (
-
-

-
SOLID DATA
-
- Silakan masuk untuk melanjutkan ke dashboard
-
-
);
-};
-
-export default Login;
+}
\ No newline at end of file
diff --git a/src/Login.module.css b/src/Login.module.css
index b2a6db5..05d7820 100644
--- a/src/Login.module.css
+++ b/src/Login.module.css
@@ -1,85 +1,959 @@
-.loginContainer {
- font-family: "Inter", sans-serif;
- background-color: #f0f5ff;
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100vh;
- margin: 0;
-}
-
-.loginBox {
- background-color: #ffffff;
- border-radius: 16px;
- padding: 40px;
- box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
- width: 100%;
- max-width: 400px;
- text-align: center;
-}
-
-.logo {
- width: 80px;
- height: auto;
- margin-bottom: 20px;
-}
-
-.h1 {
- font-size: 28px;
- font-weight: 700;
- margin-bottom: 10px;
- color: #43a0a7;
-}
-.subtitle {
- font-size: 14px;
- color: #6b7280;
- margin-bottom: 20px;
-}
-
-.form {
- display: flex;
- flex-direction: column;
- gap: 15px;
- text-align: left;
-}
-
-.input {
- width: 100%;
- padding: 12px 15px;
- font-size: 16px;
- color: #1f2937;
- background-color: #f8f9fa;
- border: 1px solid #d1d5db;
- border-radius: 8px;
+/* ===== GLOBAL STYLES ===== */
+* {
box-sizing: border-box;
}
-.button {
- background-color: #43a0a7;
- color: #ffffff;
- padding: 12px 24px;
- border-radius: 24px;
- font-size: 18px;
- font-weight: 600;
- cursor: pointer;
- border: none;
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+}
+
+/* ===== LOGIN PAGE STYLES ===== */
+.loginContainer {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #f8fafc 0%, #e0f2fe 50%, #e0e7ff 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.loginCard {
width: 100%;
- transition: background-color 0.3s;
+ max-width: 28rem;
}
-.button:hover {
- background-color: #357734; /* darker shade of #43a0a7 */
+.brandSection {
+ text-align: center;
}
-.error {
- color: red;
- font-size: 14px;
- text-align: left;
- margin-top: -10px;
+.logoIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 4rem;
+ height: 4rem;
+ background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
+ border-radius: 1rem;
+ margin-bottom: 1rem;
}
-.footer {
- margin-top: 30px;
- font-size: 12px;
+.logoIconSvg {
+ width: 2rem;
+ height: 2rem;
+ color: white;
+}
+
+.brandTitle {
+ font-size: 1.875rem;
+ font-weight: 700;
+ color: #111827;
+ margin: 0 0 0.5rem 0;
+}
+
+.brandSubtitle {
+ color: #6b7280;
+ margin: 0;
+ font-size: 0.875rem;
+}
+
+.loginForm {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px);
+ border-radius: 1.5rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.formGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.formLabel {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+.inputWithIcon {
+ position: relative;
+}
+
+.inputIcon {
+ position: absolute;
+ left: 0.75rem;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 1.25rem;
+ height: 1.25rem;
color: #9ca3af;
}
+
+.passwordInput {
+ position: relative;
+}
+
+.inputField {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ background: rgba(249, 250, 251, 0.5);
+ border: 1px solid #d1d5db;
+ border-radius: 0.75rem;
+ outline: none;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+}
+
+.inputWithIcon .inputField {
+ padding-left: 3rem;
+}
+
+.inputField:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.passwordToggle {
+ position: absolute;
+ right: 0.75rem;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #9ca3af;
+ transition: color 0.2s ease;
+ padding: 0.25rem;
+}
+
+.passwordToggle:hover {
+ color: #6b7280;
+}
+
+.toggleIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.loginButton {
+ width: 100%;
+ background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
+ color: white;
+ padding: 0.75rem 1rem;
+ border-radius: 0.75rem;
+ border: none;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+}
+
+.loginButton:hover:not(:disabled) {
+ background: linear-gradient(135deg, #1d4ed8 0%, #4338ca 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.4);
+}
+
+.loginButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.clearButton {
+ width: 100%;
+ color: #6b7280;
+ background: none;
+ border: none;
+ padding: 0.5rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 0.2s ease;
+ font-size: 0.875rem;
+}
+
+.clearButton:hover {
+ color: #374151;
+}
+
+/* ===== DATA TYPE PAGE STYLES ===== */
+.dataTypePage {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #f8fafc 0%, #e0f2fe 50%, #e0e7ff 100%);
+}
+
+.header {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.headerContent {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 1rem 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.headerBrand {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.headerLogo {
+ width: 2.5rem;
+ height: 2.5rem;
+ background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
+ border-radius: 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.headerLogoIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ color: white;
+}
+
+.headerInfo {
+ display: flex;
+ flex-direction: column;
+}
+
+.headerTitle {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #111827;
+ margin: 0;
+ line-height: 1.2;
+}
+
+.headerSubtitle {
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin: 0;
+}
+
+.headerActions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.logoutButton {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ color: #6b7280;
+ background: none;
+ border: none;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.logoutButton:hover {
+ color: #374151;
+ background: rgba(107, 114, 128, 0.1);
+}
+
+.logoutIcon {
+ width: 1rem;
+ height: 1rem;
+}
+
+.logoutText {
+ font-size: 0.875rem;
+}
+
+.mainContent {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+/* ===== CREATE SECTION ===== */
+.createSection {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px);
+ border-radius: 1rem;
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 2rem;
+}
+
+.sectionHeader {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+.sectionTitle {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #111827;
+ margin: 0 0 0.5rem 0;
+}
+
+.sectionSubtitle {
+ color: #6b7280;
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.templateSection {
+ margin-bottom: 2rem;
+}
+
+.templateTitle {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #374151;
+ margin: 0 0 1rem 0;
+}
+
+.templateGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.templateCard {
+ padding: 1.5rem;
+ border-radius: 0.75rem;
+ border: 2px solid #e5e7eb;
+ background: white;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-align: center;
+}
+
+.templateCard:hover {
+ border-color: #d1d5db;
+ box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.1);
+ transform: translateY(-2px);
+}
+
+.templateCardActive {
+ border-color: #3b82f6;
+ background: #eff6ff;
+ box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.25);
+}
+
+.customTemplateCard:hover {
+ border-color: #a855f7;
+}
+
+.customTemplateActive {
+ border-color: #a855f7;
+ background: #faf5ff;
+}
+
+.templateContent {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.templateIconContainer {
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ background: #f3f4f6;
+ color: #6b7280;
+ transition: all 0.2s ease;
+}
+
+.templateIconActive {
+ background: #dbeafe;
+ color: #2563eb;
+}
+
+.customIconActive {
+ background: #f3e8ff;
+ color: #a855f7;
+}
+
+.templateIcon {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+.templateName {
+ font-weight: 500;
+ color: #374151;
+ font-size: 0.875rem;
+}
+
+/* ===== FORM SECTION ===== */
+.formSection {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.fieldsPreview {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fieldPreview {
+ padding: 0.75rem 1rem;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+.submitButton {
+ width: 100%;
+ background: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
+ color: white;
+ padding: 0.75rem 1rem;
+ border-radius: 0.75rem;
+ border: none;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.submitButton:hover:not(:disabled) {
+ background: linear-gradient(135deg, #1d4ed8 0%, #4338ca 100%);
+ transform: translateY(-1px);
+ box-shadow: 0 10px 20px -5px rgba(59, 130, 246, 0.4);
+}
+
+.submitButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ===== EXPECTATION FORM ===== */
+.expectationForm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fieldRow {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+}
+
+.fieldInput,
+.fieldSelect {
+ padding: 0.5rem 0.75rem;
+ background: #f9fafb;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ outline: none;
+ transition: all 0.2s ease;
+ font-size: 0.875rem;
+ min-height: 2.5rem;
+}
+
+.fieldInput {
+ flex: 1;
+}
+
+.fieldSelect {
+ min-width: 120px;
+}
+
+.fieldInput:focus,
+.fieldSelect:focus {
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.removeFieldButton {
+ padding: 0.5rem;
+ color: #ef4444;
+ background: none;
+ border: none;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-height: 2.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.removeFieldButton:hover {
+ color: #dc2626;
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.removeIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.addFieldButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ color: #2563eb;
+ background: none;
+ border: 2px dashed #d1d5db;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+ font-size: 0.875rem;
+}
+
+.addFieldButton:hover {
+ color: #1d4ed8;
+ background: rgba(37, 99, 235, 0.05);
+ border-color: #2563eb;
+}
+
+.addIcon {
+ width: 1rem;
+ height: 1rem;
+}
+
+/* ===== DATA SECTION ===== */
+.dataSection {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px);
+ border-radius: 1rem;
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 2rem;
+}
+
+.dataHeader {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 1.5rem;
+ gap: 1rem;
+}
+
+.dataHeaderInfo {
+ display: flex;
+ flex-direction: column;
+}
+
+.dataTitle {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #111827;
+ margin: 0;
+}
+
+.dataSubtitle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ margin: 0.25rem 0 0 0;
+ line-height: 1.4;
+}
+
+.refreshButton {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: #dbeafe;
+ color: #2563eb;
+ border: none;
+ border-radius: 0.5rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-weight: 500;
+ font-size: 0.875rem;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.refreshButton:hover:not(:disabled) {
+ background: #bfdbfe;
+}
+
+.refreshButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.refreshIcon {
+ width: 1rem;
+ height: 1rem;
+}
+
+.spinning {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.errorMessage {
+ margin-bottom: 1rem;
+ padding: 1rem;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 0.5rem;
+ color: #b91c1c;
+ font-size: 0.875rem;
+}
+
+/* ===== TABLE STYLES ===== */
+.tableContainer {
+ overflow: hidden;
+ border-radius: 0.75rem;
+ border: 1px solid #e5e7eb;
+ overflow-x: auto;
+}
+
+.dataTable {
+ width: 100%;
+ border-collapse: collapse;
+ background: white;
+ min-width: 500px;
+}
+
+.tableHeader {
+ background: #f9fafb;
+}
+
+.tableHeaderCell {
+ padding: 1rem 1.5rem;
+ text-align: left;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom: 1px solid #e5e7eb;
+ white-space: nowrap;
+}
+
+.tableBody {
+ background: white;
+}
+
+.tableRow {
+ transition: background-color 0.2s ease;
+}
+
+.tableRow:hover {
+ background: #f9fafb;
+}
+
+.tableCell {
+ padding: 1rem 1.5rem;
+ font-size: 0.875rem;
+ color: #111827;
+ border-bottom: 1px solid #f3f4f6;
+ word-break: break-word;
+}
+
+.loadingCell,
+.emptyCell {
+ padding: 2rem 1.5rem;
+ text-align: center;
+ color: #6b7280;
+ font-size: 0.875rem;
+ border-bottom: 1px solid #f3f4f6;
+}
+
+.loadingContent {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.loadingIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.typeBadge {
+ display: inline-flex;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ background: #dbeafe;
+ color: #1e40af;
+ border-radius: 9999px;
+ white-space: nowrap;
+}
+
+/* ===== MOBILE RESPONSIVE DESIGN ===== */
+@media (max-width: 768px) {
+ .loginContainer {
+ padding: 0.75rem;
+ }
+
+ .loginForm {
+ padding: 1.5rem;
+ }
+
+ .brandTitle {
+ font-size: 1.5rem;
+ }
+
+ .headerContent {
+ padding: 1rem;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .headerBrand {
+ width: 100%;
+ }
+
+ .headerActions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .logoutText {
+ display: none;
+ }
+
+ .mainContent {
+ padding: 1rem;
+ gap: 1.5rem;
+ }
+
+ .createSection,
+ .dataSection {
+ padding: 1.5rem;
+ }
+
+ .sectionTitle {
+ font-size: 1.25rem;
+ }
+
+ .templateGrid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+ }
+
+ .templateCard {
+ padding: 1rem;
+ }
+
+ .templateName {
+ font-size: 0.8rem;
+ }
+
+ .fieldRow {
+ flex-direction: row;
+ gap: 0.5rem;
+ }
+
+ .fieldSelect {
+ width: 100%;
+ min-width: unset;
+ }
+
+ .dataHeader {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 1rem;
+ }
+
+ .refreshButton {
+ align-self: flex-end;
+ padding: 0.75rem 1rem;
+ }
+
+ .tableHeaderCell,
+ .tableCell {
+ padding: 0.75rem 1rem;
+ font-size: 0.8rem;
+ }
+
+ .typeBadge {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.4rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .loginContainer {
+ padding: 0.5rem;
+ }
+
+ .loginForm {
+ padding: 1.25rem;
+ }
+
+ .brandTitle {
+ font-size: 1.375rem;
+ }
+
+ .logoIcon {
+ width: 3.5rem;
+ height: 3.5rem;
+ }
+
+ .logoIconSvg {
+ width: 1.75rem;
+ height: 1.75rem;
+ }
+
+ .headerContent {
+ padding: 0.75rem 1rem;
+ }
+
+ .headerLogo {
+ width: 2rem;
+ height: 2rem;
+ }
+
+ .headerLogoIcon {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ .headerTitle {
+ font-size: 1.125rem;
+ }
+
+ .headerSubtitle {
+ font-size: 0.7rem;
+ }
+
+ .mainContent {
+ padding: 0.75rem;
+ gap: 1.25rem;
+ }
+
+ .createSection,
+ .dataSection {
+ padding: 1.25rem;
+ }
+
+ .sectionTitle {
+ font-size: 1.125rem;
+ }
+
+ .sectionSubtitle {
+ font-size: 0.8rem;
+ }
+
+ .templateGrid {
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ }
+
+ .templateCard {
+ padding: 0.875rem;
+ }
+
+ .templateContent {
+ gap: 0.5rem;
+ }
+
+ .templateIconContainer {
+ padding: 0.5rem;
+ }
+
+ .templateIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ }
+
+ .templateName {
+ font-size: 0.75rem;
+ }
+
+ .dataTitle {
+ font-size: 1.125rem;
+ }
+
+ .dataSubtitle {
+ font-size: 0.8rem;
+ }
+
+ .refreshButton {
+ padding: 0.625rem 0.875rem;
+ font-size: 0.8rem;
+ }
+
+ .tableHeaderCell,
+ .tableCell {
+ padding: 0.625rem 0.75rem;
+ font-size: 0.75rem;
+ }
+
+ .dataTable {
+ min-width: 400px;
+ }
+
+ .inputField,
+ .fieldInput,
+ .fieldSelect {
+ padding: 0.625rem 0.75rem;
+ font-size: 0.8rem;
+ }
+
+ .submitButton,
+ .loginButton {
+ padding: 0.875rem 1rem;
+ font-size: 0.8rem;
+ }
+}
+
+@media (max-width: 360px) {
+ .dataTable {
+ min-width: 320px;
+ }
+
+ .tableHeaderCell,
+ .tableCell {
+ padding: 0.5rem;
+ font-size: 0.7rem;
+ }
+
+ .typeBadge {
+ font-size: 0.65rem;
+ padding: 0.15rem 0.3rem;
+ }
+}
\ No newline at end of file
diff --git a/src/PickOrganization.js b/src/PickOrganization.js
new file mode 100644
index 0000000..ec895f3
--- /dev/null
+++ b/src/PickOrganization.js
@@ -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 (
+
+
+ Memuat organisasi…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
Gagal memuat organisasi
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+
Pilih Organisasi
+
Silakan pilih organisasi yang ingin Anda kelola.
+
+ {orgs.length === 0 ? (
+
+
+
Tidak ada organisasi untuk akun ini.
+
+ ) : (
+
+ {orgs.map((org) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/PickOrganization.module.css b/src/PickOrganization.module.css
new file mode 100644
index 0000000..60d6992
--- /dev/null
+++ b/src/PickOrganization.module.css
@@ -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; }
diff --git a/src/QWEN.md b/src/QWEN.md
new file mode 100644
index 0000000..e69de29
diff --git a/src/SuccessPage.js b/src/SuccessPage.js
new file mode 100644
index 0000000..bdc70cf
--- /dev/null
+++ b/src/SuccessPage.js
@@ -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 (
+
+
Processing your request...
+
If you are not redirected automatically, please check your URL.
+
+ );
+};
+
+export default SuccessPage;