This commit is contained in:
Vassshhh
2025-09-11 13:28:33 +07:00
parent 28c4c4d66b
commit e3f18d60ff
15 changed files with 3367 additions and 2058 deletions

18
package-lock.json generated
View File

@@ -13,11 +13,13 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"file-saver": "^2.0.5",
"js-levenshtein": "^1.1.6",
"lucide-react": "^0.539.0",
"pixelmatch": "^7.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.2",
"react-scripts": "5.0.1",
"recharts": "^3.0.2",
@@ -8105,6 +8107,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -13849,6 +13857,15 @@
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.1.0.tgz",
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ=="
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -17508,6 +17525,7 @@
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",

View File

@@ -8,11 +8,13 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"file-saver": "^2.0.5",
"js-levenshtein": "^1.1.6",
"lucide-react": "^0.539.0",
"pixelmatch": "^7.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.6.2",
"react-scripts": "5.0.1",
"recharts": "^3.0.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/ikasapta1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

View File

@@ -8,86 +8,100 @@ import LoginPage from "./Login";
import Expetation from "./DataTypePage";
import CameraKtp from "./KTPScanner";
import Profile from "./ProfileTab";
import PickOrganization from "./PickOrganization"; // <-- import baru
import PickOrganization from "./PickOrganization";
// LandingPage.js
const LandingPage = () => {
return (
<div>
<h1>Selamat datang di Aplikasi Kami</h1>
{/* Tambahkan konten lainnya sesuai kebutuhan */}
</div>
);
};
// Komponen untuk melindungi route dengan token
// ===== ProtectedRoute: cek token sebelum render =====
const ProtectedRoute = ({ element }) => {
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() {
const location = useLocation();
const navigate = useNavigate();
// Simpan token dari query parameter ke localStorage (jika ada)
useEffect(() => {
const params = new URLSearchParams(location.search);
const token = params.get("token");
// 1) Ambil token dari query, simpan, lalu arahkan ke pemilihan organisasi
const token = params.get("token");
if (token) {
localStorage.setItem("token", token);
// Bersihkan token dari URL setelah disimpan
const newSearch = new URLSearchParams(location.search);
newSearch.delete("token");
// Replace URL tanpa query token
navigate(
{
pathname: location.pathname,
search: newSearch.toString(),
},
{ replace: true }
);
params.delete("token");
navigate("/pick-organization", { replace: true });
return;
}
// 2) Jika tidak ada token di query, biarkan mengalir normal
}, [location, navigate]);
return (
<div className="App">
<Routes>
<Route path="/" element={<LandingPage />} />
{/* Default → login */}
<Route path="/" element={<Navigate to="/login" replace />} />
{/* Auth */}
<Route path="/login" element={<LoginPage />} />
{/* Halaman pilih organisasi (wajib setelah login) */}
{/* Setelah login → pilih organisasi */}
<Route
path="/pickorganization"
path="/pick-organization"
element={<ProtectedRoute element={<PickOrganization />} />}
/>
<Route path="/scan" element={<CameraKtp />} />
<Route path="/success" element={<SuccessPage />} />
{/* Jika user ke /dashboard tanpa memilih organisasi, arahkan ke /pickorganization */}
{/* Dashboard "polos" otomatis diarahkan ke dashboard org aktif */}
<Route
path="/dashboard"
element={<ProtectedRoute element={<Navigate to="/pickorganization" />} />}
element={<ProtectedRoute element={<RedirectToOrgDashboard />} />}
/>
{/* Dashboard spesifik organisasi */}
<Route
path="/dashboard/:organization"
path="/dashboard/:organization_id"
element={<ProtectedRoute element={<Dashboard />} />}
/>
{/* Scan "polos" otomatis diarahkan ke scan org aktif */}
<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 />} />}
/>
{/* Halaman lain */}
<Route path="/success" element={<SuccessPage />} />
<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>
</div>
);

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from "react";
import styles from "./Dashboard.module.css";
import { useNavigate, useParams } from "react-router-dom"; // ***
import { useNavigate, useParams } from "react-router-dom";
import FileListComponent from "./FileListComponent";
import {
BarChart,
@@ -11,11 +11,25 @@ import {
ResponsiveContainer,
} from "recharts";
const API_BASE = "https://bot.kediritechnopark.com/webhook/solid-data";
// Konsistenkan base URL (tanpa tanda minus)
const API_BASE = "https://bot.kediritechnopark.com/webhook/soliddata";
const Dashboard = () => {
const navigate = useNavigate();
const { organization_id: orgIdFromRoute } = useParams(); // ***
// Ambil org dari URL, lalu sediakan fallback ke localStorage
const { organization_id: orgParam } = useParams();
const [organizationId, setOrganizationId] = useState(
orgParam || localStorage.getItem("organization_id") || ""
);
useEffect(() => {
if (orgParam) {
localStorage.setItem("organization_id", orgParam);
setOrganizationId(orgParam);
}
}, [orgParam]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null);
@@ -27,98 +41,132 @@ const Dashboard = () => {
const [user, setUser] = useState({});
const [totalFilesSentToday, setTotalFilesSentToday] = useState(0);
const [totalFilesSentMonth, setTotalFilesSentMonth] = useState(0);
const [totalFileSentYear, setTotalFileSentYear] = useState(0);
const [totalFilesSentOverall, setTotalFilesSentOverall] = useState(0);
const [officerPerformanceData, setOfficerPerformanceData] = useState([]);
// === Grafik ===
const [officerPerformanceData, setOfficerPerformanceData] = useState([]); // data yang sedang ditampilkan di chart
const [byTypeSeries, setByTypeSeries] = useState([]); // dari /files: [{ label: nama_tipe, count }]
const [typeOptions, setTypeOptions] = useState([]); // daftar tipe untuk dropdown: [{id, name}]
const [performanceByType, setPerformanceByType] = useState({}); // { [nama_tipe]: monthlySeries [{label: 'YYYY-MM', count}] }
const [chartKey, setChartKey] = useState(""); // "" = semua tipe (agregat), selain itu = nama_tipe (tren bulanan)
const [officers, setOfficers] = useState([]);
// Helper: ambil orgId yang valid dari route atau localStorage
const getActiveOrg = () => {
const selected = JSON.parse(localStorage.getItem("selected_organization") || "null");
// prioritas: URL param, fallback ke localStorage
const orgId = orgIdFromRoute || selected?.organization_id;
const orgName = selected?.nama_organization || "";
return { orgId, orgName };
// Terima daftar tipe + seri per-tipe dari FileListComponent (/files)
const handleTypesLoaded = (options, series) => {
setTypeOptions(options || []);
setByTypeSeries(series || []);
// Default grafik: tampilkan agregat per-tipe
setOfficerPerformanceData(series || []);
setChartKey(""); // mode "Semua tipe"
};
// Helper: header standar, opsional kirim X-Organization-Id
// Terima seri bulanan saat tipe dibuka di FileListComponent
const handlePerformanceReady = (typeName, monthlySeries) => {
setPerformanceByType((prev) => ({ ...prev, [typeName]: monthlySeries }));
// Jika user sedang memilih tipe ini → langsung update grafik
if (chartKey === typeName) {
setOfficerPerformanceData(monthlySeries || []);
}
};
// Header auth + optional X-Organization-Id
const authHeaders = (extra = {}) => {
const token = localStorage.getItem("token");
const { orgId } = getActiveOrg();
return {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"X-Organization-Id": orgId ? String(orgId) : undefined, // backend boleh pakai header ini jika mau
...extra,
};
return headers;
};
// Pastikan sudah login & punya org yang dipilih
// Pastikan login
useEffect(() => {
const token = localStorage.getItem("token");
const { orgId } = getActiveOrg();
if (!token) {
navigate("/login");
return;
}
if (!orgId) {
navigate("/pick-organization");
return;
// Opsional: jika org kosong, arahkan user ke pemilihan organisasi
if (!organizationId) {
setErrorMessage("Organisasi tidak terdeteksi. Silakan akses dashboard melalui tautan organisasi.");
}
}, [organizationId, navigate]);
// Sinkronkan URL dengan orgId dari localStorage kalau user buka /dashboard tanpa param
if (!orgIdFromRoute) {
navigate(`/dashboard/${orgId}`, { replace: true });
}
}, [orgIdFromRoute, navigate]);
// Verifikasi token & ambil ringkasan dashboard untuk org terpilih
// Verifikasi token & fetch ringkasan dashboard org aktif
useEffect(() => {
const verifyTokenAndFetchData = async () => {
const token = localStorage.getItem("token");
const { orgId } = getActiveOrg();
if (!token || !orgId) return;
if (!token || !organizationId) return;
const toNum = (v) => {
const n = typeof v === "number" ? v : Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
};
try {
// GET -> kirim orgId lewat query string
const res = await fetch(
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(orgId)}`,
// Fetch total scans data (pakai API_BASE yang konsisten)
const totalScansRes = await fetch(
`${API_BASE}/total-scans?organization_id=${encodeURIComponent(
organizationId
)}`,
{ method: "GET", headers: authHeaders() }
);
const totalScansRaw = await totalScansRes.json();
console.log("RAW total-scans payload:", totalScansRaw);
const data = await res.json();
if (!res.ok) {
console.error("Dashboard error:", data);
if (!totalScansRes.ok) {
console.error("Total Scans error:", totalScansRaw);
} else {
const totalScansPayload = Array.isArray(totalScansRaw)
? totalScansRaw[0]
: totalScansRaw;
setTotalFilesSentToday(toNum(totalScansPayload?.total_today));
setTotalFilesSentMonth(toNum(totalScansPayload?.total_month));
setTotalFileSentYear(toNum(totalScansPayload?.total_year));
setTotalFilesSentOverall(toNum(totalScansPayload?.total_overall));
}
// Contoh normalisasi struktur user dari backend
// Pakai apa yang ada: data.user atau data[0] atau langsung isi metrik
if (data?.user) setUser(data.user);
else if (Array.isArray(data) && data.length) setUser(data[0]);
// Fetch dashboard (user, officer performance default)
const res = await fetch(
`${API_BASE}/dashboard?organization_id=${encodeURIComponent(
organizationId
)}`,
{ method: "GET", headers: authHeaders() }
);
const raw = await res.json();
console.log("RAW dashboard payload:", raw);
if (!res.ok) {
console.error("Dashboard error:", raw);
}
// Jika backend mengembalikan metrik-metrik ini, set di sini.
if (typeof data?.total_today === "number") setTotalFilesSentToday(data.total_today);
if (typeof data?.total_month === "number") setTotalFilesSentMonth(data.total_month);
if (typeof data?.total_overall === "number") setTotalFilesSentOverall(data.total_overall);
if (Array.isArray(data?.officerPerformance))
setOfficerPerformanceData(data.officerPerformance);
const payload = Array.isArray(raw) ? raw[0] : raw;
if (payload?.user) setUser(payload.user);
// Kalau backend kirim default "officerPerformance", tetap tampilkan (akan ditimpa saat /files datang)
if (Array.isArray(payload?.officerPerformance)) {
setOfficerPerformanceData(payload.officerPerformance);
setChartKey(""); // mode umum
}
} catch (err) {
console.error("Token/Fetch dashboard gagal:", err);
}
};
verifyTokenAndFetchData();
}, [orgIdFromRoute]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId]);
// Ambil daftar officer (khusus admin) untuk org terpilih
// Ambil daftar officer (khusus admin)
const fetchOfficers = async () => {
const { orgId } = getActiveOrg();
if (!orgId) return;
if (!organizationId) return;
try {
const res = await fetch(
`${API_BASE}/list-user?organization_id=${encodeURIComponent(orgId)}`,
`${API_BASE}/list-user?organization_id=${encodeURIComponent(
organizationId
)}`,
{ method: "GET", headers: authHeaders() }
);
const data = await res.json();
@@ -132,19 +180,19 @@ const Dashboard = () => {
if (user?.role === "admin") {
fetchOfficers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.role]);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
// jangan hapus selected_organization kalau mau balik lagi ke org sebelumnya
localStorage.removeItem("organization_id"); // bersihkan juga orgId
window.location.reload();
};
const handleAddOfficer = async (e) => {
e.preventDefault();
const { orgId } = getActiveOrg();
if (!orgId) return;
if (!organizationId) return;
try {
const res = await fetch(`${API_BASE}/add-officer`, {
@@ -153,7 +201,7 @@ const Dashboard = () => {
body: JSON.stringify({
username,
password,
organization_id: orgId, // *** kirim org pada body
organization_id: organizationId,
}),
});
@@ -176,11 +224,12 @@ const Dashboard = () => {
};
const handleDeleteOfficer = async (id) => {
const confirmDelete = window.confirm("Apakah Anda yakin ingin menghapus petugas ini?");
const confirmDelete = window.confirm(
"Apakah Anda yakin ingin menghapus petugas ini?"
);
if (!confirmDelete) return;
const { orgId } = getActiveOrg();
if (!orgId) return;
if (!organizationId) return;
try {
const res = await fetch(`${API_BASE}/delete-officer`, {
@@ -188,7 +237,7 @@ const Dashboard = () => {
headers: authHeaders(),
body: JSON.stringify({
id,
organization_id: orgId, // *** kirim org pada body
organization_id: organizationId,
}),
});
@@ -204,7 +253,7 @@ const Dashboard = () => {
}
};
// Tutup menu bila klik di luar
// Tutup menu ketika klik di luar
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
@@ -215,7 +264,25 @@ const Dashboard = () => {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const { orgName } = getActiveOrg();
const { orgName } = {};
// ====== WRAPPER SETTER untuk proteksi dari FileListComponent ======
const safeSetToday = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentToday(n);
};
const safeSetMonth = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentMonth(n);
};
const safeSetYear = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFileSentYear(n);
};
const safeSetOverall = (v) => {
const n = Number(v ?? 0);
if (Number.isFinite(n)) setTotalFilesSentOverall(n);
};
return (
<div className={styles.dashboardContainer}>
@@ -223,9 +290,7 @@ const Dashboard = () => {
<div className={styles.logoAndTitle}>
<img src="/ikasapta.png" alt="Bot Avatar" />
<h1 className={styles.h1}>SOLID</h1>
<h1 className={styles.h1} styles="color: #43a0a7;">DATA</h1>
{/* *** tampilkan nama org aktif */}
{orgName && <span className={styles.orgBadge}>Org: {orgName}</span>}
<h1 className={`${styles.h1} ${styles.h1Accent}`}>DATA</h1>
</div>
<div className={styles.dropdownContainer} ref={menuRef}>
@@ -235,8 +300,17 @@ const Dashboard = () => {
aria-expanded={isMenuOpen ? "true" : "false"}
aria-haspopup="true"
>
<svg width="15" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="15"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
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" />
@@ -244,15 +318,48 @@ const Dashboard = () => {
</button>
{isMenuOpen && (
<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
onClick={() => {
navigate("/scan");
// Selalu bawa organizationId saat navigasi
if (organizationId) {
navigate(`/scan/${organizationId}`);
} else {
setErrorMessage("Organisasi tidak terdeteksi.");
}
setIsMenuOpen(false);
}}
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>
{/* Logout */}
<button
onClick={() => {
handleLogout();
@@ -260,23 +367,49 @@ const Dashboard = () => {
}}
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>
</div>
)}
</div>
</div>
{/* ... sisanya tetap sama persis */}
<div className={styles.mainContent}>
{errorMessage && (
<div className={styles.error} style={{ marginBottom: 12 }}>
{errorMessage}
</div>
)}
<div className={styles.summaryCardsContainer}>
<div className={styles.summaryCard}>
<h3>Hari Ini</h3>
<p>{totalFilesSentToday.toLocaleString()}</p>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryCard}>
<h3>Bulan Ini</h3>
<p>{totalFilesSentMonth.toLocaleString()}</p>
</div>
<div className={styles.summaryCard}>
<h3>Tahun Ini</h3>
<p>{totalFileSentYear.toLocaleString()}</p>
</div>
<div className={styles.summaryCard}>
<h3>Total Keseluruhan</h3>
<p>{totalFilesSentOverall.toLocaleString()}</p>
@@ -357,14 +490,50 @@ const Dashboard = () => {
)}
<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:&nbsp;</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 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={officerPerformanceData}>
<XAxis dataKey="month" />
<XAxis dataKey="label" /> {/* label = nama_tipe ATAU YYYY-MM */}
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#00adef" />
<Bar dataKey="count" fill="var(--brand-primary)" />
</BarChart>
</ResponsiveContainer>
) : (
@@ -375,13 +544,18 @@ const Dashboard = () => {
</div>
</div>
{/* *** kirim orgId ke FileListComponent agar fetch-nya ikut org */}
<FileListComponent
organizationId={getActiveOrg().orgId} // ***
setTotalFilesSentToday={setTotalFilesSentToday}
setTotalFilesSentMonth={setTotalFilesSentMonth}
setTotalFilesSentOverall={setTotalFilesSentOverall}
organizationId={organizationId}
// Gunakan wrapper agar nilai dari child selalu angka valid
setTotalFilesSentToday={safeSetToday}
setTotalFilesSentMonth={safeSetMonth}
setTotalFileSentYear={safeSetYear}
setTotalFilesSentOverall={safeSetOverall}
// tampilkan agregat saat /files selesai
setOfficerPerformanceData={setOfficerPerformanceData}
// === baru: terima daftar tipe + agregat & seri bulanan
onTypesLoaded={handleTypesLoaded}
onPerformanceReady={handlePerformanceReady}
/>
</div>

View File

@@ -1,608 +1,512 @@
/* Dashboard.module.css - Cleaned Version */
/* Dashboard.module.css - Brand Blue/Indigo, Full Page & Responsive */
/* Modern Color Palette */
/* ==== GLOBAL FULL-HEIGHT ==== */
html, body, #root {
height: 100%;
}
* { box-sizing: border-box; }
/* ==== Palette & Tokens ==== */
:root {
--primary-blue: #3b82f6;
--secondary-blue: #60a5fa;
--dark-blue: #1e40af;
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-500: #737373;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
/* Brand */
--brand-primary: #2961eb; /* blue-600 */
--brand-primary-700: #1d4ed8; /* blue-700 */
--brand-secondary: #4f46e5; /* indigo-600 */
--brand-secondary-700: #4338ca;/* indigo-700 */
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
/* Gradients for cards */
--card-grad-1: linear-gradient(135deg, #2563eb 0%, #4f46e5 100%);
--card-grad-2: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
--card-grad-3: linear-gradient(135deg, #2563eb 10%, #4338ca 100%);
/* Neutral */
--neutral-25: #fcfcfd;
--neutral-50: #f9fafb;
--neutral-100: #f3f4f6;
--neutral-200: #e5e7eb;
--neutral-300: #d1d5db;
--neutral-400: #9ca3af;
--neutral-600: #475569;
--neutral-800: #1f2937;
--white: #ffffff;
--success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
/* Text */
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-light: #ffffff;
--border-light: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
/* Base Styles & Reset */
* {
box-sizing: border-box;
--text-on-brand: #ffffff;
/* Borders & Shadows */
--border-light: #e5e7eb;
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
/* States */
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
/* Semantic */
--error-red: #ef4444;
}
/* ==== Base ==== */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--neutral-50);
background: var(--neutral-50);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Root container: full width & height */
.dashboardContainer {
background-color: var(--neutral-50);
min-height: 100vh;
min-height: 100dvh; /* support mobile dynamic viewport */
width: 100%;
display: flex;
flex-direction: column;
background: var(--neutral-50);
}
/* --- Header --- */
/* ==== Header ==== */
.dashboardHeader {
background-color: var(--white);
color: var(--text-primary);
position: sticky; top: 0; z-index: 50;
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
border-bottom: 3px solid #43a0a7;
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(8px);
background: rgba(255,255,255,.95);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--border-light);
width: 100%;
}
.logoAndTitle {
display: flex;
align-items: center;
flex-shrink: 0;
}
.logoAndTitle { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.logoAndTitle img { width: 2.8rem; height: 2.8rem; object-fit: cover; border-radius: .6rem; box-shadow: var(--shadow-sm); }
.logoAndTitle img {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
margin-right: 0.75rem;
object-fit: cover;
}
.dashboardHeader .h1 {
margin: 2px;
font-size: 1.5rem;
font-weight: 700;
color: #43a0a7;
letter-spacing: -0.025em;
}
.data {
.h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #154666;
letter-spacing: -0.025em;
font-size: clamp(1.25rem, 1.2vw + 1rem, 2rem);
font-weight: 800; letter-spacing: -.02em;
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.h1Accent {
background: linear-gradient(135deg, var(--brand-secondary), var(--brand-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Dropdown Menu */
.dropdownContainer {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.userDisplayName {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.875rem;
.orgBadge {
margin-left: .75rem;
padding: .25rem .6rem;
font-size: .75rem; font-weight: 700;
color: var(--text-on-brand);
background: var(--brand-secondary);
border-radius: .5rem;
border: 1px solid rgba(255,255,255,.5);
}
/* ==== Dropdown ==== */
.dropdownContainer { position: relative; display: flex; align-items: center; gap: .5rem; }
.dropdownToggle {
background-color: var(--neutral-100);
color: var(--text-primary);
min-width: 2.5rem; height: 2.5rem;
display: inline-flex; align-items: center; justify-content: center;
background: var(--white);
border: 1px solid var(--border-light);
padding: 0.5rem;
border-radius: 0.5rem;
border-radius: .6rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
min-width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dropdownToggle:hover {
background-color: var(--neutral-200);
border-color: var(--neutral-300);
box-shadow: var(--shadow-sm);
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
}
.dropdownToggle:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--neutral-300); }
.dropdownToggle:focus { outline: none; box-shadow: var(--focus-ring); }
.dropdownMenu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
background-color: var(--white);
border-radius: 0.75rem;
box-shadow: var(--shadow-lg);
position: absolute; right: 0; top: calc(100% + .5rem);
min-width: 10rem; padding: .5rem;
background: var(--white);
border: 1px solid var(--border-light);
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10rem;
overflow: hidden;
padding: 0.5rem;
border-radius: .75rem;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; gap: .25rem;
}
.dropdownItem {
background: none;
border: none;
padding: 0.75rem 1rem;
text-align: left;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
margin-bottom: 0.125rem;
border: 0; background: transparent; text-align: left; cursor: pointer;
padding: .65rem .75rem; border-radius: .5rem;
font-size: .95rem; font-weight: 600; color: var(--neutral-800);
transition: background .15s ease, transform .06s ease;
}
.dropdownItem:hover { background: rgba(37,99,235,.08); transform: translateY(-1px); }
.dropdownItem:hover {
background-color: var(--neutral-100);
}
.dropdownItem:last-child {
margin-bottom: 0;
}
/* --- Main Content --- */
/* ==== Main: FULL WIDTH ==== */
.mainContent {
flex-grow: 1;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1400px;
margin: 0 auto;
flex: 1 1 auto;
width: 100%;
padding: 2rem 1.5rem;
margin: 0; /* remove center constraint */
display: flex; flex-direction: column; gap: 2rem;
min-width: 0; /* prevent overflow causing shrink */
}
/* Summary Cards Container */
/* Summary Cards */
.summaryCardsContainer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* prevent too-small cards */
gap: 1rem;
align-items: stretch;
width: 100%;
min-width: 0;
}
/* Gradient cards (3 variasi) */
.summaryCard {
background-color: var(--white);
padding: 1.5rem;
position: relative;
overflow: hidden;
background: var(--card-grad-1);
color: var(--text-on-brand);
border: 1px solid rgba(255,255,255,.18);
border-radius: 1rem;
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
padding: 1.25rem 1.5rem;
min-height: 120px;
box-shadow: 0 10px 20px rgba(37,99,235,.15);
transition: box-shadow .2s ease, transform .12s ease, background-position .4s ease, filter .2s ease;
background-size: 140% 140%;
background-position: 0% 50%;
}
.summaryCardsContainer .summaryCard:nth-child(1) { background: var(--card-grad-1); }
.summaryCardsContainer .summaryCard:nth-child(2) { background: var(--card-grad-2); }
.summaryCardsContainer .summaryCard:nth-child(3) { background: var(--card-grad-3); }
.summaryCard::before {
content: "";
position: absolute; inset: 0;
background:
radial-gradient(120% 60% at 10% -10%, rgba(255,255,255,.25) 0%, rgba(255,255,255,0) 55%),
radial-gradient(90% 50% at 90% -10%, rgba(255,255,255,.18) 0%, rgba(255,255,255,0) 60%);
pointer-events: none;
}
.summaryCard:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(37,99,235,.22);
filter: saturate(1.05);
background-position: 100% 50%;
}
.summaryCard h3 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 .5rem 0;
font-size: 1rem; letter-spacing: .06em; text-transform: uppercase;
color: rgba(255,255,255,.92);
font-weight: 800;
}
.summaryCard p {
font-size: 2rem;
font-weight: 700;
color: #43a0a7;
margin: 0;
line-height: 1;
margin: 0; line-height: 1;
font-size: clamp(2rem, 2vw + .5rem, 2.2rem);
font-weight: 900; color: #fff;
background: none !important;
-webkit-background-clip: initial !important;
-webkit-text-fill-color: #fff !important;
text-shadow: 0 1px 1px rgba(0,0,0,.15);
}
/* Dashboard Grid for Form and Chart */
/* Grid: Form & Chart */
.dashboardGrid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: 1fr; /* mobile: 1 kolom */
gap: 2rem;
flex-grow: 1;
min-width: 0;
}
.formSection,
.chartSection {
background-color: var(--white);
padding: 2rem;
border-radius: 1rem;
.formSection, .chartSection {
background: var(--white);
border: 1px solid var(--border-light);
border-radius: 1rem;
box-shadow: var(--shadow-sm);
padding: 1.5rem;
min-width: 0;
}
.formSection h2,
.chartSection h2 {
color: var(--text-primary);
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.025em;
.formSection h2, .chartSection h2 {
margin: 0 0 1rem 0;
font-size: clamp(1.05rem, .9vw + .7rem, 1.35rem);
font-weight: 800; letter-spacing: -.015em;
color: var(--neutral-800);
}
/* Form */
.form label {
text-align: left;
display: block;
margin-bottom: 1rem;
color: var(--text-primary);
font-weight: 500;
font-size: 0.875rem;
display: block; margin-bottom: 1rem;
font-size: .95rem; font-weight: 700; color: var(--neutral-800);
}
.form input[type="text"],
.form input[type="password"],
.form select {
width: 100%;
padding: 0.75rem 1rem;
margin-top: 0.375rem;
width: 100%; margin-top: .35rem;
padding: .7rem 1rem;
font-size: .95rem; color: var(--neutral-800);
background: var(--white);
border: 1px solid var(--border-light);
border-radius: 0.5rem;
font-size: 0.875rem;
transition: all 0.2s ease;
background-color: var(--white);
color: var(--text-primary);
border-radius: .55rem;
transition: border-color .2s ease, box-shadow .2s ease;
}
.form input[type="text"]:focus,
.form input[type="password"]:focus,
.form select:focus {
border-color: var(--primary-blue);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
outline: none;
outline: none; border-color: var(--brand-primary);
box-shadow: var(--focus-ring);
}
/* Buttons */
.submitButton {
background-color: #43a0a7;
color: var(--text-light);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
margin-top: 1rem;
width: 100%;
transition: all 0.2s ease;
letter-spacing: 0.025em;
}
.submitButton:hover {
background-color: #357734;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.submitButton:active {
transform: translateY(0);
display: inline-flex; align-items: center; justify-content: center;
padding: .9rem 1.2rem;
border: none; border-radius: .6rem; cursor: pointer;
font-size: 1rem; font-weight: 800; letter-spacing: .02em;
color: var(--text-on-brand);
background: var(--brand-primary);
box-shadow: 0 6px 18px rgba(37,99,235,.18);
transition: transform .12s ease, box-shadow .2s ease, background .2s ease;
}
.submitButton:hover { transform: translateY(-1px); background: var(--brand-primary-700); box-shadow: 0 8px 22px rgba(37,99,235,.22); }
.submitButton:active { transform: translateY(0); }
.submitButton:focus { outline: none; box-shadow: var(--focus-ring); }
/* Messages */
.success, .warning {
margin-top: 1rem; padding: .85rem 1rem;
border-radius: .6rem; font-size: .92rem; font-weight: 700;
border: 1px solid transparent;
}
.success {
background-color: rgb(67 160 167 / 0.1);
color: var(--success-green);
border: 1px solid rgb(67 160 167 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--brand-primary);
background: rgba(37,99,235,.08);
border-color: rgba(37,99,235,.18);
}
.error {
background-color: rgb(239 68 68 / 0.1);
color: var(--error-red);
border: 1px solid rgb(239 68 68 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.warning {
background-color: rgb(67 160 167 / 0.1);
color: #43a0a7;
border: 1px solid rgb(67 160 167 / 0.2);
padding: 1rem;
border-radius: 0.5rem;
margin-top: 1rem;
font-weight: 500;
font-size: 0.875rem;
color: var(--brand-secondary);
background: rgba(79,70,229,.08);
border-color: rgba(79,70,229,.18);
}
.error {
margin-top: 1rem; padding: .85rem 1rem;
border-radius: .6rem; font-size: .92rem; font-weight: 700;
color: var(--error-red);
background: rgba(239,68,68,.08);
border: 1px solid rgba(239,68,68,.18);
}
/* Footer */
.footer {
background-color: var(--white);
color: var(--text-secondary);
margin-top: auto;
text-align: center;
padding: 1rem;
margin-top: auto;
font-size: 0.75rem;
font-size: .85rem;
color: var(--text-secondary);
background: var(--white);
border-top: 1px solid var(--border-light);
}
/* Chart placeholder (kalau perlu) */
.chartPlaceholder {
background-color: var(--neutral-50);
height: 20rem;
display: flex;
justify-content: center;
align-items: center;
color: var(--text-secondary);
font-style: italic;
border-radius: 0.75rem;
display: flex; align-items: center; justify-content: center;
background: var(--neutral-50);
border: 2px dashed var(--border-light);
font-size: 0.875rem;
border-radius: .75rem;
color: var(--text-secondary);
font-style: italic; font-size: .95rem;
}
/* --- Media Queries for Tablets and Desktops --- */
/* Tablet-sized screens and up */
@media (min-width: 768px) {
.dashboardHeader {
padding: 1rem 2rem;
}
.logoAndTitle img {
width: 3rem;
height: 3rem;
}
.dashboardHeader .h1 {
font-size: 1.75rem;
}
.userDisplayName {
font-size: 0.875rem;
}
.mainContent {
padding: 2.5rem 2rem;
gap: 2.5rem;
}
.summaryCardsContainer {
grid-template-columns: repeat(3, 1fr);
}
.dashboardGrid {
grid-template-columns: 1fr 2fr;
gap: 2.5rem;
}
.formSection,
.chartSection {
padding: 2.5rem;
}
.formSection h2,
.chartSection h2 {
font-size: 1.5rem;
}
.chartPlaceholder {
height: 25rem;
}
}
/* Desktop-sized screens and up */
@media (min-width: 1024px) {
.dashboardHeader {
padding: 1.25rem 3rem;
}
.logoAndTitle img {
width: 3.5rem;
height: 3.5rem;
}
.dashboardHeader .h1 {
font-size: 2rem;
}
.mainContent {
padding: 3rem 2.5rem;
gap: 3rem;
}
.dashboardGrid {
gap: 3rem;
}
.formSection,
.chartSection {
padding: 3rem;
}
.chartPlaceholder {
height: 30rem;
}
}
/* Single column layout when only one section is present */
@media (min-width: 768px) {
.dashboardGrid > *:only-child {
grid-column: 1 / -1;
}
}
/* CSS untuk styling daftar petugas */
/* Officers List */
.officerListContainer {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: var(--white);
border: 1px solid var(--border-light);
border-radius: .8rem;
padding: 1rem;
box-shadow: var(--shadow-sm);
margin-bottom: 1.25rem;
}
.officerList {
max-height: 300px;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
border-radius: 6px;
background: white;
border: 1px solid #e9ecef;
max-height: 300px; overflow-y: auto;
list-style: none; margin: 0; padding: 0;
border: 1px solid var(--border-light);
border-radius: .6rem; background: var(--white);
}
.officerItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e9ecef;
transition: background-color 0.2s ease;
display: flex; align-items: center; justify-content: space-between;
padding: .95rem 1rem;
border-bottom: 1px solid var(--border-light);
transition: background .15s ease;
}
.officerItem:last-child {
border-bottom: none;
}
.officerItem:hover {
background-color: #f8f9fa;
}
.officerInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.officerIcon {
font-size: 20px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.officerDetails {
display: flex;
flex-direction: column;
gap: 2px;
}
.officerName {
font-weight: 600;
color: #2c3e50;
font-size: 14px;
}
.officerRole {
font-size: 12px;
color: #6c757d;
font-style: italic;
text-transform: capitalize;
}
.officerItem:last-child { border-bottom: 0; }
.officerItem:hover { background: rgba(79,70,229,.05); }
.officerInfo { display: flex; align-items: center; gap: .75rem; flex: 1; }
.officerIcon { width: 24px; height: 24px; display: grid; place-items: center; font-size: 18px; }
.officerDetails { display: flex; flex-direction: column; gap: 2px; }
.officerName { font-weight: 800; color: var(--neutral-800); font-size: .98rem; }
.officerRole { font-size: .85rem; color: var(--text-secondary); text-transform: capitalize; font-style: italic; }
.deleteButton {
background: transparent; border: 0; cursor: pointer;
font-size: .85rem; padding: .45rem .65rem; border-radius: .4rem;
color: var(--error-red);
transition: background .15s ease, transform .06s ease;
opacity: .9;
}
.deleteButton:hover { background: rgba(239,68,68,.08); transform: translateY(-1px); }
.deleteButton:focus { outline: none; box-shadow: var(--focus-ring); }
.emptyState { text-align: center; padding: 36px 18px; color: var(--text-secondary); }
.emptyState span { display: block; font-size: 32px; margin-bottom: .5rem; }
.separator { border: 0; border-top: 1px solid var(--border-light); margin: 20px 0; }
/* Scrollbar */
.officerList::-webkit-scrollbar { width: 8px; }
.officerList::-webkit-scrollbar-track { background: var(--neutral-100); border-radius: 4px; }
.officerList::-webkit-scrollbar-thumb { background: var(--neutral-300); border-radius: 4px; }
.officerList::-webkit-scrollbar-thumb:hover { background: var(--neutral-400); }
/* ==== Responsive ==== */
@media (min-width: 768px) {
.dashboardHeader { padding: 1rem 2rem; }
.logoAndTitle img { width: 3rem; height: 3rem; }
.mainContent { padding: 2.25rem 2rem; gap: 2rem; }
.dashboardGrid { grid-template-columns: 1.1fr 2fr; gap: 2rem; } /* 2 kolom, tetap lega */
}
@media (min-width: 1280px) {
.mainContent { padding: 2.5rem 2.25rem; gap: 2.25rem; }
.dashboardGrid { grid-template-columns: 1fr 2fr; gap: 2.25rem; }
}
/* ==== FIX: jika hanya ada 1 section, lebarkan full ==== */
.dashboardGrid > *:only-child {
grid-column: 1 / -1;
width: 100%;
}
/* Pastikan chart section benar-benar membentang */
.chartSection {
width: 100%;
min-width: 0; /* cegah overflow anak bikin kontainer menyempit */
}
/* Rapikan baris kartu ringkasan */
.summaryCardsContainer {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
align-items: stretch;
justify-items: stretch;
gap: 1.25rem;
}
/* Tinggi & padding kartu konsisten + bayangan lebih halus */
.summaryCard {
min-height: 110px;
padding: 1.25rem 1.5rem;
box-shadow: 0 8px 18px rgba(37,99,235,.12);
}
/* Judul chart dan box warning sedikit lebih rapat */
.chartSection h2 { margin-bottom: .75rem; }
.warning { margin-top: .75rem; }
/* Header biar konten tidak terlalu mepet tepi di layar lebar */
@media (min-width: 1280px) {
.dashboardHeader { padding-left: 2rem; padding-right: 2rem; }
.mainContent { padding-left: 2rem; padding-right: 2rem; }
}
/* (Opsional) Kalau tetap terlihat terlalu ke kiri,
kamu bisa center-kan isi utama tanpa mengubah full width list dokumen */
@media (min-width: 1024px) {
.mainContent { max-width: 1280px; margin-left: auto; margin-right: auto; }
}
.dashboard {
font-family: sans-serif;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.logo {
display: flex;
align-items: center;
gap: 6px;
font-weight: bold;
color: #333;
}
.menuWrapper {
position: relative;
}
.menuButton {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
opacity: 0.7;
padding: 6px;
border-radius: 6px;
}
.deleteButton:hover {
background-color: #fee;
opacity: 1;
.menuButton:hover {
background: #f2f2f2;
}
.deleteButton:focus {
outline: 2px solid #dc3545;
outline-offset: 2px;
.dropdownMenu {
position: absolute;
right: 0;
top: 45px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 8px;
width: 220px;
z-index: 1000;
}
.emptyState {
text-align: center;
padding: 40px 20px;
color: #6c757d;
.dropdownItem,
.dropdownItemStatic {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 6px;
cursor: pointer;
}
.emptyState span {
font-size: 32px;
.dropdownItem:hover {
background: #f5f5f5;
}
.dropdownItemStatic {
cursor: default;
background: #fafafa;
display: block;
margin-bottom: 8px;
}
.emptyState p {
margin: 0;
font-size: 14px;
.dropdownIcon {
flex-shrink: 0;
color: #444;
}
.separator {
border: none;
border-top: 1px solid #dee2e6;
margin: 24px 0;
.dropdownText {
display: flex;
flex-direction: column;
}
/* Custom scrollbar untuk daftar petugas */
.officerList::-webkit-scrollbar {
width: 8px;
}
.officerList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.officerList::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.officerList::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Responsive design */
@media (max-width: 768px) {
.officerItem {
padding: 10px 12px;
}
.officerInfo {
gap: 8px;
}
.officerName {
font-size: 13px;
}
.officerRole {
font-size: 11px;
}
.orgName {
font-size: 12px;
color: #666;
}

View File

@@ -1,5 +1,8 @@
import React, { useEffect, useState } from "react";
import { User, Users, Baby, Settings, Plus, X } from "lucide-react";
import React, { useEffect, useState, useRef } from "react";
import { User, Users, Baby, Settings, Plus, X, Scan, CheckCircle, AlertTriangle, FolderOpen } from "lucide-react";
import { useNavigate } from "react-router-dom";
import styles from "./Dashboard.module.css"; // Import Dashboard CSS
import expetationStyles from "./Expetation.module.css"; // Import Expetation CSS
/* ============================
Helpers
@@ -151,20 +154,20 @@ const ExpectationForm = ({ fields, setFields }) => {
};
return (
<div style={expectationFormStyles.container}>
<div className={expetationStyles.expectationFormContainer}>
{safeFields.map((f, i) => (
<div key={i} style={expectationFormStyles.fieldRow}>
<div key={i} className={expetationStyles.fieldRow}>
<input
type="text"
placeholder="Field name"
value={f.key}
onChange={(e) => updateField(i, "key", e.target.value)}
style={expectationFormStyles.fieldInput}
className={expetationStyles.fieldInput}
/>
<select
value={f.value}
onChange={(e) => updateField(i, "value", e.target.value)}
style={expectationFormStyles.fieldSelect}
className={expetationStyles.fieldSelect}
>
<option value="">Pilih Type</option>
<option value="text">Text</option>
@@ -177,7 +180,7 @@ const ExpectationForm = ({ fields, setFields }) => {
<button
type="button"
onClick={() => removeField(i)}
style={expectationFormStyles.removeFieldButton}
className={expetationStyles.removeFieldButton}
title="Hapus field"
>
<X style={{ width: 16, height: 16 }} />
@@ -187,7 +190,7 @@ const ExpectationForm = ({ fields, setFields }) => {
<button
type="button"
onClick={addField}
style={expectationFormStyles.addFieldButton}
className={expetationStyles.addFieldButton}
>
<Plus style={{ width: 16, height: 16, marginRight: 8 }} />
Tambah Field
@@ -259,38 +262,32 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
const isFormValid = documentName.trim() && validFields.length > 0;
return (
<div style={modalStyles.overlay}>
<div style={modalStyles.modal}>
<div style={modalStyles.header}>
<h3 style={modalStyles.title}>Tambah Jenis Dokumen Baru</h3>
<button onClick={onClose} style={modalStyles.closeButton} disabled={isSubmitting}>×</button>
<div className={expetationStyles.modalOverlay}>
<div className={expetationStyles.modal}>
<div className={expetationStyles.modalHeader}>
<h3 className={expetationStyles.modalTitle}>Tambah Jenis Dokumen Baru</h3>
<button onClick={onClose} className={expetationStyles.modalCloseButton} disabled={isSubmitting}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div style={modalStyles.content}>
<div className={expetationStyles.modalContent}>
{/* Template Selection */}
<div style={modalStyles.section}>
<label style={modalStyles.sectionLabel}>Pilih Template</label>
<div style={modalStyles.templateGrid}>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.sectionLabel}>Pilih Template</label>
<div className={expetationStyles.templateGrid}>
{Object.entries(templates).map(([templateName, template]) => (
<button
key={templateName}
type="button"
onClick={() => handleTemplateSelect(templateName)}
style={{
...modalStyles.templateCard,
...(selectedTemplate === templateName ? modalStyles.templateCardActive : {})
}}
className={`${expetationStyles.templateCard} ${selectedTemplate === templateName ? expetationStyles.templateCardActive : ''}`}
disabled={isSubmitting}
>
<div style={modalStyles.templateContent}>
<div style={{
...modalStyles.templateIconContainer,
...(selectedTemplate === templateName ? modalStyles.templateIconActive : {})
}}>
<div className={expetationStyles.templateContent}>
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === templateName ? expetationStyles.templateIconActive : ''}`}>
{template.icon}
</div>
<span style={modalStyles.templateName}>{templateName}</span>
<span className={expetationStyles.templateName}>{templateName}</span>
</div>
</button>
))}
@@ -299,21 +296,14 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
<button
type="button"
onClick={() => handleTemplateSelect("Custom")}
style={{
...modalStyles.templateCard,
...modalStyles.customTemplateCard,
...(selectedTemplate === "Custom" ? modalStyles.customTemplateActive : {})
}}
className={`${expetationStyles.templateCard} ${expetationStyles.customTemplateCard} ${selectedTemplate === "Custom" ? expetationStyles.customTemplateActive : ''}`}
disabled={isSubmitting}
>
<div style={modalStyles.templateContent}>
<div style={{
...modalStyles.templateIconContainer,
...(selectedTemplate === "Custom" ? modalStyles.customIconActive : {})
}}>
<div className={expetationStyles.templateContent}>
<div className={`${expetationStyles.templateIconContainer} ${selectedTemplate === "Custom" ? expetationStyles.customIconActive : ''}`}>
<Settings style={{ width: 24, height: 24 }} />
</div>
<span style={modalStyles.templateName}>Custom</span>
<span className={expetationStyles.templateName}>Custom</span>
</div>
</button>
</div>
@@ -322,21 +312,21 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
{/* Form Section - hanya muncul jika template dipilih */}
{selectedTemplate && (
<>
<div style={modalStyles.section}>
<label style={modalStyles.label}>Nama Document Type</label>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Nama Tipe Dokumen</label>
<input
type="text"
value={documentName}
onChange={(e) => setDocumentName(e.target.value)}
placeholder="Data yang ingin di tambahkan"
style={modalStyles.input}
placeholder="Contoh: KTP, KK, Ijazah, dll"
className={expetationStyles.modalInput}
disabled={isSubmitting}
required
/>
</div>
<div style={modalStyles.section}>
<label style={modalStyles.label}>Fields</label>
<div className={expetationStyles.modalSection}>
<label className={expetationStyles.modalLabel}>Fields</label>
<ExpectationForm
fields={fields}
setFields={setFields}
@@ -347,13 +337,13 @@ const NewDocumentModal = ({ isOpen, onClose, onSubmit }) => {
</div>
{selectedTemplate && (
<div style={modalStyles.footer}>
<button type="button" onClick={onClose} style={modalStyles.cancelButton} disabled={isSubmitting}>
<div className={expetationStyles.modalFooter}>
<button type="button" onClick={onClose} className={expetationStyles.cancelButton} disabled={isSubmitting}>
Batal
</button>
<button
type="submit"
style={modalStyles.submitButton}
className={expetationStyles.submitButton}
disabled={isSubmitting || !isFormValid}
>
{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 navigate = useNavigate();
const [documentTypes, setDocumentTypes] = useState([]);
const [loadingDocumentTypes, setLoadingDocumentTypes] = useState(true);
const [isEditMode, setIsEditMode] = useState(false);
const [showNewDocumentModal, setShowNewDocumentModal] = useState(false);
const [showEditDocumentModal, setShowEditDocumentModal] = useState(false);
const [editingDocument, setEditingDocument] = useState(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef(null);
const getDocumentDisplayInfo = (doc) => {
const base = (doc?.display_name ?? doc?.nama_tipe ?? "").toString();
@@ -385,7 +471,8 @@ const Expetation = ({ onSelect }) => {
// Normalisasi data dari server "show"
const normalizeItem = (doc) => {
const humanName = doc.display_name ?? doc.nama_tipe ?? doc.document_type ?? "";
// UBAH: Prioritas nama_tipe > display_name > document_type
const humanName = doc.nama_tipe ?? doc.display_name ?? doc.document_type ?? "";
const slug = toSlug(humanName);
let expectationObj = {};
@@ -401,9 +488,10 @@ const Expetation = ({ onSelect }) => {
return {
id: doc.id ?? doc.data_type_id ?? safeUUID(),
nama_tipe: slug,
nama_tipe: slug, // UBAH: pastikan selalu ada nama_tipe
display_name: humanName,
expectation: expectationObj,
entry_name: doc.entry_name
};
};
@@ -445,7 +533,11 @@ const Expetation = ({ onSelect }) => {
const data = await response.json();
const normalized = (Array.isArray(data) ? data : [])
.filter((doc) => (doc.nama_tipe ?? doc.document_type) !== "INACTIVE")
// UBAH: filter berdasarkan nama_tipe dan document_type
.filter((doc) => {
const namaType = doc.nama_tipe ?? doc.document_type ?? "";
return namaType !== "INACTIVE";
})
.map(normalizeItem);
setDocumentTypes(normalized);
@@ -479,12 +571,12 @@ const Expetation = ({ onSelect }) => {
if (window.confirm(`Apakah Anda yakin ingin menghapus dokumen tipe "${namaTipe}"?`)) {
try {
const orgId = getActiveOrgId();
const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-document-type", {
const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/delete-expetation-type", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
id,
nama_tipe: namaTipe,
nama_tipe: namaTipe, // UBAH: konsisten gunakan nama_tipe
...(orgId ? { organization_id: orgId } : {}),
}),
});
@@ -516,7 +608,7 @@ const Expetation = ({ onSelect }) => {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
nama_tipe: documentName, // EXACT seperti input
nama_tipe: documentName, // UBAH: konsisten gunakan nama_tipe
expectation: expectationObj,
...(orgId ? { organization_id: orgId } : {}),
}),
@@ -535,6 +627,36 @@ const Expetation = ({ onSelect }) => {
}
};
// Edit tipe dokumen (POST body + header X-Organization-Id)
const handleEditDocumentSubmit = async (id, documentName, expectationObj) => {
try {
const orgId = getActiveOrgId();
const resp = await fetch("https://bot.kediritechnopark.com/webhook/edit-data-type", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
id,
nama_tipe: documentName,
expectation: expectationObj,
...(orgId ? { organization_id: orgId } : {}),
}),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`HTTP ${resp.status} ${resp.statusText} - ${text}`);
}
// Asumsi berhasil jika respons HTTP OK dan tidak ada error lain
await resp.json(); // Tetap baca JSON untuk memastikan respons selesai
await fetchDocumentTypes();
alert(`Dokumen tipe "${documentName}" berhasil diperbarui.`);
window.location.reload(); // Refresh halaman setelah berhasil
} catch (err) {
console.error("Error update:", err);
alert(`Terjadi kesalahan saat update dokumen: ${err.message || "Silakan cek konsol untuk detail lebih lanjut."}`);
}
};
const handleDocumentTypeSelection = (item) => {
if (!item) return;
if (item === "new") {
@@ -544,182 +666,295 @@ const Expetation = ({ onSelect }) => {
}
};
return (
<div style={selectionStyles.selectionContainer}>
<div style={selectionStyles.selectionContent}>
<div style={selectionStyles.selectionHeader}>
<h2 style={selectionStyles.selectionTitle}>Pilih Jenis Dokumen</h2>
<button onClick={() => setIsEditMode(!isEditMode)} style={selectionStyles.editButton}>
{isEditMode ? "Selesai" : "Edit"}
</button>
</div>
<p style={selectionStyles.selectionSubtitle}>Silakan pilih jenis dokumen yang akan Anda scan</p>
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.reload();
};
<div style={selectionStyles.documentGrid}>
{loadingDocumentTypes ? (
<div style={selectionStyles.spinnerContainer}>
<div style={selectionStyles.spinner} />
<style>{spinnerStyle}</style>
</div>
) : (
<>
<button onClick={() => handleDocumentTypeSelection("new")} style={selectionStyles.documentCard}>
<div style={selectionStyles.documentIconContainer}>
<div style={selectionStyles.plusIcon}>+</div>
</div>
<div style={selectionStyles.documentLabel}>new</div>
// Tutup menu ketika klik di luar
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Colors for different document types
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>
{documentTypes.map((doc) => {
const displayInfo = getDocumentDisplayInfo(doc);
return (
<div key={doc.id} style={selectionStyles.documentCardWrapper}>
<button onClick={() => handleDocumentTypeSelection(doc)} style={selectionStyles.documentCard}>
<div style={{ ...selectionStyles.documentIconContainer, backgroundColor: "#f0f0f0" }}>
<div style={selectionStyles.documentIcon}>{displayInfo.icon}</div>
</div>
<div style={selectionStyles.documentLabel}>{displayInfo.name}</div>
</button>
{isEditMode && (
<button
style={selectionStyles.deleteIcon}
onClick={() => handleDeleteDocumentType(doc.id, doc.nama_tipe)}
>
</button>
)}
</div>
);
})}
</>
{/* Scan */}
<button
onClick={() => {
navigate("/scan");
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="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>
{/* 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
isOpen={showNewDocumentModal}
onClose={() => setShowNewDocumentModal(false)}
onSubmit={handleNewDocumentSubmit}
/>
<EditDocumentModal
isOpen={showEditDocumentModal}
onClose={() => setShowEditDocumentModal(false)}
document={editingDocument}
onSubmit={handleEditDocumentSubmit}
/>
</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;

773
src/Expetation.module.css Normal file
View 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;
}
}

View File

@@ -1,485 +1,380 @@
import React, { useState, useEffect } from "react";
import styles from "./FileListComponent.module.css";
import * as XLSX from "xlsx";
import { PDFDownloadLink } from "@react-pdf/renderer";
import KTPPDF from "./KTPPDF";
import { saveAs } from "file-saver";
import styles from "./FileListComponent.module.css";
const FileListComponent = ({
setTotalFilesSentToday,
setTotalFilesSentMonth,
setTotalFilesSentOverall,
setOfficerPerformanceData,
setOfficerPerformanceData, // optional: kirim seri ke parent (compat)
onTypesLoaded, // NEW: kirim daftar tipe & seri agregat per tipe
onPerformanceReady, // NEW: kirim seri per-bulan saat tipe dibuka
}) => {
const [files, setFiles] = useState([]);
const [filteredFiles, setFilteredFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState(null);
const [successMessage, setSuccessMessage] = useState("");
const [selectedDocumentType, setSelectedDocumentType] = useState("");
const [selectedType, setSelectedType] = useState(null);
const [entries, setEntries] = useState([]);
const [loadingEntries, setLoadingEntries] = useState(false);
const [selectedEntry, setSelectedEntry] = useState(null);
const [showModal, setShowModal] = useState(false);
// Helper function to convert snake_case to Title Case
const formatKeyToLabel = (key) => {
return key
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
};
// Helper function to check if value is a date string and convert it
const formatValue = (key, value) => {
if (value === null || value === undefined || value === '') {
const getOrganizationId = () => {
try {
const orgData = localStorage.getItem("selected_organization");
if (!orgData) return null;
const parsed = JSON.parse(orgData);
return parsed.organization_id || null;
} catch (e) {
console.error("Gagal membaca organization_id:", e);
return null;
}
};
// Check if the value looks like a date
if (typeof value === 'string' &&
(key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
// 👉 Download Excel dari daftar entries yang sedang dibuka
const downloadExcel = () => {
if (!entries.length) {
alert("Tidak ada data untuk diunduh.");
return;
}
return value;
};
const flattened = entries.map((e) => ({
nama_tipe: e.nama_tipe,
...e.data,
}));
// Dynamic function to process data for Excel export
const processDataForExcel = (data) => {
if (!data || data.length === 0) return [];
const worksheet = XLSX.utils.json_to_sheet(flattened);
return data.map((item) => {
const processedItem = {};
Object.entries(item).forEach(([key, value]) => {
// Skip null, undefined, or empty string values
if (value === null || value === undefined || value === '') {
return;
}
// Skip certain keys that are not needed in export
const excludedKeys = ['id', 'document_type', 'created_at', 'data', 'foto_url'];
if (excludedKeys.includes(key)) {
return;
}
// Format the key as label
const label = formatKeyToLabel(key);
// Format the value
const formattedValue = formatValue(key, value);
processedItem[label] = formattedValue;
// Auto width kolom
const objectMaxLength = [];
flattened.forEach((row) => {
Object.keys(row).forEach((key, colIndex) => {
const value = row[key] ? row[key].toString() : "";
objectMaxLength[colIndex] = Math.max(
objectMaxLength[colIndex] || key.length,
value.length
);
});
return processedItem;
});
worksheet["!cols"] = objectMaxLength.map((w) => ({ wch: w + 2 }));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
const excelBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
const blob = new Blob([excelBuffer], { type: "application/octet-stream" });
saveAs(blob, `data_${selectedType?.nama || "dokumen"}.xlsx`);
};
// Dynamic function to get unique document types
const getUniqueDocumentTypes = (data) => {
const types = [...new Set(data.map(item => item.document_type).filter(Boolean))];
return types;
};
// Fetch daftar tipe dari webhook /files
useEffect(() => {
const fetchFiles = async () => {
const token = localStorage.getItem("token");
const orgId = getOrganizationId();
try {
const response = await fetch(
"https://bot.kediritechnopark.com/webhook/solid-data/files",
{
method: "GET",
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ organization_id: orgId }),
}
);
if (!response.ok)
throw new Error(`HTTP error! Status: ${response.status}`);
const text = await response.text();
if (!text) throw new Error("Server membalas kosong.");
const data = JSON.parse(text);
if (!data.success || !Array.isArray(data.data))
throw new Error("Format respons tidak valid.");
const fileData = data.data;
setFiles(fileData);
setFilteredFiles(fileData);
const today = new Date().toISOString().slice(0, 10);
const totalToday = fileData.filter((f) =>
f.created_at && f.created_at.startsWith(today)
).length;
setTotalFilesSentToday(totalToday);
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const totalThisMonth = fileData.filter((f) => {
if (!f.created_at) return false;
const d = new Date(f.created_at);
return (
d.getMonth() === currentMonth && d.getFullYear() === currentYear
);
}).length;
setTotalFilesSentMonth(totalThisMonth);
setTotalFilesSentOverall(fileData.length);
const dateObjects = fileData
.filter(item => item.created_at)
.map((item) => new Date(item.created_at));
if (dateObjects.length > 0) {
const minDate = new Date(Math.min(...dateObjects));
const maxDate = new Date(Math.max(...dateObjects));
const monthlyDataMap = {};
let current = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
const end = new Date(maxDate.getFullYear(), maxDate.getMonth(), 1);
while (current <= end) {
const monthKey = `${current.getFullYear()}-${String(
current.getMonth() + 1
).padStart(2, "0")}`;
monthlyDataMap[monthKey] = 0;
current.setMonth(current.getMonth() + 1);
}
fileData
.filter(item => item.created_at)
.forEach((item) => {
const d = new Date(item.created_at);
const monthKey = `${d.getFullYear()}-${String(
d.getMonth() + 1
).padStart(2, "0")}`;
if (monthlyDataMap[monthKey] !== undefined)
monthlyDataMap[monthKey]++;
});
const performanceArray = Object.entries(monthlyDataMap).map(
([month, count]) => {
const dateObj = new Date(`${month}-01`);
const label = new Intl.DateTimeFormat("id-ID", {
month: "long",
year: "numeric",
}).format(dateObj);
return { month: label, count };
}
);
setOfficerPerformanceData(performanceArray);
if (!text) {
setFiles([]);
return;
}
} catch (error) {
console.error("Gagal mengambil data dari server:", error.message);
let data;
try {
data = JSON.parse(text);
} catch (err) {
console.error("Respons bukan JSON valid:", text);
setFiles([]);
return;
}
const fileData = Array.isArray(data) ? data : data?.data || [];
setFiles(fileData);
// === Kirim daftar tipe & seri agregat per tipe ke parent ===
const typeOptions = fileData.map((f) => ({
id: f.data_type_id,
name: f.nama_tipe,
}));
const byTypeSeries = fileData.map((f) => ({
label: f.nama_tipe, // sumbu X
count: Number(f.total_entries || 0), // tinggi bar
}));
if (typeof onTypesLoaded === "function") {
onTypesLoaded(typeOptions, byTypeSeries);
}
// (opsional) kompatibel: tampilkan juga langsung di grafik
if (typeof setOfficerPerformanceData === "function") {
setOfficerPerformanceData(byTypeSeries);
}
} catch (e) {
console.error("Gagal fetch files:", e);
setFiles([]);
} finally {
setLoading(false);
}
};
// fetchFiles();
fetchFiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (selectedDocumentType) {
setFilteredFiles(
files.filter((file) => file.document_type === selectedDocumentType)
);
} else {
setFilteredFiles(files);
// Fetch entries per data_type_id (saat user klik suatu tipe)
const fetchEntries = async (dataTypeId, nama_tipe, entryName, expectation) => {
let resolvedEntryName = entryName;
if (!resolvedEntryName && expectation && Object.keys(expectation).length > 0) {
resolvedEntryName = Object.keys(expectation)[0];
}
}, [selectedDocumentType, files]);
const handleRowClick = async (file) => {
const token = localStorage.getItem("token");
if (!token) {
alert("Token tidak ditemukan. Silakan login kembali.");
return;
}
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
setEntries([]);
setLoadingEntries(true);
try {
const token = localStorage.getItem("token");
const response = await fetch(
`https://bot.kediritechnopark.com/webhook/solid-data/merged?nama_lengkap=${encodeURIComponent(
file.nama_lengkap || ''
)}`,
"https://bot.kediritechnopark.com/webhook/solid-data/files/entry",
{
method: "GET",
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ data_type_id: dataTypeId }),
}
);
if (!response.ok)
throw new Error(`HTTP error! Status: ${response.status}`);
const text = await response.text();
if (!text) throw new Error("Respons kosong dari server.");
if (!response.ok) throw new Error("Gagal ambil entries");
const data = JSON.parse(text);
if (data.error) {
alert(data.error);
return;
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
console.error("Respons bukan JSON valid:", text);
data = [];
}
console.log("Data received from merged API:", data[0]); // Debug log
console.log("All keys in data:", Object.keys(data[0])); // Debug log
console.log("Non-null values:", Object.entries(data[0]).filter(([k,v]) => v !== null)); // Debug log
setSelectedFile(data[0]);
} catch (error) {
console.error("Gagal mengambil detail:", error.message);
alert("Gagal mengambil detail. Pastikan data tersedia.");
const entryList = Array.isArray(data) ? data : data?.data || [];
// Fallback nama field untuk judul kartu
if (!resolvedEntryName && entryList.length > 0) {
resolvedEntryName = Object.keys(entryList[0].data || {})[0] || null;
setSelectedType({ id: dataTypeId, nama: nama_tipe, entryName: resolvedEntryName });
}
setEntries(entryList);
// ====== Hitung seri per-bulan utk tipe yang dibuka ======
const parseDate = (v) => {
if (!v) return null;
const d = new Date(v);
return isNaN(d) ? null : d;
};
const pickDate = (entry) => {
const cands = [
entry.created_at,
entry.updated_at,
entry.data?.tanggal,
entry.data?.tgl,
entry.data?.date,
entry.data?.created_at,
];
for (const c of cands) {
const d = parseDate(c);
if (d) return d;
}
return null;
};
const mmKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const counts = new Map();
for (const e of entryList) {
const d = pickDate(e);
if (!d) continue;
const k = mmKey(d);
counts.set(k, (counts.get(k) || 0) + 1);
}
const monthlySeries = Array.from(counts.entries())
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.map(([k, v]) => ({ label: k, count: v })); // "label" = YYYY-MM
if (typeof onPerformanceReady === "function") {
onPerformanceReady(nama_tipe, monthlySeries);
}
// (opsional) bila ingin langsung tampilkan seri bulanan ini
// if (typeof setOfficerPerformanceData === "function") {
// setOfficerPerformanceData(monthlySeries);
// }
} catch (err) {
console.error("Gagal fetch entries:", err);
setEntries([]);
} finally {
setLoadingEntries(false);
}
};
const getImageSrc = (base64) => {
if (!base64) return null;
const cleaned = base64.replace(/\s/g, "");
if (cleaned.startsWith("iVBOR")) return `data:image/png;base64,${cleaned}`;
if (cleaned.startsWith("/9j/")) return `data:image/jpeg;base64,${cleaned}`;
if (cleaned.startsWith("UklGR")) return `data:image/webp;base64,${cleaned}`;
return `data:image/*;base64,${cleaned}`;
const openEntryModal = (entry) => {
setSelectedEntry(entry);
setShowModal(true);
};
const closeModal = () => setSelectedFile(null);
const formatPhoneNumber = (phone) =>
phone?.replace(/(\d{4})(\d{4})(\d{4})/, "$1-$2-$3");
const exportToExcel = (data) => {
const processedData = processDataForExcel(data);
if (processedData.length === 0) {
alert("Tidak ada data untuk diekspor.");
return;
}
const worksheet = XLSX.utils.json_to_sheet(processedData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Data");
XLSX.writeFile(workbook, "data-export.xlsx");
const closeModal = () => {
setShowModal(false);
setSelectedEntry(null);
};
// Get unique document types for dropdown
const documentTypes = getUniqueDocumentTypes(files);
const backToTypes = () => {
setSelectedType(null);
setEntries([]);
};
return (
<div className={styles.fileListSection}>
<div className={styles.fileListHeader}>
<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>
<div className={styles.container}>
<h2 className={styles.title}>📑 Daftar Jenis Dokumen</h2>
{successMessage && (
<div className={styles.successMessage}>
<span></span>
{successMessage}
{!selectedType ? (
<>
{loading ? (
<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 className={styles.tableContainer}>
{filteredFiles.length === 0 ? (
<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 && (
{/* Modal untuk detail entry */}
{showModal && selectedEntry && (
<div className={styles.modalOverlay} onClick={closeModal}>
<div
className={styles.modalContent}
onClick={(e) => e.stopPropagation()}
>
{selectedFile.data && (
<img
src={getImageSrc(selectedFile.data)}
alt={`Foto Document - ${selectedFile.nik || 'Unknown'}`}
style={{
width: "100%",
maxHeight: "300px",
objectFit: "contain",
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 className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
Detail Data:{" "}
{selectedEntry.data?.[selectedType.entryName] ||
selectedEntry.data?.nama ||
selectedEntry.data?.name ||
"Data"}
</h3>
<button className={styles.closeButton} onClick={closeModal}>
×
</button>
</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
let displayValue = value;
if (typeof value === 'string' &&
(key.includes('tanggal') || key.includes('lahir') || key.includes('berlaku') || key.includes('pembuatan') || key.includes('created_at'))) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
displayValue = date.toLocaleDateString('id-ID');
}
}
return (
<tr key={key}>
<td>{formatKeyToLabel(key)}</td>
<td>{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
<button className={styles.closeButton} onClick={closeModal}>
Tutup
</button>
<div className={styles.modalContent}>
<div className={styles.detailGrid}>
{Object.entries(selectedEntry.data || {}).map(([key, value]) => (
<div key={key} className={styles.detailItem}>
<div className={styles.detailLabel}>
{key
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</div>
<div className={styles.detailValue}>{value || "-"}</div>
</div>
))}
</div>
</div>
</div>
</div>
)}

View File

@@ -1,169 +1,95 @@
/* FileListComponent.module.css - Updated to match Dashboard design */
/* FileListComponent.module.css - Brand Blue/Indigo & Mobile-First */
/* Use the same color palette as Dashboard */
/* ===== CSS Variables ===== */
:root {
--primary-blue: #3b82f6;
--secondary-blue: #60a5fa;
--dark-blue: #1e40af;
--neutral-50: #fafafa;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-300: #d4d4d4;
--neutral-500: #737373;
--neutral-700: #404040;
--neutral-800: #262626;
--neutral-900: #171717;
--brand-primary: #2961eb;
--brand-primary-700: #1d4ed8;
--brand-secondary: #4f46e5;
--brand-secondary-700: #4338ca;
--brand-gradient: linear-gradient(135deg, var(--brand-primary), var(--brand-secondary));
--neutral-25: #fcfcfd;
--neutral-50: #f9fafb;
--neutral-100: #f3f4f6;
--neutral-200: #e5e7eb;
--neutral-300: #d1d5db;
--neutral-400: #9ca3af;
--neutral-600: #475569;
--neutral-700: #374151;
--neutral-800: #1f2937;
--white: #ffffff;
--success-green: #43a0a7;
--warning-amber: #f59e0b;
--error-red: #ef4444;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-light: #ffffff;
--border-light: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
--text-on-brand: #ffffff;
--border-light: #e5e7eb;
--shadow-sm: 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 10px rgba(2,6,23,.08);
--shadow-lg: 0 12px 22px rgba(2,6,23,.12);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, .18);
}
/* File List Section */
.fileListSection {
background-color: var(--white);
padding: 2rem;
border-radius: 1rem;
/* ===== Container ===== */
.container {
background: var(--white);
border: 1px solid var(--border-light);
border-radius: 1rem;
box-shadow: var(--shadow-sm);
margin: 2rem auto;
max-width: 1200px;
width: 100%;
overflow: hidden;
max-height: 600px;
height: auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
transition: all 0.2s ease;
}
.fileListSection:hover {
box-shadow: var(--shadow-md);
}
.fileListHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
flex-shrink: 0;
width: 100%;
}
.fileListTitle {
padding: 1.5rem;
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.fileCount {
font-size: 0.6rem;
color: #ffffff;
font-weight: 500;
background-color: #43a0a7;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
border: 1px solid var(--border-light);
}
.successMessage {
background-color: rgb(67 160 167 / 0.1);
color: var(--success-green);
border: 1px solid rgb(67 160 167 / 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
width: 100%;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
}
.tableContainer {
flex: 1;
overflow: auto;
border-radius: 0.75rem;
border: 1px solid var(--border-light);
background-color: var(--white);
width: 100%;
/* ===== Title ===== */
.title {
margin: 0 0 1.5rem 0;
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.5rem);
font-weight: 800;
letter-spacing: -0.015em;
color: var(--neutral-800);
background: var(--brand-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.fileTable {
width: 100%;
min-width: 600px;
table-layout: auto;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--white);
}
.fileTable th {
background-color: #43a0a7;
padding: 0.75rem;
/* ===== Loading State ===== */
.loading {
text-align: center;
font-weight: 600;
color: #ffffff;
border-bottom: 1px solid var(--border-light);
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.fileTable td {
padding: 0.75rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-primary);
vertical-align: middle;
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--neutral-300);
border-top: 3px solid var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
.tableRow {
cursor: pointer;
transition: background-color 0.2s ease;
}
.tableRow:hover {
background-color: var(--neutral-50);
}
.nameColumn {
font-weight: 500;
color: var(--text-primary);
min-width: 200px;
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Empty State ===== */
.emptyState {
text-align: center;
padding: 3rem 2rem;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.emptyStateTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--neutral-800);
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 600;
}
.emptyStateText {
@@ -172,327 +98,514 @@
color: var(--text-secondary);
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--neutral-300);
border-top: 3px solid #43a0a7;
/* ===== Type List ===== */
.typeList {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 0.75rem;
background: var(--white);
overflow: hidden;
}
.typeItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s ease;
min-height: 60px;
}
.typeItem:last-child {
border-bottom: none;
}
.typeItem:hover {
background: rgba(79, 70, 229, 0.05);
transform: translateX(4px);
}
.typeInfo {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.typeNumber {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-gradient);
color: var(--text-on-brand);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(41, 97, 235, 0.2);
}
@keyframes spin {
0% {
transform: rotate(0deg);
.typeDetails {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.typeName {
font-weight: 700;
color: var(--neutral-800);
font-size: 0.95rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typeCount {
font-size: 0.8rem;
color: var(--text-secondary);
font-style: italic;
}
.typeArrow {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
flex-shrink: 0;
}
/* ===== Entry Section ===== */
.entrySection {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
100% {
transform: rotate(360deg);
to {
opacity: 1;
transform: translateY(0);
}
}
/* Custom Scrollbar */
.tableContainer::-webkit-scrollbar {
.backButton {
background: var(--neutral-600);
color: var(--text-on-brand);
border: none;
padding: 0.75rem 1.25rem;
border-radius: 0.6rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 700;
margin-bottom: 1.5rem;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 2px 4px rgba(71, 85, 105, 0.2);
}
.backButton:hover {
background: var(--neutral-700);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(71, 85, 105, 0.3);
}
.entryTitle {
margin: 0 0 1rem 0;
font-size: clamp(1rem, 0.9vw + 0.7rem, 1.25rem);
font-weight: 700;
color: var(--neutral-800);
}
/* ===== Entry List ===== */
.entryList {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border-light);
border-radius: 0.75rem;
background: var(--white);
max-height: 400px;
overflow-y: auto;
}
.entryItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s ease;
min-height: 60px;
}
.entryItem:last-child {
border-bottom: none;
}
.entryItem:hover {
background: rgba(41, 97, 235, 0.05);
transform: translateX(4px);
}
.entryInfo {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.entryNumber {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--brand-gradient);
color: var(--text-on-brand);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.2);
}
.entryDetails {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.entryName {
font-weight: 700;
color: var(--neutral-800);
font-size: 0.9rem;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entryHint {
font-size: 0.75rem;
color: var(--text-secondary);
font-style: italic;
}
.entryArrow {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
flex-shrink: 0;
}
/* ===== Scrollbar Styling ===== */
.entryList::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.tableContainer::-webkit-scrollbar-track {
.entryList::-webkit-scrollbar-track {
background: var(--neutral-100);
border-radius: 4px;
}
.tableContainer::-webkit-scrollbar-thumb {
background: #43a0a7;
.entryList::-webkit-scrollbar-thumb {
background: var(--neutral-300);
border-radius: 4px;
transition: background 0.2s ease;
}
.tableContainer::-webkit-scrollbar-thumb:hover {
background: #306a2f;
.entryList::-webkit-scrollbar-thumb:hover {
background: var(--neutral-400);
}
.tableContainer::-webkit-scrollbar-corner {
background: var(--neutral-100);
}
.tableContainer {
scrollbar-width: thin;
scrollbar-color: #43a0a7 var(--neutral-100);
}
/* Modal Styles - Matching Dashboard Design */
/* ===== Modal ===== */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
padding: 1rem;
box-sizing: border-box;
animation: fadeIn 0.3s ease-out;
}
.modalContent {
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: var(--white);
padding: 2rem;
border-radius: 1rem;
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-light);
}
.modalContent h3 {
margin: 0 0 1.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.025em;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
}
.detailTable {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
font-size: 0.875rem;
text-align: left;
max-width: 900px;
max-height: 85vh;
overflow: hidden;
animation: modalSlideIn 0.3s ease-out;
display: flex;
flex-direction: column;
}
.detailTable tr:nth-child(even) {
background-color: var(--neutral-50);
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.detailTable td {
padding: 0.75rem;
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0 1.5rem;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
flex-shrink: 0;
}
.detailTable td:first-child {
font-weight: 600;
color: var(--text-secondary);
width: 35%;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.detailTable td:last-child {
color: var(--text-primary);
font-weight: 500;
.modalTitle {
font-size: clamp(1.125rem, 1vw + 0.8rem, 1.375rem);
font-weight: 800;
color: var(--neutral-800);
margin: 0;
letter-spacing: -0.015em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 1rem;
}
.closeButton {
background-color: #43a0a7;
color: var(--text-light);
background: #ef4444;
color: var(--text-on-brand);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
width: 100%;
transition: all 0.2s ease;
letter-spacing: 0.025em;
font-size: 1.25rem;
font-weight: 700;
transition: all 0.15s ease;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
}
.closeButton:hover {
background-color: #dc2626;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: #dc2626;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
}
.closeButton:active {
transform: translateY(0);
.modalContent {
padding: 0 1.5rem 1.5rem 1.5rem;
overflow-y: auto;
flex: 1;
}
/* Responsive Design */
@media (max-width: 768px) {
.fileListSection {
padding: 1.5rem;
margin: 1rem;
max-width: 100%;
height: auto;
max-height: 70vh;
.detailGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.detailItem {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.detailLabel {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detailValue {
font-size: 0.875rem;
color: var(--neutral-800);
font-weight: 600;
padding: 0.75rem;
background: var(--neutral-50);
border-radius: 0.5rem;
border: 1px solid var(--border-light);
min-height: 44px;
display: flex;
align-items: center;
word-break: break-word;
overflow-wrap: break-word;
}
/* ===== Responsive Design ===== */
/* Mobile First - Already defined above */
/* Tablet */
@media (min-width: 768px) {
.container {
padding: 2rem;
margin: 2rem auto;
max-width: 1200px;
}
.fileListHeader {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
.typeItem,
.entryItem {
padding: 1.25rem;
min-height: 70px;
}
.fileListTitle {
font-size: 1.125rem;
}
/* Mobile: Show only NIK and Name columns */
.fileTable {
min-width: 100%;
}
.fileTable th:not(:nth-child(2)):not(:nth-child(3)),
.fileTable td:not(:nth-child(2)):not(:nth-child(3)) {
display: none;
}
.fileTable th,
.fileTable td {
padding: 0.75rem 0.5rem;
.typeNumber,
.entryNumber {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
.fileTable th:nth-child(2) {
width: 40%;
.typeName,
.entryName {
font-size: 1rem;
}
.fileTable th:nth-child(3) {
width: 60%;
.modalHeader {
padding: 2rem 2rem 0 2rem;
margin-bottom: 2rem;
}
.nameColumn {
min-width: unset;
}
/* Modal responsive */
.modalContent {
padding: 1.5rem;
width: 95%;
max-height: 90vh;
border-radius: 0.75rem;
}
.modalContent h3 {
font-size: 1.125rem;
}
.detailTable {
font-size: 0.8125rem;
}
.detailTable td {
padding: 0.625rem 0.5rem;
}
.detailTable td:first-child {
width: 40%;
padding: 0 2rem 2rem 2rem;
}
}
/* Desktop */
@media (min-width: 1024px) {
.container {
padding: 2.5rem;
margin: 2.5rem auto;
}
.detailGrid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.entryList {
max-height: 500px;
}
}
/* Small Mobile */
@media (max-width: 480px) {
.fileListSection {
.container {
padding: 1rem;
margin: 0.5rem;
border-radius: 0.75rem;
}
.fileListTitle {
font-size: 1rem;
.typeItem,
.entryItem {
padding: 0.75rem;
min-height: 56px;
}
.fileCount {
font-size: 0.6rem;
padding: 0.25rem 0.5rem;
.typeNumber,
.entryNumber {
width: 28px;
height: 28px;
font-size: 0.7rem;
}
.fileTable th,
.fileTable td {
padding: 0.5rem 0.375rem;
font-size: 0.8125rem;
.typeName,
.entryName {
font-size: 0.85rem;
}
.modalContent {
padding: 1rem;
width: 98%;
border-radius: 0.5rem;
}
.modalContent h3 {
font-size: 1rem;
.modalHeader {
padding: 1rem 1rem 0 1rem;
margin-bottom: 1rem;
}
.detailTable {
font-size: 0.75rem;
.modalContent {
padding: 0 1rem 1rem 1rem;
}
.detailTable td {
padding: 0.5rem 0.375rem;
.detailGrid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.closeButton {
padding: 0.625rem 1rem;
.detailValue {
padding: 0.625rem;
font-size: 0.8125rem;
}
}
/* Tablet and Desktop enhancements */
@media (min-width: 769px) {
.fileListSection {
padding: 2.5rem;
margin: 2.5rem auto;
}
.fileListTitle {
font-size: 1.5rem;
}
.fileTable th,
.fileTable td {
padding: 1rem 0.75rem;
}
.modalContent {
padding: 2.5rem;
border-radius: 1rem;
}
.modalContent h3 {
font-size: 1.5rem;
margin-bottom: 2rem;
}
.detailTable {
font-size: 0.875rem;
}
.detailTable td {
padding: 1rem 0.75rem;
}
.closeButton {
padding: 0.875rem 2rem;
display: block;
width: 32px;
height: 32px;
font-size: 1.1rem;
}
}
@media (min-width: 1024px) {
.fileListSection {
padding: 3rem;
margin: 3rem auto;
/* Prevent text selection on interactive elements */
.typeItem,
.entryItem,
.backButton,
.closeButton {
user-select: none;
}
/* Focus styles for accessibility */
.typeItem:focus,
.entryItem:focus,
.backButton:focus,
.closeButton:focus {
outline: none;
box-shadow: var(--focus-ring);
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.typeNumber,
.entryNumber {
border: 2px solid var(--text-primary);
}
.modal {
border: 2px solid var(--text-primary);
}
}
.downloadButton {
background-color: #164665;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
cursor: pointer;
font-weight: bold;
font-size: 0.6rem;
transition: background-color 0.3s ease;
}
.downloadButton:hover {
background-color: #008fc4;
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ import styles from "./Login.module.css";
export default function LoginPage({ onLoggedIn }) {
const login = () => {
const baseUrl = "http://localhost:3001/";
const baseUrl = "https://kediritechnopark.com/";
const modal = "product";
const productId = 9;

View File

@@ -3,11 +3,7 @@ import React from "react";
import styles from "./Header.module.css";
const Header = () => {
return (
<div className={styles.header}>
<div className={styles.title}>Officers & Roles</div>
</div>
);
};
export default Header;

View File

@@ -1,11 +1,14 @@
// Sidebar.js
import React from "react";
import { Link } from "react-router-dom";
import styles from "./Sidebar.module.css";
const Sidebar = () => {
return (
<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.menuItem}>Officers</div>
<div className={styles.menuItem}>Roles</div>