V1
This commit is contained in:
18
package-lock.json
generated
18
package-lock.json
generated
@@ -13,11 +13,13 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
@@ -8105,6 +8107,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
"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",
|
"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=="
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -17508,6 +17525,7 @@
|
|||||||
"version": "0.18.5",
|
"version": "0.18.5",
|
||||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adler-32": "~1.3.0",
|
"adler-32": "~1.3.0",
|
||||||
"cfb": "~1.2.1",
|
"cfb": "~1.2.1",
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"js-levenshtein": "^1.1.6",
|
"js-levenshtein": "^1.1.6",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"pixelmatch": "^7.1.0",
|
"pixelmatch": "^7.1.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"recharts": "^3.0.2",
|
"recharts": "^3.0.2",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 1.5 MiB |
BIN
public/ikasapta1.png
Normal file
BIN
public/ikasapta1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
92
src/App.js
92
src/App.js
@@ -8,86 +8,100 @@ import LoginPage from "./Login";
|
|||||||
import Expetation from "./DataTypePage";
|
import Expetation from "./DataTypePage";
|
||||||
import CameraKtp from "./KTPScanner";
|
import CameraKtp from "./KTPScanner";
|
||||||
import Profile from "./ProfileTab";
|
import Profile from "./ProfileTab";
|
||||||
import PickOrganization from "./PickOrganization"; // <-- import baru
|
import PickOrganization from "./PickOrganization";
|
||||||
|
|
||||||
// LandingPage.js
|
// ===== ProtectedRoute: cek token sebelum render =====
|
||||||
const LandingPage = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Selamat datang di Aplikasi Kami</h1>
|
|
||||||
{/* Tambahkan konten lainnya sesuai kebutuhan */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Komponen untuk melindungi route dengan token
|
|
||||||
const ProtectedRoute = ({ element }) => {
|
const ProtectedRoute = ({ element }) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
return token ? element : <Navigate to="/login" />;
|
return token ? element : <Navigate to="/login" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Redirector: /dashboard → /dashboard/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||||
|
const RedirectToOrgDashboard = () => {
|
||||||
|
const orgId = localStorage.getItem("organization_id");
|
||||||
|
if (orgId) return <Navigate to={`/dashboard/${orgId}`} replace />;
|
||||||
|
return <Navigate to="/pick-organization" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Redirector: /scan → /scan/:organization_id (kalau ada), kalau tidak ada ke /pick-organization =====
|
||||||
|
const RedirectToOrgScan = () => {
|
||||||
|
const orgId = localStorage.getItem("organization_id");
|
||||||
|
if (orgId) return <Navigate to={`/scan/${orgId}`} replace />;
|
||||||
|
return <Navigate to="/pick-organization" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Simpan token dari query parameter ke localStorage (jika ada)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
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) {
|
if (token) {
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
|
params.delete("token");
|
||||||
// Bersihkan token dari URL setelah disimpan
|
navigate("/pick-organization", { replace: true });
|
||||||
const newSearch = new URLSearchParams(location.search);
|
return;
|
||||||
newSearch.delete("token");
|
|
||||||
|
|
||||||
// Replace URL tanpa query token
|
|
||||||
navigate(
|
|
||||||
{
|
|
||||||
pathname: location.pathname,
|
|
||||||
search: newSearch.toString(),
|
|
||||||
},
|
|
||||||
{ replace: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
// 2) Jika tidak ada token di query, biarkan mengalir normal
|
||||||
}, [location, navigate]);
|
}, [location, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage />} />
|
{/* Default → login */}
|
||||||
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
{/* Halaman pilih organisasi (wajib setelah login) */}
|
{/* Setelah login → pilih organisasi */}
|
||||||
<Route
|
<Route
|
||||||
path="/pickorganization"
|
path="/pick-organization"
|
||||||
element={<ProtectedRoute element={<PickOrganization />} />}
|
element={<ProtectedRoute element={<PickOrganization />} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/scan" element={<CameraKtp />} />
|
{/* Dashboard "polos" otomatis diarahkan ke dashboard org aktif */}
|
||||||
<Route path="/success" element={<SuccessPage />} />
|
|
||||||
|
|
||||||
{/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={<ProtectedRoute element={<Navigate to="/pickorganization" />} />}
|
element={<ProtectedRoute element={<RedirectToOrgDashboard />} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard spesifik organisasi */}
|
{/* Dashboard spesifik organisasi */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/:organization"
|
path="/dashboard/:organization_id"
|
||||||
element={<ProtectedRoute element={<Dashboard />} />}
|
element={<ProtectedRoute element={<Dashboard />} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Scan "polos" otomatis diarahkan ke scan org aktif */}
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard/:organization/scan"
|
path="/scan"
|
||||||
|
element={<ProtectedRoute element={<RedirectToOrgScan />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan spesifik organisasi */}
|
||||||
|
<Route
|
||||||
|
path="/scan/:organization_id"
|
||||||
|
element={<ProtectedRoute element={<CameraKtp />} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Alur scan di dalam dashboard (jika memang ada halaman ini) */}
|
||||||
|
<Route
|
||||||
|
path="/dashboard/:organization_id/scan"
|
||||||
element={<ProtectedRoute element={<Expetation />} />}
|
element={<ProtectedRoute element={<Expetation />} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Halaman lain */}
|
||||||
|
<Route path="/success" element={<SuccessPage />} />
|
||||||
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
|
<Route path="/profile" element={<ProtectedRoute element={<Profile />} />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
{/* Contoh: ShowImage jika masih dipakai */}
|
||||||
|
<Route path="/show-image" element={<ProtectedRoute element={<ShowImage />} />} />
|
||||||
|
|
||||||
|
{/* Fallback */}
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
332
src/Dashboard.js
332
src/Dashboard.js
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import styles from "./Dashboard.module.css";
|
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 FileListComponent from "./FileListComponent";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -11,11 +11,25 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} 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 Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
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 [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
@@ -27,98 +41,132 @@ const Dashboard = () => {
|
|||||||
const [user, setUser] = useState({});
|
const [user, setUser] = useState({});
|
||||||
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
|
||||||
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
|
||||||
|
const [totalFileSentYear, setTotalFileSentYear] = useState(0);
|
||||||
const [totalFilesSentOverall, setTotalFilesSentOverall] = 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([]);
|
const [officers, setOfficers] = useState([]);
|
||||||
|
|
||||||
// Helper: ambil orgId yang valid dari route atau localStorage
|
// Terima daftar tipe + seri per-tipe dari FileListComponent (/files)
|
||||||
const getActiveOrg = () => {
|
const handleTypesLoaded = (options, series) => {
|
||||||
const selected = JSON.parse(localStorage.getItem("selected_organization") || "null");
|
setTypeOptions(options || []);
|
||||||
// prioritas: URL param, fallback ke localStorage
|
setByTypeSeries(series || []);
|
||||||
const orgId = orgIdFromRoute || selected?.organization_id;
|
// Default grafik: tampilkan agregat per-tipe
|
||||||
const orgName = selected?.nama_organization || "";
|
setOfficerPerformanceData(series || []);
|
||||||
return { orgId, orgName };
|
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 authHeaders = (extra = {}) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const { orgId } = getActiveOrg();
|
const headers = {
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Organization-Id": orgId ? String(orgId) : undefined, // backend boleh pakai header ini jika mau
|
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pastikan sudah login & punya org yang dipilih
|
// Pastikan login
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const { orgId } = getActiveOrg();
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!orgId) {
|
// Opsional: jika org kosong, arahkan user ke pemilihan organisasi
|
||||||
navigate("/pick-organization");
|
if (!organizationId) {
|
||||||
return;
|
setErrorMessage("Organisasi tidak terdeteksi. Silakan akses dashboard melalui tautan organisasi.");
|
||||||
}
|
}
|
||||||
|
}, [organizationId, navigate]);
|
||||||
|
|
||||||
// Sinkronkan URL dengan orgId dari localStorage kalau user buka /dashboard tanpa param
|
// Verifikasi token & fetch ringkasan dashboard org aktif
|
||||||
if (!orgIdFromRoute) {
|
|
||||||
navigate(`/dashboard/${orgId}`, { replace: true });
|
|
||||||
}
|
|
||||||
}, [orgIdFromRoute, navigate]);
|
|
||||||
|
|
||||||
// Verifikasi token & ambil ringkasan dashboard untuk org terpilih
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verifyTokenAndFetchData = async () => {
|
const verifyTokenAndFetchData = async () => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const { orgId } = getActiveOrg();
|
if (!token || !organizationId) return;
|
||||||
if (!token || !orgId) return;
|
|
||||||
|
const toNum = (v) => {
|
||||||
|
const n = typeof v === "number" ? v : Number(v ?? 0);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// GET -> kirim orgId lewat query string
|
// Fetch total scans data (pakai API_BASE yang konsisten)
|
||||||
const res = await fetch(
|
const totalScansRes = await fetch(
|
||||||
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(orgId)}`,
|
`${API_BASE}/total-scans?organization_id=${encodeURIComponent(
|
||||||
|
organizationId
|
||||||
|
)}`,
|
||||||
{ method: "GET", headers: authHeaders() }
|
{ method: "GET", headers: authHeaders() }
|
||||||
);
|
);
|
||||||
|
const totalScansRaw = await totalScansRes.json();
|
||||||
|
console.log("RAW total-scans payload:", totalScansRaw);
|
||||||
|
|
||||||
const data = await res.json();
|
if (!totalScansRes.ok) {
|
||||||
|
console.error("Total Scans error:", totalScansRaw);
|
||||||
if (!res.ok) {
|
} else {
|
||||||
console.error("Dashboard error:", data);
|
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
|
// Fetch dashboard (user, officer performance default)
|
||||||
// Pakai apa yang ada: data.user atau data[0] atau langsung isi metrik
|
const res = await fetch(
|
||||||
if (data?.user) setUser(data.user);
|
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(
|
||||||
else if (Array.isArray(data) && data.length) setUser(data[0]);
|
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.
|
const payload = Array.isArray(raw) ? raw[0] : raw;
|
||||||
if (typeof data?.total_today === "number") setTotalFilesSentToday(data.total_today);
|
if (payload?.user) setUser(payload.user);
|
||||||
if (typeof data?.total_month === "number") setTotalFilesSentMonth(data.total_month);
|
|
||||||
if (typeof data?.total_overall === "number") setTotalFilesSentOverall(data.total_overall);
|
// Kalau backend kirim default "officerPerformance", tetap tampilkan (akan ditimpa saat /files datang)
|
||||||
if (Array.isArray(data?.officerPerformance))
|
if (Array.isArray(payload?.officerPerformance)) {
|
||||||
setOfficerPerformanceData(data.officerPerformance);
|
setOfficerPerformanceData(payload.officerPerformance);
|
||||||
|
setChartKey(""); // mode umum
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Token/Fetch dashboard gagal:", err);
|
console.error("Token/Fetch dashboard gagal:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
verifyTokenAndFetchData();
|
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 fetchOfficers = async () => {
|
||||||
const { orgId } = getActiveOrg();
|
if (!organizationId) return;
|
||||||
if (!orgId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE}/list-user?organization_id=${encodeURIComponent(orgId)}`,
|
`${API_BASE}/list-user?organization_id=${encodeURIComponent(
|
||||||
|
organizationId
|
||||||
|
)}`,
|
||||||
{ method: "GET", headers: authHeaders() }
|
{ method: "GET", headers: authHeaders() }
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -132,19 +180,19 @@ const Dashboard = () => {
|
|||||||
if (user?.role === "admin") {
|
if (user?.role === "admin") {
|
||||||
fetchOfficers();
|
fetchOfficers();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.role]);
|
}, [user?.role]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user");
|
||||||
// jangan hapus selected_organization kalau mau balik lagi ke org sebelumnya
|
localStorage.removeItem("organization_id"); // bersihkan juga orgId
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddOfficer = async (e) => {
|
const handleAddOfficer = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { orgId } = getActiveOrg();
|
if (!organizationId) return;
|
||||||
if (!orgId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/add-officer`, {
|
const res = await fetch(`${API_BASE}/add-officer`, {
|
||||||
@@ -153,7 +201,7 @@ const Dashboard = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
organization_id: orgId, // *** kirim org pada body
|
organization_id: organizationId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,11 +224,12 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteOfficer = async (id) => {
|
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;
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
const { orgId } = getActiveOrg();
|
if (!organizationId) return;
|
||||||
if (!orgId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/delete-officer`, {
|
const res = await fetch(`${API_BASE}/delete-officer`, {
|
||||||
@@ -188,7 +237,7 @@ const Dashboard = () => {
|
|||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id,
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
@@ -215,7 +264,25 @@ const Dashboard = () => {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
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 (
|
return (
|
||||||
<div className={styles.dashboardContainer}>
|
<div className={styles.dashboardContainer}>
|
||||||
@@ -223,9 +290,7 @@ const Dashboard = () => {
|
|||||||
<div className={styles.logoAndTitle}>
|
<div className={styles.logoAndTitle}>
|
||||||
<img src="/ikasapta.png" alt="Bot Avatar" />
|
<img src="/ikasapta.png" alt="Bot Avatar" />
|
||||||
<h1 className={styles.h1}>SOLID</h1>
|
<h1 className={styles.h1}>SOLID</h1>
|
||||||
<h1 className={styles.h1} styles="color: #43a0a7;">DATA</h1>
|
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
|
||||||
{/* *** tampilkan nama org aktif */}
|
|
||||||
{orgName && <span className={styles.orgBadge}>Org: {orgName}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.dropdownContainer} ref={menuRef}>
|
<div className={styles.dropdownContainer} ref={menuRef}>
|
||||||
@@ -235,8 +300,17 @@ const Dashboard = () => {
|
|||||||
aria-expanded={isMenuOpen ? "true" : "false"}
|
aria-expanded={isMenuOpen ? "true" : "false"}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
>
|
>
|
||||||
<svg width="15" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg
|
||||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
width="15"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
@@ -244,15 +318,48 @@ const Dashboard = () => {
|
|||||||
</button>
|
</button>
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<div className={styles.dropdownMenu}>
|
<div className={styles.dropdownMenu}>
|
||||||
|
{/* Static Organization */}
|
||||||
|
<div className={styles.dropdownItemStatic}>
|
||||||
|
<div className={styles.dropdownText}>
|
||||||
|
<strong>Organisasi</strong>
|
||||||
|
<div className={styles.orgName}>{orgName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate("/scan");
|
// Selalu bawa organizationId saat navigasi
|
||||||
|
if (organizationId) {
|
||||||
|
navigate(`/scan/${organizationId}`);
|
||||||
|
} else {
|
||||||
|
setErrorMessage("Organisasi tidak terdeteksi.");
|
||||||
|
}
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className={styles.dropdownItem}
|
className={styles.dropdownItem}
|
||||||
>
|
>
|
||||||
Scan
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||||
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||||
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>Scan</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleLogout();
|
handleLogout();
|
||||||
@@ -260,23 +367,49 @@ const Dashboard = () => {
|
|||||||
}}
|
}}
|
||||||
className={styles.dropdownItem}
|
className={styles.dropdownItem}
|
||||||
>
|
>
|
||||||
Logout
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ... sisanya tetap sama persis */}
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className={styles.error} style={{ marginBottom: 12 }}>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.summaryCardsContainer}>
|
<div className={styles.summaryCardsContainer}>
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
<h3>Hari Ini</h3>
|
<h3>Hari Ini</h3>
|
||||||
<p>{totalFilesSentToday.toLocaleString()}</p>
|
<p>{totalFilesSentToday.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
<h3>Bulan Ini</h3>
|
<h3>Bulan Ini</h3>
|
||||||
<p>{totalFilesSentMonth.toLocaleString()}</p>
|
<p>{totalFilesSentMonth.toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.summaryCard}>
|
||||||
|
<h3>Tahun Ini</h3>
|
||||||
|
<p>{totalFileSentYear.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
<h3>Total Keseluruhan</h3>
|
<h3>Total Keseluruhan</h3>
|
||||||
<p>{totalFilesSentOverall.toLocaleString()}</p>
|
<p>{totalFilesSentOverall.toLocaleString()}</p>
|
||||||
@@ -357,14 +490,50 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartSection}>
|
||||||
<h2>Grafik Upload Document</h2>
|
<div className={styles.chartHeader}>
|
||||||
|
<h2>Grafik Upload Dokumen</h2>
|
||||||
|
|
||||||
|
<div className={styles.chartFilter}>
|
||||||
|
<label htmlFor="chartTypeSelect">Filter tipe: </label>
|
||||||
|
<select
|
||||||
|
id="chartTypeSelect"
|
||||||
|
value={chartKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const key = e.target.value;
|
||||||
|
setChartKey(key);
|
||||||
|
if (!key) {
|
||||||
|
// Semua tipe → pakai agregat per-tipe
|
||||||
|
setOfficerPerformanceData(byTypeSeries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Jika sudah ada seri bulanan tipe tsb → pakai
|
||||||
|
if (performanceByType[key]?.length) {
|
||||||
|
setOfficerPerformanceData(performanceByType[key]);
|
||||||
|
} else {
|
||||||
|
// fallback sementara: 1 bar dari agregat tipe
|
||||||
|
const one = byTypeSeries.find((s) => s.label === key);
|
||||||
|
setOfficerPerformanceData(one ? [one] : []);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={typeOptions.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">Semua tipe</option>
|
||||||
|
{typeOptions.map((t) => (
|
||||||
|
<option key={t.id} value={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{officerPerformanceData.length > 0 ? (
|
{officerPerformanceData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={officerPerformanceData}>
|
<BarChart data={officerPerformanceData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="label" /> {/* label = nama_tipe ATAU YYYY-MM */}
|
||||||
<YAxis allowDecimals={false} />
|
<YAxis allowDecimals={false} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="count" fill="#00adef" />
|
<Bar dataKey="count" fill="var(--brand-primary)" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
@@ -375,13 +544,18 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */}
|
|
||||||
<FileListComponent
|
<FileListComponent
|
||||||
organizationId={getActiveOrg().orgId} // ***
|
organizationId={organizationId}
|
||||||
setTotalFilesSentToday={setTotalFilesSentToday}
|
// Gunakan wrapper agar nilai dari child selalu angka valid
|
||||||
setTotalFilesSentMonth={setTotalFilesSentMonth}
|
setTotalFilesSentToday={safeSetToday}
|
||||||
setTotalFilesSentOverall={setTotalFilesSentOverall}
|
setTotalFilesSentMonth={safeSetMonth}
|
||||||
|
setTotalFileSentYear={safeSetYear}
|
||||||
|
setTotalFilesSentOverall={safeSetOverall}
|
||||||
|
// tampilkan agregat saat /files selesai
|
||||||
setOfficerPerformanceData={setOfficerPerformanceData}
|
setOfficerPerformanceData={setOfficerPerformanceData}
|
||||||
|
// === baru: terima daftar tipe + agregat & seri bulanan
|
||||||
|
onTypesLoaded={handleTypesLoaded}
|
||||||
|
onPerformanceReady={handlePerformanceReady}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
--primary-blue: #3b82f6;
|
/* Brand */
|
||||||
--secondary-blue: #60a5fa;
|
--brand-primary: #2961eb; /* blue-600 */
|
||||||
--dark-blue: #1e40af;
|
--brand-primary-700: #1d4ed8; /* blue-700 */
|
||||||
--neutral-50: #fafafa;
|
--brand-secondary: #4f46e5; /* indigo-600 */
|
||||||
--neutral-100: #f5f5f5;
|
--brand-secondary-700: #4338ca;/* indigo-700 */
|
||||||
--neutral-200: #e5e5e5;
|
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||||
--neutral-300: #d4d4d4;
|
|
||||||
--neutral-500: #737373;
|
/* Gradients for cards */
|
||||||
--neutral-700: #404040;
|
--card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
|
||||||
--neutral-800: #262626;
|
--card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
|
||||||
--neutral-900: #171717;
|
--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;
|
--white: #ffffff;
|
||||||
--success-green: #43a0a7;
|
|
||||||
--warning-amber: #f59e0b;
|
/* Text */
|
||||||
--error-red: #ef4444;
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #64748b;
|
||||||
--text-light: #ffffff;
|
--text-on-brand: #ffffff;
|
||||||
--border-light: #e2e8f0;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
/* Borders & Shadows */
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--border-light: #e5e7eb;
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||||
}
|
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||||
|
|
||||||
/* Base Styles & Reset */
|
/* States */
|
||||||
* {
|
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||||
box-sizing: border-box;
|
|
||||||
|
/* Semantic */
|
||||||
|
--error-red: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==== Base ==== */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--neutral-50);
|
background: var(--neutral-50);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Root container: full width & height */
|
||||||
.dashboardContainer {
|
.dashboardContainer {
|
||||||
background-color: var(--neutral-50);
|
min-height: 100dvh; /* support mobile dynamic viewport */
|
||||||
min-height: 100vh;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--neutral-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Header --- */
|
/* ==== Header ==== */
|
||||||
.dashboardHeader {
|
.dashboardHeader {
|
||||||
background-color: var(--white);
|
position: sticky; top: 0; z-index: 50;
|
||||||
color: var(--text-primary);
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
display: flex;
|
background: rgba(255,255,255,.95);
|
||||||
justify-content: space-between;
|
backdrop-filter: blur(6px);
|
||||||
align-items: center;
|
border-bottom: 1px solid var(--border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
width: 100%;
|
||||||
border-bottom: 3px solid #43a0a7;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 50;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoAndTitle {
|
.logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
|
||||||
display: flex;
|
.logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); }
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoAndTitle img {
|
.h1 {
|
||||||
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 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem);
|
||||||
font-weight: 700;
|
font-weight: 800; letter-spacing: -.02em;
|
||||||
color: #154666;
|
background: var(--brand-gradient);
|
||||||
letter-spacing: -0.025em;
|
-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 */
|
.orgBadge {
|
||||||
.dropdownContainer {
|
margin-left: .75rem;
|
||||||
position: relative;
|
padding: .25rem .6rem;
|
||||||
display: flex;
|
font-size: .75rem; font-weight: 700;
|
||||||
align-items: center;
|
color: var(--text-on-brand);
|
||||||
gap: 0.75rem;
|
background: var(--brand-secondary);
|
||||||
flex-shrink: 0;
|
border-radius: .5rem;
|
||||||
}
|
border: 1px solid rgba(255,255,255,.5);
|
||||||
|
|
||||||
.userDisplayName {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==== Dropdown ==== */
|
||||||
|
.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; }
|
||||||
.dropdownToggle {
|
.dropdownToggle {
|
||||||
background-color: var(--neutral-100);
|
min-width: 2.5rem; height: 2.5rem;
|
||||||
color: var(--text-primary);
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--white);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
padding: 0.5rem;
|
border-radius: .6rem;
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.2s ease;
|
transition: transform .15s ease, box-shadow .2s ease, border-color .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);
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.dropdownMenu {
|
||||||
position: absolute;
|
position: absolute; right: 0; top: calc(100% + .5rem);
|
||||||
top: calc(100% + 0.5rem);
|
min-width: 10rem; padding: .5rem;
|
||||||
right: 0;
|
background: var(--white);
|
||||||
background-color: var(--white);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
z-index: 10;
|
border-radius: .75rem;
|
||||||
display: flex;
|
box-shadow: var(--shadow-lg);
|
||||||
flex-direction: column;
|
display: flex; flex-direction: column; gap: .25rem;
|
||||||
min-width: 10rem;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownItem {
|
.dropdownItem {
|
||||||
background: none;
|
border: 0; background: transparent; text-align: left; cursor: pointer;
|
||||||
border: none;
|
padding: .65rem .75rem; border-radius: .5rem;
|
||||||
padding: 0.75rem 1rem;
|
font-size: .95rem; font-weight: 600; color: var(--neutral-800);
|
||||||
text-align: left;
|
transition: background .15s ease, transform .06s ease;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); }
|
||||||
|
|
||||||
.dropdownItem:hover {
|
/* ==== Main: FULL WIDTH ==== */
|
||||||
background-color: var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Main Content --- */
|
|
||||||
.mainContent {
|
.mainContent {
|
||||||
flex-grow: 1;
|
flex: 1 1 auto;
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
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 {
|
.summaryCardsContainer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gradient cards (3 variasi) */
|
||||||
.summaryCard {
|
.summaryCard {
|
||||||
background-color: var(--white);
|
position: relative;
|
||||||
padding: 1.5rem;
|
overflow: hidden;
|
||||||
|
background: var(--card-grad-1);
|
||||||
|
color: var(--text-on-brand);
|
||||||
|
border: 1px solid rgba(255,255,255,.18);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
border: 1px solid var(--border-light);
|
padding: 1.25rem 1.5rem;
|
||||||
box-shadow: var(--shadow-sm);
|
min-height: 120px;
|
||||||
transition: all 0.2s ease;
|
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 {
|
.summaryCard:hover {
|
||||||
box-shadow: var(--shadow-md);
|
transform: translateY(-2px);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 14px 28px rgba(37,99,235,.22);
|
||||||
|
filter: saturate(1.05);
|
||||||
|
background-position: 100% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryCard h3 {
|
.summaryCard h3 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 .5rem 0;
|
||||||
font-size: 0.875rem;
|
font-size: 1rem; letter-spacing: .06em; text-transform: uppercase;
|
||||||
color: var(--text-secondary);
|
color: rgba(255,255,255,.92);
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summaryCard p {
|
.summaryCard p {
|
||||||
font-size: 2rem;
|
margin: 0; line-height: 1;
|
||||||
font-weight: 700;
|
font-size: clamp(2rem, 2vw + .5rem, 2.2rem);
|
||||||
color: #43a0a7;
|
font-weight: 900; color: #fff;
|
||||||
margin: 0;
|
background: none !important;
|
||||||
line-height: 1;
|
-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 {
|
.dashboardGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr; /* mobile: 1 kolom */
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formSection,
|
.formSection, .chartSection {
|
||||||
.chartSection {
|
background: var(--white);
|
||||||
background-color: var(--white);
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 1.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formSection h2,
|
.formSection h2, .chartSection h2 {
|
||||||
.chartSection h2 {
|
margin: 0 0 1rem 0;
|
||||||
color: var(--text-primary);
|
font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem);
|
||||||
margin: 0 0 1.5rem 0;
|
font-weight: 800; letter-spacing: -.015em;
|
||||||
font-size: 1.25rem;
|
color: var(--neutral-800);
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
.form label {
|
.form label {
|
||||||
text-align: left;
|
display: block; margin-bottom: 1rem;
|
||||||
display: block;
|
font-size: .95rem; font-weight: 700; color: var(--neutral-800);
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input[type="text"],
|
.form input[type="text"],
|
||||||
.form input[type="password"],
|
.form input[type="password"],
|
||||||
.form select {
|
.form select {
|
||||||
width: 100%;
|
width: 100%; margin-top: .35rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: .7rem 1rem;
|
||||||
margin-top: 0.375rem;
|
font-size: .95rem; color: var(--neutral-800);
|
||||||
|
background: var(--white);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 0.5rem;
|
border-radius: .55rem;
|
||||||
font-size: 0.875rem;
|
transition: border-color .2s ease, box-shadow .2s ease;
|
||||||
transition: all 0.2s ease;
|
|
||||||
background-color: var(--white);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form input[type="text"]:focus,
|
.form input[type="text"]:focus,
|
||||||
.form input[type="password"]:focus,
|
.form input[type="password"]:focus,
|
||||||
.form select:focus {
|
.form select:focus {
|
||||||
border-color: var(--primary-blue);
|
outline: none; border-color: var(--brand-primary);
|
||||||
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
|
box-shadow: var(--focus-ring);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
.submitButton {
|
.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%;
|
width: 100%;
|
||||||
transition: all 0.2s ease;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
letter-spacing: 0.025em;
|
padding: .9rem 1.2rem;
|
||||||
}
|
border: none; border-radius: .6rem; cursor: pointer;
|
||||||
|
font-size: 1rem; font-weight: 800; letter-spacing: .02em;
|
||||||
.submitButton:hover {
|
color: var(--text-on-brand);
|
||||||
background-color: #357734;
|
background: var(--brand-primary);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 6px 18px rgba(37,99,235,.18);
|
||||||
box-shadow: var(--shadow-md);
|
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
|
||||||
}
|
|
||||||
|
|
||||||
.submitButton:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
}
|
||||||
|
.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 */
|
/* Messages */
|
||||||
|
.success, .warning {
|
||||||
|
margin-top: 1rem; padding: .85rem 1rem;
|
||||||
|
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
.success {
|
.success {
|
||||||
background-color: rgb(67 160 167 / 0.1);
|
color: var(--brand-primary);
|
||||||
color: var(--success-green);
|
background: rgba(37,99,235,.08);
|
||||||
border: 1px solid rgb(67 160 167 / 0.2);
|
border-color: rgba(37,99,235,.18);
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.warning {
|
||||||
background-color: rgb(67 160 167 / 0.1);
|
color: var(--brand-secondary);
|
||||||
color: #43a0a7;
|
background: rgba(79,70,229,.08);
|
||||||
border: 1px solid rgb(67 160 167 / 0.2);
|
border-color: rgba(79,70,229,.18);
|
||||||
padding: 1rem;
|
}
|
||||||
border-radius: 0.5rem;
|
.error {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem; padding: .85rem 1rem;
|
||||||
font-weight: 500;
|
border-radius: .6rem; font-size: .92rem; font-weight: 700;
|
||||||
font-size: 0.875rem;
|
color: var(--error-red);
|
||||||
|
background: rgba(239,68,68,.08);
|
||||||
|
border: 1px solid rgba(239,68,68,.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background-color: var(--white);
|
margin-top: auto;
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: auto;
|
font-size: .85rem;
|
||||||
font-size: 0.75rem;
|
color: var(--text-secondary);
|
||||||
|
background: var(--white);
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart placeholder (kalau perlu) */
|
||||||
.chartPlaceholder {
|
.chartPlaceholder {
|
||||||
background-color: var(--neutral-50);
|
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
justify-content: center;
|
background: var(--neutral-50);
|
||||||
align-items: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 2px dashed var(--border-light);
|
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 --- */
|
/* Officers List */
|
||||||
|
|
||||||
/* 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 */
|
|
||||||
|
|
||||||
.officerListContainer {
|
.officerListContainer {
|
||||||
background: #f8f9fa;
|
background: var(--white);
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: .8rem;
|
||||||
padding: 16px;
|
padding: 1rem;
|
||||||
margin-bottom: 20px;
|
box-shadow: var(--shadow-sm);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.officerList {
|
.officerList {
|
||||||
max-height: 300px;
|
max-height: 300px; overflow-y: auto;
|
||||||
overflow-y: auto;
|
list-style: none; margin: 0; padding: 0;
|
||||||
padding: 0;
|
border: 1px solid var(--border-light);
|
||||||
margin: 0;
|
border-radius: .6rem; background: var(--white);
|
||||||
list-style: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.officerItem {
|
.officerItem {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
align-items: center;
|
padding: .95rem 1rem;
|
||||||
justify-content: space-between;
|
border-bottom: 1px solid var(--border-light);
|
||||||
padding: 12px 16px;
|
transition: background .15s ease;
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
.officerItem:last-child { border-bottom: 0; }
|
||||||
.officerItem:last-child {
|
.officerItem:hover { background: rgba(79,70,229,.05); }
|
||||||
border-bottom: none;
|
.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; }
|
||||||
.officerItem:hover {
|
.officerName { font-weight: 800; color: var(--neutral-800); font-size: .98rem; }
|
||||||
background-color: #f8f9fa;
|
.officerRole { font-size: .85rem; color: var(--text-secondary); text-transform: capitalize; font-style: italic; }
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton {
|
.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;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
padding: 6px;
|
||||||
padding: 4px 8px;
|
border-radius: 6px;
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton:hover {
|
.menuButton:hover {
|
||||||
background-color: #fee;
|
background: #f2f2f2;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteButton:focus {
|
.dropdownMenu {
|
||||||
outline: 2px solid #dc3545;
|
position: absolute;
|
||||||
outline-offset: 2px;
|
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 {
|
.dropdownItem,
|
||||||
text-align: center;
|
.dropdownItemStatic {
|
||||||
padding: 40px 20px;
|
display: flex;
|
||||||
color: #6c757d;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyState span {
|
.dropdownItem:hover {
|
||||||
font-size: 32px;
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownItemStatic {
|
||||||
|
cursor: default;
|
||||||
|
background: #fafafa;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyState p {
|
.dropdownIcon {
|
||||||
margin: 0;
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.dropdownText {
|
||||||
border: none;
|
display: flex;
|
||||||
border-top: 1px solid #dee2e6;
|
flex-direction: column;
|
||||||
margin: 24px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar untuk daftar petugas */
|
.orgName {
|
||||||
.officerList::-webkit-scrollbar {
|
font-size: 12px;
|
||||||
width: 8px;
|
color: #666;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { User, Users, Baby, Settings, Plus, X } from "lucide-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
|
Helpers
|
||||||
@@ -151,20 +154,20 @@ const ExpectationForm = ({ fields, setFields }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={expectationFormStyles.container}>
|
<div className={expetationStyles.expectationFormContainer}>
|
||||||
{safeFields.map((f, i) => (
|
{safeFields.map((f, i) => (
|
||||||
<div key={i} style={expectationFormStyles.fieldRow}>
|
<div key={i} className={expetationStyles.fieldRow}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Field name"
|
placeholder="Field name"
|
||||||
value={f.key}
|
value={f.key}
|
||||||
onChange={(e) => updateField(i, "key", e.target.value)}
|
onChange={(e) => updateField(i, "key", e.target.value)}
|
||||||
style={expectationFormStyles.fieldInput}
|
className={expetationStyles.fieldInput}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={f.value}
|
value={f.value}
|
||||||
onChange={(e) => updateField(i, "value", e.target.value)}
|
onChange={(e) => updateField(i, "value", e.target.value)}
|
||||||
style={expectationFormStyles.fieldSelect}
|
className={expetationStyles.fieldSelect}
|
||||||
>
|
>
|
||||||
<option value="">Pilih Type</option>
|
<option value="">Pilih Type</option>
|
||||||
<option value="text">Text</option>
|
<option value="text">Text</option>
|
||||||
@@ -177,7 +180,7 @@ const ExpectationForm = ({ fields, setFields }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeField(i)}
|
onClick={() => removeField(i)}
|
||||||
style={expectationFormStyles.removeFieldButton}
|
className={expetationStyles.removeFieldButton}
|
||||||
title="Hapus field"
|
title="Hapus field"
|
||||||
>
|
>
|
||||||
<X style={{ width: 16, height: 16 }} />
|
<X style={{ width: 16, height: 16 }} />
|
||||||
@@ -187,7 +190,7 @@ const ExpectationForm = ({ fields, setFields }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addField}
|
onClick={addField}
|
||||||
style={expectationFormStyles.addFieldButton}
|
className={expetationStyles.addFieldButton}
|
||||||
>
|
>
|
||||||
<Plus style={{ width: 16, height: 16, marginRight: 8 }} />
|
<Plus style={{ width: 16, height: 16, marginRight: 8 }} />
|
||||||
Tambah Field
|
Tambah Field
|
||||||
@@ -259,38 +262,32 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
|||||||
const isFormValid = documentName.trim() && validFields.length > 0;
|
const isFormValid = documentName.trim() && validFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={modalStyles.overlay}>
|
<div className={expetationStyles.modalOverlay}>
|
||||||
<div style={modalStyles.modal}>
|
<div className={expetationStyles.modal}>
|
||||||
<div style={modalStyles.header}>
|
<div className={expetationStyles.modalHeader}>
|
||||||
<h3 style={modalStyles.title}>Tambah Jenis Dokumen Baru</h3>
|
<h3 className={expetationStyles.modalTitle}>Tambah Jenis Dokumen Baru</h3>
|
||||||
<button onClick={onClose} style={modalStyles.closeButton} disabled={isSubmitting}>×</button>
|
<button onClick={onClose} className={expetationStyles.modalCloseButton} disabled={isSubmitting}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div style={modalStyles.content}>
|
<div className={expetationStyles.modalContent}>
|
||||||
{/* Template Selection */}
|
{/* Template Selection */}
|
||||||
<div style={modalStyles.section}>
|
<div className={expetationStyles.modalSection}>
|
||||||
<label style={modalStyles.sectionLabel}>Pilih Template</label>
|
<label className={expetationStyles.sectionLabel}>Pilih Template</label>
|
||||||
<div style={modalStyles.templateGrid}>
|
<div className={expetationStyles.templateGrid}>
|
||||||
{Object.entries(templates).map(([templateName, template]) => (
|
{Object.entries(templates).map(([templateName, template]) => (
|
||||||
<button
|
<button
|
||||||
key={templateName}
|
key={templateName}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTemplateSelect(templateName)}
|
onClick={() => handleTemplateSelect(templateName)}
|
||||||
style={{
|
className={`${expetationStyles.templateCard} ${selectedTemplate === templateName ? expetationStyles.templateCardActive : ''}`}
|
||||||
...modalStyles.templateCard,
|
|
||||||
...(selectedTemplate === templateName ? modalStyles.templateCardActive : {})
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<div style={modalStyles.templateContent}>
|
<div className={expetationStyles.templateContent}>
|
||||||
<div style={{
|
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === templateName ? expetationStyles.templateIconActive : ''}`}>
|
||||||
...modalStyles.templateIconContainer,
|
|
||||||
...(selectedTemplate === templateName ? modalStyles.templateIconActive : {})
|
|
||||||
}}>
|
|
||||||
{template.icon}
|
{template.icon}
|
||||||
</div>
|
</div>
|
||||||
<span style={modalStyles.templateName}>{templateName}</span>
|
<span className={expetationStyles.templateName}>{templateName}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -299,21 +296,14 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTemplateSelect("Custom")}
|
onClick={() => handleTemplateSelect("Custom")}
|
||||||
style={{
|
className={`${expetationStyles.templateCard} ${expetationStyles.customTemplateCard} ${selectedTemplate === "Custom" ? expetationStyles.customTemplateActive : ''}`}
|
||||||
...modalStyles.templateCard,
|
|
||||||
...modalStyles.customTemplateCard,
|
|
||||||
...(selectedTemplate === "Custom" ? modalStyles.customTemplateActive : {})
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<div style={modalStyles.templateContent}>
|
<div className={expetationStyles.templateContent}>
|
||||||
<div style={{
|
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === "Custom" ? expetationStyles.customIconActive : ''}`}>
|
||||||
...modalStyles.templateIconContainer,
|
|
||||||
...(selectedTemplate === "Custom" ? modalStyles.customIconActive : {})
|
|
||||||
}}>
|
|
||||||
<Settings style={{ width: 24, height: 24 }} />
|
<Settings style={{ width: 24, height: 24 }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={modalStyles.templateName}>Custom</span>
|
<span className={expetationStyles.templateName}>Custom</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,21 +312,21 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
|||||||
{/* Form Section - hanya muncul jika template dipilih */}
|
{/* Form Section - hanya muncul jika template dipilih */}
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<>
|
<>
|
||||||
<div style={modalStyles.section}>
|
<div className={expetationStyles.modalSection}>
|
||||||
<label style={modalStyles.label}>Nama Document Type</label>
|
<label className={expetationStyles.modalLabel}>Nama Tipe Dokumen</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={documentName}
|
value={documentName}
|
||||||
onChange={(e) => setDocumentName(e.target.value)}
|
onChange={(e) => setDocumentName(e.target.value)}
|
||||||
placeholder="Data yang ingin di tambahkan"
|
placeholder="Contoh: KTP, KK, Ijazah, dll"
|
||||||
style={modalStyles.input}
|
className={expetationStyles.modalInput}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={modalStyles.section}>
|
<div className={expetationStyles.modalSection}>
|
||||||
<label style={modalStyles.label}>Fields</label>
|
<label className={expetationStyles.modalLabel}>Fields</label>
|
||||||
<ExpectationForm
|
<ExpectationForm
|
||||||
fields={fields}
|
fields={fields}
|
||||||
setFields={setFields}
|
setFields={setFields}
|
||||||
@@ -347,13 +337,13 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<div style={modalStyles.footer}>
|
<div className={expetationStyles.modalFooter}>
|
||||||
<button type="button" onClick={onClose} style={modalStyles.cancelButton} disabled={isSubmitting}>
|
<button type="button" onClick={onClose} className={expetationStyles.cancelButton} disabled={isSubmitting}>
|
||||||
Batal
|
Batal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
style={modalStyles.submitButton}
|
className={expetationStyles.submitButton}
|
||||||
disabled={isSubmitting || !isFormValid}
|
disabled={isSubmitting || !isFormValid}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Mengirim..." : "Tambah"}
|
{isSubmitting ? "Mengirim..." : "Tambah"}
|
||||||
@@ -367,13 +357,109 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ============================
|
/* ============================
|
||||||
Komponen Utama: Expetation
|
Modal: Edit Document
|
||||||
|
============================ */
|
||||||
|
const EditDocumentModal = ({ isOpen, onClose, document, onSubmit }) => {
|
||||||
|
const [documentName, setDocumentName] = useState("");
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && document) {
|
||||||
|
setDocumentName(document.display_name || document.nama_tipe || "");
|
||||||
|
// expectation object -> array of { key, value }
|
||||||
|
const initFields = Object.entries(document.expectation || {}).map(([k, v]) => ({
|
||||||
|
key: k,
|
||||||
|
value: v
|
||||||
|
}));
|
||||||
|
setFields(initFields.length ? initFields : [{ key: "", value: "" }]);
|
||||||
|
}
|
||||||
|
}, [isOpen, document]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!documentName.trim()) return;
|
||||||
|
|
||||||
|
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
|
||||||
|
if (!validFields.length) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const expectationObj = fieldsToExpectationObject(validFields);
|
||||||
|
await onSubmit(document.id, documentName.trim(), expectationObj);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error update document type:", err);
|
||||||
|
alert("Gagal memperbarui tipe dokumen.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const validFields = (fields || []).filter(f => f.key && f.key.trim() && f.value);
|
||||||
|
const isFormValid = documentName.trim() && validFields.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={expetationStyles.modalOverlay}>
|
||||||
|
<div className={expetationStyles.modal}>
|
||||||
|
<div className={expetationStyles.modalHeader}>
|
||||||
|
<h3 className={expetationStyles.modalTitle}>Edit Jenis Dokumen</h3>
|
||||||
|
<button onClick={onClose} className={expetationStyles.modalCloseButton} disabled={isSubmitting}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={expetationStyles.modalContent}>
|
||||||
|
<div className={expetationStyles.modalSection}>
|
||||||
|
<label className={expetationStyles.modalLabel}>Nama Tipe Dokumen</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={documentName}
|
||||||
|
onChange={(e) => setDocumentName(e.target.value)}
|
||||||
|
className={expetationStyles.modalInput}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={expetationStyles.modalSection}>
|
||||||
|
<label className={expetationStyles.modalLabel}>Fields</label>
|
||||||
|
<ExpectationForm fields={fields} setFields={setFields} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={expetationStyles.modalFooter}>
|
||||||
|
<button type="button" onClick={onClose} className={expetationStyles.cancelButton} disabled={isSubmitting}>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={expetationStyles.submitButton}
|
||||||
|
disabled={isSubmitting || !isFormValid}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Menyimpan..." : "Simpan"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
Komponen Utama: Expetation (Dashboard Style)
|
||||||
============================ */
|
============================ */
|
||||||
const Expetation = ({ onSelect }) => {
|
const Expetation = ({ onSelect }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [documentTypes, setDocumentTypes] = useState([]);
|
const [documentTypes, setDocumentTypes] = useState([]);
|
||||||
const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true);
|
const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [showNewDocumentModal, setShowNewDocumentModal] = 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 getDocumentDisplayInfo = (doc) => {
|
||||||
const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString();
|
const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString();
|
||||||
@@ -385,7 +471,8 @@ const Expetation = ({ onSelect }) => {
|
|||||||
|
|
||||||
// Normalisasi data dari server "show"
|
// Normalisasi data dari server "show"
|
||||||
const normalizeItem = (doc) => {
|
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);
|
const slug = toSlug(humanName);
|
||||||
|
|
||||||
let expectationObj = {};
|
let expectationObj = {};
|
||||||
@@ -401,9 +488,10 @@ const Expetation = ({ onSelect }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: doc.id ?? doc.data_type_id ?? safeUUID(),
|
id: doc.id ?? doc.data_type_id ?? safeUUID(),
|
||||||
nama_tipe: slug,
|
nama_tipe: slug, // UBAH: pastikan selalu ada nama_tipe
|
||||||
display_name: humanName,
|
display_name: humanName,
|
||||||
expectation: expectationObj,
|
expectation: expectationObj,
|
||||||
|
entry_name: doc.entry_name
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,7 +533,11 @@ const Expetation = ({ onSelect }) => {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
const normalized = (Array.isArray(data) ? data : [])
|
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);
|
.map(normalizeItem);
|
||||||
|
|
||||||
setDocumentTypes(normalized);
|
setDocumentTypes(normalized);
|
||||||
@@ -479,12 +571,12 @@ const Expetation = ({ onSelect }) => {
|
|||||||
if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${namaTipe}"?`)) {
|
if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${namaTipe}"?`)) {
|
||||||
try {
|
try {
|
||||||
const orgId = getActiveOrgId();
|
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",
|
method: "POST",
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id,
|
id,
|
||||||
nama_tipe: namaTipe,
|
nama_tipe: namaTipe, // UBAH: konsisten gunakan nama_tipe
|
||||||
...(orgId ? { organization_id: orgId } : {}),
|
...(orgId ? { organization_id: orgId } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -516,7 +608,7 @@ const Expetation = ({ onSelect }) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
nama_tipe: documentName, // EXACT seperti input
|
nama_tipe: documentName, // UBAH: konsisten gunakan nama_tipe
|
||||||
expectation: expectationObj,
|
expectation: expectationObj,
|
||||||
...(orgId ? { organization_id: orgId } : {}),
|
...(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) => {
|
const handleDocumentTypeSelection = (item) => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (item === "new") {
|
if (item === "new") {
|
||||||
@@ -544,182 +666,295 @@ const Expetation = ({ onSelect }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleLogout = () => {
|
||||||
<div style={selectionStyles.selectionContainer}>
|
localStorage.removeItem("token");
|
||||||
<div style={selectionStyles.selectionContent}>
|
localStorage.removeItem("user");
|
||||||
<div style={selectionStyles.selectionHeader}>
|
window.location.reload();
|
||||||
<h2 style={selectionStyles.selectionTitle}>Pilih Jenis Dokumen</h2>
|
};
|
||||||
<button onClick={() => setIsEditMode(!isEditMode)} style={selectionStyles.editButton}>
|
|
||||||
{isEditMode ? "Selesai" : "Edit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p style={selectionStyles.selectionSubtitle}>Silakan pilih jenis dokumen yang akan Anda scan</p>
|
|
||||||
|
|
||||||
<div style={selectionStyles.documentGrid}>
|
// Tutup menu ketika klik di luar
|
||||||
{loadingDocumentTypes ? (
|
useEffect(() => {
|
||||||
<div style={selectionStyles.spinnerContainer}>
|
const handleClickOutside = (event) => {
|
||||||
<div style={selectionStyles.spinner} />
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
<style>{spinnerStyle}</style>
|
setIsMenuOpen(false);
|
||||||
</div>
|
}
|
||||||
) : (
|
};
|
||||||
<>
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
<button onClick={() => handleDocumentTypeSelection("new")} style={selectionStyles.documentCard}>
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
<div style={selectionStyles.documentIconContainer}>
|
}, []);
|
||||||
<div style={selectionStyles.plusIcon}>+</div>
|
|
||||||
</div>
|
// Colors for different document types
|
||||||
<div style={selectionStyles.documentLabel}>new</div>
|
const getDocumentColors = (index) => {
|
||||||
|
const colors = [
|
||||||
|
{ bg: '#E8F4FF', icon: '#2563EB' },
|
||||||
|
{ bg: '#FFF7E6', icon: '#F59E0B' },
|
||||||
|
{ bg: '#F0FDF4', icon: '#10B981' },
|
||||||
|
{ bg: '#F3E8FF', icon: '#8B5CF6' },
|
||||||
|
{ bg: '#FEF2F2', icon: '#EF4444' },
|
||||||
|
{ bg: '#F0F9FF', icon: '#0EA5E9' },
|
||||||
|
{ bg: '#FDF4FF', icon: '#D946EF' },
|
||||||
|
{ bg: '#F7FEE7', icon: '#65A30D' },
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardContainer}>
|
||||||
|
{/* Dashboard Header */}
|
||||||
|
<div className={styles.dashboardHeader}>
|
||||||
|
<div className={styles.logoAndTitle}>
|
||||||
|
<img src="/ikasapta.png" alt="Bot Avatar" />
|
||||||
|
<h1 className={styles.h1}>SOLID</h1>
|
||||||
|
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.dropdownContainer} ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className={styles.dropdownToggle}
|
||||||
|
aria-expanded={isMenuOpen ? "true" : "false"}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className={styles.dropdownMenu}>
|
||||||
|
{/* Dashboard */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/dashboard");
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={styles.dropdownItem}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{documentTypes.map((doc) => {
|
{/* Scan */}
|
||||||
const displayInfo = getDocumentDisplayInfo(doc);
|
<button
|
||||||
return (
|
onClick={() => {
|
||||||
<div key={doc.id} style={selectionStyles.documentCardWrapper}>
|
navigate("/scan");
|
||||||
<button onClick={() => handleDocumentTypeSelection(doc)} style={selectionStyles.documentCard}>
|
setIsMenuOpen(false);
|
||||||
<div style={{ ...selectionStyles.documentIconContainer, backgroundColor: "#f0f0f0" }}>
|
}}
|
||||||
<div style={selectionStyles.documentIcon}>{displayInfo.icon}</div>
|
className={styles.dropdownItem}
|
||||||
</div>
|
>
|
||||||
<div style={selectionStyles.documentLabel}>{displayInfo.name}</div>
|
<svg
|
||||||
</button>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{isEditMode && (
|
width="18"
|
||||||
<button
|
height="18"
|
||||||
style={selectionStyles.deleteIcon}
|
viewBox="0 0 24 24"
|
||||||
onClick={() => handleDeleteDocumentType(doc.id, doc.nama_tipe)}
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
−
|
strokeWidth="2"
|
||||||
</button>
|
strokeLinecap="round"
|
||||||
)}
|
strokeLinejoin="round"
|
||||||
</div>
|
>
|
||||||
);
|
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||||
})}
|
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||||
</>
|
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
<span>Scan</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={styles.dropdownItem}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - Dashboard Style */}
|
||||||
|
<div className={styles.mainContent}>
|
||||||
|
|
||||||
|
<div className={expetationStyles.dashboardContainer}>
|
||||||
|
{/* Header dengan Edit Button */}
|
||||||
|
<div className={expetationStyles.dashboardHeaderWithEdit}>
|
||||||
|
<div className={expetationStyles.dashboardHeaderText}>
|
||||||
|
<h2 className={expetationStyles.dashboardHeader}>
|
||||||
|
Document Types
|
||||||
|
</h2>
|
||||||
|
<p className={expetationStyles.paragraf}>
|
||||||
|
Choose a document type to scan or create a new one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dashboard Grid */}
|
||||||
|
<div className={expetationStyles.dashboardGrid}>
|
||||||
|
{/* Add New Document Type Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewDocumentModal(true)}
|
||||||
|
className={expetationStyles.dashboardCard}
|
||||||
|
>
|
||||||
|
<div className={`${expetationStyles.dashboardIconContainer} ${expetationStyles.dashboardIconAdd}`}>
|
||||||
|
<Plus size={28} />
|
||||||
|
</div>
|
||||||
|
<div className={expetationStyles.dashboardCardText}>
|
||||||
|
<div className={expetationStyles.dashboardCardTitle}>
|
||||||
|
Add New
|
||||||
|
</div>
|
||||||
|
<div className={expetationStyles.dashboardCardSubtitle}>
|
||||||
|
Document Type
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loadingDocumentTypes ? (
|
||||||
|
<div className={expetationStyles.dashboardCard}>
|
||||||
|
<div className={expetationStyles.spinnerContainer}>
|
||||||
|
<div className={expetationStyles.spinner} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* User Created Document Types */
|
||||||
|
documentTypes.map((doc, index) => {
|
||||||
|
const displayInfo = getDocumentDisplayInfo(doc);
|
||||||
|
const colors = getDocumentColors(index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={doc.id} className={expetationStyles.documentCardWrapper}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDocumentTypeSelection(doc)}
|
||||||
|
className={expetationStyles.dashboardCard}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={expetationStyles.dashboardIconContainer}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
color: colors.icon
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={28} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className={expetationStyles.dashboardCardText}>
|
||||||
|
<div className={expetationStyles.dashboardCardTitle}>
|
||||||
|
{displayInfo.name}
|
||||||
|
</div>
|
||||||
|
<div className={expetationStyles.dashboardCardSubtitle}>
|
||||||
|
Document Type
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Edit and Delete Buttons (only visible in edit mode) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className={expetationStyles.editDeleteWrapper}>
|
||||||
|
<button
|
||||||
|
className={expetationStyles.editIcon}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDocument(doc);
|
||||||
|
setShowEditDocumentModal(true);
|
||||||
|
}}
|
||||||
|
title="Edit document type"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={expetationStyles.deleteIcon}
|
||||||
|
onClick={() => handleDeleteDocumentType(doc.id, doc.nama_tipe)}
|
||||||
|
title="Delete document type"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Button - Positioned at Bottom */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
className={expetationStyles.editButton}
|
||||||
|
>
|
||||||
|
{isEditMode ? "Done" : "Edit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={styles.footer}>
|
||||||
|
© 2025 Kediri Technopark • Dashboard SOLID DATA
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
<NewDocumentModal
|
<NewDocumentModal
|
||||||
isOpen={showNewDocumentModal}
|
isOpen={showNewDocumentModal}
|
||||||
onClose={() => setShowNewDocumentModal(false)}
|
onClose={() => setShowNewDocumentModal(false)}
|
||||||
onSubmit={handleNewDocumentSubmit}
|
onSubmit={handleNewDocumentSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditDocumentModal
|
||||||
|
isOpen={showEditDocumentModal}
|
||||||
|
onClose={() => setShowEditDocumentModal(false)}
|
||||||
|
document={editingDocument}
|
||||||
|
onSubmit={handleEditDocumentSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ============================
|
|
||||||
Styles
|
|
||||||
============================ */
|
|
||||||
const spinnerStyle = `
|
|
||||||
@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const expectationFormStyles = {
|
|
||||||
container: { marginTop: "10px" },
|
|
||||||
fieldRow: { display: "flex", alignItems: "center", marginBottom: "12px", gap: "8px" },
|
|
||||||
fieldInput: {
|
|
||||||
flex: "2",
|
|
||||||
padding: "10px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "8px",
|
|
||||||
fontSize: "14px",
|
|
||||||
outline: "none",
|
|
||||||
transition: "border-color 0.3s ease",
|
|
||||||
},
|
|
||||||
fieldSelect: {
|
|
||||||
flex: "1",
|
|
||||||
padding: "10px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "8px",
|
|
||||||
fontSize: "14px",
|
|
||||||
outline: "none",
|
|
||||||
backgroundColor: "white",
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
removeFieldButton: {
|
|
||||||
backgroundColor: "#dc3545",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
width: "32px",
|
|
||||||
height: "32px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
addFieldButton: {
|
|
||||||
backgroundColor: "#007bff",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "10px 15px",
|
|
||||||
fontSize: "14px",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: "100%",
|
|
||||||
marginTop: "10px",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectionStyles = {
|
|
||||||
selectionContainer: {
|
|
||||||
display: "flex", justifyContent: "center", alignItems: "center",
|
|
||||||
minHeight: "calc(100vh - 70px)", padding: "20px", boxSizing: "border-box", backgroundColor: "#f0f2f5",
|
|
||||||
},
|
|
||||||
selectionContent: {
|
|
||||||
backgroundColor: "white", borderRadius: "16px", padding: "30px", textAlign: "center",
|
|
||||||
boxShadow: "0 8px 20px rgba(0,0,0,0.1)", maxWidth: "600px", width: "100%",
|
|
||||||
},
|
|
||||||
selectionHeader: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" },
|
|
||||||
selectionTitle: { fontSize: "28px", fontWeight: "bold", marginBottom: "10px", color: "#333" },
|
|
||||||
selectionSubtitle: { fontSize: "16px", color: "#666", marginBottom: "30px" },
|
|
||||||
documentGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))", gap: "20px", justifyContent: "center" },
|
|
||||||
documentCard: {
|
|
||||||
backgroundColor: "#f8f9fa", borderRadius: "12px", padding: "20px", display: "flex", flexDirection: "column",
|
|
||||||
alignItems: "center", justifyContent: "center", gap: "10px", cursor: "pointer", border: "1px solid #e9ecef",
|
|
||||||
transition: "transform 0.2s, box-shadow 0.2s",
|
|
||||||
},
|
|
||||||
documentIconContainer: { width: "60px", height: "60px", borderRadius: "50%", backgroundColor: "#e0f7fa", display: "flex", justifyContent: "center", alignItems: "center" },
|
|
||||||
documentIcon: { fontSize: "30px" },
|
|
||||||
plusIcon: { fontSize: "40px", color: "#43a0a7", fontWeight: "200" },
|
|
||||||
documentLabel: { fontSize: "15px", fontWeight: "bold", color: "#333", textTransform: "capitalize" },
|
|
||||||
spinnerContainer: { display: "flex", justifyContent: "center", alignItems: "center", height: "100px" },
|
|
||||||
spinner: { border: "4px solid #f3f3f3", borderTop: "4px solid #429241", borderRadius: "50%", width: "40px", height: "40px", animation: "spin 1s linear infinite" },
|
|
||||||
editButton: { backgroundColor: "#007bff", color: "white", padding: "8px 15px", borderRadius: "8px", border: "none", fontSize: "14px", fontWeight: "bold", cursor: "pointer" },
|
|
||||||
documentCardWrapper: { position: "relative", display: "flex", flexDirection: "column", alignItems: "center" },
|
|
||||||
deleteIcon: {
|
|
||||||
position: "absolute", top: "-10px", right: "-10px", backgroundColor: "#dc3545", color: "white", borderRadius: "50%",
|
|
||||||
width: "28px", height: "28px", fontSize: "20px", display: "flex", justifyContent: "center", alignItems: "center",
|
|
||||||
cursor: "pointer", border: "2px solid white", boxShadow: "0 2px 5px rgba(0,0,0,0.2)", zIndex: 10,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const modalStyles = {
|
|
||||||
overlay: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 },
|
|
||||||
modal: { backgroundColor: "white", borderRadius: "16px", width: "90%", maxWidth: "600px", boxShadow: "0 10px 25px rgba(0, 0, 0, 0.2)", maxHeight: "85vh", overflowY: "auto" },
|
|
||||||
header: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "20px 20px 0 20px", borderBottom: "1px solid #e9ecef", marginBottom: "20px" },
|
|
||||||
title: { margin: 0, fontSize: "18px", fontWeight: "bold", color: "#333" },
|
|
||||||
closeButton: { background: "none", border: "none", fontSize: "24px", cursor: "pointer", color: "gray", padding: 0, width: "30px", height: "30px", display: "flex", alignItems: "center", justifyContent: "center" },
|
|
||||||
content: { padding: "0 20px 20px 20px" },
|
|
||||||
section: { marginBottom: "25px" },
|
|
||||||
sectionLabel: { display: "block", marginBottom: "15px", fontWeight: "bold", color: "#333", fontSize: "16px" },
|
|
||||||
label: { display: "block", marginBottom: "8px", fontWeight: "bold", color: "#333", fontSize: "14px" },
|
|
||||||
input: { width: "100%", padding: "12px", border: "2px solid #e9ecef", borderRadius: "8px", fontSize: "16px", outline: "none", transition: "border-color 0.3s ease", boxSizing: "border-box" },
|
|
||||||
templateGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "12px" },
|
|
||||||
templateCard: { backgroundColor: "#f8f9fa", borderRadius: "12px", padding: "15px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", cursor: "pointer", border: "2px solid transparent", transition: "all 0.2s ease" },
|
|
||||||
templateCardActive: { borderColor: "#007bff", backgroundColor: "#e3f2fd" },
|
|
||||||
customTemplateCard: { backgroundColor: "#fff3cd" },
|
|
||||||
customTemplateActive: { borderColor: "#ffc107", backgroundColor: "#fff3cd" },
|
|
||||||
templateContent: { display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" },
|
|
||||||
templateIconContainer: { width: "40px", height: "40px", borderRadius: "50%", backgroundColor: "#e0f7fa", display: "flex", justifyContent: "center", alignItems: "center", transition: "background-color 0.2s ease" },
|
|
||||||
templateIconActive: { backgroundColor: "#007bff", color: "white" },
|
|
||||||
customIconActive: { backgroundColor: "#ffc107", color: "white" },
|
|
||||||
templateName: { fontSize: "12px", fontWeight: "bold", color: "#333", textAlign: "center" },
|
|
||||||
footer: { display: "flex", gap: "10px", padding: "20px", borderTop: "1px solid #e9ecef" },
|
|
||||||
cancelButton: { flex: 1, padding: "12px", border: "2px solid #e9ecef", borderRadius: "8px", backgroundColor: "white", cursor: "pointer", fontSize: "16px", fontWeight: "bold", color: "#666" },
|
|
||||||
submitButton: { flex: 1, padding: "12px", border: "none", borderRadius: "8px", backgroundColor: "#429241", color: "white", cursor: "pointer", fontSize: "16px", fontWeight: "bold" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Expetation;
|
export default Expetation;
|
||||||
|
|||||||
773
src/Expetation.module.css
Normal file
773
src/Expetation.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,485 +1,380 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import styles from "./FileListComponent.module.css";
|
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { PDFDownloadLink } from "@react-pdf/renderer";
|
import { saveAs } from "file-saver";
|
||||||
import KTPPDF from "./KTPPDF";
|
import styles from "./FileListComponent.module.css";
|
||||||
|
|
||||||
const FileListComponent = ({
|
const FileListComponent = ({
|
||||||
setTotalFilesSentToday,
|
setOfficerPerformanceData, // optional: kirim seri ke parent (compat)
|
||||||
setTotalFilesSentMonth,
|
onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe
|
||||||
setTotalFilesSentOverall,
|
onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka
|
||||||
setOfficerPerformanceData,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
const [entries, setEntries] = useState([]);
|
||||||
const [selectedDocumentType, setSelectedDocumentType] = 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 getOrganizationId = () => {
|
||||||
const formatKeyToLabel = (key) => {
|
try {
|
||||||
return key
|
const orgData = localStorage.getItem("selected_organization");
|
||||||
.replace(/_/g, ' ')
|
if (!orgData) return null;
|
||||||
.replace(/\b\w/g, l => l.toUpperCase());
|
const parsed = JSON.parse(orgData);
|
||||||
};
|
return parsed.organization_id || null;
|
||||||
|
} catch (e) {
|
||||||
// Helper function to check if value is a date string and convert it
|
console.error("Gagal membaca organization_id:", e);
|
||||||
const formatValue = (key, value) => {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check if the value looks like a date
|
// 👉 Download Excel dari daftar entries yang sedang dibuka
|
||||||
if (typeof value === 'string' &&
|
const downloadExcel = () => {
|
||||||
(key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) {
|
if (!entries.length) {
|
||||||
const date = new Date(value);
|
alert("Tidak ada data untuk diunduh.");
|
||||||
if (!isNaN(date.getTime())) {
|
return;
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
const flattened = entries.map((e) => ({
|
||||||
};
|
nama_tipe: e.nama_tipe,
|
||||||
|
...e.data,
|
||||||
|
}));
|
||||||
|
|
||||||
// Dynamic function to process data for Excel export
|
const worksheet = XLSX.utils.json_to_sheet(flattened);
|
||||||
const processDataForExcel = (data) => {
|
|
||||||
if (!data || data.length === 0) return [];
|
|
||||||
|
|
||||||
return data.map((item) => {
|
// Auto width kolom
|
||||||
const processedItem = {};
|
const objectMaxLength = [];
|
||||||
|
flattened.forEach((row) => {
|
||||||
Object.entries(item).forEach(([key, value]) => {
|
Object.keys(row).forEach((key, colIndex) => {
|
||||||
// Skip null, undefined, or empty string values
|
const value = row[key] ? row[key].toString() : "";
|
||||||
if (value === null || value === undefined || value === '') {
|
objectMaxLength[colIndex] = Math.max(
|
||||||
return;
|
objectMaxLength[colIndex] || key.length,
|
||||||
}
|
value.length
|
||||||
|
);
|
||||||
// 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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// Fetch daftar tipe dari webhook /files
|
||||||
const getUniqueDocumentTypes = (data) => {
|
|
||||||
const types = [...new Set(data.map(item => item.document_type).filter(Boolean))];
|
|
||||||
return types;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFiles = async () => {
|
const fetchFiles = async () => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
const orgId = getOrganizationId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://bot.kediritechnopark.com/webhook/solid-data/files",
|
"https://bot.kediritechnopark.com/webhook/solid-data/files",
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"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();
|
const text = await response.text();
|
||||||
if (!text) throw new Error("Server membalas kosong.");
|
if (!text) {
|
||||||
|
setFiles([]);
|
||||||
const data = JSON.parse(text);
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// fetchFiles();
|
fetchFiles();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Fetch entries per data_type_id (saat user klik suatu tipe)
|
||||||
if (selectedDocumentType) {
|
const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => {
|
||||||
setFilteredFiles(
|
let resolvedEntryName = entryName;
|
||||||
files.filter((file) => file.document_type === selectedDocumentType)
|
if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) {
|
||||||
);
|
resolvedEntryName = Object.keys(expectation)[0];
|
||||||
} else {
|
|
||||||
setFilteredFiles(files);
|
|
||||||
}
|
}
|
||||||
}, [selectedDocumentType, files]);
|
|
||||||
|
|
||||||
const handleRowClick = async (file) => {
|
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||||
const token = localStorage.getItem("token");
|
setEntries([]);
|
||||||
if (!token) {
|
setLoadingEntries(true);
|
||||||
alert("Token tidak ditemukan. Silakan login kembali.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://bot.kediritechnopark.com/webhook/solid-data/merged?nama_lengkap=${encodeURIComponent(
|
"https://bot.kediritechnopark.com/webhook/solid-data/files/entry",
|
||||||
file.nama_lengkap || ''
|
|
||||||
)}`,
|
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({ data_type_id: dataTypeId }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok)
|
if (!response.ok) throw new Error("Gagal ambil entries");
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
const text = await response.text();
|
|
||||||
if (!text) throw new Error("Respons kosong dari server.");
|
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
const text = await response.text();
|
||||||
if (data.error) {
|
let data;
|
||||||
alert(data.error);
|
try {
|
||||||
return;
|
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
|
const entryList = Array.isArray(data) ? data : data?.data || [];
|
||||||
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
|
// Fallback nama field untuk judul kartu
|
||||||
setSelectedFile(data[0]);
|
if (!resolvedEntryName && entryList.length > 0) {
|
||||||
} catch (error) {
|
resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null;
|
||||||
console.error("Gagal mengambil detail:", error.message);
|
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
|
||||||
alert("Gagal mengambil detail. Pastikan data tersedia.");
|
}
|
||||||
|
|
||||||
|
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) => {
|
const openEntryModal = (entry) => {
|
||||||
if (!base64) return null;
|
setSelectedEntry(entry);
|
||||||
const cleaned = base64.replace(/\s/g, "");
|
setShowModal(true);
|
||||||
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 closeModal = () => setSelectedFile(null);
|
const closeModal = () => {
|
||||||
|
setShowModal(false);
|
||||||
const formatPhoneNumber = (phone) =>
|
setSelectedEntry(null);
|
||||||
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");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get unique document types for dropdown
|
const backToTypes = () => {
|
||||||
const documentTypes = getUniqueDocumentTypes(files);
|
setSelectedType(null);
|
||||||
|
setEntries([]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fileListSection}>
|
<div className={styles.container}>
|
||||||
<div className={styles.fileListHeader}>
|
<h2 className={styles.title}>📑 Daftar Jenis Dokumen</h2>
|
||||||
<h2 className={styles.fileListTitle}>📁 Daftar Document</h2>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
||||||
<select
|
|
||||||
value={selectedDocumentType}
|
|
||||||
onChange={(e) => setSelectedDocumentType(e.target.value)}
|
|
||||||
className={styles.fileCount}
|
|
||||||
>
|
|
||||||
<option value="">Semua</option>
|
|
||||||
{documentTypes.map(type => (
|
|
||||||
<option key={type} value={type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
exportToExcel(filteredFiles);
|
|
||||||
}}
|
|
||||||
className={styles.downloadButton}
|
|
||||||
>
|
|
||||||
⬇️ Unduh Excel
|
|
||||||
</button>
|
|
||||||
<span className={styles.fileCount}>
|
|
||||||
{filteredFiles.length} document
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{successMessage && (
|
{!selectedType ? (
|
||||||
<div className={styles.successMessage}>
|
<>
|
||||||
<span>✅</span>
|
{loading ? (
|
||||||
{successMessage}
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Sedang memuat...</p>
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateTitle}>Belum ada data</div>
|
||||||
|
<p className={styles.emptyStateText}>
|
||||||
|
Tidak ada jenis dokumen yang tersedia saat ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.typeList}>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<li
|
||||||
|
key={file.data_type_id}
|
||||||
|
className={styles.typeItem}
|
||||||
|
onClick={() =>
|
||||||
|
fetchEntries(
|
||||||
|
file.data_type_id,
|
||||||
|
file.nama_tipe,
|
||||||
|
file.entry_name,
|
||||||
|
file.expectation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.typeInfo}>
|
||||||
|
<div className={styles.typeNumber}>{index + 1}</div>
|
||||||
|
<div className={styles.typeDetails}>
|
||||||
|
<div className={styles.typeName}>{file.nama_tipe}</div>
|
||||||
|
<div className={styles.typeCount}>
|
||||||
|
{file.total_entries} data tersedia
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.typeArrow}>→</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.entrySection}>
|
||||||
|
<button className={styles.backButton} onClick={backToTypes}>
|
||||||
|
← Kembali ke Daftar Jenis
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className={styles.entryTitle}>
|
||||||
|
📂 Isi Dokumen: {selectedType.nama}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 👉 Tombol Download Excel */}
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<button className={styles.downloadButton} onClick={downloadExcel}>
|
||||||
|
⬇️ Download Excel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingEntries ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Sedang memuat data...</p>
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateTitle}>Belum ada entry</div>
|
||||||
|
<p className={styles.emptyStateText}>
|
||||||
|
Belum ada entry untuk dokumen ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className={styles.entryList}>
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<li
|
||||||
|
key={entry.data_id}
|
||||||
|
className={styles.entryItem}
|
||||||
|
onClick={() => openEntryModal(entry)}
|
||||||
|
>
|
||||||
|
<div className={styles.entryInfo}>
|
||||||
|
<div className={styles.entryNumber}>{index + 1}</div>
|
||||||
|
<div className={styles.entryDetails}>
|
||||||
|
<div className={styles.entryName}>
|
||||||
|
{entry.data?.[selectedType.entryName] || "Data tidak tersedia"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.entryHint}>
|
||||||
|
Klik untuk melihat detail lengkap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.entryArrow}>→</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
{/* Modal untuk detail entry */}
|
||||||
{filteredFiles.length === 0 ? (
|
{showModal && selectedEntry && (
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<div className={styles.emptyStateTitle}>Belum ada data</div>
|
|
||||||
<p className={styles.emptyStateText}>
|
|
||||||
Tidak ada data yang tersedia saat ini.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className={styles.fileTable}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>No</th>
|
|
||||||
<th>NIK</th>
|
|
||||||
<th>Jenis</th>
|
|
||||||
<th className={styles.nameColumn}>Nama Lengkap</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredFiles.map((file, index) => (
|
|
||||||
<tr
|
|
||||||
key={file.id || index}
|
|
||||||
onClick={() => handleRowClick(file)}
|
|
||||||
className={styles.tableRow}
|
|
||||||
>
|
|
||||||
<td>{index + 1}</td>
|
|
||||||
<td>{file.nik || '-'}</td>
|
|
||||||
<td>{file.document_type || '-'}</td>
|
|
||||||
<td className={styles.nameColumn}>{file.nama_lengkap || '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
{selectedFile && (
|
|
||||||
<div className={styles.modalOverlay} onClick={closeModal}>
|
<div className={styles.modalOverlay} onClick={closeModal}>
|
||||||
<div
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
className={styles.modalContent}
|
<div className={styles.modalHeader}>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<h3 className={styles.modalTitle}>
|
||||||
>
|
Detail Data:{" "}
|
||||||
{selectedFile.data && (
|
{selectedEntry.data?.[selectedType.entryName] ||
|
||||||
<img
|
selectedEntry.data?.nama ||
|
||||||
src={getImageSrc(selectedFile.data)}
|
selectedEntry.data?.name ||
|
||||||
alt={`Foto Document - ${selectedFile.nik || 'Unknown'}`}
|
"Data"}
|
||||||
style={{
|
</h3>
|
||||||
width: "100%",
|
<button className={styles.closeButton} onClick={closeModal}>
|
||||||
maxHeight: "300px",
|
×
|
||||||
objectFit: "contain",
|
</button>
|
||||||
marginBottom: "1rem",
|
|
||||||
borderRadius: "8px",
|
|
||||||
boxShadow: "0 2px 6px rgba(0,0,0,0.2)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h3>🪪 Detail Data Document</h3>
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<PDFDownloadLink
|
|
||||||
document={
|
|
||||||
<KTPPDF
|
|
||||||
data={{
|
|
||||||
...selectedFile,
|
|
||||||
data:
|
|
||||||
selectedFile.data?.startsWith("/") ||
|
|
||||||
selectedFile.data?.length < 50
|
|
||||||
? null
|
|
||||||
: selectedFile.data.replace(/\s/g, ""),
|
|
||||||
fallbackImage: selectedFile.foto_url,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
</PDFDownloadLink>
|
|
||||||
</div>
|
</div>
|
||||||
<table className={styles.detailTable}>
|
|
||||||
<tbody>
|
|
||||||
{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 (
|
|
||||||
<tr key={key}>
|
|
||||||
<td>{formatKeyToLabel(key)}</td>
|
|
||||||
<td>
|
|
||||||
{value.map((member, idx) => (
|
|
||||||
<div key={idx} style={{ marginBottom: "10px", borderBottom: "1px dashed #eee", paddingBottom: "5px" }}>
|
|
||||||
{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]) => (
|
|
||||||
<div key={memberKey}>
|
|
||||||
<strong>{formatKeyToLabel(memberKey)}:</strong> {memberValue}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format dates for display
|
<div className={styles.modalContent}>
|
||||||
let displayValue = value;
|
<div className={styles.detailGrid}>
|
||||||
if (typeof value === 'string' &&
|
{Object.entries(selectedEntry.data || {}).map(([key, value]) => (
|
||||||
(key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) {
|
<div key={key} className={styles.detailItem}>
|
||||||
const date = new Date(value);
|
<div className={styles.detailLabel}>
|
||||||
if (!isNaN(date.getTime())) {
|
{key
|
||||||
displayValue = date.toLocaleDateString('id-ID');
|
.replace(/_/g, " ")
|
||||||
}
|
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
}
|
</div>
|
||||||
|
<div className={styles.detailValue}>{value || "-"}</div>
|
||||||
return (
|
</div>
|
||||||
<tr key={key}>
|
))}
|
||||||
<td>{formatKeyToLabel(key)}</td>
|
</div>
|
||||||
<td>{displayValue}</td>
|
</div>
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<button className={styles.closeButton} onClick={closeModal}>
|
|
||||||
Tutup
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
--primary-blue: #3b82f6;
|
--brand-primary: #2961eb;
|
||||||
--secondary-blue: #60a5fa;
|
--brand-primary-700: #1d4ed8;
|
||||||
--dark-blue: #1e40af;
|
--brand-secondary: #4f46e5;
|
||||||
--neutral-50: #fafafa;
|
--brand-secondary-700: #4338ca;
|
||||||
--neutral-100: #f5f5f5;
|
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
|
||||||
--neutral-200: #e5e5e5;
|
|
||||||
--neutral-300: #d4d4d4;
|
--neutral-25: #fcfcfd;
|
||||||
--neutral-500: #737373;
|
--neutral-50: #f9fafb;
|
||||||
--neutral-700: #404040;
|
--neutral-100: #f3f4f6;
|
||||||
--neutral-800: #262626;
|
--neutral-200: #e5e7eb;
|
||||||
--neutral-900: #171717;
|
--neutral-300: #d1d5db;
|
||||||
|
--neutral-400: #9ca3af;
|
||||||
|
--neutral-600: #475569;
|
||||||
|
--neutral-700: #374151;
|
||||||
|
--neutral-800: #1f2937;
|
||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--success-green: #43a0a7;
|
|
||||||
--warning-amber: #f59e0b;
|
|
||||||
--error-red: #ef4444;
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #0f172a;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #64748b;
|
||||||
--text-light: #ffffff;
|
--text-on-brand: #ffffff;
|
||||||
--border-light: #e2e8f0;
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--border-light: #e5e7eb;
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
|
||||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
|
||||||
|
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File List Section */
|
/* ===== Container ===== */
|
||||||
.fileListSection {
|
.container {
|
||||||
background-color: var(--white);
|
background: var(--white);
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
margin: 2rem auto;
|
padding: 1.5rem;
|
||||||
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 {
|
|
||||||
margin: 0;
|
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%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer {
|
/* ===== Title ===== */
|
||||||
flex: 1;
|
.title {
|
||||||
overflow: auto;
|
margin: 0 0 1.5rem 0;
|
||||||
border-radius: 0.75rem;
|
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem);
|
||||||
border: 1px solid var(--border-light);
|
font-weight: 800;
|
||||||
background-color: var(--white);
|
letter-spacing: -0.015em;
|
||||||
width: 100%;
|
color: var(--neutral-800);
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable {
|
/* ===== Loading State ===== */
|
||||||
width: 100%;
|
.loading {
|
||||||
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;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
padding: 3rem 1rem;
|
||||||
color: #ffffff;
|
color: var(--text-secondary);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable td {
|
.spinner {
|
||||||
padding: 0.75rem;
|
width: 2rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
height: 2rem;
|
||||||
color: var(--text-primary);
|
border: 3px solid var(--neutral-300);
|
||||||
vertical-align: middle;
|
border-top: 3px solid var(--brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableRow {
|
@keyframes spin {
|
||||||
cursor: pointer;
|
to { transform: rotate(360deg); }
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableRow:hover {
|
|
||||||
background-color: var(--neutral-50);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nameColumn {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Empty State ===== */
|
||||||
.emptyState {
|
.emptyState {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 2rem;
|
padding: 3rem 1rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyStateTitle {
|
.emptyStateTitle {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--neutral-800);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyStateText {
|
.emptyStateText {
|
||||||
@@ -172,327 +98,514 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
/* ===== Type List ===== */
|
||||||
width: 2rem;
|
.typeList {
|
||||||
height: 2rem;
|
list-style: none;
|
||||||
border: 3px solid var(--neutral-300);
|
margin: 0;
|
||||||
border-top: 3px solid #43a0a7;
|
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%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
font-size: 0.75rem;
|
||||||
margin: 0 auto 1rem;
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.typeDetails {
|
||||||
0% {
|
display: flex;
|
||||||
transform: rotate(0deg);
|
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% {
|
to {
|
||||||
transform: rotate(360deg);
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
.backButton {
|
||||||
.tableContainer::-webkit-scrollbar {
|
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;
|
width: 8px;
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-track {
|
.entryList::-webkit-scrollbar-track {
|
||||||
background: var(--neutral-100);
|
background: var(--neutral-100);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-thumb {
|
.entryList::-webkit-scrollbar-thumb {
|
||||||
background: #43a0a7;
|
background: var(--neutral-300);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-thumb:hover {
|
.entryList::-webkit-scrollbar-thumb:hover {
|
||||||
background: #306a2f;
|
background: var(--neutral-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableContainer::-webkit-scrollbar-corner {
|
/* ===== Modal ===== */
|
||||||
background: var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableContainer {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #43a0a7 var(--neutral-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles - Matching Dashboard Design */
|
|
||||||
.modalOverlay {
|
.modalOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
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);
|
background: var(--white);
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-width: 600px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 85vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--border-light);
|
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%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
max-width: 900px;
|
||||||
margin-bottom: 1.5rem;
|
max-height: 85vh;
|
||||||
font-size: 0.875rem;
|
overflow: hidden;
|
||||||
text-align: left;
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable tr:nth-child(even) {
|
@keyframes modalSlideIn {
|
||||||
background-color: var(--neutral-50);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td {
|
.modalHeader {
|
||||||
padding: 0.75rem;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
vertical-align: top;
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td:first-child {
|
.modalTitle {
|
||||||
font-weight: 600;
|
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem);
|
||||||
color: var(--text-secondary);
|
font-weight: 800;
|
||||||
width: 35%;
|
color: var(--neutral-800);
|
||||||
text-transform: uppercase;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
letter-spacing: -0.015em;
|
||||||
letter-spacing: 0.05em;
|
overflow: hidden;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
.detailTable td:last-child {
|
padding-right: 1rem;
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.closeButton {
|
||||||
background-color: #43a0a7;
|
background: #ef4444;
|
||||||
color: var(--text-light);
|
color: var(--text-on-brand);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem 1.5rem;
|
border-radius: 50%;
|
||||||
border-radius: 0.5rem;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
width: 100%;
|
transition: all 0.15s ease;
|
||||||
transition: all 0.2s ease;
|
flex-shrink: 0;
|
||||||
letter-spacing: 0.025em;
|
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:hover {
|
.closeButton:hover {
|
||||||
background-color: #dc2626;
|
background: #dc2626;
|
||||||
transform: translateY(-1px);
|
transform: scale(1.05);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton:active {
|
.modalContent {
|
||||||
transform: translateY(0);
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
.detailGrid {
|
||||||
@media (max-width: 768px) {
|
display: grid;
|
||||||
.fileListSection {
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
padding: 1.5rem;
|
gap: 1rem;
|
||||||
margin: 1rem;
|
}
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
.detailItem {
|
||||||
max-height: 70vh;
|
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 {
|
.typeItem,
|
||||||
flex-direction: column;
|
.entryItem {
|
||||||
align-items: flex-start;
|
padding: 1.25rem;
|
||||||
gap: 0.75rem;
|
min-height: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileListTitle {
|
.typeNumber,
|
||||||
font-size: 1.125rem;
|
.entryNumber {
|
||||||
}
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
/* 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;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th:nth-child(2) {
|
.typeName,
|
||||||
width: 40%;
|
.entryName {
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th:nth-child(3) {
|
.modalHeader {
|
||||||
width: 60%;
|
padding: 2rem 2rem 0 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nameColumn {
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal responsive */
|
|
||||||
.modalContent {
|
.modalContent {
|
||||||
padding: 1.5rem;
|
padding: 0 2rem 2rem 2rem;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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) {
|
@media (max-width: 480px) {
|
||||||
.fileListSection {
|
.container {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileListTitle {
|
.typeItem,
|
||||||
font-size: 1rem;
|
.entryItem {
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileCount {
|
.typeNumber,
|
||||||
font-size: 0.6rem;
|
.entryNumber {
|
||||||
padding: 0.25rem 0.5rem;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileTable th,
|
.typeName,
|
||||||
.fileTable td {
|
.entryName {
|
||||||
padding: 0.5rem 0.375rem;
|
font-size: 0.85rem;
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modalContent {
|
.modalHeader {
|
||||||
padding: 1rem;
|
padding: 1rem 1rem 0 1rem;
|
||||||
width: 98%;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modalContent h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable {
|
.modalContent {
|
||||||
font-size: 0.75rem;
|
padding: 0 1rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailTable td {
|
.detailGrid {
|
||||||
padding: 0.5rem 0.375rem;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeButton {
|
.detailValue {
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem;
|
||||||
font-size: 0.8125rem;
|
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 {
|
.closeButton {
|
||||||
padding: 0.875rem 2rem;
|
width: 32px;
|
||||||
display: block;
|
height: 32px;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
/* Prevent text selection on interactive elements */
|
||||||
.fileListSection {
|
.typeItem,
|
||||||
padding: 3rem;
|
.entryItem,
|
||||||
margin: 3rem auto;
|
.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);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.modal {
|
||||||
.downloadButton {
|
border: 2px solid var(--text-primary);
|
||||||
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;
|
|
||||||
}
|
|
||||||
1124
src/KTPScanner.js
1124
src/KTPScanner.js
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import styles from "./Login.module.css";
|
|||||||
export default function LoginPage({ onLoggedIn }) {
|
export default function LoginPage({ onLoggedIn }) {
|
||||||
|
|
||||||
const login = () => {
|
const login = () => {
|
||||||
const baseUrl = "http://localhost:3001/";
|
const baseUrl = "https://kediritechnopark.com/";
|
||||||
const modal = "product";
|
const modal = "product";
|
||||||
const productId = 9;
|
const productId = 9;
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import React from "react";
|
|||||||
import styles from "./Header.module.css";
|
import styles from "./Header.module.css";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
return (
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.title}>Officers & Roles</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Sidebar.js
|
// Sidebar.js
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import styles from "./Sidebar.module.css";
|
import styles from "./Sidebar.module.css";
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.sidebar}>
|
<div className={styles.sidebar}>
|
||||||
<div className={styles.logo}>Dashboard</div>
|
<Link to="/dashboard" className={styles.logo}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
<div className={styles.menu}>
|
<div className={styles.menu}>
|
||||||
<div className={styles.menuItem}>Officers</div>
|
<div className={styles.menuItem}>Officers</div>
|
||||||
<div className={styles.menuItem}>Roles</div>
|
<div className={styles.menuItem}>Roles</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user