Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// assets/js/pages/manager/ManagerApp.js
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const ManagerApp = () => {
|
||||
const state = useManagerState();
|
||||
const handlers = useManagerHandlers(state);
|
||||
|
||||
const {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
showDetailModal,
|
||||
setShowDetailModal,
|
||||
selectedItem,
|
||||
showVerifyModal,
|
||||
setShowVerifyModal,
|
||||
selectedClaim,
|
||||
toast,
|
||||
setToast,
|
||||
showCloseCaseModal,
|
||||
setShowCloseCaseModal,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
loading,
|
||||
showReportFoundModal,
|
||||
setShowReportFoundModal,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
showManualClaimModal,
|
||||
setShowManualClaimModal,
|
||||
selectedItemForClaim,
|
||||
setSelectedItemForClaim,
|
||||
showApproveModal,
|
||||
setShowApproveModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
loadData,
|
||||
handleApproveClaim,
|
||||
handleRejectClaim,
|
||||
handleCloseCase,
|
||||
submitCloseCase,
|
||||
submitReportFound,
|
||||
handlePhotoChange,
|
||||
handleLogout,
|
||||
submitApproveClaim,
|
||||
} = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthUtils.checkAuthAndRedirect("manager")) return;
|
||||
const currentUser = AuthUtils.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "claims") {
|
||||
handlers.loadClaims();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
|
||||
<Navbar user={user} onLogout={handleLogout} userType="manager" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
Dashboard Manager
|
||||
</h1>
|
||||
<p className="text-slate-400">
|
||||
Kelola barang temuan dan verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Total Barang"
|
||||
value={stats.total_items || 0}
|
||||
icon="📦"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Claim"
|
||||
value={stats.pending_claims || 0}
|
||||
icon="⏳"
|
||||
colorClass="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Verified"
|
||||
value={stats.verified || 0}
|
||||
icon="✅"
|
||||
colorClass="text-green-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Expired"
|
||||
value={stats.expired || 0}
|
||||
icon="⚠️"
|
||||
colorClass="text-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveTab("items")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "items"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📦 Kelola Barang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("lost-items");
|
||||
handlers.loadLostItems();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "lost-items"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
😢 Kelola Barang Hilang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("claims")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "claims"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🤝 Verifikasi Klaim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "items" && (
|
||||
<ItemsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "lost-items" && (
|
||||
<LostItemsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "claims" && (
|
||||
<ClaimsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<DetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedItem}
|
||||
/>
|
||||
|
||||
<VerifyClaimModal
|
||||
isOpen={showVerifyModal}
|
||||
onClose={() => setShowVerifyModal(false)}
|
||||
claim={selectedClaim}
|
||||
onApprove={handleApproveClaim}
|
||||
onReject={handleRejectClaim}
|
||||
onCloseCase={handleCloseCase}
|
||||
/>
|
||||
|
||||
<CloseCaseModal
|
||||
isOpen={showCloseCaseModal}
|
||||
onClose={() => setShowCloseCaseModal(false)}
|
||||
claim={selectedClaim}
|
||||
closeCaseData={closeCaseData}
|
||||
setCloseCaseData={setCloseCaseData}
|
||||
onSubmit={submitCloseCase}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ManagerReportFoundModal
|
||||
isOpen={showReportFoundModal}
|
||||
onClose={() => {
|
||||
setShowReportFoundModal(false);
|
||||
setPhotoPreview(null);
|
||||
}}
|
||||
categories={state.categories}
|
||||
onSubmit={submitReportFound}
|
||||
loading={loading}
|
||||
photoPreview={photoPreview}
|
||||
onPhotoChange={(e) => handlePhotoChange(e)}
|
||||
/>
|
||||
|
||||
<MatchLostItemModal
|
||||
isOpen={state.showMatchLostItemModal}
|
||||
onClose={() => {
|
||||
state.setShowMatchLostItemModal(false);
|
||||
state.setSelectedLostItem(null);
|
||||
}}
|
||||
lostItem={state.selectedLostItem}
|
||||
items={state.items}
|
||||
onSubmit={handlers.submitMatchLostItem}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ApproveClaimModal
|
||||
isOpen={showApproveModal}
|
||||
onClose={() => setShowApproveModal(false)}
|
||||
onSubmit={submitApproveClaim}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ManagerManualClaimModal
|
||||
isOpen={showManualClaimModal}
|
||||
onClose={() => {
|
||||
setShowManualClaimModal(false);
|
||||
setSelectedItemForClaim(null);
|
||||
}}
|
||||
item={selectedItemForClaim}
|
||||
onSubmit={handlers.submitManualClaim}
|
||||
loading={loading}
|
||||
/>
|
||||
<EditItemModal state={state} handlers={handlers} />
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
<AIChatbot />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<ManagerApp />, document.getElementById("root"));
|
||||
@@ -0,0 +1,305 @@
|
||||
// web/js/pages/manager/ManagerManualClaimModal.js
|
||||
// Modal untuk manager input klaim manual ketika user datang langsung (offline)
|
||||
|
||||
const ManagerManualClaimModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
item,
|
||||
}) => {
|
||||
const [users, setUsers] = React.useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = React.useState(false);
|
||||
const [selectedUser, setSelectedUser] = React.useState(null);
|
||||
|
||||
// State untuk input manual
|
||||
const [claimerName, setClaimerName] = React.useState("");
|
||||
const [claimerContact, setClaimerContact] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [managerNotes, setManagerNotes] = React.useState("");
|
||||
|
||||
// Load users when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
resetForm();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedUser(null);
|
||||
setClaimerName("");
|
||||
setClaimerContact("");
|
||||
setDescription("");
|
||||
setManagerNotes("");
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`http://localhost:8080/api/manager/users?page=1&limit=1000`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSelect = (e) => {
|
||||
const userId = e.target.value;
|
||||
if (!userId) {
|
||||
setSelectedUser(null);
|
||||
setClaimerName("");
|
||||
setClaimerContact("");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.find((u) => u.id === parseInt(userId));
|
||||
if (user) {
|
||||
setSelectedUser(user);
|
||||
setClaimerName(user.name);
|
||||
setClaimerContact(user.phone || "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Validasi
|
||||
if (!claimerName || !claimerContact || !description) {
|
||||
alert("Nama pengklaim, kontak, dan deskripsi wajib diisi!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const formData = {
|
||||
item_id: item?.id,
|
||||
user_id: selectedUser?.id || null,
|
||||
claimer_name: claimerName,
|
||||
contact: claimerContact,
|
||||
description: description,
|
||||
manager_notes: managerNotes,
|
||||
};
|
||||
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
🤝 Input Klaim Manual (Offline)
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl text-slate-400 hover:text-white transition"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="text-blue-400 block mb-1">
|
||||
Klaim Offline
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300">
|
||||
Fitur ini untuk mencatat klaim yang disampaikan langsung oleh
|
||||
user kepada manager. Jika pengklaim memiliki akun, pilih di
|
||||
bawah. Jika tidak, isi data manual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
{item && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
📦 Barang yang Diklaim:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong> {item.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Lokasi:</strong>{" "}
|
||||
{item.location}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Tanggal:</strong>{" "}
|
||||
{new Date(item.date_found).toLocaleDateString("id-ID")}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Status:</strong>{" "}
|
||||
{item.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Selection */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User (Pengklaim){" "}
|
||||
<span className="text-slate-500 font-normal text-sm">
|
||||
(Opsional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
onChange={handleUserSelect}
|
||||
disabled={loadingUsers}
|
||||
value={selectedUser?.id || ""}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Tamu / Tidak Terdaftar --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} - {user.email} ({user.nrp})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih jika pengklaim sudah punya akun. Jika belum, biarkan kosong
|
||||
dan isi manual.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selected User Info */}
|
||||
{selectedUser && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
✅ User Terpilih:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{selectedUser.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Email:</strong>{" "}
|
||||
{selectedUser.email}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">NRP:</strong>{" "}
|
||||
{selectedUser.nrp}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Telepon:</strong>{" "}
|
||||
{selectedUser.phone || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Detail Klaim
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Claimer Name */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pengklaim *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claimerName}
|
||||
onChange={(e) => setClaimerName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Nama lengkap pengklaim"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Otomatis terisi jika memilih user, atau isi manual
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pengklaim *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claimerContact}
|
||||
onChange={(e) => setClaimerContact(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim (Ciri-ciri yang Disebutkan Pengklaim) *
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Tuliskan ciri-ciri barang yang disebutkan oleh pengklaim..."
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Pastikan mencatat detail yang disebutkan pengklaim untuk
|
||||
verifikasi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manager Notes */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Manager (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={managerNotes}
|
||||
onChange={(e) => setManagerNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan internal untuk referensi..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Memproses..." : "✅ Submit Klaim"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export ke window untuk digunakan di file lain
|
||||
window.ManagerManualClaimModal = ManagerManualClaimModal;
|
||||
@@ -0,0 +1,469 @@
|
||||
// assets/js/pages/manager/ManagerModals.js
|
||||
|
||||
const DetailModal = ({ isOpen, onClose, item }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Barang">
|
||||
{item && (
|
||||
<div>
|
||||
<img
|
||||
src={item.photo_url || "https://via.placeholder.com/600x400"}
|
||||
alt={item.name}
|
||||
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold mb-4 text-white">{item.name}</h3>
|
||||
|
||||
<div className="space-y-3 text-slate-300">
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kategori:</strong>
|
||||
<span className="ml-2">
|
||||
{item.category ||
|
||||
Helpers.getCategoryName(item.category_id) ||
|
||||
"Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Lokasi:</strong>
|
||||
<span className="ml-2">{item.location || "Tidak ada data"}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Tanggal Ditemukan:</strong>
|
||||
<span className="ml-2">
|
||||
{Helpers.formatDate(item.date_found) || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Status:</strong>
|
||||
<span
|
||||
className={`ml-2 px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Deskripsi Umum:</strong>
|
||||
<p className="text-slate-300 mt-2">
|
||||
{item.description || "Tidak ada deskripsi"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{item.reporter_name || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kontak Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{item.reporter_contact || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<strong className="text-yellow-400 text-lg">
|
||||
Ciri Khusus Rahasia (Untuk Verifikasi)
|
||||
</strong>
|
||||
</div>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
{item.secret_details || "Tidak ada deskripsi rahasia"}
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400 mt-2">
|
||||
⚠️ Info ini RAHASIA - gunakan untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const VerifyClaimModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
claim,
|
||||
onApprove,
|
||||
onReject,
|
||||
onCloseCase,
|
||||
}) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Verifikasi Klaim"
|
||||
size="large"
|
||||
>
|
||||
{claim && (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-blue-500/10 p-4 rounded-xl border border-blue-500/30">
|
||||
<h4 className="font-semibold mb-3 text-blue-400">
|
||||
Deskripsi Asli Barang (Rahasia)
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300 font-medium">
|
||||
{claim.item_secret_details || "Tidak ada ciri khusus rahasia"}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-600/50">
|
||||
<span className="text-xs text-slate-500 uppercase">
|
||||
Deskripsi Umum:
|
||||
</span>
|
||||
<p className="text-xs text-slate-400">{claim.item_description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<h4 className="font-semibold mb-3 text-yellow-400">
|
||||
Deskripsi dari Pengklaim
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300">{claim.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`p-6 rounded-xl text-center mb-6 ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-yellow-500/10 border border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-slate-300">Similarity Match:</strong>
|
||||
<div
|
||||
className={`text-4xl font-bold mt-2 ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl mb-6 border border-slate-700">
|
||||
<strong className="text-slate-300">Info Pengklaim:</strong>
|
||||
<div className="mt-2 space-y-1 text-slate-400">
|
||||
<div>Nama: {claim.user_name}</div>
|
||||
<div>Kontak: {claim.contact}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{claim.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={onApprove}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
✓ Approve Klaim
|
||||
</button>
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-xl hover:from-red-700 hover:to-red-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
✗ Reject Klaim
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claim.status === "approved" && !claim.berita_acara_no && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onCloseCase(claim);
|
||||
}}
|
||||
className="w-full mt-4 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
📋 Close Case (Serah Terima)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{claim.status === "approved" && claim.berita_acara_no && (
|
||||
<div className="mt-4 p-4 bg-green-500/10 border-2 border-green-500/30 rounded-xl">
|
||||
<div className="text-green-400 font-semibold text-center mb-2">
|
||||
✓ Case Already Closed
|
||||
</div>
|
||||
<div className="text-sm text-slate-300 text-center">
|
||||
No. BA: <strong>{claim.berita_acara_no}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const CloseCaseModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
claim,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setCloseCaseData({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
}}
|
||||
title="Close Case - Serah Terima Barang"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="bg-blue-500/10 p-4 rounded-lg border border-blue-500/30 mb-4">
|
||||
<strong className="text-blue-400">Barang:</strong>
|
||||
<p className="text-white mt-1">{claim?.item_name}</p>
|
||||
<strong className="text-blue-400 mt-2 block">Penerima:</strong>
|
||||
<p className="text-white">{claim?.user_name}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Berita Acara *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={closeCaseData.berita_acara_no}
|
||||
onChange={(e) =>
|
||||
setCloseCaseData({
|
||||
...closeCaseData,
|
||||
berita_acara_no: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="BA/LNF/2025/001"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Bukti Serah Terima (Foto/PDF)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="bukti_file"
|
||||
accept="image/*,application/pdf"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={closeCaseData.notes}
|
||||
onChange={(e) =>
|
||||
setCloseCaseData({ ...closeCaseData, notes: e.target.value })
|
||||
}
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan tambahan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "Memproses..." : "✓ Close Case"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const LostItemDetailModal = ({ isOpen, onClose, item }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="😢 Detail Barang Hilang">
|
||||
{item && (
|
||||
<div className="space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-slate-800 to-slate-900 p-5 rounded-xl border-l-4 border-yellow-500 shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-white mb-1">{item.name}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className="bg-slate-700 px-2 py-1 rounded text-xs text-white border border-slate-600">
|
||||
{item.status === "active" ? "Aktif" : "Ditemukan"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>ID #{item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Informasi Utama */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Kategori
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
🏷️ {item.category || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Tanggal Hilang
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
📅 {Helpers.formatDate(item.date_lost)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Warna
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
🎨 {item.color || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Lokasi
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
📍 {item.location || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<div className="bg-slate-900/50 p-5 rounded-xl border border-slate-600">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2 flex items-center gap-2">
|
||||
<span>📝</span> Deskripsi Lengkap
|
||||
</h4>
|
||||
<p className="text-slate-300 leading-relaxed text-sm">
|
||||
"{item.description}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Informasi Pelapor */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-5 rounded-xl">
|
||||
<h4 className="text-blue-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<span>👤</span> Informasi Pelapor
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Nama Pelapor</p>
|
||||
<p className="text-white font-medium">{item.user_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Kontak</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">
|
||||
{item.user_contact || "-"}
|
||||
</span>
|
||||
{item.user_contact && (
|
||||
<a
|
||||
href={`https://wa.me/${item.user_contact
|
||||
.replace(/^0/, "62")
|
||||
.replace(/\D/g, "")}`}
|
||||
target="_blank"
|
||||
className="text-xs bg-green-600 px-2 py-1 rounded text-white hover:bg-green-700 transition"
|
||||
>
|
||||
Chat WA
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const ApproveClaimModal = ({ isOpen, onClose, onSubmit, loading }) => {
|
||||
const [notes, setNotes] = React.useState("");
|
||||
|
||||
// Reset input saat modal dibuka
|
||||
React.useEffect(() => {
|
||||
if (isOpen) setNotes("");
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(notes); // Kirim notes ke handler submitApproveClaim
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="✅ Konfirmasi Approval"
|
||||
size="small"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<p className="text-sm text-green-200">
|
||||
Apakah Anda yakin ingin menyetujui klaim ini? Status barang akan
|
||||
berubah menjadi <strong>Approved</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Approval (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-green-500 focus:outline-none transition"
|
||||
placeholder="Contoh: Identitas telah diverifikasi..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 bg-slate-700 text-white rounded-xl hover:bg-slate-600 transition font-semibold"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Ya, Approve"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Jangan lupa export ke window
|
||||
window.LostItemDetailModal = LostItemDetailModal;
|
||||
|
||||
window.DetailModal = DetailModal;
|
||||
window.VerifyClaimModal = VerifyClaimModal;
|
||||
window.CloseCaseModal = CloseCaseModal;
|
||||
window.LostItemDetailModal = LostItemDetailModal;
|
||||
window.ApproveClaimModal = ApproveClaimModal;
|
||||
@@ -0,0 +1,320 @@
|
||||
const ManagerReportFoundModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
photoPreview,
|
||||
onPhotoChange,
|
||||
categories,
|
||||
}) => {
|
||||
const [users, setUsers] = React.useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = React.useState(false);
|
||||
|
||||
// ✅ ADD: State untuk menyimpan user yang dipilih
|
||||
const [selectedUser, setSelectedUser] = React.useState(null);
|
||||
|
||||
// ✅ ADD: State untuk input manual
|
||||
const [reporterName, setReporterName] = React.useState("");
|
||||
const [reporterContact, setReporterContact] = React.useState("");
|
||||
|
||||
// Load users when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
// Reset form saat modal dibuka
|
||||
setSelectedUser(null);
|
||||
setReporterName("");
|
||||
setReporterContact("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const response = await fetch(
|
||||
`${CONFIG.API_URL}/api/manager/users?page=1&limit=1000`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${AuthUtils.getToken()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ UPDATE: Handle user selection
|
||||
const handleUserSelect = (e) => {
|
||||
const userId = e.target.value;
|
||||
if (!userId) {
|
||||
setSelectedUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.find((u) => u.id === parseInt(userId));
|
||||
if (user) {
|
||||
setSelectedUser(user);
|
||||
setReporterName(user.name);
|
||||
setReporterContact(user.phone || "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="📦 Laporan Barang Ditemukan (Offline)"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="text-blue-400 block mb-1">
|
||||
Laporan Offline
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300">
|
||||
Fitur ini untuk mencatat barang yang diserahkan langsung kepada
|
||||
manager. Jika pelapor memiliki akun, silakan pilih di bawah.
|
||||
Jika tidak, cukup isi nama manual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Selection */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User (Penemu Barang){" "}
|
||||
<span className="text-slate-500 font-normal text-sm">
|
||||
(Opsional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
name="reporter_user_id"
|
||||
onChange={handleUserSelect}
|
||||
disabled={loadingUsers}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Tamu / Tidak Terdaftar --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} - {user.email} ({user.nrp})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih jika pelapor sudah punya akun. Jika belum, biarkan kosong.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ✅ FIXED: Selected User Info - sekarang selectedUser sudah didefinisikan */}
|
||||
{selectedUser && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
✅ User Terpilih:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{selectedUser.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Email:</strong>{" "}
|
||||
{selectedUser.email}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">NRP:</strong>{" "}
|
||||
{selectedUser.nrp}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Telepon:</strong>{" "}
|
||||
{selectedUser.phone || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Detail Barang Ditemukan
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Item Details */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Barang *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Dompet Kulit Coklat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Foto Barang
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={onPhotoChange}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
{photoPreview && (
|
||||
<div className="mt-3">
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-cover rounded-xl border-2 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kategori *
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{/* PERBAIKAN: Gunakan 'categories' langsung, bukan 'state.categories' */}
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Lokasi Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Perpustakaan Lantai 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Umum *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Deskripsi umum barang yang akan ditampilkan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Ciri Khusus Rahasia * 🔒
|
||||
</label>
|
||||
<textarea
|
||||
name="secret_details"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan ciri khusus yang HANYA pemilik asli yang tahu (contoh: ada goresan di bagian belakang, ada tulisan nama di dalam, dll)"
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Info ini RAHASIA dan hanya digunakan untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tanggal Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_found"
|
||||
required
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ✅ INPUT MANUAL dengan controlled state */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_name"
|
||||
required
|
||||
value={reporterName}
|
||||
onChange={(e) => setReporterName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Nama lengkap penemu barang"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
required
|
||||
value={reporterContact}
|
||||
onChange={(e) => setReporterContact(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Manager (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="manager_notes"
|
||||
rows="2"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan internal dari manager..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Submit Laporan"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
window.ManagerReportFoundModal = ManagerReportFoundModal;
|
||||
@@ -0,0 +1,108 @@
|
||||
// assets/js/pages/manager/ManagerTabs.js - FIXED
|
||||
|
||||
const ItemsTabManager = ({ state, handlers }) => {
|
||||
const { showExpired, setShowExpired, items, expiredItems, stats } = state;
|
||||
const {
|
||||
handleViewDetail,
|
||||
handleManualClaimClick,
|
||||
handleEditItemClick,
|
||||
handleDeleteItem,
|
||||
} = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{showExpired ? "📦 Barang Expired" : "📦 Daftar Barang Ditemukan"}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* ✅ TOMBOL BARU */}
|
||||
<button
|
||||
onClick={() => state.setShowReportFoundModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
📝 Lapor Barang Ditemukan (Offline)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(false)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
!showExpired
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
📦 Aktif ({items.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(true)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
showExpired
|
||||
? "bg-gradient-to-r from-red-600 to-red-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
⚠️ Expired ({stats.expired})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{(showExpired ? expiredItems : items).map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onViewDetail={handleViewDetail}
|
||||
onManualClaim={handleManualClaimClick}
|
||||
// ✅ TAMBAHKAN 3 PROPS INI:
|
||||
isManager={true} // Agar logika di ItemCard jalan
|
||||
onEdit={handleEditItemClick} // Fungsi ketika tombol diklik
|
||||
onDelete={handleDeleteItem} // (Opsional) Jika manager boleh hapus
|
||||
showActions={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(showExpired ? expiredItems : items).length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">{showExpired ? "⚠️" : "📦"}</div>
|
||||
<p>{showExpired ? "Belum ada barang expired" : "Belum ada barang"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClaimsTabManager = ({ state, handlers }) => {
|
||||
const { claims } = state;
|
||||
const { handleVerifyClaim, handleReopenCase, handleCancelApproval } =
|
||||
handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-6 text-white">Daftar Klaim</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{claims.map((claim) => (
|
||||
<ClaimCard
|
||||
key={claim.id}
|
||||
claim={claim}
|
||||
onVerify={handleVerifyClaim} // Ini mentrigger modal verify/close
|
||||
onReopen={handleReopenCase}
|
||||
onCancelApproval={handleCancelApproval} // ✅ Pass props baru
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{claims.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🤝</div>
|
||||
<p>Belum ada klaim</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ItemsTabManager = ItemsTabManager;
|
||||
window.ClaimsTabManager = ClaimsTabManager;
|
||||
@@ -0,0 +1,448 @@
|
||||
// web/js/pages/manager/tabs/LostItemsTabManager.js
|
||||
|
||||
const LostItemsTabManager = ({ state, handlers }) => {
|
||||
const { lostItems, items, loading } = state;
|
||||
const {
|
||||
showToast,
|
||||
loadLostItems,
|
||||
handleDeleteLostItem,
|
||||
handleMatchLostItem,
|
||||
handleViewLostItemDetail,
|
||||
} = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Kelola Laporan Barang Hilang
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Lihat semua laporan barang hilang dan hubungkan dengan barang
|
||||
ditemukan
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadLostItems()}
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-yellow-400 font-bold text-2xl">
|
||||
{lostItems.filter((i) => i.status === "active").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Aktif</div>
|
||||
</div>
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{lostItems.filter((i) => i.status === "found").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Ditemukan</div>
|
||||
</div>
|
||||
<div className="bg-slate-500/10 border border-slate-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-slate-400 font-bold text-2xl">
|
||||
{lostItems.length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : lostItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{lostItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl p-5 hover:shadow-xl transition-all ${
|
||||
item.status === "found"
|
||||
? "border-green-500/50 hover:border-green-500"
|
||||
: "border-slate-600 hover:border-blue-500 hover:shadow-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-white">{item.name}</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
👤 {item.user_name}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status === "active" ? "😢 Hilang" : "✅ Ditemukan"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">🏷️</span>
|
||||
<span>{item.category || item.category_name}</span>
|
||||
</div>
|
||||
|
||||
{item.color && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">🎨</span>
|
||||
<span>{item.color}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📍</span>
|
||||
<span>{item.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📅</span>
|
||||
<span>{Helpers.formatDate(item.date_lost)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📞</span>
|
||||
<span>{item.user_contact || "Tidak ada kontak"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<strong className="text-xs text-slate-400 uppercase block mb-1">
|
||||
Deskripsi:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300 line-clamp-3">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Matched Item (if found) */}
|
||||
{item.status === "found" && item.matched_item_id && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-3 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🎉</span>
|
||||
<strong className="text-green-400">Sudah Ditemukan</strong>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300">
|
||||
Dihubungkan dengan barang:{" "}
|
||||
<strong>
|
||||
{item.matched_item_name || "ID #" + item.matched_item_id}
|
||||
</strong>
|
||||
</p>
|
||||
{item.matched_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pada: {Helpers.formatDateTime(item.matched_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions - Description Based */}
|
||||
{item.status === "active" &&
|
||||
item.ai_suggestions &&
|
||||
item.ai_suggestions.length > 0 && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-3 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<strong className="text-blue-400 text-sm">
|
||||
AI Suggestions (Description Similarity)
|
||||
</strong>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mb-2">
|
||||
Berdasarkan kesamaan deskripsi barang hilang dengan ciri
|
||||
khusus barang ditemukan
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{item.ai_suggestions
|
||||
.slice(0, 3)
|
||||
.map((suggestion, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs text-slate-300 bg-slate-900/50 p-2 rounded"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold">
|
||||
{suggestion.item_name}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${
|
||||
suggestion.match_score >= 70
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: suggestion.match_score >= 50
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-orange-500/20 text-orange-400"
|
||||
}`}
|
||||
>
|
||||
{suggestion.match_score}% Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-[10px] mb-1">
|
||||
📍 {suggestion.location} • 📅{" "}
|
||||
{Helpers.formatDateShort(suggestion.date_found)}
|
||||
</p>
|
||||
{suggestion.preview_text && (
|
||||
<p className="text-slate-500 text-[10px] italic">
|
||||
"{suggestion.preview_text}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{item.status === "active" && (
|
||||
<button
|
||||
onClick={() => handleMatchLostItem(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
🔗 Hubungkan
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleViewLostItemDetail(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg flex items-center justify-center gap-1"
|
||||
>
|
||||
<span>📋</span> Detail
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus laporan "${item.name}" dari ${item.user_name}?\n\nTindakan ini tidak dapat dibatalkan.`
|
||||
)
|
||||
) {
|
||||
handleDeleteLostItem(item.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
|
||||
title="Hapus Laporan"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">😢</div>
|
||||
<p>Belum ada laporan barang hilang</p>
|
||||
</div>
|
||||
)}
|
||||
<LostItemDetailModal
|
||||
isOpen={state.showLostDetailModal}
|
||||
onClose={() => state.setShowLostDetailModal(false)}
|
||||
item={state.selectedLostDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Modal untuk menghubungkan barang hilang dengan barang ditemukan
|
||||
const MatchLostItemModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
lostItem,
|
||||
items,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => {
|
||||
const [selectedItemId, setSelectedItemId] = React.useState("");
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" &&
|
||||
(item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.location.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedItemId) {
|
||||
alert("Pilih barang yang ditemukan!");
|
||||
return;
|
||||
}
|
||||
onSubmit(lostItem.id, parseInt(selectedItemId));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
🔗 Hubungkan Barang Hilang dengan Barang Ditemukan
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl text-slate-400 hover:text-white transition"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
{/* Lost Item Info */}
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl mb-6">
|
||||
<strong className="text-yellow-400 block mb-2">
|
||||
😢 Barang Hilang:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{lostItem.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Kategori:</strong>{" "}
|
||||
{lostItem.category || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Warna:</strong>{" "}
|
||||
{lostItem.color || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Lokasi:</strong>{" "}
|
||||
{lostItem.location || "-"}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<strong className="text-slate-200">Deskripsi:</strong>{" "}
|
||||
{lostItem.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Cari Barang Ditemukan
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Cari berdasarkan nama atau lokasi..."
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih Barang yang Sesuai *
|
||||
</label>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 bg-slate-900/50 p-3 rounded-xl border border-slate-600">
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItemId(item.id.toString())}
|
||||
className={`p-4 rounded-lg cursor-pointer transition border-2 ${
|
||||
selectedItemId === item.id.toString()
|
||||
? "bg-blue-500/20 border-blue-500"
|
||||
: "bg-slate-800 border-slate-700 hover:border-blue-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Radio */}
|
||||
<input
|
||||
type="radio"
|
||||
name="item_id"
|
||||
value={item.id}
|
||||
checked={selectedItemId === item.id.toString()}
|
||||
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
{/* Photo */}
|
||||
{item.photo_url && (
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt={item.name}
|
||||
className="w-16 h-16 object-cover rounded-lg border border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/64?text=No+Image")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-semibold">
|
||||
{item.name}
|
||||
</h4>
|
||||
<div className="text-xs text-slate-400 space-y-1 mt-1">
|
||||
<div>🏷️ {item.category || "-"}</div>
|
||||
<div>📍 {item.location}</div>
|
||||
<div>📅 {Helpers.formatDate(item.date_found)}</div>
|
||||
<div>👤 Ditemukan oleh: {item.reporter_name}</div>
|
||||
</div>
|
||||
|
||||
{/* Show secret details preview for matching */}
|
||||
{item.secret_details && (
|
||||
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded">
|
||||
<div className="text-[10px] text-yellow-400 font-bold mb-1">
|
||||
🔒 Ciri Khusus:
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-300 italic line-clamp-2">
|
||||
"{item.secret_details}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<p>Tidak ada barang ditemukan yang sesuai</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedItemId}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Menghubungkan..." : "✅ Hubungkan Barang"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export
|
||||
window.LostItemsTabManager = LostItemsTabManager;
|
||||
window.MatchLostItemModal = MatchLostItemModal;
|
||||
@@ -0,0 +1,677 @@
|
||||
// assets/js/pages/manager/useManagerHandlers.js
|
||||
const useManagerHandlers = (state) => {
|
||||
const {
|
||||
setToast,
|
||||
setLoading,
|
||||
setStats,
|
||||
setItems,
|
||||
setClaims,
|
||||
setExpiredItems,
|
||||
setSelectedItem,
|
||||
setShowDetailModal,
|
||||
setSelectedClaim,
|
||||
setShowVerifyModal,
|
||||
setShowCloseCaseModal,
|
||||
setCloseCaseData,
|
||||
selectedClaim,
|
||||
closeCaseData,
|
||||
setShowReportFoundModal,
|
||||
setPhotoPreview,
|
||||
setShowEditItemModal,
|
||||
setSelectedItemToEdit,
|
||||
selectedItemToEdit,
|
||||
} = state;
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [itemsResponse, claimsResponse, expiredResponse] =
|
||||
await Promise.all([
|
||||
ApiUtils.get(`${CONFIG.API_ENDPOINTS.ITEMS}?page=1&limit=1000`),
|
||||
ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS),
|
||||
ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}?status=expired&page=1&limit=1000`
|
||||
),
|
||||
]);
|
||||
|
||||
const allItems = itemsResponse.data || [];
|
||||
const allClaims = claimsResponse.data || [];
|
||||
const allExpired = expiredResponse.data || [];
|
||||
|
||||
const calculatedStats = {
|
||||
total_items: allItems.length,
|
||||
pending_claims: allClaims.filter((c) => c.status === "pending").length,
|
||||
verified: allItems.filter((i) => i.status === "verified").length,
|
||||
expired: allExpired.length,
|
||||
};
|
||||
|
||||
setStats(calculatedStats);
|
||||
setItems(allItems);
|
||||
setClaims(allClaims);
|
||||
setExpiredItems(allExpired);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat data: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPhotoPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Edit Click
|
||||
const handleEditItemClick = async (item) => {
|
||||
try {
|
||||
// Reset preview foto agar tidak nyangkut foto sebelumnya
|
||||
setPhotoPreview(null); // ✅ TAMBAHKAN BARIS INI
|
||||
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
|
||||
);
|
||||
setSelectedItemToEdit(response.data || response);
|
||||
setShowEditItemModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat data barang", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Update Item
|
||||
const handleUpdateItem = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Memproses update barang...", "info");
|
||||
|
||||
// ✅ LOGIKA BARU: Upload Foto jika ada
|
||||
let photoUrl = state.selectedItemToEdit.photo_url; // Default ke foto lama
|
||||
const photoFile = formData.get("photo");
|
||||
|
||||
if (photoFile && photoFile.size > 0) {
|
||||
showToast("Mengupload foto baru...", "info");
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("image", photoFile);
|
||||
|
||||
const uploadData = await ApiUtils.uploadFile(
|
||||
CONFIG.API_ENDPOINTS.UPLOAD,
|
||||
uploadFormData
|
||||
);
|
||||
photoUrl = uploadData.data.url;
|
||||
}
|
||||
// ✅ SELESAI LOGIKA UPLOAD
|
||||
|
||||
const dateFound = formData.get("date_found");
|
||||
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
|
||||
|
||||
const itemData = {
|
||||
name: formData.get("name"),
|
||||
category_id: parseInt(formData.get("category_id")),
|
||||
photo_url: photoUrl, // ✅ Masukkan URL foto ke payload
|
||||
location: formData.get("location"),
|
||||
description: formData.get("description"),
|
||||
secret_details: formData.get("secret_details"),
|
||||
date_found: dateFoundISO,
|
||||
reporter_name: formData.get("reporter_name"),
|
||||
reporter_contact: formData.get("reporter_contact"),
|
||||
status: formData.get("status"), // ✅ KIRIM STATUS BARU
|
||||
reason: formData.get("reason") || "Admin/Manager update",
|
||||
};
|
||||
|
||||
await ApiUtils.put(
|
||||
// ✅ GUNAKAN PUT
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${state.selectedItemToEdit.id}`,
|
||||
itemData // ✅ Kirim data langsung
|
||||
);
|
||||
|
||||
showToast("Barang berhasil diupdate!", "success");
|
||||
state.setShowEditItemModal(false);
|
||||
state.setSelectedItemToEdit(null);
|
||||
setPhotoPreview(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal update barang: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLostItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}?page=1&limit=1000`
|
||||
);
|
||||
|
||||
// Enhance with AI suggestions (optional - can be done by backend)
|
||||
const lostItemsWithSuggestions = await Promise.all(
|
||||
(response.data || []).map(async (lostItem) => {
|
||||
if (lostItem.status === "active") {
|
||||
// Get potential matches based on category and date
|
||||
const potentialMatches = state.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" &&
|
||||
item.category_id === lostItem.category_id &&
|
||||
Math.abs(
|
||||
new Date(item.date_found) - new Date(lostItem.date_lost)
|
||||
) <
|
||||
7 * 24 * 60 * 60 * 1000 // Within 7 days
|
||||
)
|
||||
.map((item) => ({
|
||||
item_id: item.id,
|
||||
item_name: item.name,
|
||||
location: item.location,
|
||||
date_found: item.date_found,
|
||||
match_score: calculateMatchScore(lostItem, item),
|
||||
}))
|
||||
.sort((a, b) => b.match_score - a.match_score)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
...lostItem,
|
||||
ai_suggestions: potentialMatches,
|
||||
};
|
||||
}
|
||||
return lostItem;
|
||||
})
|
||||
);
|
||||
|
||||
state.setLostItems(lostItemsWithSuggestions);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat laporan barang hilang", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Simple match score calculator
|
||||
const calculateMatchScore = (lostItem, foundItem) => {
|
||||
// Helper function untuk hitung skor teks sederhana
|
||||
const getKeywordScore = (sourceText, targetText, minWordLength = 3) => {
|
||||
if (!sourceText || !targetText) return 0;
|
||||
|
||||
const source = sourceText.toLowerCase();
|
||||
const target = targetText.toLowerCase();
|
||||
|
||||
// 1. Cocok Sempurna
|
||||
if (source === target) return 100;
|
||||
|
||||
// 2. Target mengandung source utuh
|
||||
if (target.includes(source)) return 95;
|
||||
|
||||
// 3. Keyword Matching
|
||||
const keywords = source
|
||||
.split(/[\s,.]+/)
|
||||
.filter((w) => w.length >= minWordLength);
|
||||
if (keywords.length === 0) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
keywords.forEach((word) => {
|
||||
if (target.includes(word)) {
|
||||
matchCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return (matchCount / keywords.length) * 100;
|
||||
};
|
||||
|
||||
// --- HITUNG SKOR ---
|
||||
|
||||
// 1. Skor Nama Barang (Bobot 50%)
|
||||
// Membandingkan Nama di laporan hilang vs Nama di barang temuan
|
||||
const nameScore = getKeywordScore(lostItem.name, foundItem.name, 2);
|
||||
|
||||
// 2. Skor Secret Details (Bobot 50%)
|
||||
// Membandingkan Deskripsi User vs Secret Details Admin
|
||||
const secretScore = getKeywordScore(
|
||||
lostItem.description,
|
||||
foundItem.secret_details,
|
||||
3
|
||||
);
|
||||
|
||||
// --- TOTAL SKOR ---
|
||||
// Menggabungkan kedua skor dengan rata-rata
|
||||
const totalScore = nameScore * 0.5 + secretScore * 0.5;
|
||||
|
||||
return Math.round(totalScore);
|
||||
};
|
||||
|
||||
const handleMatchLostItem = (lostItem) => {
|
||||
state.setSelectedLostItem(lostItem);
|
||||
state.setShowMatchLostItemModal(true);
|
||||
};
|
||||
|
||||
const submitMatchLostItem = async (lostItemId, foundItemId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Menghubungkan barang...", "info");
|
||||
|
||||
// Call backend API to match
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}/match`,
|
||||
{
|
||||
item_id: foundItemId,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Barang berhasil dihubungkan!", "success");
|
||||
state.setShowMatchLostItemModal(false);
|
||||
state.setSelectedLostItem(null);
|
||||
loadLostItems();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghubungkan barang: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLostItem = async (lostItemId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}`);
|
||||
showToast("Laporan barang hilang berhasil dihapus!", "success");
|
||||
loadLostItems();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghapus laporan: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Delete Item
|
||||
const handleDeleteItem = async (itemId) => {
|
||||
try {
|
||||
state.setLoading(true);
|
||||
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.ITEMS}/${itemId}`);
|
||||
showToast("Barang berhasil dihapus!", "success");
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghapus barang: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReportFound = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Memproses laporan...", "info");
|
||||
|
||||
// Upload photo if exists
|
||||
let photoUrl = "";
|
||||
const photoFile = formData.get("photo");
|
||||
|
||||
if (photoFile && photoFile.size > 0) {
|
||||
showToast("Mengupload foto...", "info");
|
||||
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("image", photoFile);
|
||||
|
||||
const uploadData = await ApiUtils.uploadFile(
|
||||
CONFIG.API_ENDPOINTS.UPLOAD,
|
||||
uploadFormData
|
||||
);
|
||||
photoUrl = uploadData.data.url;
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const dateFound = formData.get("date_found");
|
||||
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
|
||||
|
||||
const reporterUserIdRaw = formData.get("reporter_user_id");
|
||||
const reporterUserId = reporterUserIdRaw
|
||||
? parseInt(reporterUserIdRaw)
|
||||
: 0;
|
||||
|
||||
const itemData = {
|
||||
name: formData.get("name"),
|
||||
category_id: parseInt(formData.get("category_id")),
|
||||
photo_url: photoUrl,
|
||||
location: formData.get("location"),
|
||||
description: formData.get("description"),
|
||||
secret_details: formData.get("secret_details"),
|
||||
date_found: dateFoundISO,
|
||||
reporter_name: formData.get("reporter_name"),
|
||||
reporter_contact: formData.get("reporter_contact"),
|
||||
reporter_user_id: reporterUserId,
|
||||
manager_notes: formData.get("manager_notes") || "",
|
||||
};
|
||||
|
||||
await ApiUtils.post(CONFIG.API_ENDPOINTS.ITEMS, itemData);
|
||||
|
||||
showToast("Laporan berhasil disimpan!", "success");
|
||||
setShowReportFoundModal(false); // ✅ Now this works
|
||||
setPhotoPreview(null); // ✅ Now this works
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menyimpan laporan: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLostItemDetail = (item) => {
|
||||
state.setSelectedLostDetail(item);
|
||||
state.setShowLostDetailModal(true);
|
||||
};
|
||||
|
||||
const handleManualClaimClick = (item) => {
|
||||
state.setSelectedItemForClaim(item);
|
||||
state.setShowManualClaimModal(true);
|
||||
};
|
||||
|
||||
const submitManualClaim = async (formData) => {
|
||||
try {
|
||||
state.setLoading(true);
|
||||
showToast("Memproses klaim manual...", "info");
|
||||
|
||||
const payload = {
|
||||
item_id: parseInt(formData.item_id),
|
||||
user_id: formData.user_id ? parseInt(formData.user_id) : null,
|
||||
description: formData.description,
|
||||
contact: formData.contact,
|
||||
claimer_name: formData.claimer_name,
|
||||
manager_notes: formData.manager_notes || "",
|
||||
};
|
||||
|
||||
await ApiUtils.post(CONFIG.API_ENDPOINTS.CLAIMS, payload);
|
||||
|
||||
showToast("Klaim manual berhasil dicatat!", "success");
|
||||
state.setShowManualClaimModal(false);
|
||||
state.setSelectedItemForClaim(null);
|
||||
loadData();
|
||||
loadClaims();
|
||||
} catch (error) {
|
||||
showToast("Gagal mencatat klaim: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadClaims = async () => {
|
||||
try {
|
||||
const response = await ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS);
|
||||
setClaims(response.data || []);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat klaim", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (Helpers.showConfirm("Apakah Anda yakin ingin logout?")) {
|
||||
AuthUtils.clearAuth();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (item) => {
|
||||
try {
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
|
||||
);
|
||||
|
||||
let itemData = response.data || response;
|
||||
|
||||
const finalData = {
|
||||
...itemData,
|
||||
reporter_name: itemData.reporter_name || "Data tidak tersedia",
|
||||
reporter_contact: itemData.reporter_contact || "Data tidak tersedia",
|
||||
description: itemData.description || "Tidak ada deskripsi",
|
||||
secret_details:
|
||||
itemData.secret_details || "Tidak ada deskripsi rahasia",
|
||||
};
|
||||
|
||||
setSelectedItem(finalData);
|
||||
setShowDetailModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat detail: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyClaim = async (claim) => {
|
||||
try {
|
||||
const detailData = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}`
|
||||
);
|
||||
|
||||
if (!detailData || !detailData.data) {
|
||||
throw new Error("Invalid claim data");
|
||||
}
|
||||
|
||||
setSelectedClaim(detailData.data);
|
||||
setShowVerifyModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat klaim: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveClaim = () => {
|
||||
// Tutup modal verifikasi detail agar tidak tumpuk (opsional, tergantung preferensi UX)
|
||||
state.setShowVerifyModal(false);
|
||||
// Buka modal konfirmasi approve
|
||||
state.setShowApproveModal(true);
|
||||
};
|
||||
|
||||
// 2. Fungsi eksekusi: Melakukan request API (Dipanggil saat tombol "Ya, Approve" di modal diklik)
|
||||
const submitApproveClaim = async (notes) => {
|
||||
// Validasi sederhana
|
||||
if (!state.selectedClaim || !state.selectedClaim.id) {
|
||||
showToast("Data klaim tidak valid!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true); // Aktifkan loading state
|
||||
|
||||
// Request ke API
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${state.selectedClaim.id}/verify`,
|
||||
{
|
||||
status: "approved",
|
||||
notes: notes || "", // Menggunakan notes dari input Modal
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Klaim berhasil diapprove!", "success");
|
||||
|
||||
// Tutup modal dan refresh data
|
||||
state.setShowApproveModal(false);
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal approve klaim: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false); // Matikan loading state
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectClaim = async () => {
|
||||
if (!selectedClaim || !selectedClaim.id) {
|
||||
showToast("Data klaim tidak valid!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = prompt("Alasan penolakan (wajib):");
|
||||
if (!notes) {
|
||||
showToast("Alasan penolakan harus diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/verify`,
|
||||
{
|
||||
status: "rejected",
|
||||
notes,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Klaim berhasil ditolak!", "success");
|
||||
setShowVerifyModal(false);
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal reject klaim: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCase = async (claim) => {
|
||||
setSelectedClaim(claim);
|
||||
setShowCloseCaseModal(true);
|
||||
};
|
||||
|
||||
const submitCloseCase = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!closeCaseData.berita_acara_no) {
|
||||
showToast("No. Berita Acara wajib diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
let buktiUrl = closeCaseData.bukti_serah_terima;
|
||||
const buktiFile = document.querySelector('input[name="bukti_file"]')
|
||||
?.files[0];
|
||||
|
||||
if (buktiFile) {
|
||||
showToast("Mengupload bukti...", "info");
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("proof", buktiFile);
|
||||
|
||||
const uploadResponse = await ApiUtils.uploadFile(
|
||||
"/api/upload/claim-proof",
|
||||
uploadFormData
|
||||
);
|
||||
buktiUrl = uploadResponse.data.url;
|
||||
}
|
||||
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/close`,
|
||||
{
|
||||
berita_acara_no: closeCaseData.berita_acara_no,
|
||||
bukti_serah_terima: buktiUrl,
|
||||
notes: closeCaseData.notes,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Case berhasil ditutup!", "success");
|
||||
setShowCloseCaseModal(false);
|
||||
setCloseCaseData({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal close case: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelApproval = async (claimId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Kita panggil endpoint baru (perlu dibuat di backend)
|
||||
// Atau gunakan endpoint verify dengan status khusus jika backend mendukung
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claimId}/cancel-approval`,
|
||||
{}
|
||||
);
|
||||
|
||||
showToast(
|
||||
"Status Approved berhasil dibatalkan. Klaim kembali ke Pending.",
|
||||
"success"
|
||||
);
|
||||
loadClaims();
|
||||
loadData(); // Refresh stats
|
||||
} catch (error) {
|
||||
showToast("Gagal membatalkan approval: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReopenCase = async (claim) => {
|
||||
const reason = prompt("Alasan Reopen Case (WAJIB):");
|
||||
|
||||
if (!reason || reason.trim() === "") {
|
||||
showToast("Alasan reopen wajib diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`Yakin reopen case untuk "${claim.item_name}"?\n\nAlasan: ${reason}`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await ApiUtils.post(`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}/reopen`, {
|
||||
reason,
|
||||
});
|
||||
|
||||
showToast("Case berhasil dibuka kembali!", "success");
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal reopen case: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showToast,
|
||||
loadData,
|
||||
loadClaims,
|
||||
handleLogout,
|
||||
handleViewDetail,
|
||||
handleVerifyClaim,
|
||||
handleApproveClaim,
|
||||
handleRejectClaim,
|
||||
handleCloseCase,
|
||||
submitCloseCase,
|
||||
handleReopenCase,
|
||||
handlePhotoChange, // ✅ Pastikan ini ada
|
||||
submitReportFound,
|
||||
// ✅ REMOVED: loadAuditLogs (not needed in manager)
|
||||
handleManualClaimClick,
|
||||
submitManualClaim,
|
||||
handleCancelApproval,
|
||||
handleEditItemClick,
|
||||
handleUpdateItem,
|
||||
handleDeleteItem,
|
||||
loadLostItems,
|
||||
handleMatchLostItem,
|
||||
submitMatchLostItem,
|
||||
handleDeleteLostItem,
|
||||
handleViewLostItemDetail,
|
||||
handleApproveClaim, // Fungsi pemicu (dihubungkan ke tombol di VerifyModal)
|
||||
submitApproveClaim, // Fungsi submit (dihubungkan ke form di ApproveClaimModal)
|
||||
};
|
||||
};
|
||||
|
||||
window.useManagerHandlers = useManagerHandlers;
|
||||
@@ -0,0 +1,100 @@
|
||||
const { useState } = React;
|
||||
|
||||
const useManagerState = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("items");
|
||||
const [stats, setStats] = useState({
|
||||
total_items: 0,
|
||||
pending_claims: 0,
|
||||
verified: 0,
|
||||
expired: 0,
|
||||
});
|
||||
const [items, setItems] = useState([]);
|
||||
const [claims, setClaims] = useState([]);
|
||||
const [expiredItems, setExpiredItems] = useState([]);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [selectedClaim, setSelectedClaim] = useState(null);
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCloseCaseModal, setShowCloseCaseModal] = useState(false);
|
||||
const [closeCaseData, setCloseCaseData] = useState({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
const [showReportFoundModal, setShowReportFoundModal] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showManualClaimModal, setShowManualClaimModal] = useState(false);
|
||||
const [selectedItemForClaim, setSelectedItemForClaim] = useState(null);
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showEditItemModal, setShowEditItemModal] = useState(false);
|
||||
const [selectedItemToEdit, setSelectedItemToEdit] = useState(null);
|
||||
const [lostItems, setLostItems] = useState([]);
|
||||
const [showMatchLostItemModal, setShowMatchLostItemModal] = useState(false);
|
||||
const [selectedLostItem, setSelectedLostItem] = useState(null);
|
||||
const [showLostDetailModal, setShowLostDetailModal] = useState(false);
|
||||
const [selectedLostDetail, setSelectedLostDetail] = useState(null);
|
||||
|
||||
return {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
setStats,
|
||||
items,
|
||||
setItems,
|
||||
claims,
|
||||
setClaims,
|
||||
expiredItems,
|
||||
setExpiredItems,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
showDetailModal,
|
||||
setShowDetailModal,
|
||||
selectedClaim,
|
||||
setSelectedClaim,
|
||||
showVerifyModal,
|
||||
setShowVerifyModal,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
setLoading,
|
||||
showCloseCaseModal,
|
||||
setShowCloseCaseModal,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
showReportFoundModal,
|
||||
setShowReportFoundModal,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
categories,
|
||||
setCategories,
|
||||
showManualClaimModal,
|
||||
setShowManualClaimModal,
|
||||
selectedItemForClaim,
|
||||
setSelectedItemForClaim,
|
||||
showApproveModal,
|
||||
setShowApproveModal,
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
lostItems,
|
||||
setLostItems,
|
||||
showMatchLostItemModal,
|
||||
setShowMatchLostItemModal,
|
||||
selectedLostItem,
|
||||
setSelectedLostItem,
|
||||
showLostDetailModal,
|
||||
setShowLostDetailModal,
|
||||
selectedLostDetail,
|
||||
setSelectedLostDetail,
|
||||
};
|
||||
};
|
||||
|
||||
// Export ke global scope
|
||||
window.useManagerState = useManagerState;
|
||||
Reference in New Issue
Block a user