diff --git a/package-lock.json b/package-lock.json index 6fd4d6f..a83e886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "file-saver": "^2.0.5", "js-levenshtein": "^1.1.6", "lucide-react": "^0.539.0", "pixelmatch": "^7.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.5.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", "recharts": "^3.0.2", @@ -8105,6 +8107,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -13849,6 +13857,15 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz", "integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==" }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -17508,6 +17525,7 @@ "version": "0.18.5", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", diff --git a/package.json b/package.json index 543361c..f73f033 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "file-saver": "^2.0.5", "js-levenshtein": "^1.1.6", "lucide-react": "^0.539.0", "pixelmatch": "^7.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-icons": "^5.5.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", "recharts": "^3.0.2", diff --git a/public/ikasapta.png b/public/ikasapta.png index 612c497..8ddd75d 100644 Binary files a/public/ikasapta.png and b/public/ikasapta.png differ diff --git a/public/ikasapta1.png b/public/ikasapta1.png new file mode 100644 index 0000000..612c497 Binary files /dev/null and b/public/ikasapta1.png differ diff --git a/src/App.js b/src/App.js index fc76cf2..0a231f8 100644 --- a/src/App.js +++ b/src/App.js @@ -8,86 +8,100 @@ import LoginPage from "./Login"; import Expetation from "./DataTypePage"; import CameraKtp from "./KTPScanner"; import Profile from "./ProfileTab"; -import PickOrganization from "./PickOrganization"; // <-- import baru +import PickOrganization from "./PickOrganization"; -// LandingPage.js -const LandingPage = () => { - return ( -
-

Selamat datang di Aplikasi Kami

- {/* Tambahkan konten lainnya sesuai kebutuhan */} -
- ); -}; - -// Komponen untuk melindungi route dengan token +// ===== ProtectedRoute: cek token sebelum render ===== const ProtectedRoute = ({ element }) => { const token = localStorage.getItem("token"); - return token ? element : ; + return token ? element : ; +}; + +// ===== Redirector: /dashboard → /dashboard/:organization_id (kalau ada), kalau tidak ada ke /pick-organization ===== +const RedirectToOrgDashboard = () => { + const orgId = localStorage.getItem("organization_id"); + if (orgId) return ; + return ; +}; + +// ===== Redirector: /scan → /scan/:organization_id (kalau ada), kalau tidak ada ke /pick-organization ===== +const RedirectToOrgScan = () => { + const orgId = localStorage.getItem("organization_id"); + if (orgId) return ; + return ; }; 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"); + // 1) Ambil token dari query, simpan, lalu arahkan ke pemilihan organisasi + 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 } - ); + params.delete("token"); + navigate("/pick-organization", { replace: true }); + return; } + // 2) Jika tidak ada token di query, biarkan mengalir normal }, [location, navigate]); return (
- } /> + {/* Default → login */} + } /> + + {/* Auth */} } /> - {/* Halaman pilih organisasi (wajib setelah login) */} + {/* Setelah login → pilih organisasi */} } />} /> - } /> - } /> - - {/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */} + {/* Dashboard "polos" otomatis diarahkan ke dashboard org aktif */} } />} + element={} />} /> {/* Dashboard spesifik organisasi */} } />} /> + {/* Scan "polos" otomatis diarahkan ke scan org aktif */} } />} + /> + + {/* Scan spesifik organisasi */} + } />} + /> + + {/* Alur scan di dalam dashboard (jika memang ada halaman ini) */} + } />} /> + {/* Halaman lain */} + } /> } />} /> - } /> + {/* Contoh: ShowImage jika masih dipakai */} + } />} /> + + {/* Fallback */} + } />
); diff --git a/src/Dashboard.js b/src/Dashboard.js index 538a44b..23ec746 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, useParams } from "react-router-dom"; // *** +import { useNavigate, useParams } from "react-router-dom"; import FileListComponent from "./FileListComponent"; import { BarChart, @@ -11,11 +11,25 @@ import { ResponsiveContainer, } from "recharts"; -const API_BASE = "https://bot.kediritechnopark.com/webhook/solid-data"; +// Konsistenkan base URL (tanpa tanda minus) +const API_BASE = "https://bot.kediritechnopark.com/webhook/soliddata"; const Dashboard = () => { const navigate = useNavigate(); - const { organization_id: orgIdFromRoute } = useParams(); // *** + + // Ambil org dari URL, lalu sediakan fallback ke localStorage + const { organization_id: orgParam } = useParams(); + const [organizationId, setOrganizationId] = useState( + orgParam || localStorage.getItem("organization_id") || "" + ); + + useEffect(() => { + if (orgParam) { + localStorage.setItem("organization_id", orgParam); + setOrganizationId(orgParam); + } + }, [orgParam]); + const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -27,98 +41,132 @@ const Dashboard = () => { const [user, setUser] = useState({}); const [totalFilesSentToday, setTotalFilesSentToday] = useState(0); const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0); + const [totalFileSentYear, setTotalFileSentYear] = useState(0); const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0); - const [officerPerformanceData, setOfficerPerformanceData] = useState([]); + + // === Grafik === + const [officerPerformanceData, setOfficerPerformanceData] = useState([]); // data yang sedang ditampilkan di chart + const [byTypeSeries, setByTypeSeries] = useState([]); // dari /files: [{ label: nama_tipe, count }] + const [typeOptions, setTypeOptions] = useState([]); // daftar tipe untuk dropdown: [{id, name}] + const [performanceByType, setPerformanceByType] = useState({}); // { [nama_tipe]: monthlySeries [{label: 'YYYY-MM', count}] } + const [chartKey, setChartKey] = useState(""); // "" = semua tipe (agregat), selain itu = nama_tipe (tren bulanan) 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 }; + // Terima daftar tipe + seri per-tipe dari FileListComponent (/files) + const handleTypesLoaded = (options, series) => { + setTypeOptions(options || []); + setByTypeSeries(series || []); + // Default grafik: tampilkan agregat per-tipe + setOfficerPerformanceData(series || []); + setChartKey(""); // mode "Semua tipe" }; - // Helper: header standar, opsional kirim X-Organization-Id + // Terima seri bulanan saat tipe dibuka di FileListComponent + const handlePerformanceReady = (typeName, monthlySeries) => { + setPerformanceByType((prev) => ({ ...prev, [typeName]: monthlySeries })); + // Jika user sedang memilih tipe ini → langsung update grafik + if (chartKey === typeName) { + setOfficerPerformanceData(monthlySeries || []); + } + }; + + // Header auth + optional X-Organization-Id const authHeaders = (extra = {}) => { const token = localStorage.getItem("token"); - const { orgId } = getActiveOrg(); - return { + const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", - "X-Organization-Id": orgId ? String(orgId) : undefined, // backend boleh pakai header ini jika mau ...extra, }; + return headers; }; - // Pastikan sudah login & punya org yang dipilih + // Pastikan login useEffect(() => { const token = localStorage.getItem("token"); - const { orgId } = getActiveOrg(); - if (!token) { navigate("/login"); return; } - if (!orgId) { - navigate("/pick-organization"); - return; + // Opsional: jika org kosong, arahkan user ke pemilihan organisasi + if (!organizationId) { + setErrorMessage("Organisasi tidak terdeteksi. Silakan akses dashboard melalui tautan organisasi."); } + }, [organizationId, navigate]); - // 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 + // Verifikasi token & fetch ringkasan dashboard org aktif useEffect(() => { const verifyTokenAndFetchData = async () => { const token = localStorage.getItem("token"); - const { orgId } = getActiveOrg(); - if (!token || !orgId) return; + if (!token || !organizationId) return; + + const toNum = (v) => { + const n = typeof v === "number" ? v : Number(v ?? 0); + return Number.isFinite(n) ? n : 0; + }; try { - // GET -> kirim orgId lewat query string - const res = await fetch( - `${API_BASE}/dashboard?organization_id=${encodeURIComponent(orgId)}`, + // Fetch total scans data (pakai API_BASE yang konsisten) + const totalScansRes = await fetch( + `${API_BASE}/total-scans?organization_id=${encodeURIComponent( + organizationId + )}`, { method: "GET", headers: authHeaders() } ); + const totalScansRaw = await totalScansRes.json(); + console.log("RAW total-scans payload:", totalScansRaw); - const data = await res.json(); - - if (!res.ok) { - console.error("Dashboard error:", data); + if (!totalScansRes.ok) { + console.error("Total Scans error:", totalScansRaw); + } else { + const totalScansPayload = Array.isArray(totalScansRaw) + ? totalScansRaw[0] + : totalScansRaw; + setTotalFilesSentToday(toNum(totalScansPayload?.total_today)); + setTotalFilesSentMonth(toNum(totalScansPayload?.total_month)); + setTotalFileSentYear(toNum(totalScansPayload?.total_year)); + setTotalFilesSentOverall(toNum(totalScansPayload?.total_overall)); } - // 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]); + // Fetch dashboard (user, officer performance default) + const res = await fetch( + `${API_BASE}/dashboard?organization_id=${encodeURIComponent( + organizationId + )}`, + { method: "GET", headers: authHeaders() } + ); + const raw = await res.json(); + console.log("RAW dashboard payload:", raw); + if (!res.ok) { + console.error("Dashboard error:", raw); + } - // 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); + const payload = Array.isArray(raw) ? raw[0] : raw; + if (payload?.user) setUser(payload.user); + + // Kalau backend kirim default "officerPerformance", tetap tampilkan (akan ditimpa saat /files datang) + if (Array.isArray(payload?.officerPerformance)) { + setOfficerPerformanceData(payload.officerPerformance); + setChartKey(""); // mode umum + } } catch (err) { console.error("Token/Fetch dashboard gagal:", err); } }; verifyTokenAndFetchData(); - }, [orgIdFromRoute]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organizationId]); - // Ambil daftar officer (khusus admin) untuk org terpilih + // Ambil daftar officer (khusus admin) const fetchOfficers = async () => { - const { orgId } = getActiveOrg(); - if (!orgId) return; + if (!organizationId) return; try { const res = await fetch( - `${API_BASE}/list-user?organization_id=${encodeURIComponent(orgId)}`, + `${API_BASE}/list-user?organization_id=${encodeURIComponent( + organizationId + )}`, { method: "GET", headers: authHeaders() } ); const data = await res.json(); @@ -132,19 +180,19 @@ const Dashboard = () => { if (user?.role === "admin") { fetchOfficers(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.role]); const handleLogout = () => { localStorage.removeItem("token"); localStorage.removeItem("user"); - // jangan hapus selected_organization kalau mau balik lagi ke org sebelumnya + localStorage.removeItem("organization_id"); // bersihkan juga orgId window.location.reload(); }; const handleAddOfficer = async (e) => { e.preventDefault(); - const { orgId } = getActiveOrg(); - if (!orgId) return; + if (!organizationId) return; try { const res = await fetch(`${API_BASE}/add-officer`, { @@ -153,7 +201,7 @@ const Dashboard = () => { body: JSON.stringify({ username, password, - organization_id: orgId, // *** kirim org pada body + organization_id: organizationId, }), }); @@ -176,11 +224,12 @@ const Dashboard = () => { }; const handleDeleteOfficer = async (id) => { - const confirmDelete = window.confirm("Apakah Anda yakin ingin menghapus petugas ini?"); + const confirmDelete = window.confirm( + "Apakah Anda yakin ingin menghapus petugas ini?" + ); if (!confirmDelete) return; - const { orgId } = getActiveOrg(); - if (!orgId) return; + if (!organizationId) return; try { const res = await fetch(`${API_BASE}/delete-officer`, { @@ -188,7 +237,7 @@ const Dashboard = () => { headers: authHeaders(), body: JSON.stringify({ id, - organization_id: orgId, // *** kirim org pada body + organization_id: organizationId, }), }); @@ -204,7 +253,7 @@ const Dashboard = () => { } }; - // Tutup menu bila klik di luar + // Tutup menu ketika klik di luar useEffect(() => { const handleClickOutside = (event) => { if (menuRef.current && !menuRef.current.contains(event.target)) { @@ -215,7 +264,25 @@ const Dashboard = () => { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - const { orgName } = getActiveOrg(); + const { orgName } = {}; + + // ====== WRAPPER SETTER untuk proteksi dari FileListComponent ====== + const safeSetToday = (v) => { + const n = Number(v ?? 0); + if (Number.isFinite(n)) setTotalFilesSentToday(n); + }; + const safeSetMonth = (v) => { + const n = Number(v ?? 0); + if (Number.isFinite(n)) setTotalFilesSentMonth(n); + }; + const safeSetYear = (v) => { + const n = Number(v ?? 0); + if (Number.isFinite(n)) setTotalFileSentYear(n); + }; + const safeSetOverall = (v) => { + const n = Number(v ?? 0); + if (Number.isFinite(n)) setTotalFilesSentOverall(n); + }; return (
@@ -223,9 +290,7 @@ const Dashboard = () => {
Bot Avatar

SOLID

-

DATA

- {/* *** tampilkan nama org aktif */} - {orgName && Org: {orgName}} +

DATA

@@ -235,8 +300,17 @@ const Dashboard = () => { aria-expanded={isMenuOpen ? "true" : "false"} aria-haspopup="true" > - +
+ {/* Static Organization */} +
+
+ Organisasi +
{orgName}
+
+
+ + {/* Scan */} + + {/* Logout */}
)}
+ {/* ... sisanya tetap sama persis */}
+ {errorMessage && ( +
+ {errorMessage} +
+ )} +

Hari Ini

{totalFilesSentToday.toLocaleString()}

-
+

Bulan Ini

{totalFilesSentMonth.toLocaleString()}

+
+

Tahun Ini

+

{totalFileSentYear.toLocaleString()}

+

Total Keseluruhan

{totalFilesSentOverall.toLocaleString()}

@@ -357,14 +490,50 @@ const Dashboard = () => { )}
-

Grafik Upload Document

+
+

Grafik Upload Dokumen

+ +
+ + +
+
+ {officerPerformanceData.length > 0 ? ( - + {/* label = nama_tipe ATAU YYYY-MM */} - + ) : ( @@ -375,13 +544,18 @@ const Dashboard = () => {
- {/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */}
diff --git a/src/Dashboard.module.css b/src/Dashboard.module.css index 9a9ea26..f4d69a0 100644 --- a/src/Dashboard.module.css +++ b/src/Dashboard.module.css @@ -1,608 +1,512 @@ -/* Dashboard.module.css - Cleaned Version */ +/* Dashboard.module.css - Brand Blue/Indigo, Full Page & Responsive */ -/* Modern Color Palette */ +/* ==== GLOBAL FULL-HEIGHT ==== */ +html, body, #root { + height: 100%; +} +* { box-sizing: border-box; } + +/* ==== Palette & Tokens ==== */ :root { - --primary-blue: #3b82f6; - --secondary-blue: #60a5fa; - --dark-blue: #1e40af; - --neutral-50: #fafafa; - --neutral-100: #f5f5f5; - --neutral-200: #e5e5e5; - --neutral-300: #d4d4d4; - --neutral-500: #737373; - --neutral-700: #404040; - --neutral-800: #262626; - --neutral-900: #171717; + /* Brand */ + --brand-primary: #2961eb; /* blue-600 */ + --brand-primary-700: #1d4ed8; /* blue-700 */ + --brand-secondary: #4f46e5; /* indigo-600 */ + --brand-secondary-700: #4338ca;/* indigo-700 */ + --brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary)); + + /* Gradients for cards */ + --card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%); + --card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%); + --card-grad-3: linear-gradient(135deg, #2563eb 10%, #4338ca 100%); + + + /* Neutral */ + --neutral-25: #fcfcfd; + --neutral-50: #f9fafb; + --neutral-100: #f3f4f6; + --neutral-200: #e5e7eb; + --neutral-300: #d1d5db; + --neutral-400: #9ca3af; + --neutral-600: #475569; + --neutral-800: #1f2937; --white: #ffffff; - --success-green: #43a0a7; - --warning-amber: #f59e0b; - --error-red: #ef4444; + + /* Text */ --text-primary: #0f172a; --text-secondary: #64748b; - --text-light: #ffffff; - --border-light: #e2e8f0; - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), - 0 4px 6px -4px rgb(0 0 0 / 0.1); -} - -/* Base Styles & Reset */ -* { - box-sizing: border-box; + --text-on-brand: #ffffff; + + /* Borders & Shadows */ + --border-light: #e5e7eb; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow-md: 0 4px 10px rgba(2,6,23,.08); + --shadow-lg: 0 12px 22px rgba(2,6,23,.12); + + /* States */ + --focus-ring: 0 0 0 3px rgba(37, 99, 235, .18); + + /* Semantic */ + --error-red: #ef4444; } +/* ==== Base ==== */ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.5; color: var(--text-primary); - background-color: var(--neutral-50); + background: var(--neutral-50); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +/* Root container: full width & height */ .dashboardContainer { - background-color: var(--neutral-50); - min-height: 100vh; + min-height: 100dvh; /* support mobile dynamic viewport */ + width: 100%; display: flex; flex-direction: column; + background: var(--neutral-50); } -/* --- Header --- */ +/* ==== Header ==== */ .dashboardHeader { - background-color: var(--white); - color: var(--text-primary); + position: sticky; top: 0; z-index: 50; + display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; - display: flex; - justify-content: space-between; - align-items: center; - box-shadow: var(--shadow-sm); - border-bottom: 3px solid #43a0a7; - position: sticky; - top: 0; - z-index: 50; - backdrop-filter: blur(8px); + background: rgba(255,255,255,.95); + backdrop-filter: blur(6px); + border-bottom: 1px solid var(--border-light); + width: 100%; } -.logoAndTitle { - display: flex; - align-items: center; - flex-shrink: 0; -} +.logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; } +.logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); } -.logoAndTitle img { - width: 2.5rem; - height: 2.5rem; - border-radius: 0.75rem; - margin-right: 0.75rem; - object-fit: cover; -} - -.dashboardHeader .h1 { - margin: 2px; - font-size: 1.5rem; - font-weight: 700; - color: #43a0a7; - letter-spacing: -0.025em; -} - -.data { +.h1 { margin: 0; - font-size: 1.5rem; - font-weight: 700; - color: #154666; - letter-spacing: -0.025em; + font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem); + font-weight: 800; letter-spacing: -.02em; + background: var(--brand-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.h1Accent { + background: linear-gradient(135deg, var(--brand-secondary), var(--brand-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; } -/* Dropdown Menu */ -.dropdownContainer { - position: relative; - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; -} - -.userDisplayName { - color: var(--text-secondary); - font-weight: 500; - font-size: 0.875rem; +.orgBadge { + margin-left: .75rem; + padding: .25rem .6rem; + font-size: .75rem; font-weight: 700; + color: var(--text-on-brand); + background: var(--brand-secondary); + border-radius: .5rem; + border: 1px solid rgba(255,255,255,.5); } +/* ==== Dropdown ==== */ +.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; } .dropdownToggle { - background-color: var(--neutral-100); - color: var(--text-primary); + min-width: 2.5rem; height: 2.5rem; + display: inline-flex; align-items: center; justify-content: center; + background: var(--white); border: 1px solid var(--border-light); - padding: 0.5rem; - border-radius: 0.5rem; + border-radius: .6rem; cursor: pointer; - font-size: 1rem; - transition: all 0.2s ease; - min-width: 2.5rem; - height: 2.5rem; - display: flex; - align-items: center; - justify-content: center; -} - -.dropdownToggle:hover { - background-color: var(--neutral-200); - border-color: var(--neutral-300); + box-shadow: var(--shadow-sm); + transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease; } +.dropdownToggle:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--neutral-300); } +.dropdownToggle:focus { outline: none; box-shadow: var(--focus-ring); } .dropdownMenu { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - background-color: var(--white); - border-radius: 0.75rem; - box-shadow: var(--shadow-lg); + position: absolute; right: 0; top: calc(100% + .5rem); + min-width: 10rem; padding: .5rem; + background: var(--white); border: 1px solid var(--border-light); - z-index: 10; - display: flex; - flex-direction: column; - min-width: 10rem; - overflow: hidden; - padding: 0.5rem; + border-radius: .75rem; + box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; gap: .25rem; } .dropdownItem { - background: none; - border: none; - padding: 0.75rem 1rem; - text-align: left; - cursor: pointer; - color: var(--text-primary); - transition: background-color 0.2s ease; - font-size: 0.875rem; - font-weight: 500; - border-radius: 0.5rem; - margin-bottom: 0.125rem; + border: 0; background: transparent; text-align: left; cursor: pointer; + padding: .65rem .75rem; border-radius: .5rem; + font-size: .95rem; font-weight: 600; color: var(--neutral-800); + transition: background .15s ease, transform .06s ease; } +.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); } -.dropdownItem:hover { - background-color: var(--neutral-100); -} - -.dropdownItem:last-child { - margin-bottom: 0; -} - -/* --- Main Content --- */ +/* ==== Main: FULL WIDTH ==== */ .mainContent { - flex-grow: 1; - padding: 2rem 1.5rem; - display: flex; - flex-direction: column; - gap: 2rem; - max-width: 1400px; - margin: 0 auto; + flex: 1 1 auto; width: 100%; + padding: 2rem 1.5rem; + margin: 0; /* remove center constraint */ + display: flex; flex-direction: column; gap: 2rem; + min-width: 0; /* prevent overflow causing shrink */ } -/* Summary Cards Container */ +/* Summary Cards */ .summaryCardsContainer { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */ + gap: 1rem; + align-items: stretch; + width: 100%; + min-width: 0; } +/* Gradient cards (3 variasi) */ .summaryCard { - background-color: var(--white); - padding: 1.5rem; + position: relative; + overflow: hidden; + background: var(--card-grad-1); + color: var(--text-on-brand); + border: 1px solid rgba(255,255,255,.18); border-radius: 1rem; - border: 1px solid var(--border-light); - box-shadow: var(--shadow-sm); - transition: all 0.2s ease; + padding: 1.25rem 1.5rem; + min-height: 120px; + box-shadow: 0 10px 20px rgba(37,99,235,.15); + transition: box-shadow .2s ease, transform .12s ease, background-position .4s ease, filter .2s ease; + background-size: 140% 140%; + background-position: 0% 50%; +} +.summaryCardsContainer .summaryCard:nth-child(1) { background: var(--card-grad-1); } +.summaryCardsContainer .summaryCard:nth-child(2) { background: var(--card-grad-2); } +.summaryCardsContainer .summaryCard:nth-child(3) { background: var(--card-grad-3); } + +.summaryCard::before { + content: ""; + position: absolute; inset: 0; + background: + radial-gradient(120% 60% at 10% -10%, rgba(255,255,255,.25) 0%, rgba(255,255,255,0) 55%), + radial-gradient(90% 50% at 90% -10%, rgba(255,255,255,.18) 0%, rgba(255,255,255,0) 60%); + pointer-events: none; } .summaryCard:hover { - box-shadow: var(--shadow-md); - transform: translateY(-1px); + transform: translateY(-2px); + box-shadow: 0 14px 28px rgba(37,99,235,.22); + filter: saturate(1.05); + background-position: 100% 50%; } .summaryCard h3 { - margin: 0 0 0.5rem 0; - font-size: 0.875rem; - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; + margin: 0 0 .5rem 0; + font-size: 1rem; letter-spacing: .06em; text-transform: uppercase; + color: rgba(255,255,255,.92); + font-weight: 800; } - .summaryCard p { - font-size: 2rem; - font-weight: 700; - color: #43a0a7; - margin: 0; - line-height: 1; + margin: 0; line-height: 1; + font-size: clamp(2rem, 2vw + .5rem, 2.2rem); + font-weight: 900; color: #fff; + background: none !important; + -webkit-background-clip: initial !important; + -webkit-text-fill-color: #fff !important; + text-shadow: 0 1px 1px rgba(0,0,0,.15); } -/* Dashboard Grid for Form and Chart */ +/* Grid: Form & Chart */ .dashboardGrid { display: grid; - grid-template-columns: 1fr; + grid-template-columns: 1fr; /* mobile: 1 kolom */ gap: 2rem; flex-grow: 1; + min-width: 0; } -.formSection, -.chartSection { - background-color: var(--white); - padding: 2rem; - border-radius: 1rem; +.formSection, .chartSection { + background: var(--white); border: 1px solid var(--border-light); + border-radius: 1rem; box-shadow: var(--shadow-sm); + padding: 1.5rem; + min-width: 0; } -.formSection h2, -.chartSection h2 { - color: var(--text-primary); - margin: 0 0 1.5rem 0; - font-size: 1.25rem; - font-weight: 600; - letter-spacing: -0.025em; +.formSection h2, .chartSection h2 { + margin: 0 0 1rem 0; + font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem); + font-weight: 800; letter-spacing: -.015em; + color: var(--neutral-800); } +/* Form */ .form label { - text-align: left; - display: block; - margin-bottom: 1rem; - color: var(--text-primary); - font-weight: 500; - font-size: 0.875rem; + display: block; margin-bottom: 1rem; + font-size: .95rem; font-weight: 700; color: var(--neutral-800); } - .form input[type="text"], .form input[type="password"], .form select { - width: 100%; - padding: 0.75rem 1rem; - margin-top: 0.375rem; + width: 100%; margin-top: .35rem; + padding: .7rem 1rem; + font-size: .95rem; color: var(--neutral-800); + background: var(--white); border: 1px solid var(--border-light); - border-radius: 0.5rem; - font-size: 0.875rem; - transition: all 0.2s ease; - background-color: var(--white); - color: var(--text-primary); + border-radius: .55rem; + transition: border-color .2s ease, box-shadow .2s ease; } - .form input[type="text"]:focus, .form input[type="password"]:focus, .form select:focus { - border-color: var(--primary-blue); - box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1); - outline: none; + outline: none; border-color: var(--brand-primary); + box-shadow: var(--focus-ring); } +/* Buttons */ .submitButton { - background-color: #43a0a7; - color: var(--text-light); - border: none; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 600; - margin-top: 1rem; width: 100%; - transition: all 0.2s ease; - letter-spacing: 0.025em; -} - -.submitButton:hover { - background-color: #357734; - transform: translateY(-1px); - box-shadow: var(--shadow-md); -} - -.submitButton:active { - transform: translateY(0); + display: inline-flex; align-items: center; justify-content: center; + padding: .9rem 1.2rem; + border: none; border-radius: .6rem; cursor: pointer; + font-size: 1rem; font-weight: 800; letter-spacing: .02em; + color: var(--text-on-brand); + background: var(--brand-primary); + box-shadow: 0 6px 18px rgba(37,99,235,.18); + transition: transform .12s ease, box-shadow .2s ease, background .2s ease; } +.submitButton:hover { transform: translateY(-1px); background: var(--brand-primary-700); box-shadow: 0 8px 22px rgba(37,99,235,.22); } +.submitButton:active { transform: translateY(0); } +.submitButton:focus { outline: none; box-shadow: var(--focus-ring); } /* Messages */ +.success, .warning { + margin-top: 1rem; padding: .85rem 1rem; + border-radius: .6rem; font-size: .92rem; font-weight: 700; + border: 1px solid transparent; +} .success { - background-color: rgb(67 160 167 / 0.1); - color: var(--success-green); - border: 1px solid rgb(67 160 167 / 0.2); - padding: 0.75rem 1rem; - border-radius: 0.5rem; - margin-top: 1rem; - font-size: 0.875rem; - font-weight: 500; + color: var(--brand-primary); + background: rgba(37,99,235,.08); + border-color: rgba(37,99,235,.18); } - -.error { - background-color: rgb(239 68 68 / 0.1); - color: var(--error-red); - border: 1px solid rgb(239 68 68 / 0.2); - padding: 0.75rem 1rem; - border-radius: 0.5rem; - margin-top: 1rem; - font-size: 0.875rem; - font-weight: 500; -} - .warning { - background-color: rgb(67 160 167 / 0.1); - color: #43a0a7; - border: 1px solid rgb(67 160 167 / 0.2); - padding: 1rem; - border-radius: 0.5rem; - margin-top: 1rem; - font-weight: 500; - font-size: 0.875rem; + color: var(--brand-secondary); + background: rgba(79,70,229,.08); + border-color: rgba(79,70,229,.18); +} +.error { + margin-top: 1rem; padding: .85rem 1rem; + border-radius: .6rem; font-size: .92rem; font-weight: 700; + color: var(--error-red); + background: rgba(239,68,68,.08); + border: 1px solid rgba(239,68,68,.18); } /* Footer */ .footer { - background-color: var(--white); - color: var(--text-secondary); + margin-top: auto; text-align: center; padding: 1rem; - margin-top: auto; - font-size: 0.75rem; + font-size: .85rem; + color: var(--text-secondary); + background: var(--white); border-top: 1px solid var(--border-light); } +/* Chart placeholder (kalau perlu) */ .chartPlaceholder { - background-color: var(--neutral-50); height: 20rem; - display: flex; - justify-content: center; - align-items: center; - color: var(--text-secondary); - font-style: italic; - border-radius: 0.75rem; + display: flex; align-items: center; justify-content: center; + background: var(--neutral-50); border: 2px dashed var(--border-light); - font-size: 0.875rem; + border-radius: .75rem; + color: var(--text-secondary); + font-style: italic; font-size: .95rem; } -/* --- Media Queries for Tablets and Desktops --- */ - -/* Tablet-sized screens and up */ -@media (min-width: 768px) { - .dashboardHeader { - padding: 1rem 2rem; - } - - .logoAndTitle img { - width: 3rem; - height: 3rem; - } - - .dashboardHeader .h1 { - font-size: 1.75rem; - } - - .userDisplayName { - font-size: 0.875rem; - } - - .mainContent { - padding: 2.5rem 2rem; - gap: 2.5rem; - } - - .summaryCardsContainer { - grid-template-columns: repeat(3, 1fr); - } - - .dashboardGrid { - grid-template-columns: 1fr 2fr; - gap: 2.5rem; - } - - .formSection, - .chartSection { - padding: 2.5rem; - } - - .formSection h2, - .chartSection h2 { - font-size: 1.5rem; - } - - .chartPlaceholder { - height: 25rem; - } -} - -/* Desktop-sized screens and up */ -@media (min-width: 1024px) { - .dashboardHeader { - padding: 1.25rem 3rem; - } - - .logoAndTitle img { - width: 3.5rem; - height: 3.5rem; - } - - .dashboardHeader .h1 { - font-size: 2rem; - } - - .mainContent { - padding: 3rem 2.5rem; - gap: 3rem; - } - - .dashboardGrid { - gap: 3rem; - } - - .formSection, - .chartSection { - padding: 3rem; - } - - .chartPlaceholder { - height: 30rem; - } -} - -/* Single column layout when only one section is present */ -@media (min-width: 768px) { - .dashboardGrid > *:only-child { - grid-column: 1 / -1; - } -} - -/* CSS untuk styling daftar petugas */ - +/* Officers List */ .officerListContainer { - background: #f8f9fa; - border: 1px solid #dee2e6; - border-radius: 8px; - padding: 16px; - margin-bottom: 20px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: var(--white); + border: 1px solid var(--border-light); + border-radius: .8rem; + padding: 1rem; + box-shadow: var(--shadow-sm); + margin-bottom: 1.25rem; } - .officerList { - max-height: 300px; - overflow-y: auto; - padding: 0; - margin: 0; - list-style: none; - border-radius: 6px; - background: white; - border: 1px solid #e9ecef; + max-height: 300px; overflow-y: auto; + list-style: none; margin: 0; padding: 0; + border: 1px solid var(--border-light); + border-radius: .6rem; background: var(--white); } - .officerItem { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #e9ecef; - transition: background-color 0.2s ease; + display: flex; align-items: center; justify-content: space-between; + padding: .95rem 1rem; + border-bottom: 1px solid var(--border-light); + transition: background .15s ease; } - -.officerItem:last-child { - border-bottom: none; -} - -.officerItem:hover { - background-color: #f8f9fa; -} - -.officerInfo { - display: flex; - align-items: center; - gap: 12px; - flex: 1; -} - -.officerIcon { - font-size: 20px; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.officerDetails { - display: flex; - flex-direction: column; - gap: 2px; -} - -.officerName { - font-weight: 600; - color: #2c3e50; - font-size: 14px; -} - -.officerRole { - font-size: 12px; - color: #6c757d; - font-style: italic; - text-transform: capitalize; -} - +.officerItem:last-child { border-bottom: 0; } +.officerItem:hover { background: rgba(79,70,229,.05); } +.officerInfo { display: flex; align-items: center; gap: .75rem; flex: 1; } +.officerIcon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; } +.officerDetails { display: flex; flex-direction: column; gap: 2px; } +.officerName { font-weight: 800; color: var(--neutral-800); font-size: .98rem; } +.officerRole { font-size: .85rem; color: var(--text-secondary); text-transform: capitalize; font-style: italic; } .deleteButton { + background: transparent; border: 0; cursor: pointer; + font-size: .85rem; padding: .45rem .65rem; border-radius: .4rem; + color: var(--error-red); + transition: background .15s ease, transform .06s ease; + opacity: .9; +} +.deleteButton:hover { background: rgba(239,68,68,.08); transform: translateY(-1px); } +.deleteButton:focus { outline: none; box-shadow: var(--focus-ring); } +.emptyState { text-align: center; padding: 36px 18px; color: var(--text-secondary); } +.emptyState span { display: block; font-size: 32px; margin-bottom: .5rem; } +.separator { border: 0; border-top: 1px solid var(--border-light); margin: 20px 0; } + +/* Scrollbar */ +.officerList::-webkit-scrollbar { width: 8px; } +.officerList::-webkit-scrollbar-track { background: var(--neutral-100); border-radius: 4px; } +.officerList::-webkit-scrollbar-thumb { background: var(--neutral-300); border-radius: 4px; } +.officerList::-webkit-scrollbar-thumb:hover { background: var(--neutral-400); } + +/* ==== Responsive ==== */ +@media (min-width: 768px) { + .dashboardHeader { padding: 1rem 2rem; } + .logoAndTitle img { width: 3rem; height: 3rem; } + + .mainContent { padding: 2.25rem 2rem; gap: 2rem; } + .dashboardGrid { grid-template-columns: 1.1fr 2fr; gap: 2rem; } /* 2 kolom, tetap lega */ +} + +@media (min-width: 1280px) { + .mainContent { padding: 2.5rem 2.25rem; gap: 2.25rem; } + .dashboardGrid { grid-template-columns: 1fr 2fr; gap: 2.25rem; } +} + +/* ==== FIX: jika hanya ada 1 section, lebarkan full ==== */ +.dashboardGrid > *:only-child { + grid-column: 1 / -1; + width: 100%; +} + +/* Pastikan chart section benar-benar membentang */ +.chartSection { + width: 100%; + min-width: 0; /* cegah overflow anak bikin kontainer menyempit */ +} + +/* Rapikan baris kartu ringkasan */ +.summaryCardsContainer { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-items: stretch; + justify-items: stretch; + gap: 1.25rem; +} + +/* Tinggi & padding kartu konsisten + bayangan lebih halus */ +.summaryCard { + min-height: 110px; + padding: 1.25rem 1.5rem; + box-shadow: 0 8px 18px rgba(37,99,235,.12); +} + +/* Judul chart dan box warning sedikit lebih rapat */ +.chartSection h2 { margin-bottom: .75rem; } +.warning { margin-top: .75rem; } + +/* Header biar konten tidak terlalu mepet tepi di layar lebar */ +@media (min-width: 1280px) { + .dashboardHeader { padding-left: 2rem; padding-right: 2rem; } + .mainContent { padding-left: 2rem; padding-right: 2rem; } +} + +/* (Opsional) Kalau tetap terlihat terlalu ke kiri, + kamu bisa center-kan isi utama tanpa mengubah full width list dokumen */ +@media (min-width: 1024px) { + .mainContent { max-width: 1280px; margin-left: auto; margin-right: auto; } +} + +.dashboard { + font-family: sans-serif; +} + +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background: #ffffff; + border-bottom: 1px solid #e5e5e5; +} + +.logo { + display: flex; + align-items: center; + gap: 6px; + font-weight: bold; + color: #333; +} + +.menuWrapper { + position: relative; +} + +.menuButton { background: none; border: none; cursor: pointer; - font-size: 12px; - padding: 4px 8px; - border-radius: 4px; - transition: background-color 0.2s ease; - opacity: 0.7; + padding: 6px; + border-radius: 6px; } -.deleteButton:hover { - background-color: #fee; - opacity: 1; +.menuButton:hover { + background: #f2f2f2; } -.deleteButton:focus { - outline: 2px solid #dc3545; - outline-offset: 2px; +.dropdownMenu { + position: absolute; + right: 0; + top: 45px; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + padding: 8px; + width: 220px; + z-index: 1000; } -.emptyState { - text-align: center; - padding: 40px 20px; - color: #6c757d; +.dropdownItem, +.dropdownItemStatic { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 6px; + cursor: pointer; } -.emptyState span { - font-size: 32px; +.dropdownItem:hover { + background: #f5f5f5; +} + +.dropdownItemStatic { + cursor: default; + background: #fafafa; display: block; - margin-bottom: 8px; } -.emptyState p { - margin: 0; - font-size: 14px; +.dropdownIcon { + flex-shrink: 0; + color: #444; } -.separator { - border: none; - border-top: 1px solid #dee2e6; - margin: 24px 0; +.dropdownText { + display: flex; + flex-direction: column; } -/* Custom scrollbar untuk daftar petugas */ -.officerList::-webkit-scrollbar { - width: 8px; -} - -.officerList::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; -} - -.officerList::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 4px; -} - -.officerList::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; -} - -/* Responsive design */ -@media (max-width: 768px) { - .officerItem { - padding: 10px 12px; - } - - .officerInfo { - gap: 8px; - } - - .officerName { - font-size: 13px; - } - - .officerRole { - font-size: 11px; - } +.orgName { + font-size: 12px; + color: #666; } diff --git a/src/Expetation.js b/src/Expetation.js index 8db4b22..3b2a2c2 100644 --- a/src/Expetation.js +++ b/src/Expetation.js @@ -1,5 +1,8 @@ -import React, { useEffect, useState } from "react"; -import { User, Users, Baby, Settings, Plus, X } from "lucide-react"; +import React, { useEffect, useState, useRef } from "react"; +import { User, Users, Baby, Settings, Plus, X, Scan, CheckCircle, AlertTriangle, FolderOpen } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import styles from "./Dashboard.module.css"; // Import Dashboard CSS +import expetationStyles from "./Expetation.module.css"; // Import Expetation CSS /* ============================ Helpers @@ -151,20 +154,20 @@ const ExpectationForm = ({ fields, setFields }) => { }; return ( -
+
{safeFields.map((f, i) => ( -
+
updateField(i, "key", e.target.value)} - style={expectationFormStyles.fieldInput} + className={expetationStyles.fieldInput} /> setDocumentName(e.target.value)} - placeholder="Data yang ingin di tambahkan" - style={modalStyles.input} + placeholder="Contoh: KTP, KK, Ijazah, dll" + className={expetationStyles.modalInput} disabled={isSubmitting} required />
-
- +
+ {
{selectedTemplate && ( -
- +
+ +
+
+
+ + setDocumentName(e.target.value)} + className={expetationStyles.modalInput} + disabled={isSubmitting} + required + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ ); +}; + +/* ============================ + Komponen Utama: Expetation (Dashboard Style) ============================ */ const Expetation = ({ onSelect }) => { + const navigate = useNavigate(); const [documentTypes, setDocumentTypes] = useState([]); const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true); const [isEditMode, setIsEditMode] = useState(false); const [showNewDocumentModal, setShowNewDocumentModal] = useState(false); + const [showEditDocumentModal, setShowEditDocumentModal] = useState(false); + const [editingDocument, setEditingDocument] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); const getDocumentDisplayInfo = (doc) => { const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString(); @@ -385,7 +471,8 @@ const Expetation = ({ onSelect }) => { // Normalisasi data dari server "show" const normalizeItem = (doc) => { - const humanName = doc.display_name ?? doc.nama_tipe ?? doc.document_type ?? ""; + // UBAH: Prioritas nama_tipe > display_name > document_type + const humanName = doc.nama_tipe ?? doc.display_name ?? doc.document_type ?? ""; const slug = toSlug(humanName); let expectationObj = {}; @@ -401,9 +488,10 @@ const Expetation = ({ onSelect }) => { return { id: doc.id ?? doc.data_type_id ?? safeUUID(), - nama_tipe: slug, + nama_tipe: slug, // UBAH: pastikan selalu ada nama_tipe display_name: humanName, expectation: expectationObj, + entry_name: doc.entry_name }; }; @@ -445,7 +533,11 @@ const Expetation = ({ onSelect }) => { const data = await response.json(); const normalized = (Array.isArray(data) ? data : []) - .filter((doc) => (doc.nama_tipe ?? doc.document_type) !== "INACTIVE") + // UBAH: filter berdasarkan nama_tipe dan document_type + .filter((doc) => { + const namaType = doc.nama_tipe ?? doc.document_type ?? ""; + return namaType !== "INACTIVE"; + }) .map(normalizeItem); setDocumentTypes(normalized); @@ -479,12 +571,12 @@ const Expetation = ({ onSelect }) => { 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", { + const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-expetation-type", { method: "POST", headers: authHeaders(), body: JSON.stringify({ id, - nama_tipe: namaTipe, + nama_tipe: namaTipe, // UBAH: konsisten gunakan nama_tipe ...(orgId ? { organization_id: orgId } : {}), }), }); @@ -516,7 +608,7 @@ const Expetation = ({ onSelect }) => { method: "POST", headers: authHeaders(), body: JSON.stringify({ - nama_tipe: documentName, // EXACT seperti input + nama_tipe: documentName, // UBAH: konsisten gunakan nama_tipe expectation: expectationObj, ...(orgId ? { organization_id: orgId } : {}), }), @@ -535,6 +627,36 @@ const Expetation = ({ onSelect }) => { } }; + // Edit tipe dokumen (POST body + header X-Organization-Id) + const handleEditDocumentSubmit = async (id, documentName, expectationObj) => { + try { + const orgId = getActiveOrgId(); + const resp = await fetch("https://bot.kediritechnopark.com/webhook/edit-data-type", { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + id, + nama_tipe: documentName, + expectation: expectationObj, + ...(orgId ? { organization_id: orgId } : {}), + }), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`HTTP ${resp.status} ${resp.statusText} - ${text}`); + } + + // Asumsi berhasil jika respons HTTP OK dan tidak ada error lain + await resp.json(); // Tetap baca JSON untuk memastikan respons selesai + await fetchDocumentTypes(); + alert(`Dokumen tipe "${documentName}" berhasil diperbarui.`); + window.location.reload(); // Refresh halaman setelah berhasil + } catch (err) { + console.error("Error update:", err); + alert(`Terjadi kesalahan saat update dokumen: ${err.message || "Silakan cek konsol untuk detail lebih lanjut."}`); + } + }; + const handleDocumentTypeSelection = (item) => { if (!item) return; if (item === "new") { @@ -544,182 +666,295 @@ const Expetation = ({ onSelect }) => { } }; - return ( -
-
-
-

Pilih Jenis Dokumen

- -
-

Silakan pilih jenis dokumen yang akan Anda scan

+ const handleLogout = () => { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + window.location.reload(); + }; -
- {loadingDocumentTypes ? ( -
-
- -
- ) : ( - <> - + {isMenuOpen && ( +
+ {/* Dashboard */} + - {documentTypes.map((doc) => { - const displayInfo = getDocumentDisplayInfo(doc); - return ( -
- - {isEditMode && ( - - )} -
- ); - })} - + {/* Scan */} + + + {/* Logout */} + +
)}
+ {/* Main Content - Dashboard Style */} +
+ +
+ {/* Header dengan Edit Button */} +
+
+

+ Document Types +

+

+ Choose a document type to scan or create a new one +

+
+
+ + {/* Dashboard Grid */} +
+ {/* Add New Document Type Button */} + + + {/* Loading State */} + {loadingDocumentTypes ? ( +
+
+
+
+
+ ) : ( + /* User Created Document Types */ + documentTypes.map((doc, index) => { + const displayInfo = getDocumentDisplayInfo(doc); + const colors = getDocumentColors(index); + + return ( +
+ + + {/* Edit and Delete Buttons (only visible in edit mode) */} + {isEditMode && ( +
+ + +
+ )} + +
+ ); + }) + )} + +
+ + {/* Edit Button - Positioned at Bottom */} + +
+
+ + {/* Footer */} +
+ © 2025 Kediri Technopark • Dashboard SOLID DATA +
+ + {/* Modals */} setShowNewDocumentModal(false)} onSubmit={handleNewDocumentSubmit} /> + + setShowEditDocumentModal(false)} + document={editingDocument} + onSubmit={handleEditDocumentSubmit} + />
); }; -/* ============================ - 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/Expetation.module.css b/src/Expetation.module.css new file mode 100644 index 0000000..83e40ac --- /dev/null +++ b/src/Expetation.module.css @@ -0,0 +1,773 @@ +/* ============================ + Dashboard Main Styles +============================ */ +.dashboardContainer { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.dashboardHeaderWithEdit { + display: flex; + flex-direction: column; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid #E5E7EB; +} + +.dashboardHeaderText { + width: 100%; + text-align: center; +} + +.dashboardHeader { + font-size: 32px; + font-weight: 700; + color: #1F2937; + margin: 0 0 12px 0; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.paragraf { + font-size: 16px; + color: #6B7280; + margin: 0; + line-height: 1.5; + font-weight: 400; + max-width: 600px; + margin: 0 auto; +} + +/* Edit button akan ditempatkan di bawah grid */ +.editButton { + background-color: #F8FAFC; + color: #475569; + padding: 12px 24px; + border-radius: 12px; + border: 1px solid #E2E8F0; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + min-width: 100px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + align-self: center; + margin-top: 20px; +} + +.editButton:hover { + background-color: #F1F5F9; + border-color: #CBD5E1; + color: #334155; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.editButton:active { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +/* FIXED: Grid dengan spacing yang lebih rapi */ +.dashboardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 20px; + margin-bottom: 30px; + padding: 0; +} + +/* FIXED: Card dengan design yang lebih premium */ +.dashboardCard { + background-color: white; + border: 1px solid #E5E7EB; + border-radius: 16px; + padding: 24px; + text-align: center; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + height: 160px; + width: 100%; + position: relative; + box-sizing: border-box; +} + +.dashboardCard:hover { + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); + border-color: #C7D2FE; +} + +.dashboardCard:active { + transform: translateY(-1px); +} + +/* FIXED: Icon container dengan design yang lebih modern */ +.dashboardIconContainer { + width: 52px; + height: 52px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +/* Updated colors dengan gradient subtle */ +.dashboardIconBlue { + background: linear-gradient(135deg, #DBEAFE 0%, #BFDBFE 100%); + color: #1E40AF; +} + +.dashboardIconYellow { + background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%); + color: #D97706; +} + +.dashboardIconGreen { + background: linear-gradient(135deg, #D1FAE5 0%, #A7F3D0 100%); + color: #059669; +} + +.dashboardIconPurple { + background: linear-gradient(135deg, #EDE9FE 0%, #DDD6FE 100%); + color: #7C3AED; +} + +.dashboardIconAdd { + background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%); + color: #6B7280; + border: 2px dashed #D1D5DB; + box-shadow: none; +} + +.dashboardIconAdd:hover { + background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%); + border-color: #3B82F6; + color: #1D4ED8; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15); +} + +/* FIXED: Text dengan typography yang lebih baik */ +.dashboardCardText { + text-align: center; + width: 100%; + overflow: hidden; +} + +.dashboardCardTitle { + font-size: 15px; + font-weight: 600; + color: #111827; + margin-bottom: 6px; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + letter-spacing: -0.02em; +} + +.dashboardCardSubtitle { + font-size: 12px; + color: #9CA3AF; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Document Card Wrapper untuk Edit Mode - Design yang lebih clean */ +.documentCardWrapper { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100%; +} + +.deleteIcon { + position: absolute; + top: -10px; + right: -10px; + background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%); + color: white; + border-radius: 50%; + width: 28px; + height: 28px; + font-size: 18px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + border: 3px solid white; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); + z-index: 10; + transition: all 0.2s ease; + font-weight: 600; +} + +.deleteIcon:hover { + background: linear-gradient(135deg, #DC2626 0%, #B91C1C 100%); + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4); +} + +/* Loading Spinner - FIXED untuk mengikuti ukuran card */ +.spinnerContainer { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; +} + +.spinner { + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ============================ + Modal Styles +============================ */ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background-color: white; + border-radius: 16px; + width: 90%; + max-width: 600px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + max-height: 85vh; + overflow-y: auto; +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 20px 0 20px; + border-bottom: 1px solid #e9ecef; + margin-bottom: 20px; +} + +.modalTitle { + margin: 0; + font-size: 18px; + font-weight: bold; + color: #333; +} + +.modalCloseButton { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: gray; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease; +} + +.modalCloseButton:hover { + color: #333; +} + +.modalContent { + padding: 0 20px 20px 20px; +} + +.modalSection { + margin-bottom: 25px; +} + +.sectionLabel { + display: block; + margin-bottom: 15px; + font-weight: bold; + color: #333; + font-size: 16px; +} + +.modalLabel { + display: block; + margin-bottom: 8px; + font-weight: bold; + color: #333; + font-size: 14px; +} + +.modalInput { + width: 100%; + padding: 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 16px; + outline: none; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.modalInput:focus { + border-color: #007bff; +} + +.templateGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 12px; +} + +.templateCard { + background-color: #f8f9fa; + border-radius: 12px; + padding: 15px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.2s ease; +} + +.templateCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.templateCardActive { + border-color: #007bff; + background-color: #e3f2fd; +} + +.customTemplateCard { + background-color: #fff3cd; +} + +.customTemplateActive { + border-color: #ffc107; + background-color: #fff3cd; +} + +.templateContent { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.templateIconContainer { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #e0f7fa; + display: flex; + justify-content: center; + align-items: center; + transition: background-color 0.2s ease; +} + +.templateIconActive { + background-color: #007bff; + color: white; +} + +.customIconActive { + background-color: #ffc107; + color: white; +} + +.templateName { + font-size: 12px; + font-weight: bold; + color: #333; + text-align: center; +} + +.modalFooter { + display: flex; + gap: 10px; + padding: 20px; + border-top: 1px solid #e9ecef; +} + +.cancelButton { + flex: 1; + padding: 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + background-color: white; + cursor: pointer; + font-size: 16px; + font-weight: bold; + color: #666; + transition: all 0.2s ease; +} + +.cancelButton:hover { + border-color: #007bff; + color: #007bff; +} + +.submitButton { + flex: 1; + padding: 12px; + border: none; + border-radius: 8px; + background-color: #007bff; + color: white; + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: background-color 0.2s ease; +} + +.submitButton:hover:not(:disabled) { + background-color: #0056b3; +} + +.submitButton:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +/* ============================ + Expectation Form Styles +============================ */ +.expectationFormContainer { + margin-top: 10px; +} + +.fieldRow { + display: flex; + align-items: center; + margin-bottom: 12px; + gap: 8px; +} + +.fieldInput { + flex: 2; + padding: 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + outline: none; + transition: border-color 0.3s ease; +} + +.fieldInput:focus { + border-color: #007bff; +} + +.fieldSelect { + flex: 1; + padding: 10px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + outline: none; + background-color: white; + cursor: pointer; +} + +.fieldSelect:focus { + border-color: #007bff; +} + +.removeFieldButton { + background-color: #dc3545; + color: white; + border: none; + border-radius: 6px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.2s ease; +} + +.removeFieldButton:hover { + background-color: #c82333; +} + +.addFieldButton { + background-color: #007bff; + color: white; + border: none; + border-radius: 8px; + padding: 10px 15px; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 10px; + transition: background-color 0.2s ease; +} + +.addFieldButton:hover { + background-color: #0056b3; +} + +/* ============================ + Legacy Selection Styles (for backward compatibility) +============================ */ +.selectionContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: calc(100vh - 140px); + padding: 20px; + box-sizing: border-box; +} + +.selectionContent { + background-color: white; + border-radius: 16px; + padding: 30px; + text-align: center; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); + max-width: 600px; + width: 100%; +} + +.selectionHeader { + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.selectionTitle { + font-size: 15px; + font-weight: bold; + margin-bottom: 10px; + color: #333; +} + +.selectionSubtitle { + font-size: 10px; + color: #666; + margin-bottom: 30px; +} + +.documentGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 20px; + justify-content: center; +} + +.documentCard { + background-color: #f8f9fa; + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + border: 1px solid #e9ecef; + transition: transform 0.2s, box-shadow 0.2s; +} + +.documentCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.documentIconContainer { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: #e0f7fa; + display: flex; + justify-content: center; + align-items: center; +} + +.documentIconContainerFilled { + background-color: #f0f0f0; +} + +.documentIcon { + font-size: 30px; +} + +.plusIcon { + font-size: 40px; + color: #43a0a7; + font-weight: 200; +} + +.documentLabel { + font-size: 15px; + font-weight: bold; + color: #333; + text-transform: capitalize; +} + +.paragraf { + font-size: 16px; + color: #666; + margin-bottom: 30px; + line-height: 1.5; +} + +/* ============================ + Responsive Design - UPDATED untuk design yang lebih rapi +============================ */ +@media (max-width: 768px) { + .dashboardContainer { + padding: 16px; + } + + .dashboardHeaderWithEdit { + margin-bottom: 24px; + padding-bottom: 16px; + } + + .dashboardHeader { + font-size: 26px; + } + + .paragraf { + font-size: 15px; + } + + .dashboardGrid { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + .dashboardCard { + height: 140px; + padding: 20px; + } + + .dashboardIconContainer { + width: 44px; + height: 44px; + border-radius: 12px; + } + + .dashboardCardTitle { + font-size: 14px; + } + + .dashboardCardSubtitle { + font-size: 11px; + } + + .editButton { + padding: 10px 20px; + font-size: 13px; + min-width: 80px; + margin-top: 16px; + } + + .deleteIcon { + width: 24px; + height: 24px; + font-size: 16px; + top: -8px; + right: -8px; + } +} + +@media (max-width: 480px) { + .dashboardContainer { + padding: 12px; + } + + .dashboardHeader { + font-size: 24px; + } + + .paragraf { + font-size: 14px; + } + + .dashboardGrid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .dashboardCard { + height: 130px; + padding: 16px; + } + + .dashboardIconContainer { + width: 40px; + height: 40px; + border-radius: 10px; + } + + .dashboardCardTitle { + font-size: 13px; + } + + .dashboardCardSubtitle { + font-size: 10px; + } + + .editButton { + padding: 8px 16px; + font-size: 12px; + min-width: 70px; + margin-top: 12px; + } + + .deleteIcon { + width: 22px; + height: 22px; + font-size: 14px; + top: -6px; + right: -6px; + } +} \ No newline at end of file diff --git a/src/FileListComponent.js b/src/FileListComponent.js index f42f95b..e586b1d 100644 --- a/src/FileListComponent.js +++ b/src/FileListComponent.js @@ -1,485 +1,380 @@ import React, { useState, useEffect } from "react"; -import styles from "./FileListComponent.module.css"; import * as XLSX from "xlsx"; -import { PDFDownloadLink } from "@react-pdf/renderer"; -import KTPPDF from "./KTPPDF"; +import { saveAs } from "file-saver"; +import styles from "./FileListComponent.module.css"; const FileListComponent = ({ - setTotalFilesSentToday, - setTotalFilesSentMonth, - setTotalFilesSentOverall, - setOfficerPerformanceData, + setOfficerPerformanceData, // optional: kirim seri ke parent (compat) + onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe + onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka }) => { const [files, setFiles] = useState([]); - const [filteredFiles, setFilteredFiles] = useState([]); const [loading, setLoading] = useState(true); - const [selectedFile, setSelectedFile] = useState(null); - const [successMessage, setSuccessMessage] = useState(""); - const [selectedDocumentType, setSelectedDocumentType] = useState(""); + const [selectedType, setSelectedType] = useState(null); + const [entries, setEntries] = useState([]); + const [loadingEntries, setLoadingEntries] = useState(false); + const [selectedEntry, setSelectedEntry] = useState(null); + const [showModal, setShowModal] = useState(false); - // Helper function to convert snake_case to Title Case - const formatKeyToLabel = (key) => { - return key - .replace(/_/g, ' ') - .replace(/\b\w/g, l => l.toUpperCase()); - }; - - // Helper function to check if value is a date string and convert it - const formatValue = (key, value) => { - if (value === null || value === undefined || value === '') { + const getOrganizationId = () => { + try { + const orgData = localStorage.getItem("selected_organization"); + if (!orgData) return null; + const parsed = JSON.parse(orgData); + return parsed.organization_id || null; + } catch (e) { + console.error("Gagal membaca organization_id:", e); return null; } + }; - // Check if the value looks like a date - if (typeof value === 'string' && - (key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) { - const date = new Date(value); - if (!isNaN(date.getTime())) { - return date; - } + // 👉 Download Excel dari daftar entries yang sedang dibuka + const downloadExcel = () => { + if (!entries.length) { + alert("Tidak ada data untuk diunduh."); + return; } - return value; - }; + const flattened = entries.map((e) => ({ + nama_tipe: e.nama_tipe, + ...e.data, + })); - // Dynamic function to process data for Excel export - const processDataForExcel = (data) => { - if (!data || data.length === 0) return []; + const worksheet = XLSX.utils.json_to_sheet(flattened); - return data.map((item) => { - const processedItem = {}; - - Object.entries(item).forEach(([key, value]) => { - // Skip null, undefined, or empty string values - if (value === null || value === undefined || value === '') { - return; - } - - // Skip certain keys that are not needed in export - const excludedKeys = ['id', 'document_type', 'created_at', 'data', 'foto_url']; - if (excludedKeys.includes(key)) { - return; - } - - // Format the key as label - const label = formatKeyToLabel(key); - - // Format the value - const formattedValue = formatValue(key, value); - - processedItem[label] = formattedValue; + // Auto width kolom + const objectMaxLength = []; + flattened.forEach((row) => { + Object.keys(row).forEach((key, colIndex) => { + const value = row[key] ? row[key].toString() : ""; + objectMaxLength[colIndex] = Math.max( + objectMaxLength[colIndex] || key.length, + value.length + ); }); - - return processedItem; }); + worksheet["!cols"] = objectMaxLength.map((w) => ({ wch: w + 2 })); + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Data"); + const excelBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" }); + const blob = new Blob([excelBuffer], { type: "application/octet-stream" }); + saveAs(blob, `data_${selectedType?.nama || "dokumen"}.xlsx`); }; - // Dynamic function to get unique document types - const getUniqueDocumentTypes = (data) => { - const types = [...new Set(data.map(item => item.document_type).filter(Boolean))]; - return types; - }; - + // Fetch daftar tipe dari webhook /files useEffect(() => { const fetchFiles = async () => { const token = localStorage.getItem("token"); + const orgId = getOrganizationId(); + try { const response = await fetch( "https://bot.kediritechnopark.com/webhook/solid-data/files", { - method: "GET", + method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + body: JSON.stringify({ organization_id: orgId }), } ); - if (!response.ok) - throw new Error(`HTTP error! Status: ${response.status}`); const text = await response.text(); - if (!text) throw new Error("Server membalas kosong."); - - const data = JSON.parse(text); - if (!data.success || !Array.isArray(data.data)) - throw new Error("Format respons tidak valid."); - - const fileData = data.data; - setFiles(fileData); - setFilteredFiles(fileData); - - const today = new Date().toISOString().slice(0, 10); - const totalToday = fileData.filter((f) => - f.created_at && f.created_at.startsWith(today) - ).length; - setTotalFilesSentToday(totalToday); - - const now = new Date(); - const currentMonth = now.getMonth(); - const currentYear = now.getFullYear(); - const totalThisMonth = fileData.filter((f) => { - if (!f.created_at) return false; - const d = new Date(f.created_at); - return ( - d.getMonth() === currentMonth && d.getFullYear() === currentYear - ); - }).length; - setTotalFilesSentMonth(totalThisMonth); - - setTotalFilesSentOverall(fileData.length); - - const dateObjects = fileData - .filter(item => item.created_at) - .map((item) => new Date(item.created_at)); - - if (dateObjects.length > 0) { - const minDate = new Date(Math.min(...dateObjects)); - const maxDate = new Date(Math.max(...dateObjects)); - - const monthlyDataMap = {}; - let current = new Date(minDate.getFullYear(), minDate.getMonth(), 1); - const end = new Date(maxDate.getFullYear(), maxDate.getMonth(), 1); - - while (current <= end) { - const monthKey = `${current.getFullYear()}-${String( - current.getMonth() + 1 - ).padStart(2, "0")}`; - monthlyDataMap[monthKey] = 0; - current.setMonth(current.getMonth() + 1); - } - - fileData - .filter(item => item.created_at) - .forEach((item) => { - const d = new Date(item.created_at); - const monthKey = `${d.getFullYear()}-${String( - d.getMonth() + 1 - ).padStart(2, "0")}`; - if (monthlyDataMap[monthKey] !== undefined) - monthlyDataMap[monthKey]++; - }); - - const performanceArray = Object.entries(monthlyDataMap).map( - ([month, count]) => { - const dateObj = new Date(`${month}-01`); - const label = new Intl.DateTimeFormat("id-ID", { - month: "long", - year: "numeric", - }).format(dateObj); - return { month: label, count }; - } - ); - - setOfficerPerformanceData(performanceArray); + if (!text) { + setFiles([]); + return; } - } catch (error) { - console.error("Gagal mengambil data dari server:", error.message); + + let data; + try { + data = JSON.parse(text); + } catch (err) { + console.error("Respons bukan JSON valid:", text); + setFiles([]); + return; + } + + const fileData = Array.isArray(data) ? data : data?.data || []; + setFiles(fileData); + + // === Kirim daftar tipe & seri agregat per tipe ke parent === + const typeOptions = fileData.map((f) => ({ + id: f.data_type_id, + name: f.nama_tipe, + })); + + const byTypeSeries = fileData.map((f) => ({ + label: f.nama_tipe, // sumbu X + count: Number(f.total_entries || 0), // tinggi bar + })); + + if (typeof onTypesLoaded === "function") { + onTypesLoaded(typeOptions, byTypeSeries); + } + // (opsional) kompatibel: tampilkan juga langsung di grafik + if (typeof setOfficerPerformanceData === "function") { + setOfficerPerformanceData(byTypeSeries); + } + } catch (e) { + console.error("Gagal fetch files:", e); + setFiles([]); } finally { setLoading(false); } }; - // fetchFiles(); + fetchFiles(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (selectedDocumentType) { - setFilteredFiles( - files.filter((file) => file.document_type === selectedDocumentType) - ); - } else { - setFilteredFiles(files); + // Fetch entries per data_type_id (saat user klik suatu tipe) + const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => { + let resolvedEntryName = entryName; + if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) { + resolvedEntryName = Object.keys(expectation)[0]; } - }, [selectedDocumentType, files]); - const handleRowClick = async (file) => { - const token = localStorage.getItem("token"); - if (!token) { - alert("Token tidak ditemukan. Silakan login kembali."); - return; - } + setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName }); + setEntries([]); + setLoadingEntries(true); try { + const token = localStorage.getItem("token"); const response = await fetch( - `https://bot.kediritechnopark.com/webhook/solid-data/merged?nama_lengkap=${encodeURIComponent( - file.nama_lengkap || '' - )}`, + "https://bot.kediritechnopark.com/webhook/solid-data/files/entry", { - method: "GET", + method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + body: JSON.stringify({ data_type_id: dataTypeId }), } ); - if (!response.ok) - throw new Error(`HTTP error! Status: ${response.status}`); - const text = await response.text(); - if (!text) throw new Error("Respons kosong dari server."); + if (!response.ok) throw new Error("Gagal ambil entries"); - const data = JSON.parse(text); - if (data.error) { - alert(data.error); - return; + const text = await response.text(); + let data; + try { + data = JSON.parse(text); + } catch (e) { + console.error("Respons bukan JSON valid:", text); + data = []; } - console.log("Data received from merged API:", data[0]); // Debug log - console.log("All keys in data:", Object.keys(data[0])); // Debug log - console.log("Non-null values:", Object.entries(data[0]).filter(([k,v]) => v !== null)); // Debug log - setSelectedFile(data[0]); - } catch (error) { - console.error("Gagal mengambil detail:", error.message); - alert("Gagal mengambil detail. Pastikan data tersedia."); + const entryList = Array.isArray(data) ? data : data?.data || []; + + // Fallback nama field untuk judul kartu + if (!resolvedEntryName && entryList.length > 0) { + resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null; + setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName }); + } + + setEntries(entryList); + + // ====== Hitung seri per-bulan utk tipe yang dibuka ====== + const parseDate = (v) => { + if (!v) return null; + const d = new Date(v); + return isNaN(d) ? null : d; + }; + const pickDate = (entry) => { + const cands = [ + entry.created_at, + entry.updated_at, + entry.data?.tanggal, + entry.data?.tgl, + entry.data?.date, + entry.data?.created_at, + ]; + for (const c of cands) { + const d = parseDate(c); + if (d) return d; + } + return null; + }; + const mmKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + + const counts = new Map(); + for (const e of entryList) { + const d = pickDate(e); + if (!d) continue; + const k = mmKey(d); + counts.set(k, (counts.get(k) || 0) + 1); + } + + const monthlySeries = Array.from(counts.entries()) + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .map(([k, v]) => ({ label: k, count: v })); // "label" = YYYY-MM + + if (typeof onPerformanceReady === "function") { + onPerformanceReady(nama_tipe, monthlySeries); + } + // (opsional) bila ingin langsung tampilkan seri bulanan ini + // if (typeof setOfficerPerformanceData === "function") { + // setOfficerPerformanceData(monthlySeries); + // } + } catch (err) { + console.error("Gagal fetch entries:", err); + setEntries([]); + } finally { + setLoadingEntries(false); } }; - const getImageSrc = (base64) => { - if (!base64) return null; - const cleaned = base64.replace(/\s/g, ""); - if (cleaned.startsWith("iVBOR")) return `data:image/png;base64,${cleaned}`; - if (cleaned.startsWith("/9j/")) return `data:image/jpeg;base64,${cleaned}`; - if (cleaned.startsWith("UklGR")) return `data:image/webp;base64,${cleaned}`; - return `data:image/*;base64,${cleaned}`; + const openEntryModal = (entry) => { + setSelectedEntry(entry); + setShowModal(true); }; - const closeModal = () => setSelectedFile(null); - - const formatPhoneNumber = (phone) => - phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3"); - - const exportToExcel = (data) => { - const processedData = processDataForExcel(data); - if (processedData.length === 0) { - alert("Tidak ada data untuk diekspor."); - return; - } - - const worksheet = XLSX.utils.json_to_sheet(processedData); - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, "Data"); - XLSX.writeFile(workbook, "data-export.xlsx"); + const closeModal = () => { + setShowModal(false); + setSelectedEntry(null); }; - // Get unique document types for dropdown - const documentTypes = getUniqueDocumentTypes(files); + const backToTypes = () => { + setSelectedType(null); + setEntries([]); + }; return ( -
-
-

📁 Daftar Document

-
- - - - {filteredFiles.length} document - -
-
+
+

📑 Daftar Jenis Dokumen

- {successMessage && ( -
- - {successMessage} + {!selectedType ? ( + <> + {loading ? ( +
+
+

Sedang memuat...

+
+ ) : files.length === 0 ? ( +
+
Belum ada data
+

+ Tidak ada jenis dokumen yang tersedia saat ini. +

+
+ ) : ( +
    + {files.map((file, index) => ( +
  • + fetchEntries( + file.data_type_id, + file.nama_tipe, + file.entry_name, + file.expectation + ) + } + > +
    +
    {index + 1}
    +
    +
    {file.nama_tipe}
    +
    + {file.total_entries} data tersedia +
    +
    +
    +
    +
  • + ))} +
+ )} + + ) : ( +
+ + +

+ 📂 Isi Dokumen: {selectedType.nama} +

+ + {/* 👉 Tombol Download Excel */} + {entries.length > 0 && ( + + )} + + {loadingEntries ? ( +
+
+

Sedang memuat data...

+
+ ) : entries.length === 0 ? ( +
+
Belum ada entry
+

+ Belum ada entry untuk dokumen ini. +

+
+ ) : ( +
    + {entries.map((entry, index) => ( +
  • openEntryModal(entry)} + > +
    +
    {index + 1}
    +
    +
    + {entry.data?.[selectedType.entryName] || "Data tidak tersedia"} +
    +
    + Klik untuk melihat detail lengkap +
    +
    +
    +
    +
  • + ))} +
+ )}
)} -
- {filteredFiles.length === 0 ? ( -
-
Belum ada data
-

- Tidak ada data yang tersedia saat ini. -

-
- ) : ( - - - - - - - - - - - {filteredFiles.map((file, index) => ( - handleRowClick(file)} - className={styles.tableRow} - > - - - - - - ))} - -
NoNIKJenisNama Lengkap
{index + 1}{file.nik || '-'}{file.document_type || '-'}{file.nama_lengkap || '-'}
- )} -
- - {/* Modal */} - {selectedFile && ( + {/* Modal untuk detail entry */} + {showModal && selectedEntry && (
-
e.stopPropagation()} - > - {selectedFile.data && ( - {`Foto - )} -

🪪 Detail Data Document

-
- - } - fileName={`Document_${selectedFile.nik || selectedFile.id || 'unknown'}.pdf`} - style={{ - textDecoration: "none", - padding: "8px 16px", - color: "#fff", - backgroundColor: "#00adef", - borderRadius: "6px", - display: "inline-block", - }} - > - {({ loading }) => - loading ? "Menyiapkan PDF..." : "⬇️ Unduh PDF" - } - +
e.stopPropagation()}> +
+

+ Detail Data:{" "} + {selectedEntry.data?.[selectedType.entryName] || + selectedEntry.data?.nama || + selectedEntry.data?.name || + "Data"} +

+
- - - {selectedFile && (console.log("selectedFile in modal:", selectedFile), true) && - Object.entries(selectedFile) - .map(([key, value]) => { - console.log(`Processing: ${key} = ${value} (type: ${typeof value})`); - return [key, value]; - }) - .filter(([key, value]) => { - console.log(`Filtering: ${key} = ${value}`); - - // Exclude specific keys that are not part of the display data - const excludedKeys = [ - "id", - "document_type", - "created_at", - "data", // Exclude image data - "foto_url", // Exclude image URL - ]; - - if (excludedKeys.includes(key)) { - console.log(`Excluded key: ${key}`); - return false; - } - - if (value === null) { - console.log(`Null value for key: ${key}`); - return false; - } - if (value === undefined) { - console.log(`Undefined value for key: ${key}`); - return false; - } - if (typeof value === 'string' && value.trim() === '') { - console.log(`Empty string for key: ${key}`); - return false; - } - - console.log(`Keeping key: ${key} with value: ${value}`); - return true; - }) - .map(([key, value]) => { - console.log(`Rendering field: ${key} = ${value}`); - - // Special handling for 'anggota' array - if (key === "anggota" && Array.isArray(value)) { - return ( - - - - - ); - } - // Format dates for display - let displayValue = value; - if (typeof value === 'string' && - (key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) { - const date = new Date(value); - if (!isNaN(date.getTime())) { - displayValue = date.toLocaleDateString('id-ID'); - } - } - - return ( - - - - - ); - })} - -
{formatKeyToLabel(key)} - {value.map((member, idx) => ( -
- {Object.entries(member) - .filter(([_, memberValue]) => { - if (memberValue === null || memberValue === undefined) return false; - if (typeof memberValue === 'string' && memberValue.trim() === '') return false; - return true; - }) - .map(([memberKey, memberValue]) => ( -
- {formatKeyToLabel(memberKey)}: {memberValue} -
- ))} -
- ))} -
{formatKeyToLabel(key)}{displayValue}
- +
+
+ {Object.entries(selectedEntry.data || {}).map(([key, value]) => ( +
+
+ {key + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase())} +
+
{value || "-"}
+
+ ))} +
+
)} diff --git a/src/FileListComponent.module.css b/src/FileListComponent.module.css index bc0f14e..62dbd26 100644 --- a/src/FileListComponent.module.css +++ b/src/FileListComponent.module.css @@ -1,169 +1,95 @@ -/* FileListComponent.module.css - Updated to match Dashboard design */ +/* FileListComponent.module.css - Brand Blue/Indigo & Mobile-First */ -/* Use the same color palette as Dashboard */ +/* ===== CSS Variables ===== */ :root { - --primary-blue: #3b82f6; - --secondary-blue: #60a5fa; - --dark-blue: #1e40af; - --neutral-50: #fafafa; - --neutral-100: #f5f5f5; - --neutral-200: #e5e5e5; - --neutral-300: #d4d4d4; - --neutral-500: #737373; - --neutral-700: #404040; - --neutral-800: #262626; - --neutral-900: #171717; + --brand-primary: #2961eb; + --brand-primary-700: #1d4ed8; + --brand-secondary: #4f46e5; + --brand-secondary-700: #4338ca; + --brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary)); + + --neutral-25: #fcfcfd; + --neutral-50: #f9fafb; + --neutral-100: #f3f4f6; + --neutral-200: #e5e7eb; + --neutral-300: #d1d5db; + --neutral-400: #9ca3af; + --neutral-600: #475569; + --neutral-700: #374151; + --neutral-800: #1f2937; --white: #ffffff; - --success-green: #43a0a7; - --warning-amber: #f59e0b; - --error-red: #ef4444; + --text-primary: #0f172a; --text-secondary: #64748b; - --text-light: #ffffff; - --border-light: #e2e8f0; - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), - 0 4px 6px -4px rgb(0 0 0 / 0.1); + --text-on-brand: #ffffff; + + --border-light: #e5e7eb; + --shadow-sm: 0 1px 2px rgba(0,0,0,.06); + --shadow-md: 0 4px 10px rgba(2,6,23,.08); + --shadow-lg: 0 12px 22px rgba(2,6,23,.12); + --focus-ring: 0 0 0 3px rgba(37, 99, 235, .18); } -/* File List Section */ -.fileListSection { - background-color: var(--white); - padding: 2rem; - border-radius: 1rem; +/* ===== Container ===== */ +.container { + background: var(--white); border: 1px solid var(--border-light); + border-radius: 1rem; box-shadow: var(--shadow-sm); - margin: 2rem auto; - max-width: 1200px; - width: 100%; - overflow: hidden; - max-height: 600px; - height: auto; - min-height: 0; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; - transition: all 0.2s ease; -} - -.fileListSection:hover { - box-shadow: var(--shadow-md); -} - -.fileListHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - flex-wrap: wrap; - gap: 1rem; - flex-shrink: 0; - width: 100%; -} - -.fileListTitle { + padding: 1.5rem; margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - letter-spacing: -0.025em; -} - -.fileCount { - font-size: 0.6rem; - color: #ffffff; - font-weight: 500; - background-color: #43a0a7; - padding: 0.25rem 0.75rem; - border-radius: 1rem; - border: 1px solid var(--border-light); -} - -.successMessage { - background-color: rgb(67 160 167 / 0.1); - color: var(--success-green); - border: 1px solid rgb(67 160 167 / 0.2); - padding: 0.75rem 1rem; - border-radius: 0.5rem; - margin-bottom: 1rem; - font-size: 0.875rem; - font-weight: 500; - display: flex; - align-items: center; - gap: 0.5rem; - flex-shrink: 0; width: 100%; + min-width: 0; + overflow: hidden; + box-sizing: border-box; } -.tableContainer { - flex: 1; - overflow: auto; - border-radius: 0.75rem; - border: 1px solid var(--border-light); - background-color: var(--white); - width: 100%; +/* ===== Title ===== */ +.title { + margin: 0 0 1.5rem 0; + font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem); + font-weight: 800; + letter-spacing: -0.015em; + color: var(--neutral-800); + background: var(--brand-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } -.fileTable { - width: 100%; - min-width: 600px; - table-layout: auto; - border-collapse: collapse; - font-size: 0.875rem; - background-color: var(--white); -} - -.fileTable th { - background-color: #43a0a7; - padding: 0.75rem; +/* ===== Loading State ===== */ +.loading { text-align: center; - font-weight: 600; - color: #ffffff; - border-bottom: 1px solid var(--border-light); - white-space: nowrap; - position: sticky; - top: 0; - z-index: 1; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; + padding: 3rem 1rem; + color: var(--text-secondary); } -.fileTable td { - padding: 0.75rem; - border-bottom: 1px solid var(--border-light); - color: var(--text-primary); - vertical-align: middle; +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--neutral-300); + border-top: 3px solid var(--brand-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; } -.tableRow { - cursor: pointer; - transition: background-color 0.2s ease; -} - -.tableRow:hover { - background-color: var(--neutral-50); -} - -.nameColumn { - font-weight: 500; - color: var(--text-primary); - min-width: 200px; +@keyframes spin { + to { transform: rotate(360deg); } } +/* ===== Empty State ===== */ .emptyState { text-align: center; - padding: 3rem 2rem; + padding: 3rem 1rem; color: var(--text-secondary); } .emptyStateTitle { font-size: 1.125rem; + font-weight: 700; + color: var(--neutral-800); margin-bottom: 0.5rem; - color: var(--text-primary); - font-weight: 600; } .emptyStateText { @@ -172,327 +98,514 @@ color: var(--text-secondary); } -.spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--neutral-300); - border-top: 3px solid #43a0a7; +/* ===== Type List ===== */ +.typeList { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border-light); + border-radius: 0.75rem; + background: var(--white); + overflow: hidden; +} + +.typeItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid var(--border-light); + cursor: pointer; + transition: all 0.15s ease; + min-height: 60px; +} + +.typeItem:last-child { + border-bottom: none; +} + +.typeItem:hover { + background: rgba(79, 70, 229, 0.05); + transform: translateX(4px); +} + +.typeInfo { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.typeNumber { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--brand-gradient); + color: var(--text-on-brand); border-radius: 50%; - animation: spin 1s linear infinite; - margin: 0 auto 1rem; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2); } -@keyframes spin { - 0% { - transform: rotate(0deg); +.typeDetails { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.typeName { + font-weight: 700; + color: var(--neutral-800); + font-size: 0.95rem; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typeCount { + font-size: 0.8rem; + color: var(--text-secondary); + font-style: italic; +} + +.typeArrow { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; + flex-shrink: 0; +} + +/* ===== Entry Section ===== */ +.entrySection { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); } - 100% { - transform: rotate(360deg); + to { + opacity: 1; + transform: translateY(0); } } -/* Custom Scrollbar */ -.tableContainer::-webkit-scrollbar { +.backButton { + background: var(--neutral-600); + color: var(--text-on-brand); + border: none; + padding: 0.75rem 1.25rem; + border-radius: 0.6rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 700; + margin-bottom: 1.5rem; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 2px 4px rgba(71, 85, 105, 0.2); +} + +.backButton:hover { + background: var(--neutral-700); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(71, 85, 105, 0.3); +} + +.entryTitle { + margin: 0 0 1rem 0; + font-size: clamp(1rem, 0.9vw + 0.7rem, 1.25rem); + font-weight: 700; + color: var(--neutral-800); +} + +/* ===== Entry List ===== */ +.entryList { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border-light); + border-radius: 0.75rem; + background: var(--white); + max-height: 400px; + overflow-y: auto; +} + +.entryItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid var(--border-light); + cursor: pointer; + transition: all 0.15s ease; + min-height: 60px; +} + +.entryItem:last-child { + border-bottom: none; +} + +.entryItem:hover { + background: rgba(41, 97, 235, 0.05); + transform: translateX(4px); +} + +.entryInfo { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.entryNumber { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--brand-gradient); + color: var(--text-on-brand); + border-radius: 50%; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2); +} + +.entryDetails { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.entryName { + font-weight: 700; + color: var(--neutral-800); + font-size: 0.9rem; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entryHint { + font-size: 0.75rem; + color: var(--text-secondary); + font-style: italic; +} + +.entryArrow { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; + flex-shrink: 0; +} + +/* ===== Scrollbar Styling ===== */ +.entryList::-webkit-scrollbar { width: 8px; - height: 8px; } -.tableContainer::-webkit-scrollbar-track { +.entryList::-webkit-scrollbar-track { background: var(--neutral-100); border-radius: 4px; } -.tableContainer::-webkit-scrollbar-thumb { - background: #43a0a7; +.entryList::-webkit-scrollbar-thumb { + background: var(--neutral-300); border-radius: 4px; transition: background 0.2s ease; } -.tableContainer::-webkit-scrollbar-thumb:hover { - background: #306a2f; +.entryList::-webkit-scrollbar-thumb:hover { + background: var(--neutral-400); } -.tableContainer::-webkit-scrollbar-corner { - background: var(--neutral-100); -} - -.tableContainer { - scrollbar-width: thin; - scrollbar-color: #43a0a7 var(--neutral-100); -} - -/* Modal Styles - Matching Dashboard Design */ +/* ===== Modal ===== */ .modalOverlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.6); display: flex; - justify-content: center; align-items: center; + justify-content: center; z-index: 1000; backdrop-filter: blur(4px); + padding: 1rem; + box-sizing: border-box; + animation: fadeIn 0.3s ease-out; } -.modalContent { +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal { background: var(--white); - padding: 2rem; border-radius: 1rem; - max-width: 600px; - width: 90%; - max-height: 85vh; - overflow-y: auto; box-shadow: var(--shadow-lg); border: 1px solid var(--border-light); -} - -.modalContent h3 { - margin: 0 0 1.5rem 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - letter-spacing: -0.025em; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-light); -} - -.detailTable { width: 100%; - border-collapse: collapse; - margin-bottom: 1.5rem; - font-size: 0.875rem; - text-align: left; + max-width: 900px; + max-height: 85vh; + overflow: hidden; + animation: modalSlideIn 0.3s ease-out; + display: flex; + flex-direction: column; } -.detailTable tr:nth-child(even) { - background-color: var(--neutral-50); +@keyframes modalSlideIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } } -.detailTable td { - padding: 0.75rem; +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 1.5rem 0 1.5rem; border-bottom: 1px solid var(--border-light); - vertical-align: top; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + flex-shrink: 0; } -.detailTable td:first-child { - font-weight: 600; - color: var(--text-secondary); - width: 35%; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 0.05em; -} - -.detailTable td:last-child { - color: var(--text-primary); - font-weight: 500; +.modalTitle { + font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem); + font-weight: 800; + color: var(--neutral-800); + margin: 0; + letter-spacing: -0.015em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 1rem; } .closeButton { - background-color: #43a0a7; - color: var(--text-light); + background: #ef4444; + color: var(--text-on-brand); border: none; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; - font-size: 0.875rem; - font-weight: 600; - width: 100%; - transition: all 0.2s ease; - letter-spacing: 0.025em; + font-size: 1.25rem; + font-weight: 700; + transition: all 0.15s ease; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2); } .closeButton:hover { - background-color: #dc2626; - transform: translateY(-1px); - box-shadow: var(--shadow-md); + background: #dc2626; + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3); } -.closeButton:active { - transform: translateY(0); +.modalContent { + padding: 0 1.5rem 1.5rem 1.5rem; + overflow-y: auto; + flex: 1; } -/* Responsive Design */ -@media (max-width: 768px) { - .fileListSection { - padding: 1.5rem; - margin: 1rem; - max-width: 100%; - height: auto; - max-height: 70vh; +.detailGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} + +.detailItem { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.detailLabel { + font-size: 0.75rem; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detailValue { + font-size: 0.875rem; + color: var(--neutral-800); + font-weight: 600; + padding: 0.75rem; + background: var(--neutral-50); + border-radius: 0.5rem; + border: 1px solid var(--border-light); + min-height: 44px; + display: flex; + align-items: center; + word-break: break-word; + overflow-wrap: break-word; +} + +/* ===== Responsive Design ===== */ + +/* Mobile First - Already defined above */ + +/* Tablet */ +@media (min-width: 768px) { + .container { + padding: 2rem; + margin: 2rem auto; + max-width: 1200px; } - .fileListHeader { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; + .typeItem, + .entryItem { + padding: 1.25rem; + min-height: 70px; } - .fileListTitle { - font-size: 1.125rem; - } - - /* Mobile: Show only NIK and Name columns */ - .fileTable { - min-width: 100%; - } - - .fileTable th:not(:nth-child(2)):not(:nth-child(3)), - .fileTable td:not(:nth-child(2)):not(:nth-child(3)) { - display: none; - } - - .fileTable th, - .fileTable td { - padding: 0.75rem 0.5rem; + .typeNumber, + .entryNumber { + width: 36px; + height: 36px; font-size: 0.875rem; } - .fileTable th:nth-child(2) { - width: 40%; + .typeName, + .entryName { + font-size: 1rem; } - .fileTable th:nth-child(3) { - width: 60%; + .modalHeader { + padding: 2rem 2rem 0 2rem; + margin-bottom: 2rem; } - .nameColumn { - min-width: unset; - } - - /* Modal responsive */ .modalContent { - padding: 1.5rem; - width: 95%; - max-height: 90vh; - border-radius: 0.75rem; - } - - .modalContent h3 { - font-size: 1.125rem; - } - - .detailTable { - font-size: 0.8125rem; - } - - .detailTable td { - padding: 0.625rem 0.5rem; - } - - .detailTable td:first-child { - width: 40%; + padding: 0 2rem 2rem 2rem; } } +/* Desktop */ +@media (min-width: 1024px) { + .container { + padding: 2.5rem; + margin: 2.5rem auto; + } + + .detailGrid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + + .entryList { + max-height: 500px; + } +} + +/* Small Mobile */ @media (max-width: 480px) { - .fileListSection { + .container { padding: 1rem; margin: 0.5rem; border-radius: 0.75rem; } - .fileListTitle { - font-size: 1rem; + .typeItem, + .entryItem { + padding: 0.75rem; + min-height: 56px; } - .fileCount { - font-size: 0.6rem; - padding: 0.25rem 0.5rem; + .typeNumber, + .entryNumber { + width: 28px; + height: 28px; + font-size: 0.7rem; } - .fileTable th, - .fileTable td { - padding: 0.5rem 0.375rem; - font-size: 0.8125rem; + .typeName, + .entryName { + font-size: 0.85rem; } - .modalContent { - padding: 1rem; - width: 98%; - border-radius: 0.5rem; - } - - .modalContent h3 { - font-size: 1rem; + .modalHeader { + padding: 1rem 1rem 0 1rem; margin-bottom: 1rem; } - .detailTable { - font-size: 0.75rem; + .modalContent { + padding: 0 1rem 1rem 1rem; } - .detailTable td { - padding: 0.5rem 0.375rem; + .detailGrid { + grid-template-columns: 1fr; + gap: 0.75rem; } - .closeButton { - padding: 0.625rem 1rem; + .detailValue { + padding: 0.625rem; font-size: 0.8125rem; } -} - -/* Tablet and Desktop enhancements */ -@media (min-width: 769px) { - .fileListSection { - padding: 2.5rem; - margin: 2.5rem auto; - } - - .fileListTitle { - font-size: 1.5rem; - } - - .fileTable th, - .fileTable td { - padding: 1rem 0.75rem; - } - - .modalContent { - padding: 2.5rem; - border-radius: 1rem; - } - - .modalContent h3 { - font-size: 1.5rem; - margin-bottom: 2rem; - } - - .detailTable { - font-size: 0.875rem; - } - - .detailTable td { - padding: 1rem 0.75rem; - } .closeButton { - padding: 0.875rem 2rem; - display: block; + width: 32px; + height: 32px; + font-size: 1.1rem; } } -@media (min-width: 1024px) { - .fileListSection { - padding: 3rem; - margin: 3rem auto; +/* Prevent text selection on interactive elements */ +.typeItem, +.entryItem, +.backButton, +.closeButton { + user-select: none; +} + +/* Focus styles for accessibility */ +.typeItem:focus, +.entryItem:focus, +.backButton:focus, +.closeButton:focus { + outline: none; + box-shadow: var(--focus-ring); +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .typeNumber, + .entryNumber { + border: 2px solid var(--text-primary); } -} - -.downloadButton { - background-color: #164665; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 1rem; - cursor: pointer; - font-weight: bold; - font-size: 0.6rem; - transition: background-color 0.3s ease; -} - -.downloadButton:hover { - background-color: #008fc4; -} + + .modal { + border: 2px solid var(--text-primary); + } +} \ No newline at end of file diff --git a/src/KTPScanner.js b/src/KTPScanner.js index c0d5229..1a8675d 100644 --- a/src/KTPScanner.js +++ b/src/KTPScanner.js @@ -3,129 +3,150 @@ import { useNavigate } from "react-router-dom"; import PaginatedFormEditable from "./PaginatedFormEditable"; import Modal from "./Modal"; import Expetation from "./Expetation"; +import LiquidGlassHeader from "./components/Header"; +import { Loader2 } from "lucide-react"; + + +/** + * Perubahan utama: + * 1) Tambah komponen yang muncul saat `loading === true` di SEMUA step + * (kamera maupun upload). + * 2) `handleManualUpload` sekarang memanggil `setLoading(true)` SEBELUM membaca file, agar + * animasi langsung tampil begitu user memilih file. + * 3) Tombol-tombol dinonaktifkan ketika loading untuk mencegah double action. + * 4) Tambah teks status akesibel (aria-live) agar ramah pembaca layar. + */ const spinnerStyle = ` -@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} } +@keyframes spin { + 0% { transform: rotate(0deg);} + 100% { transform: rotate(360deg);} +} +@keyframes dots { + 0% { content: "."; } + 33% { content: ".."; } + 66% { content: "..."; } + 100% { content: "."; } +} +.loading-dots::after { content: "."; animation: dots 1.2s infinite steps(3,end); } `; -const ctaBtn = { - padding: 10, - backgroundColor: "#ef4444", - borderRadius: 15, - color: "white", - fontWeight: "bold", - cursor: "pointer", - marginBottom: "10px", -}; - 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", + sourceButton: { + backgroundColor: "#5856d6", + color: "white", + padding: "16px 24px", + borderRadius: "12px", + border: "none", + fontSize: "16px", + fontWeight: "600", cursor: "pointer", - fontSize: "1rem", - minWidth: "2.5rem", - height: "2.5rem", - display: "flex", - alignItems: "center", - justifyContent: "center", + margin: "8px 0", + width: "200px", + textAlign: "center", + transition: "opacity .2s ease, transform .05s ease", }, - dropdownMenu: { - position: "absolute", - top: "calc(100% + 0.5rem)", + sourceButtonText: { + color: "#666", + fontSize: "16px", + margin: "16px 0", + fontWeight: "500", + }, + cameraContainer: { + position: "fixed", + top: 0, + left: 0, 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, + bottom: 0, + backgroundColor: "black", + zIndex: 1000, + overflow: "hidden", 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", + position: "absolute", + top: "20px", + left: "20px", + backgroundColor: "rgba(255, 255, 255, 0.2)", color: "white", - padding: "10px 15px", - borderRadius: "8px", border: "none", - fontSize: "14px", - fontWeight: "bold", + borderRadius: "50%", + width: "40px", + height: "40px", + fontSize: "18px", cursor: "pointer", - marginBottom: "15px", - width: "100%", - }, - spinnerContainer: { + zIndex: 1001, display: "flex", - justifyContent: "center", alignItems: "center", - height: "100px", + justifyContent: "center", + backdropFilter: "blur(10px)", + boxShadow: "0 2px 8px rgba(0,0,0,0.3)", + }, + captureButton: { + position: "absolute", + bottom: "30px", + left: "50%", + transform: "translateX(-50%)", + backgroundColor: "white", + border: "3px solid rgba(255, 255, 255, 0.3)", + borderRadius: "50%", + width: "70px", + height: "70px", + fontSize: "24px", + cursor: "pointer", + zIndex: 1001, + display: "flex", + alignItems: "center", + justifyContent: "center", + boxShadow: "0 4px 12px rgba(0,0,0,0.4)", + transition: "transform 0.1s ease", + }, + canvas: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 2, + objectFit: 'cover', + }, + guideText: { + position: "absolute", + top: "80px", + left: "50%", + transform: "translateX(-50%)", + color: "white", + fontSize: "16px", + fontWeight: "500", + zIndex: 1001, + textAlign: "center", + backgroundColor: "rgba(0, 0, 0, 0.5)", + padding: "12px 20px", + borderRadius: "20px", + backdropFilter: "blur(10px)", + maxWidth: "90%", }, spinner: { border: "4px solid #f3f3f3", borderTop: "4px solid #429241", borderRadius: "50%", - width: "40px", - height: "40px", + width: "44px", + height: "44px", animation: "spin 1s linear infinite", }, + ctaBtn: { + padding: "16px 24px", + backgroundColor: "#5856d6", + borderRadius: "12px", + color: "white", + fontWeight: "600", + cursor: "pointer", + textAlign: "center", + margin: "8px auto", + width: "200px", + fontSize: "16px", + }, }; /* ============================ @@ -133,11 +154,9 @@ const styles = { ============================ */ const getCleanToken = () => { let raw = localStorage.getItem("token") || ""; - try { raw = JSON.parse(raw); } catch {} + 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") ?? @@ -145,16 +164,18 @@ const getSelectedOrganization = () => { 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 getActiveNamaType = (selectedDocumentType) => { + if (!selectedDocumentType) return ""; + if (selectedDocumentType.id) return String(selectedDocumentType.id); + if (selectedDocumentType.display_name) return String(selectedDocumentType.display_name); + return ""; +}; const authHeaders = ({ isJson = false } = {}) => { const token = getCleanToken(); const orgId = getActiveOrgId(); @@ -165,40 +186,65 @@ const authHeaders = ({ isJson = false } = {}) => { return isJson ? { "Content-Type": "application/json", ...base } : base; }; -const KTPScanner = () => { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const menuRef = useRef(null); - const navigate = useNavigate(); +/* ============================ + KOMPPN: LoadingOverlay +============================ */ +const LoadingOverlay = ({ message = "Memproses..." }) => ( +
+
+

{message}

+
+); + + +/* ============================ + MAIN COMPONENT +============================ */ +const KTPScanner = () => { + const navigate = useNavigate(); const handleLogout = () => { localStorage.removeItem("token"); localStorage.removeItem("user"); window.location.reload(); }; + // refs const videoRef = useRef(null); const canvasRef = useRef(null); const hiddenCanvasRef = useRef(null); + const fileInputRef = useRef(null); + const freezeFrameRef = useRef(null); + const rectRef = useRef({ x: 0, y: 0, width: 0, height: 0, radius: 20 }); + + // state + const [step, setStep] = useState("document"); // "document" | "source" | "camera" | "preview" const [capturedImage, setCapturedImage] = useState(null); const [fileTemp, setFileTemp] = useState(null); const [isFreeze, setIsFreeze] = useState(false); - const freezeFrameRef = useRef(null); - const [loading, setLoading] = 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 [isScanned, setIsScanned] = useState(false); const [showSuccessMessage, setShowSuccessMessage] = useState(false); const [modalOpen, setModalOpen] = useState(false); - const fileInputRef = useRef(null); - const triggerFileSelect = () => fileInputRef.current?.click(); - - const rectRef = useRef({ x: 0, y: 0, width: 0, height: 0, radius: 20 }); - + /* ============================ + CAMERA FUNCTIONS - PORTRAIT OPTIMIZED + ============================ */ const drawRoundedRect = (ctx, x, y, width, height, radius) => { ctx.beginPath(); ctx.moveTo(x + radius, y); @@ -211,14 +257,51 @@ const KTPScanner = () => { ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); - ctx.strokeStyle = "red"; + ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.stroke(); + + const cornerSize = 25; + ctx.strokeStyle = "#00ff88"; + ctx.lineWidth = 4; + + // Top-left + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + radius + cornerSize, y); + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y + radius + cornerSize); + ctx.stroke(); + + // Top-right + ctx.beginPath(); + ctx.moveTo(x + width - radius - cornerSize, y); + ctx.lineTo(x + width - radius, y); + ctx.moveTo(x + width, y + radius); + ctx.lineTo(x + width, y + radius + cornerSize); + ctx.stroke(); + + // Bottom-left + ctx.beginPath(); + ctx.moveTo(x, y + height - radius - cornerSize); + ctx.lineTo(x, y + height - radius); + ctx.moveTo(x + radius, y + height); + ctx.lineTo(x + radius + cornerSize, y + height); + ctx.stroke(); + + // Bottom-right + ctx.beginPath(); + ctx.moveTo(x + width, y + height - radius - cornerSize); + ctx.lineTo(x + width, y + height - radius); + ctx.moveTo(x + width - radius - cornerSize, y + height); + ctx.lineTo(x + width - radius, y + height); + ctx.stroke(); }; const fillOutsideRect = (ctx, rect, canvasWidth, canvasHeight) => { ctx.save(); const { x, y, width, height, radius } = rect; + ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); @@ -230,52 +313,112 @@ const KTPScanner = () => { ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); + ctx.rect(0, 0, canvasWidth, canvasHeight); - ctx.fillStyle = "rgba(173, 173, 173, 1)"; + ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fill("evenodd"); ctx.restore(); }; const initializeCamera = async () => { try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: "environment" } }, + if (typeof window !== "undefined" && window.screen?.orientation?.lock) { + try { await window.screen.orientation.lock("potrait") ; } catch (e) {} + } + + const pixelRatio = window.devicePixelRatio || 1; + + const constraints = { + video: { + facingMode: { ideal: "environment" }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30, min: 15 } + }, audio: false, - }); + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); if (videoRef.current) { videoRef.current.srcObject = stream; videoRef.current.onloadedmetadata = () => { videoRef.current.play(); + + setIsFreeze(false); + freezeFrameRef.current = null; + const video = videoRef.current; const canvas = canvasRef.current; const hiddenCanvas = hiddenCanvasRef.current; const ctx = canvas.getContext("2d"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.style.maxWidth = "100%"; - canvas.style.height = "auto"; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + canvas.width = viewportWidth * pixelRatio; + canvas.height = viewportHeight * pixelRatio; + canvas.style.width = viewportWidth + 'px'; + canvas.style.height = viewportHeight + 'px'; + + ctx.scale(pixelRatio, pixelRatio); hiddenCanvas.width = video.videoWidth; hiddenCanvas.height = video.videoHeight; - const rectWidth = canvas.width * 0.9; - const rectHeight = (53.98 / 85.6) * rectWidth; - const rectX = (canvas.width - rectWidth) / 2; - const rectY = (canvas.height - rectHeight) / 2; + const margin = 40; + const topBottomMargin = 120; + const availableWidth = viewportWidth - (margin * 2); + const availableHeight = viewportHeight - (topBottomMargin * 2); + const portraitRatio = 0.63; + let rectWidth, rectHeight; + rectWidth = Math.min(availableWidth * 0.85, 280); + rectHeight = rectWidth / portraitRatio; + if (rectHeight > availableHeight) { + rectHeight = availableHeight; + rectWidth = rectHeight * portraitRatio; + } + rectRef.current = { + x: (viewportWidth - rectWidth) / 2, + y: (viewportHeight - rectHeight) / 2, + width: rectWidth, + height: rectHeight, + radius: 20, + }; - rectRef.current = { x: rectX, y: rectY, width: rectWidth, height: rectHeight, radius: 20 }; + const drawLoop = () => { + if (video.readyState >= 2) { + ctx.clearRect(0, 0, viewportWidth, viewportHeight); - const drawToCanvas = () => { - if (video.readyState === 4) { - ctx.clearRect(0, 0, canvas.width, canvas.height); if (isFreeze && freezeFrameRef.current) { - ctx.putImageData(freezeFrameRef.current, 0, 0); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = freezeFrameRef.current.width; + tempCanvas.height = freezeFrameRef.current.height; + tempCanvas.getContext('2d').putImageData(freezeFrameRef.current, 0, 0); + ctx.drawImage(tempCanvas, 0, 0, viewportWidth, viewportHeight); } else { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const videoAspect = video.videoWidth / video.videoHeight; + const screenAspect = viewportWidth / viewportHeight; + let drawWidth, drawHeight, offsetX, offsetY; + if (videoAspect > screenAspect) { + drawHeight = viewportHeight; + drawWidth = drawHeight * videoAspect; + offsetX = (viewportWidth - drawWidth) / 2; + offsetY = 0; + } else { + drawWidth = viewportWidth; + drawHeight = drawWidth / videoAspect; + offsetX = 0; + offsetY = (viewportHeight - drawHeight) / 2; + } + ctx.drawImage(video, offsetX, offsetY, drawWidth, drawHeight); } + + if (!isFreeze) { + fillOutsideRect(ctx, rectRef.current, viewportWidth, viewportHeight); + } + drawRoundedRect( ctx, rectRef.current.x, @@ -284,98 +427,100 @@ const KTPScanner = () => { rectRef.current.height, rectRef.current.radius ); - if (isFreeze) fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); } - if (!showDocumentSelection) requestAnimationFrame(drawToCanvas); + requestAnimationFrame(drawLoop); }; - drawToCanvas(); + drawLoop(); setCameraInitialized(true); }; } } catch (err) { console.error("Gagal mendapatkan kamera:", err); + alert("Tidak dapat mengakses kamera. Pastikan browser memiliki izin kamera."); } }; - useEffect(() => { - const handleClickOutside = (event) => { - if (menuRef.current && !menuRef.current.contains(event.target)) { - setIsMenuOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - useEffect(() => { - if (cameraInitialized) { - const video = videoRef.current; - const canvas = canvasRef.current; - const ctx = canvas.getContext("2d"); - - const drawToCanvas = () => { - if (video && video.readyState === 4) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - if (isFreeze && freezeFrameRef.current) { - ctx.putImageData(freezeFrameRef.current, 0, 0); - } else { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - } - drawRoundedRect( - ctx, - rectRef.current.x, - rectRef.current.y, - rectRef.current.width, - rectRef.current.height, - rectRef.current.radius - ); - if (isFreeze) fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); - } - if (!showDocumentSelection) requestAnimationFrame(drawToCanvas); - }; - - if (!showDocumentSelection) drawToCanvas(); - } - }, [isFreeze, cameraInitialized, showDocumentSelection]); - + /* ============================ + CAPTURE & SCAN - PORTRAIT OPTIMIZED + ============================ */ const shootImage = async () => { const video = videoRef.current; + const canvas = canvasRef.current; const { x, y, width, height } = rectRef.current; const hiddenCanvas = hiddenCanvasRef.current; const hiddenCtx = hiddenCanvas.getContext("2d"); - const visibleCtx = canvasRef.current.getContext("2d"); + const visibleCtx = canvas.getContext("2d"); - freezeFrameRef.current = visibleCtx.getImageData(0, 0, canvasRef.current.width, canvasRef.current.height); - setIsFreeze(true); + // Munculkan loading segera ketika user menekan tombol tangkap setLoading(true); + const pixelRatio = window.devicePixelRatio || 1; + freezeFrameRef.current = visibleCtx.getImageData(0, 0, canvas.width, canvas.height); + setIsFreeze(true); + video.pause(); + hiddenCtx.drawImage(video, 0, 0, hiddenCanvas.width, hiddenCanvas.height); - const cropCanvas = document.createElement("canvas"); - cropCanvas.width = Math.floor(width); - cropCanvas.height = Math.floor(height); - const cropCtx = cropCanvas.getContext("2d"); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const videoAspect = video.videoWidth / video.videoHeight; + const screenAspect = viewportWidth / viewportHeight; + + let videoDisplayWidth, videoDisplayHeight, videoOffsetX, videoOffsetY; + + if (videoAspect > screenAspect) { + videoDisplayHeight = viewportHeight; + videoDisplayWidth = videoDisplayHeight * videoAspect; + videoOffsetX = (viewportWidth - videoDisplayWidth) / 2; + videoOffsetY = 0; + } else { + videoDisplayWidth = viewportWidth; + videoDisplayHeight = videoDisplayWidth / videoAspect; + videoOffsetX = 0; + videoOffsetY = (viewportHeight - videoDisplayHeight) / 2; + } + + const scaleX = hiddenCanvas.width / videoDisplayWidth; + const scaleY = hiddenCanvas.height / videoDisplayHeight; + + const cropX = (x - videoOffsetX) * scaleX; + const cropY = (y - videoOffsetY) * scaleY; + const cropWidth = width * scaleX; + const cropHeight = height * scaleY; + + const finalCropX = Math.max(0, Math.min(cropX, hiddenCanvas.width - cropWidth)); + const finalCropY = Math.max(0, Math.min(cropY, hiddenCanvas.height - cropHeight)); + const finalCropWidth = Math.min(cropWidth, hiddenCanvas.width - finalCropX); + const finalCropHeight = Math.min(cropHeight, hiddenCanvas.height - finalCropY); + + const cropCanvas = document.createElement("canvas"); + cropCanvas.width = Math.floor(finalCropWidth); + cropCanvas.height = Math.floor(finalCropHeight); + + const cropCtx = cropCanvas.getContext("2d"); cropCtx.drawImage( hiddenCanvas, - Math.floor(x), - Math.floor(y), - Math.floor(width), - Math.floor(height), + Math.floor(finalCropX), + Math.floor(finalCropY), + Math.floor(finalCropWidth), + Math.floor(finalCropHeight), 0, 0, - Math.floor(width), - Math.floor(height) + Math.floor(finalCropWidth), + Math.floor(finalCropHeight) ); - const imageDataUrl = cropCanvas.toDataURL("image/png", 1.0); - setCapturedImage(imageDataUrl); + const imageData = cropCanvas.toDataURL("image/jpeg", 0.9); + setCapturedImage(imageData); + + setLoading(false); }; - function base64ToFile(base64Data, fileName) { + const base64ToFile = (base64Data, fileName) => { const arr = base64Data.split(","); const mime = arr[0].match(/:(.*?);/)[1]; const bstr = atob(arr[1]); @@ -383,73 +528,60 @@ const KTPScanner = () => { const u8arr = new Uint8Array(n); while (n--) u8arr[n] = bstr.charCodeAt(n); return new File([u8arr], fileName, { type: mime }); - } + }; - // Scan (kirim image + expectation + organization_id) const ReadImage = async (capturedImage) => { try { setLoading(true); - const token = getCleanToken(); const orgId = getActiveOrgId(); + const namaType = getActiveNamaType(selectedDocumentType); const file = base64ToFile(capturedImage, "image.jpg"); - const formData = new FormData(); formData.append("image", file); - - // 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 + formData.append("expectation", JSON.stringify(selectedDocumentType?.expectation || {})); if (orgId) formData.append("organization_id", orgId); - - const res = await fetch( - "https://bot.kediritechnopark.com/webhook/solid-data/scan", - { - method: "POST", - headers: authHeaders(), // JANGAN set Content-Type (biar FormData yang atur) - body: formData, - } - ); + if (namaType) formData.append("data_type_id", namaType); + const res = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/scan", { + method: "POST", + headers: authHeaders(), + body: formData, + }); + const data = await res.json(); + console.log("Scan result:", data); setLoading(false); - - const data = await res.json(); if (data.responseCode === 409) { setFileTemp({ error: 409 }); - setIsScanned(true); - return; + } else if (!data || Object.keys(data).length === 0) { + setFileTemp({}); + } else { + setFileTemp(data); } - setFileTemp(data); setIsScanned(true); - } catch (error) { - console.error("Failed to read image:", error); + setStep("preview"); + + } catch (err) { + console.error("Failed to read image:", err); + setFileTemp({}); setIsScanned(true); + setLoading(false); + setStep("preview"); } }; - // SAVE (tambahkan organization_id) - const handleSaveTemp = async (verifiedData, documentType) => { + const handleSaveTemp = async (verifiedData) => { try { setLoading(true); - const token = getCleanToken(); const orgId = getActiveOrgId(); - + const namaType = getActiveNamaType(selectedDocumentType); const formData = new FormData(); formData.append("data", JSON.stringify(verifiedData)); - formData.append("document_type", documentType || ""); - - // >>> penting: sertakan organization_id if (orgId) formData.append("organization_id", orgId); + if (namaType) formData.append("data_type_id", namaType); await fetch("https://bot.kediritechnopark.com/webhook/solid-data/save", { method: "POST", - headers: authHeaders(), // Authorization + X-Organization-Id + headers: authHeaders(), body: formData, }); @@ -462,281 +594,306 @@ const KTPScanner = () => { setIsFreeze(false); setIsScanned(false); setCapturedImage(null); - }, 3000); + setStep("source"); + }, 2000); } catch (err) { - console.error("Gagal menyimpan ke server:", err); + console.error("Gagal simpan:", err); setLoading(false); } }; - // DELETE temp (sertakan organization_id) - const handleDeleteTemp = async () => { - try { - 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); - } catch (err) { - console.error("Gagal menghapus dari server:", err); - } - }; - + /* ============================ + HELPERS + ============================ */ const handleManualUpload = async (e) => { const file = e.target.files[0]; if (!file) return; + await new Promise(requestAnimationFrame); + + // TAMPILKAN LOADING SEGERA begitu user memilih file + setLoading(true); const reader = new FileReader(); - reader.onloadend = () => { - const imageDataUrl = reader.result; - setCapturedImage(imageDataUrl); + reader.onloadend = async () => { + setCapturedImage(reader.result); setIsFreeze(true); - - const image = new Image(); - image.onload = async () => { - const rectWidth = rectRef.current.width; - const rectHeight = rectRef.current.height; - const canvas = canvasRef.current; - const ctx = canvas.getContext("2d"); - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(image, 0, 0, canvas.width, canvas.height); - - drawRoundedRect( - ctx, - rectRef.current.x, - rectRef.current.y, - rectRef.current.width, - rectRef.current.height, - rectRef.current.radius - ); - fillOutsideRect(ctx, rectRef.current, canvas.width, canvas.height); - - freezeFrameRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); - - const cropCanvas = document.createElement("canvas"); - cropCanvas.width = rectWidth; - cropCanvas.height = rectHeight; - const cropCtx = cropCanvas.getContext("2d"); - - cropCtx.drawImage( - canvas, - rectRef.current.x, - rectRef.current.y, - rectWidth, - rectHeight, - 0, - 0, - rectWidth, - rectHeight - ); - }; - image.src = imageDataUrl; + await ReadImage(reader.result); + setStep("preview"); + // ReadImage sudah mengelola setLoading }; reader.readAsDataURL(file); }; - const goBackToSelection = () => { - setShowDocumentSelection(true); - setSelectedDocumentType(null); - setCameraInitialized(false); - setIsFreeze(false); + const triggerFileSelect = () => fileInputRef.current?.click(); + const resetToSource = () => { setCapturedImage(null); - setFileTemp(null); - setIsScanned(false); - setShowSuccessMessage(false); - - if (videoRef.current && videoRef.current.srcObject) { - const stream = videoRef.current.srcObject; - const tracks = stream.getTracks(); - tracks.forEach((track) => track.stop()); - videoRef.current.srcObject = null; - } - }; - - const handleHapus = () => { setFileTemp(null); setIsFreeze(false); setIsScanned(false); - setCapturedImage(null); - setShowSuccessMessage(false); - if (videoRef.current && videoRef.current.srcObject) { - const stream = videoRef.current.srcObject; - const tracks = stream.getTracks(); - tracks.forEach((track) => track.stop()); - videoRef.current.srcObject = null; + setStep("source"); + if (videoRef.current?.srcObject) { + videoRef.current.srcObject.getTracks().forEach((t) => t.stop()); + } + if (typeof window !== "undefined" && window.screen?.orientation?.unlock) { + window.screen.orientation.unlock() ; } }; + const handleRetake = () => { + // bersihkan hasil freeze/capture dan balik ke live preview kamera + setCapturedImage(null); + setFileTemp(null); + setIsScanned(false); + setIsFreeze(false); + try { + videoRef.current?.play(); // lanjutkan stream kamera + } catch {} +}; - // selection callback from Expetation (menerima OBJEK dokumen) const handleSelectDocumentType = (doc) => { setSelectedDocumentType(doc); - setShowDocumentSelection(false); - initializeCamera(); + setStep("source"); }; useEffect(() => { - const video = videoRef.current; return () => { - if (video && video.srcObject) { - video.srcObject.getTracks().forEach((t) => t.stop()); + if (videoRef.current?.srcObject) { + videoRef.current.srcObject.getTracks().forEach((t) => t.stop()); + } + if (typeof window !== "undefined" && window.screen?.orientation?.unlock) { + window.screen.orientation.unlock(); } }; }, []); + /* ============================ + RENDER + ============================ */ return (
-
-
- Bot Avatar -

SOLID

-

DATA

-
+ + + {step !== "camera" && ( + navigate("/dashboard")} + /> + )} + + {/* STEP 1: PILIH DOKUMEN */} + {step === "document" && } + + {/* STEP 2: PILIH SUMBER */} + {step === "source" && ( +
+

Pilih Sumber Gambar

-
- {isMenuOpen && ( -
- - -
- )} -
-
- - {showDocumentSelection ? ( - - ) : ( - <> -