Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1
2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions
+239
View File
@@ -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;
+469
View File
@@ -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;
+108
View File
@@ -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;
+677
View File
@@ -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;
+100
View File
@@ -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;