Init commit

This commit is contained in:
dmsanhrProject 2025-10-30 11:28:14 +07:00
commit f7d59fc077
44 changed files with 4929 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

182
README.md Normal file
View File

@ -0,0 +1,182 @@
# 🚀 Web Upload Automation Platform
> Sistem Web untuk **otomasi unggah file dan publikasi data** berbasis React + Vite.
![Preview](https://dummyimage.com/1200x300/edf2f7/333.png&text=Upload+Automation+Web+App)
---
## 📦 Teknologi yang Digunakan
| Layer | Stack |
|-------|-------|
| Frontend | **React + Vite + TailwindCSS + React Router v6** |
| State Management | **Redux Toolkit** |
| HTTP Client | **Axios (dengan interceptor JWT)** |
| Auth | **JWT Token (disimpan di LocalStorage)** |
| Backend | FastAPI (terpisah dari repo ini) |
| Database | PostgreSQL + PostGIS |
---
## ⚙️ Fitur Utama
### 🔑 Autentikasi
- Login menggunakan JWT token.
- Token disimpan di `localStorage` agar tetap login selama belum kadaluarsa.
- Proteksi route admin via `ProtectedRoute`.
### 🧩 Upload File (Multi Format)
- Dukungan **drag & drop** area (`FileDropzone`).
- Mendukung berbagai format:
- `.zip` (Shapefile / Geodatabase)
- `.csv` / `.xlsx`
- `.pdf`
### 🔍 Analisis & Validasi Data
- Backend menganalisis isi file (misalnya memvalidasi koordinat atau ejaan wilayah).
- Hasil analisis ditampilkan sebagai **preview data** dan **warning table**:
- DataPreview.jsx menampilkan:
- Cuplikan data (max 5 baris)
- Data dengan ejaan tidak valid (jika ada)
### 🧾 Validasi & Upload ke Database
- User memberi **judul tabel** sebelum disimpan ke PostGIS.
- Validasi input:
- Jika belum mengisi judul → notifikasi Tailwind muncul.
- Konfirmasi sebelum meninggalkan halaman (peringatan bawaan browser).
- Saat disimpan → hasil dari backend ditampilkan di halaman sukses.
### 🧠 Dashboard Admin
- Navigasi menggunakan **AdminLayout** dengan navbar di atas.
- Halaman admin:
- `/admin/home` Dashboard utama
- `/admin/upload` Form upload & validasi
- `/admin/publikasi` Manajemen publikasi
---
## 🧭 Alur Upload File
### **1⃣ Upload Step**
User mengunggah file (drag & drop atau pilih manual).
→ dikirim ke backend `/upload`
→ backend mengembalikan `result` (kolom, preview, warning, dll.)
### **2⃣ Validasi Step**
User:
- Melihat hasil preview dan warning.
- Mengisi “Judul Tabel”.
- Klik “Upload ke Database”.
→ dikirim ke backend `/upload_to_postgis`.
### **3⃣ Success Step**
Response backend disimpan ke Redux (`validatedData`), lalu:
- Ditampilkan di halaman sukses `/admin/upload/success`.
- Menampilkan ringkasan hasil:
- Nama tabel
- Jumlah baris
- Waktu upload
- Pesan backend
- Metadata tambahan (jika ada)
---
## ⚡ Instalasi & Menjalankan
### 1⃣ Clone Repository
```bash
git clone https://github.com/yourusername/upload-automation.git
cd upload-automation
```
### 2⃣ Install Dependencies
```bash
npm install
```
### 3⃣ Jalankan Server Development
```bash
npx vite
```
### 4⃣ (Opsional) Build untuk Produksi
```bash
npm run build
```
---
## 🔐 Konfigurasi Lingkungan (`.env`)
Buat file `.env` di root proyek dan tambahkan:
```bash
VITE_API_URL=http://localhost:8000
```
Lalu di `api.js`:
```js
baseURL: import.meta.env.VITE_API_URL
```
---
## 🧩 Contoh Respons Backend
### `/upload` (POST)
```json
{
"file_type": ".pdf",
"columns": ["id", "nama_desa", "kecamatan", "kabupaten"],
"preview": [
{ "id": 1, "nama_desa": "Kedungrejo", "kecamatan": "Waru", "kabupaten": "Sidoarjo" }
],
"geometry_valid": 120,
"geometry_empty": 3,
"warning_examples": [
{ "nama_desa": "Kedung Rejo", "kecamatan": "waru", "kabupaten": "Sidoarjo" }
]
}
```
### `/upload_to_postgis` (POST)
```json
{
"table_name": "data_wilayah_valid",
"total_rows": 123,
"upload_time": "2025-10-28T10:14:00Z",
"message": "Data berhasil disimpan ke PostGIS.",
"metadata": {
"user": "admin",
"database": "geosystem",
"duration": "1.2s"
}
}
```
---
## 🧑‍💻 Pengembang
**Nama:** Dimas Anhar
**Project:** Web Upload & Validation Automation Platform
**Tujuan:** Sistem Otomatisasi Unggah dan Validasi Data Geospasial
---
## 💬 Catatan
- Pastikan backend `FastAPI` berjalan di `localhost:8000`.
- File besar (PDF/ZIP > 50MB) sebaiknya diunggah melalui koneksi stabil.
---
## 🧠 Rencana Pengembangan Selanjutnya
- ✅ Pagination untuk tabel preview
- ✅ Progress bar upload file
- 🔄 Refresh token otomatis JWT
- 🗂️ Manajemen file hasil upload
- 🌐 Publikasi hasil ke GeoServer/GeoNetwork
---
## 🏁 Lisensi
MIT License © 2025 — Dimas Anhar

29
eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upload_otomation_fe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3462
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "upload_otomation_fe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.9.2",
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.0",
"jwt-decode": "^4.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react-swc": "^4.1.0",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tailwindcss": "^4.1.16",
"vite": "^7.1.7"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src/App.jsx Normal file
View File

@ -0,0 +1,7 @@
import AppRouter from "./routes/AppRouter";
function App() {
return <AppRouter />;
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,48 @@
import { NavLink, useNavigate } from "react-router-dom";
import { logout } from "../utils/auth";
export default function AdminNavbar() {
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/login");
};
const navItems = [
{ label: "Home", path: "/admin/home" },
{ label: "Upload", path: "/admin/upload" },
{ label: "Publikasi", path: "/admin/publikasi" },
];
return (
<nav className="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-6 py-3 flex justify-between items-center">
<h1 className="text-xl font-bold text-blue-600">Admin Panel</h1>
<div className="flex items-center space-x-6">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`text-gray-600 hover:text-blue-600 font-medium ${
isActive ? "text-blue-600 border-b-2 border-blue-600 pb-1" : ""
}`
}
>
{item.label}
</NavLink>
))}
<button
onClick={handleLogout}
className="ml-4 text-sm bg-red-500 hover:bg-red-600 text-white px-3 py-1.5 rounded"
>
Logout
</button>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,49 @@
import { useState } from "react";
/**
* Komponen Dropzone untuk unggah file.
* @param {{ onFileSelect: (file: File) => void }} props
*/
export default function FileDropzone({ onFileSelect }) {
const [isDragging, setIsDragging] = useState(false);
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) onFileSelect(file);
};
return (
<div>
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-10 text-center transition
${isDragging ? "border-blue-400 bg-blue-50" : "border-gray-300 bg-white"}`}
>
<p className="text-sm text-gray-500 mb-2">Tarik & lepas file di sini</p>
<p className="text-xs text-gray-400 mb-3">atau</p>
<label className="cursor-pointer bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
Pilih File
<input
type="file"
className="hidden"
onChange={(e) =>
e.target.files?.[0] && onFileSelect(e.target.files[0])
}
/>
</label>
</div>
<div className="flex justify-end">
<p className="text-xs text-gray-500 mt-1 mr-3 py-0">
<i>* .csv / .xlsx / .pdf / .zip</i>
</p>
</div>
</div>
);
}

View File

View File

@ -0,0 +1,25 @@
import { useEffect } from "react";
export default function Notification({ message, type = "info", onClose }) {
const colors = {
success: "bg-green-500",
error: "bg-red-500",
warning: "bg-yellow-500",
info: "bg-blue-500",
};
useEffect(() => {
const timer = setTimeout(() => {
onClose();
}, 3000); // auto close 3 detik
return () => clearTimeout(timer);
}, [onClose]);
return (
<div
className={`fixed bottom-5 right-5 z-50 px-4 py-3 rounded-lg text-white shadow-lg transition-all ${colors[type]}`}
>
{message}
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Link, useNavigate } from "react-router-dom";
import { logout } from "../utils/auth";
export default function Sidebar() {
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate("/login");
};
return (
<div className="w-60 h-screen bg-gray-800 text-white flex flex-col p-4">
<h2 className="text-xl font-bold mb-6">Admin Panel</h2>
<nav className="flex flex-col space-y-2">
<Link to="/admin/home" className="hover:bg-gray-700 p-2 rounded">Home</Link>
<Link to="/admin/upload" className="hover:bg-gray-700 p-2 rounded">Upload</Link>
<Link to="/admin/publikasi" className="hover:bg-gray-700 p-2 rounded">Publikasi</Link>
</nav>
<button
onClick={handleLogout}
className="mt-auto bg-red-500 hover:bg-red-600 py-2 rounded"
>
Logout
</button>
</div>
);
}

View File

@ -0,0 +1,130 @@
export default function FilePreview({ result }) {
if (!result) return null;
const {
columns = [],
preview = [],
geometry_valid = 0,
geometry_empty = 0,
warning_examples = [],
} = result;
return (
<div className="mt-4">
{/* Section: Warning Table */}
{warning_examples?.length > 0 ? (
<div className="mb-8">
<h3 className="font-semibold text-gray-700 mb-2">
Beberapa nama wilayah perlu diperiksa kembali.
</h3>
<p className="text-sm text-gray-600 mb-3">
{/* Sistem mendeteksi kemungkinan kesalahan penulisan, seperti ejaan yang berbeda,
spasi tidak sesuai, atau huruf besar/kecil yang tidak konsisten. */}
Sistem tidak dapat mendeteksi geometri beberapa data berdasarkan nama wilayah.
<br />
Silakan perbaiki agar nama Desa/Kelurahan, Kecamatan, dan Kab/Kota sesuai dengan
data referensi resmi.
</p>
<Table
title="Data Perlu Diperiksa"
columns={columns}
rows={warning_examples}
total={geometry_empty}
limit={100}
variant="warning"
/>
</div>
) : (
<p className="mb-4 text-sm text-green-600 font-medium"> Data 100% valid</p>
)}
{/* Section: File Preview */}
<div>
<h3 className="font-semibold text-gray-700 mb-2">📋 Cuplikan Data</h3>
<Table
title="Cuplikan Data"
columns={columns}
rows={preview}
total={geometry_valid}
limit={5}
variant="preview"
/>
</div>
</div>
);
}
function Table({ title, columns, rows, total, limit = 100, variant = "preview" }) {
const displayedRows = rows.slice(0, limit);
return (
<div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm bg-white">
<table className="min-w-full text-sm text-gray-800">
<thead
className={`border-b ${
variant === "warning" ? "bg-red-100" : "bg-gray-100"
}`}
>
<tr>
{columns.map((col) => (
<th
key={col}
className="px-3 py-2 text-left font-medium text-gray-700 whitespace-nowrap"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{displayedRows.length > 0 ? (
displayedRows.map((row, idx) => (
<tr
key={idx}
className={`border-t ${
variant === "warning"
? "bg-red-50 hover:bg-red-100"
: "even:bg-gray-50 hover:bg-blue-50"
} transition-colors`}
>
{columns.map((col) => (
<td
key={col}
className="px-3 py-2 border-t border-gray-100 whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis"
title={row[col] ?? ""}
>
{row[col] !== null && row[col] !== undefined && row[col] !== ""
? row[col]
: <span className="text-gray-400"></span>}
</td>
))}
</tr>
))
) : (
<tr>
<td
colSpan={columns.length}
className="text-center text-gray-500 py-3 italic"
>
Tidak ada data yang dapat ditampilkan
</td>
</tr>
)}
</tbody>
</table>
<div className="flex justify-between items-center p-2 text-xs text-gray-500">
<p>
Menampilkan {Math.min(limit, displayedRows.length)} dari {total} baris.
</p>
{variant === "preview" && (
<p className="italic text-gray-400">
Cuplikan sebagian data (maks. {limit} baris)
</p>
)}
</div>
</div>
);
}

1
src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import AdminNavbar from "../components/AdminNavbar";
export default function AdminLayout() {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Navbar muncul di semua halaman admin */}
<AdminNavbar />
{/* Konten halaman */}
<main className="flex-1 px-6 py-6 max-w-7xl mx-auto w-full">
<Outlet />
</main>
</div>
);
}

12
src/main.jsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { store } from "./store/store";
import { Provider } from "react-redux";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);

View File

@ -0,0 +1,10 @@
import Sidebar from "../../../components/Sidebar";
export default function ViewsAdminHome() {
return (
<div>
<h1 className="text-2xl font-bold">Dashboard Home</h1>
<p className="mt-4">Selamat datang di panel admin upload automation.</p>
</div>
);
}

View File

@ -0,0 +1,10 @@
import Sidebar from "../../../components/Sidebar";
export default function ViewsAdminPublikasi() {
return (
<div>
<h1 className="text-2xl font-bold">Publikasi Page</h1>
<p className="mt-4">Selamat datang di panel admin upload automation.</p>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setFile, setResult, setValidatedData, reset } from "../../../store/slices/uploadSlice";
import { uploadFile, uploadPdf, saveToDatabase } from "./service_admin_upload";
import { useNavigate } from "react-router-dom";
export function useUploadController() {
const dispatch = useDispatch();
const navigate = useNavigate();
const { file, result } = useSelector((state) => state.upload);
const [loading, setLoading] = useState(false);
const [selectedTable, setSelectedTable] = useState(null);
const [tableTitle, setTableTitle] = useState("");
const handleFileSelect = (f) => {
dispatch(setFile(f));
};
const handleUpload = async () => {
if (!file) return;
setLoading(true);
try {
const res = await uploadFile(file);
dispatch(setResult(res));
if (res.file_type !== ".pdf" || (res.file_type === ".pdf" && res.tables.length === 1)) {
navigate("/admin/upload/validate");
}
} finally {
setLoading(false);
}
};
const handleNextPdf = async () => {
if (!selectedTable) return;
setLoading(true);
try {
const res = await uploadPdf(selectedTable);
dispatch(setResult(res));
navigate("/admin/upload/validate");
} finally {
setLoading(false);
}
};
const handleConfirmUpload = async () => {
setLoading(true);
try {
const data = {
title: tableTitle,
columns: result.columns,
rows: result.preview,
};
const res = await saveToDatabase(data);
dispatch(setValidatedData(res));
navigate("/admin/upload/success");
} finally {
setLoading(false);
}
};
return {
loading,
file,
result,
selectedTable,
setSelectedTable,
tableTitle,
setTableTitle,
handleFileSelect,
handleUpload,
handleNextPdf,
handleConfirmUpload,
};
}

View File

@ -0,0 +1,115 @@
import { Link } from "react-router-dom";
export default function ViewsAdminUploadRules() {
return (
<div className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
<h1 className="text-3xl font-bold mb-6">📘 Panduan & Aturan Upload Data</h1>
<p className="text-gray-700 mb-6">
Halaman ini berisi aturan dan panduan teknis sebelum Anda mengunggah data ke sistem.
Mohon perhatikan format, struktur, dan ketentuan agar file dapat diproses dengan benar
oleh sistem dan diolah ke database.
</p>
{/* ===== Bagian 1: Format File ===== */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-3">🗂 Format File yang Diizinkan</h2>
<ul className="list-disc pl-5 space-y-2 text-gray-700">
<li>
Hanya mendukung <code className="bg-gray-100 px-1 rounded">.csv</code>,{" "}
<code className="bg-gray-100 px-1 rounded">.xlsx</code>,{" "}
<code className="bg-gray-100 px-1 rounded">.pdf</code>, dan{" "}
<code className="bg-gray-100 px-1 rounded">.zip</code>.
</li>
<li>
Format <code>.zip</code> digunakan untuk data spasial seperti{" "}
<code>.shp</code> atau <code>.gdb</code> (harus berisi struktur lengkap:
.shp, .shx, .dbf, .prj).
</li>
<li>
Setiap file yang diunggah hanya akan diproses sebagai <b>satu tabel</b>.
Jika file berisi banyak sheet atau layer, sistem akan mengambil sheet/layer pertama.
</li>
</ul>
</section>
{/* ===== Bagian 2: Batas & Validasi ===== */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-3"> Batasan & Validasi Sistem</h2>
<ul className="list-disc pl-5 space-y-2 text-gray-700">
<li>Maksimal ukuran file: <b>50 MB</b>.</li>
<li>
Pastikan nama file <b>tidak mengandung spasi</b> atau karakter khusus seperti{" "}
<code>/ \ : * ? " &lt; &gt; |</code>.
</li>
<li>
Hindari penggunaan nama tabel yang terlalu panjang; sistem akan
membuat nama tabel otomatis berdasarkan file Anda, misalnya:{" "}
<code>data_kabbandung_myfile_20251009</code>.
</li>
<li>
Setelah file diunggah, sistem akan menampilkan <b>struktur tabel hasil deteksi</b>{" "}
(kolom dan jumlah baris). Cek kembali sebelum menyimpan ke database.
</li>
<li>
Jika sistem menampilkan peringatan (), periksa kembali penulisan nama wilayah, ejaan,
atau format kolom agar sesuai dengan referensi data.
</li>
</ul>
</section>
{/* ===== Bagian 3: Tips Data ===== */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-3">💡 Tips Agar Data Terbaca dengan Benar</h2>
<ul className="list-disc pl-5 space-y-2 text-gray-700">
{/* <li>
Gunakan nama kolom tanpa spasi dan tanpa karakter khusus, misalnya{" "}
<code>nama_desa</code> bukan <code>Nama Desa</code>.
</li>
<li>
Untuk file Excel, pastikan data dimulai dari baris pertama tanpa judul tambahan di atas header kolom.
</li> */}
<li>
Untuk file CSV, gunakan pemisah <code>,</code> (koma) dan encoding UTF-8.
</li>
<li>
Pastikan kolom koordinat (jika ada) memiliki format numerik yang valid.
</li>
<li>
Untuk data spasial (ZIP SHP/GDB), sistem hanya membaca layer utama
dan tidak mendukung multi-layer dalam satu file.
</li>
</ul>
</section>
{/* ===== Bagian 4: Peringatan ===== */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-3">🚫 Peringatan</h2>
<ul className="list-disc pl-5 space-y-2 text-gray-700">
<li>Data yang tidak sesuai format akan ditolak oleh sistem.</li>
<li>Jika file terlalu besar atau korup, proses baca akan gagal.</li>
<li>
Pastikan koneksi internet stabil saat upload agar file tidak terputus.
</li>
<li>
Semua data yang diunggah bersifat <b>sementara</b> hingga disimpan ke database secara manual oleh pengguna.
</li>
</ul>
</section>
{/* ===== Bagian 5: Navigasi ===== */}
<div className="mt-8 flex items-center justify-between">
<Link
to="/admin/upload"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Kembali ke Halaman Upload
</Link>
<span className="text-sm text-gray-500">
Diperbarui terakhir: <b>28 Oktober 2025</b>
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
// import api from "../../../services/api";
// // Upload file ke backend
// export async function uploadFile(file) {
// const formData = new FormData();
// formData.append("file", file);
// const res = await api.post("/upload", formData, {
// headers: { "Content-Type": "multipart/form-data" },
// });
// return res.data;
// }
// // Upload PDF table hasil analisis
// export async function uploadPdf(table) {
// const res = await api.post("/upload/pdf", table);
// return res.data;
// }
// // Simpan hasil ke database
// export async function saveToDatabase(data) {
// const res = await api.post("/upload/save", data);
// return res.data;
// }
import api from "../../../services/api";
export async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
try {
const response = await api.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
} catch (error) {
console.error("Upload gagal:", error);
throw error.response?.data || { message: "Gagal mengunggah file." };
}
}
export async function uploadPdf(data) {
try {
const response = await api.post("/process-pdf", data, {
headers: { "Content-Type": "application/json" },
});
return response.data;
} catch (error) {
console.error("Gagal kirim JSON:", error);
throw error.response?.data || { message: "Terjadi kesalahan." };
}
}
export async function saveToDatabase(data) {
console.log("send:", data);
try {
const response = await api.post("/upload_to_postgis", data, {
headers: { "Content-Type": "application/json" },
});
return response.data;
} catch (error) {
console.error("Gagal kirim JSON:", error);
throw error.response?.data || { message: "Terjadi kesalahan." };
}
}

View File

@ -0,0 +1,119 @@
import { useSelector } from "react-redux";
import { Link } from "react-router-dom";
import { Navigate } from "react-router-dom";
export default function ViewsAdminUploadSuccess() {
const { validatedData } = useSelector((state) => state.upload);
if (!validatedData) {
// return (
// <div className="max-w-3xl mx-auto py-20 text-center">
// <h1 className="text-3xl font-bold text-yellow-600 mb-4"> Tidak Ada Data</h1>
// <p className="text-gray-700 mb-6">
// Tidak ditemukan hasil upload yang baru. Silakan unggah data terlebih dahulu.
// </p>
// <Link
// to="/admin/upload"
// className="bg-blue-600 text-white px-5 py-2 rounded hover:bg-blue-700"
// >
// Kembali ke Halaman Upload
// </Link>
// </div>
// );
return <Navigate to="/admin/upload" />;
}
return (
<div className="max-w-4xl mx-auto py-20 text-center">
<h1 className="text-3xl font-bold text-green-600 mb-4"> Upload Berhasil!</h1>
<p className="text-gray-700 mb-8">
Data Anda berhasil disimpan ke database.
</p>
{/* Ringkasan hasil dari backend */}
<div className="relative border border-gray-200 bg-gradient-to-b from-white to-gray-50 rounded-2xl shadow-md p-8 mb-10 text-left overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-green-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
<div className="absolute bottom-0 left-0 w-32 h-32 bg-blue-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
<div className="flex items-center gap-3 mb-6 relative z-10">
<div className="p-2 bg-green-100 text-green-600 rounded-full shadow-inner">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-800 tracking-tight">
Ringkasan Hasil Upload
</h2>
</div>
{/* Detail List */}
<div className="space-y-4 relative z-10">
{validatedData.table_name && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
<span className="text-gray-600 font-medium">📁 Nama Tabel</span>
<span className="text-gray-900 font-semibold">{validatedData.table_name}</span>
</div>
)}
{validatedData.total_rows && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
<span className="text-gray-600 font-medium">📊 Jumlah Baris</span>
<span className="text-gray-900 font-semibold">
{validatedData.total_rows.toLocaleString()} data
</span>
</div>
)}
{validatedData.upload_time && (
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
<span className="text-gray-600 font-medium">🕒 Waktu Upload</span>
<span className="text-gray-900 font-semibold">
{new Date(validatedData.upload_time).toLocaleString("id-ID", {
dateStyle: "full",
timeStyle: "short",
})}
</span>
</div>
)}
{validatedData.message && (
<div className="bg-green-50 border border-green-200 px-5 py-4 rounded-lg mt-4">
<p className="w-full text-center text-green-700 font-semibold">
{validatedData.message}
</p>
</div>
)}
</div>
{/* Metadata Section */}
{validatedData.metadata && (
<div className="mt-8 relative z-10">
<h3 className="text-sm font-semibold text-gray-600 mb-2">Metadata</h3>
<div className="bg-gray-900 text-gray-100 text-xs rounded-lg overflow-auto shadow-inner p-4 max-h-60">
<pre>{JSON.stringify(validatedData.metadata, null, 2)}</pre>
</div>
</div>
)}
</div>
<Link
to="/admin/upload"
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 transition"
>
Kembali ke Dashboard
</Link>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useUploadController } from "./controller_admin_upload";
import FileDropzone from "../../../components/FileDropzone";
import { Link } from "react-router-dom";
export default function ViewsAdminUploadStep1() {
const {
loading,
file,
result,
selectedTable,
setSelectedTable,
handleFileSelect,
handleUpload,
handleNextPdf,
} = useUploadController();
return (
<div className="max-w-4xl mx-auto py-10">
<div className="mb-6 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-800">Upload Data</h1>
<p className="text-lg text-gray-600">
<Link to="/admin/upload/rules" className="text-blue-600 hover:underline">
Panduan upload
</Link>
</p>
</div>
<FileDropzone onFileSelect={handleFileSelect} />
{file && (
<div className="mt-3">
<p className="text-gray-700 text-sm">File terpilih: {file.name}</p>
<button
onClick={handleUpload}
disabled={loading}
className="mt-3 px-5 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400"
>
{loading ? "Mengunggah..." : "Upload"}
</button>
</div>
)}
{result && result.file_type === ".pdf" && result.tables?.length > 1 && (
<div className="mt-6">
<h2 className="text-lg font-semibold mb-2 text-gray-700">Hasil Analisis Backend</h2>
<ul className="border rounded-lg divide-y overflow-hidden">
{result.tables.map((t, i) => (
<li
key={i}
onClick={() => setSelectedTable(t)}
className={`flex items-center gap-2 p-3 cursor-pointer hover:bg-blue-50 transition ${
selectedTable?.title === t.title ? "bg-blue-100 font-semibold" : ""
}`}
>
<span className={`text-green-600 ${selectedTable?.title === t.title ? "" : "opacity-0"}`}></span>
<span>{t.title}</span>
</li>
))}
</ul>
<button
onClick={handleNextPdf}
disabled={!selectedTable}
className="mt-4 px-5 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-300"
>
Lanjut ke Validasi
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useUploadController } from "./controller_admin_upload";
import { useSelector } from "react-redux";
import { useState, useEffect } from "react";
import Notification from "../../../components/Notification";
import { Navigate } from "react-router-dom";
import FilePreview from "../../../components/upload/FilePreview";
export default function ViewsAdminUploadValidate() {
const { result, file } = useSelector((state) => state.upload);
const {
loading,
tableTitle,
setTableTitle,
handleConfirmUpload,
} = useUploadController();
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState("info");
// Alert browser sebelum meninggalkan halaman
useEffect(() => {
const handleBeforeUnload = (e) => {
if (result && !loading) {
e.preventDefault();
e.returnValue =
"Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang.";
return e.returnValue;
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [result, loading]);
// Alert saat navigasi ke halaman lain dalam app (React Router)
useEffect(() => {
const handleNavigation = (e) => {
if (result && !loading) {
const confirmLeave = window.confirm(
"Data upload Anda belum disimpan. Jika Anda meninggalkan halaman ini, data akan hilang."
);
if (!confirmLeave) {
e.preventDefault();
window.history.pushState(null, "", window.location.href); // tetap di halaman
}
}
};
window.addEventListener("popstate", handleNavigation);
return () => window.removeEventListener("popstate", handleNavigation);
}, [result, loading]);
// if (!result)
// return <div className="text-center mt-10">Data belum diupload.</div>;
if (!result) return <Navigate to="/admin/upload" />;
const handleUploadClick = () => {
if (!tableTitle.trim()) {
setAlertMessage(
"❗Judul tabel belum diisi. Silakan isi sebelum melanjutkan."
);
setAlertType("error");
setShowAlert(true);
return;
}
setAlertMessage("Mengunggah ke database...");
setAlertType("info");
setShowAlert(true);
handleConfirmUpload();
};
return (
<div className="max-w-4xl mx-auto py-10">
{showAlert && (
<Notification
message={alertMessage}
type={alertType}
onClose={() => setShowAlert(false)}
/>
)}
<h1 className="text-2xl font-bold mb-4"> Validasi & Konfirmasi Data</h1>
<div className="w-full mx-auto mt-6">
<label
htmlFor="tableTitle"
className="block text-sm font-medium text-gray-700 mb-2"
>
Judul Tabel
</label>
<input
id="tableTitle"
type="text"
value={tableTitle}
onChange={(e) => setTableTitle(e.target.value)}
placeholder="Masukkan judul tabel..."
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${
!tableTitle ? "border-red-400" : ""
}`}
/>
<small className="text-gray-500">
Judul akan dijadikan nama tabel pada database
</small>
</div>
{result && <FilePreview result={result} />}
<div className="mt-6 flex justify-between">
<button
onClick={() => history.back()}
className="px-5 py-2 text-blue-600 hover:underline"
>
Kembali
</button>
<button
onClick={handleUploadClick}
disabled={loading}
className="px-5 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
>
{loading ? "Mengunggah..." : "Upload ke Database"}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { useState } from "react";
import { loginService } from "./service_auth_login";
import { saveToken } from "../../utils/auth";
import { useNavigate } from "react-router-dom";
export function useAuthLoginController() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const navigate = useNavigate();
const handleLogin = async (email, password) => {
navigate("/admin/home");
// setLoading(true);
// try {
// const data = await loginService({ email, password });
// saveToken(data.token);
// navigate("/admin/home");
// } catch (err) {
// setError("Login gagal, periksa email dan password.");
// } finally {
// setLoading(false);
// }
};
return { handleLogin, loading, error };
}

View File

@ -0,0 +1,6 @@
import api from "../../services/api";
export async function loginService(credentials) {
const response = await api.post("/auth/login", credentials);
return response.data;
}

View File

@ -0,0 +1,38 @@
import { useState } from "react";
import { useAuthLoginController } from "./controller_auth_login";
export default function ViewsAuthLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { handleLogin, loading, error } = useAuthLoginController();
return (
<div className="flex items-center justify-center h-screen bg-gray-100">
<div className="w-96 bg-white p-6 rounded-xl shadow-md">
<h1 className="text-2xl font-bold mb-4 text-center">Login</h1>
<input
type="email"
placeholder="Email"
className="w-full mb-3 p-2 border rounded"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
className="w-full mb-3 p-2 border rounded"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-red-500 text-sm mb-3">{error}</p>}
<button
onClick={() => handleLogin(email, password)}
disabled={loading}
className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 transition"
>
{loading ? "Loading..." : "Masuk"}
</button>
</div>
</div>
);
}

View File

View File

View File

@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
export default function ViewsLanding() {
return (
<div className="flex flex-col items-center justify-center h-screen bg-gradient-to-r from-blue-500 to-indigo-600 text-white">
<h1 className="text-4xl font-bold mb-4">Upload Automation Platform</h1>
<p className="mb-6 text-lg">Otomasi proses upload dan publikasi data Anda dengan mudah.</p>
<Link
to="/login"
className="bg-white text-blue-600 px-4 py-2 rounded-lg hover:bg-gray-200 transition"
>
Masuk
</Link>
</div>
);
}

80
src/routes/AppRouter.jsx Normal file
View File

@ -0,0 +1,80 @@
// import { BrowserRouter, Routes, Route } from "react-router-dom";
// import GuestRoute from "./GuestRoute";
// import ProtectedRoute from "./ProtectedRoute";
// import ViewsLanding from "../pages/landing/views_landing";
// import ViewsAuthLogin from "../pages/auth/views_auth_login";
// import ViewsAdminHome from "../pages/admin/home/views_admin_home";
// import ViewsAdminUpload from "../pages/admin/upload/views_admin_upload";
// import ViewsAdminUploadValidate from "../pages/admin/upload/views_admin_upload_validate";
// import ViewsAdminUploadSuccess from "../pages/admin/upload/views_admin_upload_success";
// import ViewsAdminPublikasi from "../pages/admin/publikasi/views_admin_publikasi";
// export default function AppRouter() {
// return (
// <BrowserRouter>
// <Routes>
// {/* Guest Routes */}
// <Route path="/" element={<GuestRoute><ViewsLanding /></GuestRoute>} />
// <Route path="/login" element={<GuestRoute><ViewsAuthLogin /></GuestRoute>} />
// {/* Protected (Admin) Routes */}
// <Route path="/admin/home" element={<ProtectedRoute><ViewsAdminHome /></ProtectedRoute>} />
// <Route path="/admin/publikasi" element={<ProtectedRoute><ViewsAdminPublikasi /></ProtectedRoute>} />
// <Route path="/admin/upload" element={<ProtectedRoute><ViewsAdminUpload /></ProtectedRoute>} />
// <Route path="/admin/upload/validate" element={<ProtectedRoute><ViewsAdminUploadValidate /></ProtectedRoute>} />
// <Route path="/admin/upload/success" element={<ProtectedRoute><ViewsAdminUploadSuccess /></ProtectedRoute>} />
// </Routes>
// </BrowserRouter>
// );
// }
import { BrowserRouter, Routes, Route } from "react-router-dom";
import GuestRoute from "./GuestRoute";
import ProtectedRoute from "./ProtectedRoute";
import ViewsLanding from "../pages/landing/views_landing";
import ViewsAuthLogin from "../pages/auth/views_auth_login";
import AdminLayout from "../layouts/AdminLayout";
import ViewsAdminHome from "../pages/admin/home/views_admin_home";
import ViewsAdminUploadStep1 from "../pages/admin/upload/views_admin_upload";
import ViewsAdminUploadValidate from "../pages/admin/upload/views_admin_validate_upload";
import ViewsAdminUploadSuccess from "../pages/admin/upload/views_admin_success_upload";
import ViewsAdminPublikasi from "../pages/admin/publikasi/views_admin_publikasi";
import ViewsAdminUploadRules from "../pages/admin/upload/rules/views_admin_rules_upload";
export default function AppRouter() {
return (
<BrowserRouter>
<Routes>
{/* Guest */}
<Route path="/" element={<GuestRoute><ViewsLanding /></GuestRoute>} />
<Route path="/login" element={<GuestRoute><ViewsAuthLogin /></GuestRoute>} />
{/* Protected Admin Layout */}
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route path="home" element={<ViewsAdminHome />} />
<Route path="upload" element={<ViewsAdminUploadStep1 />} />
<Route path="upload/validate" element={<ViewsAdminUploadValidate />} />
<Route path="upload/success" element={<ViewsAdminUploadSuccess />} />
<Route path="upload/rules" element={<ViewsAdminUploadRules />} />
<Route path="publikasi" element={<ViewsAdminPublikasi />} />
</Route>
</Routes>
</BrowserRouter>
);
}

View File

@ -0,0 +1,6 @@
import { Navigate } from "react-router-dom";
import { isAuthenticated } from "../utils/auth";
export default function GuestRoute({ children }) {
return isAuthenticated() ? <Navigate to="/admin/home" /> : children;
}

View File

@ -0,0 +1,7 @@
import { Navigate } from "react-router-dom";
import { isAuthenticated } from "../utils/auth";
export default function ProtectedRoute({ children }) {
// return isAuthenticated() ? children : <Navigate to="/login" />;
return children ;
}

14
src/services/api.js Normal file
View File

@ -0,0 +1,14 @@
import axios from "axios";
import { getToken } from "../utils/auth";
const api = axios.create({
baseURL: "http://localhost:8000",
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
export default api;

View File

@ -0,0 +1,31 @@
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
file: null,
result: null,
validatedData: null,
};
const uploadSlice = createSlice({
name: "upload",
initialState,
reducers: {
setFile: (state, action) => {
state.file = action.payload;
},
setResult: (state, action) => {
state.result = action.payload;
},
setValidatedData: (state, action) => {
state.validatedData = action.payload;
},
reset: (state) => {
state.file = null;
state.result = null;
state.validatedData = null;
},
},
});
export const { setFile, setResult, setValidatedData, reset } = uploadSlice.actions;
export default uploadSlice.reducer;

12
src/store/store.js Normal file
View File

@ -0,0 +1,12 @@
import { configureStore } from "@reduxjs/toolkit";
import uploadReducer from "./slices/uploadSlice";
export const store = configureStore({
reducer: {
upload: uploadReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false, // ✅ nonaktifkan peringatan File non-serializable
}),
});

26
src/utils/auth.js Normal file
View File

@ -0,0 +1,26 @@
import {jwtDecode} from "jwt-decode";
export function saveToken(token) {
localStorage.setItem("token", token);
}
export function getToken() {
return localStorage.getItem("token");
}
export function logout() {
localStorage.removeItem("token");
}
export function isAuthenticated() {
const token = getToken();
if (!token) return false;
try {
const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000;
return decoded.exp > currentTime;
} catch (e) {
return false;
}
}

11
vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})