This commit is contained in:
Vassshhhh
2025-09-24 14:14:54 +07:00
parent fae5b28244
commit 97b90dc6ca
5 changed files with 469 additions and 91 deletions

106
package-lock.json generated
View File

@@ -16,7 +16,8 @@
"react-dom": "^19.1.1",
"react-router-dom": "^7.7.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"autoprefixer": "^10.4.21",
@@ -4096,6 +4097,15 @@
"node": ">=8.9"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -5107,6 +5117,19 @@
"node": ">=4"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5311,6 +5334,15 @@
"node": ">=4"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -5522,6 +5554,18 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7732,6 +7776,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -14028,6 +14081,18 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -15811,6 +15876,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16185,6 +16268,27 @@
}
}
},
"node_modules/xlsx": {
"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",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",

View File

@@ -11,7 +11,8 @@
"react-dom": "^19.1.1",
"react-router-dom": "^7.7.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"xlsx": "^0.18.5"
},
"scripts": {
"start": "react-scripts start",

View File

@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { fetchEntries } from '../api';
export default function EntriesTable({ dataTypeId }) {
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadEntries = async () => {
try {
const result = await fetchEntries(dataTypeId);
setEntries(result);
} catch (error) {
console.error("Error fetching entries:", error);
} finally {
setLoading(false);
}
};
loadEntries();
}, [dataTypeId]);
if (loading) return <div>Loading...</div>;
if (!entries.length) return <div>No entries found.</div>;
// Ambil header kolom dari key properti data entry pertama
const headers = Object.keys(entries[0].data || {});
return (
<div className="overflow-x-auto">
<table className="min-w-full border-collapse">
<thead>
<tr>
{headers.map((header) => (
<th key={header} className="border px-4 py-2 bg-gray-200">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.data_id}>
{headers.map((header) => (
<td key={header} className="border px-4 py-2">
{entry.data[header]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -1,13 +1,14 @@
import { useState, useRef } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useParams, Link, useLocation } from 'react-router-dom';
import { fetchEntries } from '../api';
// Ikon (tidak ada perubahan)
// Icons (no changes)
const BackIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>);
const UploadIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>);
const CameraIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 B0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>);
const CameraIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>);
const ImageIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1"><path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>);
const TrashIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" /></svg>);
const TrashIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" /></svg>);
const CheckCircleIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg>);
export default function InputDataPage() {
const location = useLocation();
@@ -19,9 +20,38 @@ export default function InputDataPage() {
const fileInputRef = useRef(null);
const cameraInputRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
console.log(expectation)
// --- PERUBAHAN: State baru untuk progress upload ---
const [uploadProgress, setUploadProgress] = useState(0);
const [modalVisible, setModalVisible] = useState(false);
const [uploadResults, setUploadResults] = useState([]);
const [entries, setEntries] = useState([]);
const [loadingEntries, setLoadingEntries] = useState(false);
const [resultIndex, setResultIndex] = useState(0);
const [isSavingAll, setIsSavingAll] = useState(false);
// State baru untuk paginasi field
const [fieldPage, setFieldPage] = useState(0);
const loadEntries = useCallback(async () => {
if (!data_type_id) return;
setLoadingEntries(true);
try {
const data = await fetchEntries(data_type_id);
setEntries(data);
} catch (error) {
console.error("Error fetch entries:", error);
} finally {
setLoadingEntries(false);
}
}, [data_type_id]);
useEffect(() => {
loadEntries();
}, [loadEntries]);
// Reset field page saat dokumen utama berganti
useEffect(() => {
setFieldPage(0);
}, [resultIndex]);
const handleFiles = (newFiles) => {
const imageFiles = Array.from(newFiles).filter(file => file.type.startsWith('image/'));
@@ -37,95 +67,239 @@ export default function InputDataPage() {
setFilesToUpload(prevFiles => prevFiles.filter((_, i) => i !== index));
};
// --- PERUBAHAN: Fungsi handleUpload diubah total ---
const handleUpload = async () => {
if (filesToUpload.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
const totalFiles = filesToUpload.length;
const successfulUploads = [];
const failedUploads = [];
const allResults = []; // <-- Variabel untuk mengumpulkan semua hasil
const finalResults = [];
for (let i = 0; i < totalFiles; i++) {
const file = filesToUpload[i];
setUploadProgress(i + 1); // Update progress sebelum upload
setUploadProgress(i + 1);
const formData = new FormData();
formData.append("image", file);
formData.append("expectation", JSON.stringify(expectation));
formData.append("data_type_id", data_type_id);
try {
console.log(`Mengupload file ${i + 1}/${totalFiles}: ${file.name}`);
const response = await fetch("https://bot.kediritechnopark.com/webhook/solid-data/scan", {
method: "POST",
body: formData,
});
if (!response.ok) {
// Lemparkan error agar ditangkap oleh catch block
throw new Error(`Gagal mengupload ${file.name}`);
}
if (!response.ok) throw new Error(`Gagal mengupload ${file.name}`);
const resultJson = await response.json();
successfulUploads.push({ file: file.name, result: resultJson });
allResults.push(resultJson); // <-- Kumpulkan hasil response di sini
console.log(`Sukses mengupload ${file.name}:`, resultJson);
if (resultJson && resultJson.length > 0) {
finalResults.push({
file,
result: resultJson,
formData: { ...resultJson[0] },
deleted: false,
saved: false,
});
} else {
console.warn(`Hasil scan kosong untuk file ${file.name}`);
}
} catch (error) {
failedUploads.push(file.name);
console.error(`Error saat mengupload ${file.name}:`, error);
console.error(`Gagal mengupload ${file.name}:`, error);
}
}
setUploadResults(finalResults);
setModalVisible(true);
setResultIndex(0);
setFilesToUpload([]);
setIsUploading(false);
setUploadProgress(0);
// allResults sekarang berisi array hasil response dari semua file
console.log("Semua hasil response:", allResults);
// Beri ringkasan hasil upload
alert(`Selesai! Berhasil: ${successfulUploads.length}, Gagal: ${failedUploads.length}`);
// Reset daftar file setelah semua proses selesai
setFilesToUpload([]);
};
const updateField = (key, value) => {
const newResults = [...uploadResults];
if (newResults[resultIndex]) {
newResults[resultIndex].formData[key] = value;
setUploadResults(newResults);
}
};
// Render field berdasarkan tipe (sama seperti sebelumnya)
const renderField = (key, value) => {
let type = "text";
let options = [];
if (typeof expectation[key] === "object" && expectation[key] !== null) {
type = expectation[key].type;
options = expectation[key].options || [];
} else {
type = expectation[key] || "text";
}
if (type === "date") {
return (
<input
type="date"
value={value}
onChange={(e) => updateField(key, e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md p-1"
/>
);
}
if (type === "selection") {
return (
<select
value={value}
onChange={(e) => updateField(key, e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md p-1"
>
{options.map((opt, idx) => (
<option key={idx} value={opt}>
{opt}
</option>
))}
</select>
);
}
if (type === "number") {
return (
<input
type="number"
value={value}
onChange={(e) => updateField(key, e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md p-1"
/>
);
}
return (
<input
type="text"
value={value}
onChange={(e) => updateField(key, e.target.value)}
className="mt-1 block w-full border border-gray-300 rounded-md p-1"
/>
);
};
const toggleDelete = () => {
const newResults = [...uploadResults];
if (newResults[resultIndex]) {
newResults[resultIndex].deleted = !newResults[resultIndex].deleted;
setUploadResults(newResults);
}
};
const handleSaveAllValid = async () => {
const itemsToSave = uploadResults.filter(r => !r.deleted && !r.saved);
if (itemsToSave.length === 0) {
alert("Tidak ada data baru yang valid untuk disimpan.");
return;
}
setIsSavingAll(true);
const token = localStorage.getItem("token");
const orgId = JSON.parse(localStorage.getItem("selected_organization"))?.organization_id;
const savePromises = itemsToSave.map(item => {
const payload = { data: item.formData, organization_id: orgId, data_type_id };
return fetch("https://bot.kediritechnopark.com/webhook/solid-data/save", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
}).then(res => res.ok ? { success: true, item } : { success: false, item });
});
try {
const results = await Promise.all(savePromises);
const successfulSaves = results.filter(r => r.success).map(r => r.item);
const failedSavesCount = results.length - successfulSaves.length;
if (successfulSaves.length > 0) {
setUploadResults(prevResults =>
prevResults.map(res =>
successfulSaves.some(s => s.file.name === res.file.name && s.file.size === res.file.size)
? { ...res, saved: true }
: res
)
);
await loadEntries();
}
alert(`Selesai! Berhasil menyimpan ${successfulSaves.length} data. Gagal: ${failedSavesCount} data.`);
} catch (error) {
console.error("Terjadi error saat menyimpan semua data:", error);
alert("Terjadi error saat mencoba menyimpan data.");
} finally {
setIsSavingAll(false);
}
};
const goToPrevious = () => setResultIndex(prev => Math.max(0, prev - 1));
const goToNext = () => setResultIndex(prev => Math.min(uploadResults.length - 1, prev + 1));
const renderEntriesTable = () => {
if (loadingEntries) return <div className="text-center p-8">Memuat data...</div>;
if (!entries.length || entries[0]?.success === true) {
return (
<div className="mt-8 bg-white p-6 sm:p-8 rounded-2xl shadow-sm text-center text-gray-500 flex flex-col items-center">
<ImageIcon />
<p className="mt-4 text-lg font-semibold">Belum ada data yang disimpan.</p>
<p className="text-sm text-gray-400">Silakan upload gambar dan simpan data terlebih dahulu.</p>
<button onClick={() => fileInputRef.current.click()} className="mt-6 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-semibold">
Upload Sekarang
</button>
</div>
);
}
const headers = Object.keys(entries[0]?.data || {});
return (
<div className="mt-8 bg-white p-6 sm:p-8 rounded-2xl shadow-sm">
<h2 className="text-xl mb-4 font-bold text-gray-800">Data Tersimpan</h2>
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>{headers.map(header => <th key={header} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{header}</th>)}</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{entries.map(entry => <tr key={entry.data_id}>{headers.map(header => <td key={header} className="px-6 py-4 whitespace-nowrap text-sm text-gray-800">{entry.data[header]}</td>)}</tr>)}
</tbody>
</table>
</div>
</div>
);
};
const currentResult = uploadResults[resultIndex];
// Logika untuk paginasi field
const fieldsPerPage = 3;
const allFields = currentResult?.formData ? Object.entries(currentResult.formData) : [];
const totalFieldPages = Math.ceil(allFields.length / fieldsPerPage);
const visibleFields = allFields.slice(
fieldPage * fieldsPerPage,
fieldPage * fieldsPerPage + fieldsPerPage
);
const goToNextFields = () => setFieldPage(prev => Math.min(prev + 1, totalFieldPages - 1));
const goToPreviousFields = () => setFieldPage(prev => Math.max(0, prev - 1));
return (
<div className="bg-gray-100 min-h-screen">
<header className="bg-white shadow-sm p-4 flex items-center">
<header className="bg-white shadow-sm p-4 flex items-center sticky top-0 z-10">
<Link to="/dashboard" className="text-gray-600 hover:text-blue-600 mr-4"><BackIcon /></Link>
<h1 className="text-xl font-bold text-gray-800">Input Data untuk <span className="text-blue-600 capitalize">{docType.replace('-', ' ')}</span></h1>
<h1 className="text-lg sm:text-xl font-bold text-gray-800">Input Data untuk <span className="text-blue-600 capitalize">{docType.replace('-', ' ')}</span></h1>
</header>
<main className="p-4 sm:p-6 lg:p-8">
<div className="bg-white p-6 sm:p-8 rounded-2xl shadow-sm">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div
className={`rounded-xl p-8 flex flex-col justify-center items-center text-center transition-colors duration-200 ${isDragging ? 'bg-blue-100 border-2 border-solid border-blue-500' : 'bg-gray-50 border-2 border-dashed border-gray-300'}`}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
>
<input type="file" ref={fileInputRef} multiple className="hidden" onChange={(e) => handleFiles(e.target.files)}/>
<div className={`rounded-xl p-6 sm:p-8 flex flex-col justify-center items-center text-center transition-colors duration-200 ${isDragging ? 'bg-blue-100 border-2 border-solid border-blue-500' : 'bg-gray-50 border-2 border-dashed border-gray-300'}`} onDragEnter={() => setIsDragging(true)} onDragLeave={() => setIsDragging(false)} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}>
<input type="file" ref={fileInputRef} multiple className="hidden" onChange={(e) => handleFiles(e.target.files)} accept="image/*" />
<input type="file" ref={cameraInputRef} accept="image/*" capture="environment" className="hidden" onChange={(e) => handleFiles(e.target.files)} />
<h3 className="text-xl font-semibold text-gray-700">Upload Dokumen</h3>
<p className="text-gray-500 mt-1 mb-6 text-sm">Pilih file atau ambil gambar dari kamera.</p>
<div className="w-full space-y-3">
<button onClick={() => fileInputRef.current.click()} className="w-full flex items-center justify-center bg-blue-600 text-white font-semibold py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors"><UploadIcon /> Upload File</button>
<button onClick={() => cameraInputRef.current.click()} className="w-full flex items-center justify-center bg-white text-gray-700 font-semibold py-3 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors"><CameraIcon /> Ambil Gambar</button>
<button onClick={() => fileInputRef.current.click()} className="w-full flex items-center justify-center bg-blue-600 text-white font-semibold py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors">
<UploadIcon /> Upload File
</button>
<button onClick={() => cameraInputRef.current.click()} className="w-full flex items-center justify-center bg-white text-gray-700 font-semibold py-3 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors">
<CameraIcon /> Ambil Gambar
</button>
</div>
<p className="text-xs text-gray-400 mt-6">Atau, seret & lepas file di area ini.</p>
</div>
<div className="min-h-[200px]">
<div className="flex flex-col min-h-[300px] h-full">
<h3 className="text-xl font-semibold text-gray-700 mb-4">Pratinjau</h3>
{filesToUpload.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 overflow-y-auto pr-2 flex-1">
{filesToUpload.map((file, index) => (
<div key={index} className="relative group">
<img src={URL.createObjectURL(file)} alt={file.name} className="w-full h-32 object-cover rounded-lg shadow-md" />
@@ -136,27 +310,91 @@ export default function InputDataPage() {
))}
</div>
) : (
<div className="flex flex-col justify-center items-center text-center text-gray-500 bg-gray-50 rounded-xl p-8">
<div className="flex flex-1 flex-col justify-center items-center text-center text-gray-500 bg-gray-50 rounded-xl p-8 w-full h-full">
<ImageIcon />
<p className="mt-2">Pratinjau gambar akan muncul di sini.</p>
</div>
)}
</div>
</div>
{filesToUpload.length > 0 && (
<div className="mt-8 pt-6 border-t text-right">
<button onClick={handleUpload} disabled={isUploading} className="bg-indigo-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-w-[280px]">
{/* --- PERUBAHAN: Teks tombol dinamis dengan progress --- */}
{isUploading
? `Mengupload file ${uploadProgress} dari ${filesToUpload.length}...`
: `Upload ${filesToUpload.length} Gambar & Scan Data`
}
<button onClick={handleUpload} disabled={isUploading} className="bg-indigo-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
{isUploading ? `Mengupload ${uploadProgress} dari ${filesToUpload.length}...` : `Upload ${filesToUpload.length} Gambar & Scan`}
</button>
</div>
)}
</div>
{renderEntriesTable()}
</main>
{modalVisible && currentResult && (
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[95vh] flex flex-col">
<header className="flex justify-between items-center p-4 border-b">
<h2 className="text-lg font-bold text-gray-800">Verifikasi Scan ({resultIndex + 1} / {uploadResults.length})</h2>
<button onClick={() => setModalVisible(false)} className="text-gray-500 hover:text-gray-800 text-2xl font-bold">&times;</button>
</header>
<main className="p-4 sm:p-6 overflow-y-auto flex-grow relative">
{currentResult.deleted && (
<div className="absolute inset-0 bg-gray-200 bg-opacity-75 flex flex-col items-center justify-center z-10 rounded-md m-4 sm:m-6 gap-4">
<p className="text-center text-xl font-bold text-gray-700 px-4 py-2 bg-white rounded-lg shadow-md">DITANDAI UNTUK DIHAPUS</p>
<button onClick={toggleDelete} className="bg-yellow-500 text-white font-semibold py-2 px-5 rounded-lg hover:bg-yellow-600 transition-colors shadow-md">Batalkan</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<aside className="md:col-span-1 space-y-4">
<img src={URL.createObjectURL(currentResult.file)} alt={currentResult.file.name} className="w-full object-cover rounded-lg shadow-md" />
{currentResult.saved && <div className="flex items-center justify-center p-2 bg-green-100 text-green-700 font-semibold rounded-lg"><CheckCircleIcon /> Tersimpan</div>}
<button onClick={toggleDelete} className={`w-full flex items-center justify-center font-semibold py-3 px-4 rounded-lg transition-colors text-white ${currentResult.deleted ? "invisible" : "bg-red-500 hover:bg-red-600"}`}>
<TrashIcon /> Hapus Data Ini
</button>
</aside>
<section className="md:col-span-2 space-y-4 flex flex-col justify-between">
<div>
{visibleFields.map(([key, value]) => (
<div key={key} className="mb-4">
<label className="block text-sm font-medium text-gray-700">{key}</label>
{renderField(key, value)}
</div>
))}
</div>
{totalFieldPages > 1 && (
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
<button onClick={goToPreviousFields} disabled={fieldPage === 0} className="bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 disabled:opacity-50 hover:bg-gray-100 text-sm">
Sebelumnya
</button>
<span className="text-sm text-gray-600">
{fieldPage + 1} / {totalFieldPages}
</span>
<button onClick={goToNextFields} disabled={fieldPage >= totalFieldPages - 1} className="bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 disabled:opacity-50 hover:bg-gray-100 text-sm">
Selanjutnya
</button>
</div>
)}
</section>
</div>
</main>
<footer className="flex flex-col-reverse sm:flex-row sm:justify-between items-center p-4 border-t bg-gray-50 rounded-b-lg gap-3">
<div className="flex w-full sm:w-auto space-x-2">
<button onClick={goToPrevious} disabled={resultIndex === 0} className="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 disabled:opacity-50 hover:bg-gray-100">Sebelumnya</button>
<button onClick={goToNext} disabled={resultIndex === uploadResults.length - 1} className="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 disabled:opacity-50 hover:bg-gray-100">Berikutnya</button>
</div>
<div className="flex w-full sm:w-auto space-x-3">
<button onClick={() => setModalVisible(false)} className="flex-1 sm:flex-initial bg-gray-200 text-gray-800 font-semibold py-2 px-6 rounded-lg hover:bg-gray-300">Tutup</button>
<button onClick={handleSaveAllValid} disabled={isSavingAll} className="flex-1 sm:flex-initial bg-green-600 text-white font-semibold py-2 px-6 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-wait">
{isSavingAll ? 'Menyimpan...' : 'Simpan Semua'}
</button>
</div>
</footer>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Bypass Situs</title>
<meta charset="UTF-8">
</head>
<body>
<h2>🔓 Akses Situs Lain dari File Lokal</h2>
<ul>
<li><a href="https://www.google.com" target="_blank">Google</a></li>
<li><a href="https://www.youtube.com" target="_blank">YouTube</a></li>
<li><a href="https://www.reddit.com" target="_blank">Reddit</a></li>
<li><a href="https://www.wikipedia.org" target="_blank">Wikipedia</a></li>
<li><a href="https://www.proxysite.com" target="_blank">ProxySite</a></li>
<li><a href="https://www.croxyproxy.com" target="_blank">CroxyProxy</a></li>
</ul>
<p>Tips: Kalau tidak bisa dibuka, coba salin link-nya lalu tempel di tab baru.</p>
</body>
</html>