Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
// assets/js/pages/admin/AdminApp.js - WITH CATEGORIES TAB
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const AdminApp = () => {
|
||||
const state = useAdminState();
|
||||
const handlers = useAdminHandlers(state);
|
||||
|
||||
const {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
users,
|
||||
setFilteredUsers,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
selectedUser,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
showItemDetailModal,
|
||||
setShowItemDetailModal,
|
||||
selectedItemDetail,
|
||||
showClaimDetailModal,
|
||||
setShowClaimDetailModal,
|
||||
selectedClaim,
|
||||
showArchiveDetailModal,
|
||||
setShowArchiveDetailModal,
|
||||
selectedArchive,
|
||||
} = state;
|
||||
|
||||
const { loadData, loadItems, showToast, handleUpdateUser } = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthUtils.checkAuthAndRedirect("admin")) return;
|
||||
const currentUser = AuthUtils.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
loadData();
|
||||
handlers.loadCategories(); // ✅ Load categories on mount
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadData(); // Ini memuat Users menggunakan currentPage (sudah benar di handler)
|
||||
loadItems();
|
||||
handlers.loadClaims();
|
||||
handlers.loadArchives();
|
||||
|
||||
// ✅ PERBAIKAN: Kirim currentPage agar tidak reset ke 1
|
||||
handlers.loadAuditLogs(currentPage);
|
||||
}
|
||||
}, [user, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 1) {
|
||||
state.setCurrentPage(1);
|
||||
} else {
|
||||
filterUsers();
|
||||
}
|
||||
}, [searchTerm, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
filterUsers();
|
||||
}, [users]);
|
||||
|
||||
useEffect(() => {
|
||||
filterItemsData();
|
||||
}, [
|
||||
state.itemSearchTerm,
|
||||
state.itemStatusFilter,
|
||||
state.itemCategoryFilter,
|
||||
state.items,
|
||||
]);
|
||||
|
||||
const filterUsers = () => {
|
||||
let filtered = users.filter((u) => {
|
||||
const matchesSearch =
|
||||
u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.nrp.includes(searchTerm);
|
||||
const matchesRole = !roleFilter || u.role === roleFilter;
|
||||
const matchesStatus =
|
||||
!statusFilter || (u.status || "active") === statusFilter;
|
||||
return matchesSearch && matchesRole && matchesStatus;
|
||||
});
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const filterItemsData = () => {
|
||||
let filtered = state.items.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.name.toLowerCase().includes(state.itemSearchTerm.toLowerCase()) ||
|
||||
item.location
|
||||
.toLowerCase()
|
||||
.includes(state.itemSearchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
!state.itemStatusFilter || item.status === state.itemStatusFilter;
|
||||
const matchesCategory =
|
||||
!state.itemCategoryFilter ||
|
||||
Helpers.getCategoryValue(item.category_id) === state.itemCategoryFilter;
|
||||
return matchesSearch && matchesStatus && matchesCategory;
|
||||
});
|
||||
state.setFilteredItems(filtered);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
|
||||
<Navbar user={user} onLogout={handlers.handleLogout} userType="admin" />
|
||||
|
||||
<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 Admin
|
||||
</h1>
|
||||
<p className="text-slate-400">Kelola sistem Lost & Found</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Total User"
|
||||
value={stats.users?.total || 0}
|
||||
icon="👥"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Barang"
|
||||
value={stats.items?.total || 0}
|
||||
icon="📦"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Klaim"
|
||||
value={stats.claims?.total || 0}
|
||||
icon="🤝"
|
||||
/>
|
||||
<StatCard
|
||||
title="Kategori"
|
||||
value={state.categories?.length || 0}
|
||||
icon="🏷️"
|
||||
colorClass="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Di Arsip"
|
||||
value={stats.archives?.total || 0}
|
||||
icon="📂"
|
||||
colorClass="text-green-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Log"
|
||||
value={stats.audit_logs?.total || 0}
|
||||
icon="📜"
|
||||
colorClass="text-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveTab("users")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "users"
|
||||
? "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 User
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("roles");
|
||||
handlers.loadRolesAndPermissions();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "roles"
|
||||
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🔑 Role & Akses
|
||||
</button>
|
||||
|
||||
<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("lostitems")}
|
||||
className={`px-6 py-3 rounded-t-xl font-semibold transition ${
|
||||
activeTab === "lostitems"
|
||||
? "bg-gradient-to-br from-slate-800 to-slate-900 text-white border-t-2 border-x-2 border-slate-700"
|
||||
: "bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
🔍 Laporan 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"
|
||||
}`}
|
||||
>
|
||||
🤝 Kelola Klaim
|
||||
</button>
|
||||
|
||||
{/* ✅ NEW: Categories Tab */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("categories");
|
||||
handlers.loadCategories();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "categories"
|
||||
? "bg-gradient-to-r from-yellow-600 to-yellow-700 text-white shadow-lg shadow-yellow-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🏷️ Kelola Kategori
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("archives");
|
||||
handlers.loadArchives();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "archives"
|
||||
? "bg-gradient-to-r from-green-600 to-green-700 text-white shadow-lg shadow-green-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📂 Kelola Arsip
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("audit-log");
|
||||
handlers.loadAuditLogs();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "audit-log"
|
||||
? "bg-gradient-to-r from-purple-600 to-purple-700 text-white shadow-lg shadow-purple-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📜 Audit Log
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("reports")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "reports"
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
📊 Laporan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "users" && window.UsersTab && (
|
||||
<UsersTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "roles" && window.RolesTab && (
|
||||
<RolesTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "items" && window.ItemsTab && (
|
||||
<ItemsTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "lostitems" && window.LostItemsTabAdmin && (
|
||||
<>
|
||||
<LostItemsTabAdmin state={state} handlers={handlers} />
|
||||
{window.CreateLostItemModal && (
|
||||
<CreateLostItemModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.EditLostItemModal && (
|
||||
<EditLostItemModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.LostItemDetailModal && (
|
||||
<LostItemDetailModal state={state} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeTab === "claims" && window.ClaimsTabAdmin && (
|
||||
<ClaimsTabAdmin state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "categories" && window.CategoriesTab && (
|
||||
<CategoriesTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "archives" && window.ArchivesTabAdmin && (
|
||||
<ArchivesTabAdmin state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "audit-log" && window.AuditLogTab && (
|
||||
<AuditLogTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "reports" && window.ReportsTab && (
|
||||
<ReportsTab handlers={handlers} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{window.ClaimDetailModalAdmin && (
|
||||
<ClaimDetailModalAdmin
|
||||
isOpen={showClaimDetailModal}
|
||||
onClose={() => setShowClaimDetailModal(false)}
|
||||
claim={selectedClaim}
|
||||
/>
|
||||
)}
|
||||
{window.ArchiveDetailModal && (
|
||||
<ArchiveDetailModal
|
||||
isOpen={showArchiveDetailModal}
|
||||
onClose={() => setShowArchiveDetailModal(false)}
|
||||
archive={selectedArchive}
|
||||
/>
|
||||
)}
|
||||
{window.RoleModal && (
|
||||
<>
|
||||
<RoleModal
|
||||
isOpen={state.showCreateRoleModal}
|
||||
onClose={() => state.setShowCreateRoleModal(false)}
|
||||
allPermissions={state.permissions}
|
||||
onSubmit={handlers.handleCreateRole}
|
||||
loading={loading}
|
||||
/>
|
||||
<RoleModal
|
||||
isOpen={state.showEditRoleModal}
|
||||
onClose={() => {
|
||||
state.setShowEditRoleModal(false);
|
||||
state.setSelectedRole(null);
|
||||
}}
|
||||
role={state.selectedRole}
|
||||
allPermissions={state.permissions}
|
||||
onSubmit={handlers.handleUpdateRole}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{window.CreateClaimModal && (
|
||||
<CreateClaimModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.EditClaimModal && (
|
||||
<EditClaimModal state={state} handlers={handlers} />
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title="Edit User"
|
||||
>
|
||||
{selectedUser && (
|
||||
<form onSubmit={handleUpdateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedUser.name}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={selectedUser.email}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selectedUser.role}
|
||||
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="user">User</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</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 disabled:from-slate-600 disabled:to-slate-700 shadow-lg"
|
||||
>
|
||||
{loading ? "Menyimpan..." : "Update User"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Item Detail Modal */}
|
||||
<Modal
|
||||
isOpen={showItemDetailModal}
|
||||
onClose={() => setShowItemDetailModal(false)}
|
||||
title="Detail Barang"
|
||||
>
|
||||
{selectedItemDetail && (
|
||||
<div>
|
||||
<img
|
||||
src={
|
||||
selectedItemDetail.photo_url ||
|
||||
"https://via.placeholder.com/600x400"
|
||||
}
|
||||
alt={selectedItemDetail.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">
|
||||
{selectedItemDetail.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">
|
||||
{selectedItemDetail.category ||
|
||||
Helpers.getCategoryName(selectedItemDetail.category_id)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Lokasi:</strong>
|
||||
<span className="ml-2">{selectedItemDetail.location}</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(selectedItemDetail.date_found)}
|
||||
</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(
|
||||
selectedItemDetail.status
|
||||
)}`}
|
||||
>
|
||||
{selectedItemDetail.status}
|
||||
</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">
|
||||
{selectedItemDetail.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">
|
||||
{selectedItemDetail.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">
|
||||
{selectedItemDetail.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">
|
||||
{selectedItemDetail.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>
|
||||
{selectedItemDetail.case_closed_at && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
{selectedItemDetail.berita_acara_no && (
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{selectedItemDetail.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Ditutup:{" "}
|
||||
{Helpers.formatDateTime(
|
||||
selectedItemDetail.case_closed_at
|
||||
)}
|
||||
</div>
|
||||
{selectedItemDetail.case_closed_by_name && (
|
||||
<div>Oleh: {selectedItemDetail.case_closed_by_name}</div>
|
||||
)}
|
||||
{selectedItemDetail.case_closed_notes && (
|
||||
<div className="mt-2 pt-2 border-t border-green-500/30">
|
||||
Catatan: {selectedItemDetail.case_closed_notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedItemDetail.bukti_serah_terima && (
|
||||
<a
|
||||
href={selectedItemDetail.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Chatbot */}
|
||||
<AIChatbot />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Render
|
||||
ReactDOM.render(<AdminApp />, document.getElementById("root"));
|
||||
@@ -0,0 +1,187 @@
|
||||
// assets/js/pages/admin/modals/ArchiveDetailModal.js
|
||||
|
||||
const ArchiveDetailModal = ({ isOpen, onClose, archive }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Arsip" size="large">
|
||||
{archive && (
|
||||
<div>
|
||||
{/* Image */}
|
||||
<img
|
||||
src={
|
||||
archive.photo_url ||
|
||||
"https://via.placeholder.com/600x400?text=No+Image"
|
||||
}
|
||||
alt={archive.name}
|
||||
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src = "https://via.placeholder.com/600x400?text=No+Image")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">{archive.name}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-green-400">Status:</strong>{" "}
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 border border-green-500/50">
|
||||
✅ CASE CLOSED
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Kategori:</strong>{" "}
|
||||
{archive.category || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Lokasi Ditemukan:</strong>{" "}
|
||||
{archive.location}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Tanggal Ditemukan:</strong>{" "}
|
||||
{Helpers.formatDate(archive.date_found)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Sections */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">📝</span>
|
||||
<strong className="text-blue-400">Deskripsi Umum</strong>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{archive.description || "Tidak ada deskripsi"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🔒</span>
|
||||
<strong className="text-yellow-400">Ciri Khusus Rahasia</strong>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{archive.secret_details || "Tidak ada deskripsi rahasia"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reporter Info */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600 mb-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Informasi Pelapor:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Nama:</strong>{" "}
|
||||
{archive.reporter_name || "Tidak ada data"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Kontak:</strong>{" "}
|
||||
{archive.reporter_contact || "Tidak ada data"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case Closed Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-3xl">📋</span>
|
||||
<strong className="text-green-400 text-lg">
|
||||
Informasi Penutupan Case
|
||||
</strong>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<div>
|
||||
No. Berita Acara:{" "}
|
||||
<strong className="text-white text-lg">
|
||||
{archive.berita_acara_no || "-"}
|
||||
</strong>
|
||||
</div>
|
||||
{archive.case_closed_at && (
|
||||
<div>
|
||||
Tanggal Ditutup:{" "}
|
||||
<strong>
|
||||
{Helpers.formatDateTime(archive.case_closed_at)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_by_name && (
|
||||
<div>
|
||||
Ditutup Oleh: <strong>{archive.case_closed_by_name}</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.claimer_name && (
|
||||
<div>
|
||||
Diterima Oleh (Penerima):{" "}
|
||||
<strong className="text-green-400">
|
||||
{archive.claimer_name}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.claimer_contact && (
|
||||
<div>
|
||||
Kontak Penerima: <strong>{archive.claimer_contact}</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_notes && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/30">
|
||||
<strong className="block mb-1">Catatan Penutupan:</strong>
|
||||
<p className="text-slate-300 bg-slate-900/50 p-3 rounded-lg">
|
||||
{archive.case_closed_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{archive.bukti_serah_terima && (
|
||||
<a
|
||||
href={archive.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition shadow-lg"
|
||||
>
|
||||
📄 Lihat Bukti Serah Terima
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600">
|
||||
<strong className="text-slate-300 block mb-3">Timeline:</strong>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-blue-400 mt-1">📅</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Ditemukan:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.date_found)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{archive.created_at && (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-blue-400 mt-1">📝</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Dilaporkan:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_at && (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-green-400 mt-1">✅</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Ditutup:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.case_closed_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
// assets/js/pages/admin/modals/ClaimDetailModalAdmin.js
|
||||
|
||||
const ClaimDetailModalAdmin = ({ isOpen, onClose, claim }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Klaim" size="large">
|
||||
{claim && (
|
||||
<div>
|
||||
{/* Header Info */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
{claim.item_name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Pengklaim:</strong>{" "}
|
||||
{claim.user_name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Kontak:</strong> {claim.contact}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Status:</strong>{" "}
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
claim.status
|
||||
)}`}
|
||||
>
|
||||
{claim.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Tanggal:</strong>{" "}
|
||||
{Helpers.formatDateTime(claim.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Section */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<h4 className="font-semibold mb-3 text-yellow-400 flex items-center gap-2">
|
||||
<span>🔒</span>
|
||||
Deskripsi Rahasia Barang
|
||||
</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-yellow-500/30">
|
||||
<span className="text-xs text-slate-500 uppercase">
|
||||
Deskripsi Umum:
|
||||
</span>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{claim.item_description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 p-4 rounded-xl border border-blue-500/30">
|
||||
<h4 className="font-semibold mb-3 text-blue-400 flex items-center gap-2">
|
||||
<span>💬</span>
|
||||
Deskripsi dari Pengklaim
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300">{claim.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Percentage */}
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`p-6 rounded-xl text-center mb-6 ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border-2 border-green-500/30"
|
||||
: "bg-yellow-500/10 border-2 border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-slate-300">Similarity Match:</strong>
|
||||
<div
|
||||
className={`text-5xl font-bold mt-2 ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Info */}
|
||||
{claim.verified_at && (
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-700 mb-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Informasi Verifikasi:
|
||||
</strong>
|
||||
<div className="space-y-1 text-sm text-slate-400">
|
||||
<div>
|
||||
✅ Diverifikasi: {Helpers.formatDateTime(claim.verified_at)}
|
||||
</div>
|
||||
{claim.verified_by_name && (
|
||||
<div>👤 Oleh: {claim.verified_by_name}</div>
|
||||
)}
|
||||
{claim.notes && (
|
||||
<div className="mt-2 pt-2 border-t border-slate-700">
|
||||
<strong className="text-slate-300">Catatan:</strong>
|
||||
<p className="text-slate-400 mt-1">{claim.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
{claim.berita_acara_no && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-3xl">📋</span>
|
||||
<strong className="text-green-400 text-lg">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white text-lg">
|
||||
{claim.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
{claim.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(claim.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.case_closed_by_name && (
|
||||
<div>Oleh: {claim.case_closed_by_name}</div>
|
||||
)}
|
||||
{claim.case_closed_notes && (
|
||||
<div className="mt-2 pt-2 border-t border-green-500/30">
|
||||
<strong>Catatan:</strong>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{claim.case_closed_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{claim.bukti_serah_terima && (
|
||||
<a
|
||||
href={claim.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition shadow-lg"
|
||||
>
|
||||
📄 Lihat Bukti Serah Terima
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proof Image */}
|
||||
{claim.proof_url && (
|
||||
<div className="mt-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Bukti Pendukung:
|
||||
</strong>
|
||||
<img
|
||||
src={claim.proof_url}
|
||||
alt="Bukti Klaim"
|
||||
className="w-full h-64 object-cover rounded-xl border-2 border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
// assets/js/pages/admin/modals/CreateClaimModal.js
|
||||
|
||||
const CreateClaimModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
items,
|
||||
users,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleCreateClaim } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateClaimModal}
|
||||
onClose={() => setShowCreateClaimModal(false)}
|
||||
title="➕ Tambah Klaim Manual"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateClaim} className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg mb-4">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ Fitur ini untuk menambahkan klaim manual oleh admin, misalnya
|
||||
jika user melaporkan secara offline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih Barang *
|
||||
</label>
|
||||
<select
|
||||
name="item_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 Barang --</option>
|
||||
{items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" || item.status === "pending_claim"
|
||||
)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} - {item.location} (
|
||||
{Helpers.formatDate(item.date_found)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User Pengklaim *
|
||||
</label>
|
||||
<select
|
||||
name="user_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 User --</option>
|
||||
{users &&
|
||||
users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
User yang akan menjadi pengklaim barang ini
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
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="Jelaskan ciri-ciri yang diklaim oleh user..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Kontak *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="contact"
|
||||
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="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Admin (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="admin_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 untuk admin..."
|
||||
/>
|
||||
</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..." : "✅ Simpan Klaim"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
// assets/js/pages/admin/modals/CreateItemModal.js
|
||||
|
||||
const CreateItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
photoPreview,
|
||||
loading,
|
||||
categories,
|
||||
} = state;
|
||||
const { handleCreateItem, handlePhotoChange } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateItemModal}
|
||||
onClose={() => {
|
||||
setShowCreateItemModal(false);
|
||||
state.setPhotoPreview(null);
|
||||
}}
|
||||
title="➕ Tambah Barang Baru"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateItem} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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">
|
||||
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>
|
||||
|
||||
{/* UBAH BAGIAN INI */}
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option disabled>Memuat kategori...</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Foto Barang
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
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 && (
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 w-full h-48 object-cover rounded-xl border-2 border-blue-500"
|
||||
/>
|
||||
)}
|
||||
</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..."
|
||||
/>
|
||||
</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="Ciri khusus untuk verifikasi klaim..."
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Info ini RAHASIA untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
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="08123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_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="Nama lengkap pelapor"
|
||||
/>
|
||||
</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..." : "✅ Simpan Barang"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
const CreateLostItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
categories,
|
||||
users,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const { handleCreateLostItem } = handlers;
|
||||
|
||||
if (!showCreateLostItemModal) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateLostItemModal}
|
||||
onClose={() => setShowCreateLostItemModal(false)}
|
||||
title="➕ Tambah Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateLostItem} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
User ID (Pelapor) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="user_id"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih User</option>
|
||||
{users &&
|
||||
users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} (#{user.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih user yang melaporkan kehilangan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Nama Barang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: HP Samsung Galaxy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Kategori <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Warna
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Hitam, Biru"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Tanggal Hilang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_lost"
|
||||
required
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Lokasi Hilang
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
className="w-full px-4 py-2 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 className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Deskripsi <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-2 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-ciri barang yang hilang secara detail..."
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Deskripsi ini akan digunakan untuk auto-matching dengan barang
|
||||
yang ditemukan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ <strong>Info:</strong> Setelah laporan dibuat, sistem akan
|
||||
otomatis mencari kecocokan dengan barang yang telah ditemukan. User
|
||||
akan mendapat notifikasi jika ada barang yang cocok.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateLostItemModal(false)}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Memproses..." : "➕ Tambah Laporan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// assets/js/pages/admin/modals/EditClaimModal.js
|
||||
|
||||
const EditClaimModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
selectedClaimToEdit,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleUpdateClaim } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditClaimModal}
|
||||
onClose={() => setShowEditClaimModal(false)}
|
||||
title="✏️ Edit Klaim"
|
||||
size="large"
|
||||
>
|
||||
{selectedClaimToEdit && (
|
||||
<form onSubmit={handleUpdateClaim} className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Barang:</strong>{" "}
|
||||
{selectedClaimToEdit.item_name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Pengklaim:</strong>{" "}
|
||||
{selectedClaimToEdit.user_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedClaimToEdit.description}
|
||||
required
|
||||
rows="4"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Kontak *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="contact"
|
||||
defaultValue={selectedClaimToEdit.contact}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Alasan Edit *
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
required
|
||||
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="Jelaskan alasan edit klaim..."
|
||||
/>
|
||||
</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 ? "⏳ Menyimpan..." : "✅ Update Klaim"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
// assets/js/pages/admin/modals/EditItemModal.js
|
||||
|
||||
const EditItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
loading,
|
||||
photoPreview,
|
||||
setPhotoPreview, // <--- Pastikan mengambil setPhotoPreview dari state
|
||||
} = state;
|
||||
const { handleUpdateItem, handlePhotoChange } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditItemModal}
|
||||
onClose={() => {
|
||||
setShowEditItemModal(false);
|
||||
setSelectedItemToEdit(null);
|
||||
setPhotoPreview(null);
|
||||
}}
|
||||
title="✏️ Edit Barang"
|
||||
size="large"
|
||||
>
|
||||
{selectedItemToEdit && (
|
||||
<form onSubmit={handleUpdateItem} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Barang *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={selectedItemToEdit.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kategori *
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
defaultValue={selectedItemToEdit.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"
|
||||
>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ BAGIAN BARU: STATUS DROPDOWN */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Status Barang *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={selectedItemToEdit.status}
|
||||
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="unclaimed">Unclaimed (Aktif/Belum Diklaim)</option>
|
||||
<option value="pending_claim">
|
||||
Pending Claim (Sedang Diverifikasi)
|
||||
</option>
|
||||
<option value="verified">Verified (Terverifikasi)</option>
|
||||
<option value="expired">Expired (Kadaluarsa)</option>
|
||||
<option value="case_closed">
|
||||
Case Closed (Selesai/Diarsipkan)
|
||||
</option>
|
||||
</select>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
ℹ️ Ubah status ke <strong>Unclaimed</strong> jika ingin
|
||||
mengaktifkan kembali barang Expired agar bisa diklaim user.
|
||||
</p>
|
||||
</div>
|
||||
{/* ✅ AKHIR BAGIAN BARU */}
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Update Foto Barang (Opsional)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
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"
|
||||
/>
|
||||
{/* Tampilkan preview foto baru ATAU foto lama */}
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-slate-400 mb-1">Preview:</p>
|
||||
<img
|
||||
src={photoPreview || selectedItemToEdit.photo_url}
|
||||
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">
|
||||
Lokasi *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={selectedItemToEdit.location}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Umum *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedItemToEdit.description}
|
||||
required
|
||||
rows="3"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Ciri Khusus Rahasia * 🔒
|
||||
</label>
|
||||
<textarea
|
||||
name="secret_details"
|
||||
defaultValue={selectedItemToEdit.secret_details}
|
||||
required
|
||||
rows="3"
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tanggal Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_found"
|
||||
defaultValue={selectedItemToEdit.date_found?.split("T")[0]}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
defaultValue={selectedItemToEdit.reporter_contact}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_name"
|
||||
defaultValue={selectedItemToEdit.reporter_name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Alasan Edit *
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
required
|
||||
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="Jelaskan alasan edit..."
|
||||
/>
|
||||
</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 ? "⏳ Menyimpan..." : "✅ Update Barang"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
window.EditItemModal = EditItemModal;
|
||||
@@ -0,0 +1,178 @@
|
||||
const EditLostItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
selectedLostItemToEdit,
|
||||
showEditLostItemModal,
|
||||
setShowEditLostItemModal,
|
||||
categories,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleUpdateLostItem } = handlers;
|
||||
|
||||
// ✅ DEBUG: Log data saat modal dibuka
|
||||
React.useEffect(() => {
|
||||
if (selectedLostItemToEdit) {
|
||||
console.log("📋 Selected Lost Item Data:", selectedLostItemToEdit);
|
||||
console.log("🏷️ Category ID:", selectedLostItemToEdit.category_id);
|
||||
console.log("📦 Available Categories:", categories);
|
||||
}
|
||||
}, [selectedLostItemToEdit, categories]);
|
||||
|
||||
if (!showEditLostItemModal || !selectedLostItemToEdit) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditLostItemModal}
|
||||
onClose={() => setShowEditLostItemModal(false)}
|
||||
title="✏️ Edit Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleUpdateLostItem} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Nama Barang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={selectedLostItemToEdit.name}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: HP Samsung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Kategori <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{/* ✅ PERBAIKAN: Gunakan category_id, bukan category */}
|
||||
<select
|
||||
name="category_id"
|
||||
defaultValue={selectedLostItemToEdit.category_id || ""}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
onChange={(e) => {
|
||||
console.log("🔄 Category changed to:", e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* ✅ DEBUGGING: Tampilkan nilai saat ini */}
|
||||
<small className="text-slate-400 text-xs mt-1 block">
|
||||
Current: {selectedLostItemToEdit.category} (ID:{" "}
|
||||
{selectedLostItemToEdit.category_id})
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Warna
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
defaultValue={selectedLostItemToEdit.color || ""}
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Hitam, Biru"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Tanggal Hilang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_lost"
|
||||
defaultValue={selectedLostItemToEdit.date_lost?.split("T")[0]}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Lokasi Hilang
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={selectedLostItemToEdit.location || ""}
|
||||
className="w-full px-4 py-2 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 className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Deskripsi <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedLostItemToEdit.description}
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-2 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-ciri barang yang hilang..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={selectedLostItemToEdit.status}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_verification">Pending Verification</option>
|
||||
<option value="claimed">Claimed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Alasan Update
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
rows="2"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Alasan melakukan update (opsional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditLostItemModal(false)}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Memproses..." : "💾 Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
const LostItemDetailModal = ({ state }) => {
|
||||
const {
|
||||
selectedLostItemDetail,
|
||||
showLostItemDetailModal,
|
||||
setShowLostItemDetailModal,
|
||||
} = state;
|
||||
|
||||
if (!showLostItemDetailModal || !selectedLostItemDetail) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showLostItemDetailModal}
|
||||
onClose={() => setShowLostItemDetailModal(false)}
|
||||
title="📋 Detail Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Info Barang */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
🔍 Informasi Barang
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">ID Laporan</label>
|
||||
<p className="text-white font-semibold">
|
||||
#{selectedLostItemDetail.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Nama Barang</label>
|
||||
<p className="text-white font-semibold">
|
||||
{selectedLostItemDetail.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Kategori</label>
|
||||
<p className="text-white">{selectedLostItemDetail.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Warna</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.color || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Lokasi Hilang</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.location || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Tanggal Hilang</label>
|
||||
<p className="text-white">
|
||||
{Helpers.formatDate(selectedLostItemDetail.date_lost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Status</label>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
selectedLostItemDetail.status
|
||||
)}`}
|
||||
>
|
||||
{selectedLostItemDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Dibuat</label>
|
||||
<p className="text-white">
|
||||
{Helpers.formatDateTime(selectedLostItemDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-3">
|
||||
📝 Deskripsi
|
||||
</h4>
|
||||
<p className="text-slate-300 whitespace-pre-wrap">
|
||||
{selectedLostItemDetail.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Pelapor */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
👤 Informasi Pelapor
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">User ID</label>
|
||||
<p className="text-white">#{selectedLostItemDetail.user_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Nama</label>
|
||||
<p className="text-white font-semibold">
|
||||
{selectedLostItemDetail.user_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direct Claim Info (jika ada) */}
|
||||
{selectedLostItemDetail.direct_claim && (
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/50 rounded-xl p-6">
|
||||
<h4 className="text-lg font-semibold text-yellow-400 mb-4 flex items-center gap-2">
|
||||
🎯 Direct Claim
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Claim ID</label>
|
||||
<p className="text-white">
|
||||
#{selectedLostItemDetail.direct_claim.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Status Claim</label>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
selectedLostItemDetail.direct_claim.status
|
||||
)}`}
|
||||
>
|
||||
{selectedLostItemDetail.direct_claim.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Penemu</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.direct_claim.user_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Kontak</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.direct_claim.contact}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-slate-400 text-sm">
|
||||
Deskripsi Claim
|
||||
</label>
|
||||
<p className="text-white mt-1">
|
||||
{selectedLostItemDetail.direct_claim.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
const RoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
role,
|
||||
allPermissions,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => {
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: "",
|
||||
description: "",
|
||||
permission_ids: [],
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (role) {
|
||||
// Edit Mode
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permission_ids: role.permissions
|
||||
? role.permissions.map((p) => p.id)
|
||||
: [],
|
||||
});
|
||||
} else {
|
||||
// Create Mode
|
||||
setFormData({ name: "", description: "", permission_ids: [] });
|
||||
}
|
||||
}, [role, isOpen]);
|
||||
|
||||
const handlePermissionToggle = (permId) => {
|
||||
setFormData((prev) => {
|
||||
const exists = prev.permission_ids.includes(permId);
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
permission_ids: prev.permission_ids.filter((id) => id !== permId),
|
||||
};
|
||||
} else {
|
||||
return { ...prev, permission_ids: [...prev.permission_ids, permId] };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={role ? "✏️ Edit Role" : "➕ Buat Role Baru"}
|
||||
size="large"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Role
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
disabled={role && (role.name === "admin" || role.name === "user")}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
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"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Permissions (Hak Akses)
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-60 overflow-y-auto bg-slate-900/50 p-4 rounded-xl border border-slate-700">
|
||||
{allPermissions.map((perm) => (
|
||||
<label
|
||||
key={perm.id}
|
||||
className="flex items-start gap-2 cursor-pointer p-2 hover:bg-slate-700 rounded transition"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={formData.permission_ids.includes(perm.id)}
|
||||
onChange={() => handlePermissionToggle(perm.id)}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{perm.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{perm.slug}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
{loading ? "Menyimpan..." : "Simpan Role"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
window.RoleModal = RoleModal;
|
||||
@@ -0,0 +1,240 @@
|
||||
// assets/js/pages/admin/tabs/ArchivesTabAdmin.js
|
||||
|
||||
const ArchivesTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
archives,
|
||||
filteredArchives,
|
||||
archiveSearchTerm,
|
||||
setArchiveSearchTerm,
|
||||
archiveCategoryFilter,
|
||||
setArchiveCategoryFilter,
|
||||
} = state;
|
||||
|
||||
const { handleViewArchiveDetail } = handlers;
|
||||
|
||||
// Filter archives locally
|
||||
React.useEffect(() => {
|
||||
let filtered = archives.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.name.toLowerCase().includes(archiveSearchTerm.toLowerCase()) ||
|
||||
item.location.toLowerCase().includes(archiveSearchTerm.toLowerCase()) ||
|
||||
(item.berita_acara_no &&
|
||||
item.berita_acara_no
|
||||
.toLowerCase()
|
||||
.includes(archiveSearchTerm.toLowerCase()));
|
||||
const matchesCategory =
|
||||
!archiveCategoryFilter ||
|
||||
Helpers.getCategoryValue(item.category_id) === archiveCategoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
state.setFilteredArchives(filtered);
|
||||
}, [archiveSearchTerm, archiveCategoryFilter, archives]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
📂 Kelola Arsip (Case Closed)
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handlers.loadArchives()}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari arsip (nama, lokasi, no. BA)..."
|
||||
value={archiveSearchTerm}
|
||||
onChange={(e) => setArchiveSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={archiveCategoryFilter}
|
||||
onChange={(e) => setArchiveCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{archives.length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Total Arsip</div>
|
||||
</div>
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-blue-400 font-bold text-2xl">
|
||||
{
|
||||
archives.filter(
|
||||
(a) =>
|
||||
new Date(a.case_closed_at).getMonth() ===
|
||||
new Date().getMonth()
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Bulan Ini</div>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 border border-purple-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-purple-400 font-bold text-2xl">
|
||||
{
|
||||
archives.filter(
|
||||
(a) =>
|
||||
new Date(a.case_closed_at).getFullYear() ===
|
||||
new Date().getFullYear()
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Tahun Ini</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredArchives.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-green-500/30 rounded-xl p-5 hover:border-green-500 hover:shadow-xl hover:shadow-green-500/20 transition-all"
|
||||
>
|
||||
{/* 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.location} • 📅{" "}
|
||||
{Helpers.formatDate(item.date_found)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-green-500/20 text-green-400 border border-green-500/50">
|
||||
✅ CLOSED
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{item.photo_url && (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt={item.name}
|
||||
className="w-full h-40 object-cover rounded-lg border border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/400x200?text=No+Image")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{item.berita_acara_no || "-"}
|
||||
</strong>
|
||||
</div>
|
||||
{item.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(item.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{item.case_closed_by_name && (
|
||||
<div>Oleh: {item.case_closed_by_name}</div>
|
||||
)}
|
||||
{item.claimer_name && (
|
||||
<div>
|
||||
Penerima:{" "}
|
||||
<strong className="text-white">
|
||||
{item.claimer_name}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.bukti_serah_terima && (
|
||||
<a
|
||||
href={item.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reporter Info */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-400">
|
||||
<div>
|
||||
<strong className="text-slate-300">Pelapor:</strong>
|
||||
<br />
|
||||
{item.reporter_name || "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-300">Kontak:</strong>
|
||||
<br />
|
||||
{item.reporter_contact || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-slate-400">
|
||||
🏷️ {item.category || "-"}
|
||||
</span>
|
||||
{item.case_closed_notes && (
|
||||
<span className="text-xs text-blue-400">📝 Ada catatan</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewArchiveDetail(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"
|
||||
>
|
||||
📋 Detail Lengkap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredArchives.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">📂</div>
|
||||
<p>
|
||||
{archiveSearchTerm || archiveCategoryFilter
|
||||
? "Tidak ada arsip yang sesuai filter"
|
||||
: "Belum ada arsip"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
// assets/js/pages/admin/tabs/AuditLogTab.js
|
||||
|
||||
const AuditLogTab = ({ state, handlers }) => {
|
||||
const {
|
||||
filteredAuditLogs, // Data yang ditampilkan (sekarang langsung dari API)
|
||||
auditLogSearchTerm,
|
||||
setAuditLogSearchTerm,
|
||||
auditLogActionFilter,
|
||||
setAuditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
setAuditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
setAuditLogDateFilter,
|
||||
// State Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const { loadAuditLogs } = handlers;
|
||||
|
||||
// ✅ UPDATED: Trigger load data saat halaman atau filter berubah
|
||||
// Menggunakan debounce untuk search text agar tidak spam API
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// Reset ke halaman 1 jika filter berubah, tapi tetap di halaman x jika hanya navigasi
|
||||
loadAuditLogs(currentPage);
|
||||
}, 500); // Delay 500ms
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
currentPage,
|
||||
auditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
]);
|
||||
// Catatan: auditLogSearchTerm bisa dimasukkan dependency jika backend support text search
|
||||
|
||||
// Handler ganti halaman
|
||||
const handlePageChange = (newPage) => {
|
||||
state.setCurrentPage(newPage);
|
||||
// useEffect di atas akan otomatis memanggil API karena currentPage berubah
|
||||
};
|
||||
|
||||
// Helper styles (Tetap sama seperti sebelumnya)
|
||||
const getActionStyle = (action) => {
|
||||
const styles = {
|
||||
login: {
|
||||
icon: "🔐",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
},
|
||||
logout: {
|
||||
icon: "🚪",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
},
|
||||
create_item: {
|
||||
icon: "➕",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
update_item: {
|
||||
icon: "✏️",
|
||||
color: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/30",
|
||||
},
|
||||
delete_item: {
|
||||
icon: "🗑️",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
create_claim: {
|
||||
icon: "🤝",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
},
|
||||
verify_claim: {
|
||||
icon: "✅",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
reject_claim: {
|
||||
icon: "❌",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
close_case: {
|
||||
icon: "📋",
|
||||
color: "text-purple-400",
|
||||
bg: "bg-purple-500/10",
|
||||
border: "border-purple-500/30",
|
||||
},
|
||||
reopen_case: {
|
||||
icon: "🔄",
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-orange-500/30",
|
||||
},
|
||||
update_user_role: {
|
||||
icon: "👤",
|
||||
color: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/30",
|
||||
},
|
||||
block_user: {
|
||||
icon: "🚫",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
unblock_user: {
|
||||
icon: "✅",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
export_report: {
|
||||
icon: "📊",
|
||||
color: "text-cyan-400",
|
||||
bg: "bg-cyan-500/10",
|
||||
border: "border-cyan-500/30",
|
||||
},
|
||||
};
|
||||
return (
|
||||
styles[action] || {
|
||||
icon: "📌",
|
||||
color: "text-slate-400",
|
||||
bg: "bg-slate-500/10",
|
||||
border: "border-slate-500/30",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const formatActionName = (action) => {
|
||||
return action
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Daftar aksi unik untuk dropdown (bisa hardcode atau ambil dari API/konstanta jika perlu)
|
||||
// Karena pagination server-side, kita tidak bisa ambil unique dari 'all data' di frontend.
|
||||
// Sebaiknya sediakan list statis atau endpoint khusus. Disini kita pakai statis umum.
|
||||
const actionOptions = [
|
||||
"login",
|
||||
"logout",
|
||||
"create_item",
|
||||
"update_item",
|
||||
"delete_item",
|
||||
"create_claim",
|
||||
"verify_claim",
|
||||
"reject_claim",
|
||||
"close_case",
|
||||
"reopen_case",
|
||||
"update_user_role",
|
||||
"block_user",
|
||||
"unblock_user",
|
||||
"export_report",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">📜 Audit Log</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => loadAuditLogs(1)} // Reset ke halaman 1 saat refresh manual
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
{/* Note: Search text frontend mungkin tidak jalan sempurna jika backend belum support LIKE query global */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari aktivitas..."
|
||||
value={auditLogSearchTerm}
|
||||
onChange={(e) => setAuditLogSearchTerm(e.target.value)}
|
||||
disabled // Sementara disable jika backend belum support full-text search global
|
||||
className="px-4 py-2 bg-slate-800 border-2 border-slate-600 rounded-xl text-slate-500 cursor-not-allowed"
|
||||
title="Pencarian teks belum aktif"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={auditLogActionFilter}
|
||||
onChange={(e) => {
|
||||
setAuditLogActionFilter(e.target.value);
|
||||
state.setCurrentPage(1); // Reset page saat filter berubah
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Aksi</option>
|
||||
{actionOptions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{formatActionName(action)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter User ID..."
|
||||
value={auditLogUserFilter}
|
||||
onChange={(e) => {
|
||||
setAuditLogUserFilter(e.target.value);
|
||||
// state.setCurrentPage(1); // Optional: reset page immediately
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={auditLogDateFilter}
|
||||
onChange={(e) => setAuditLogDateFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredAuditLogs.map((log, index) => {
|
||||
const style = getActionStyle(log.action);
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${style.bg} border-2 ${style.border} rounded-xl p-4 hover:shadow-xl transition-all relative`}
|
||||
>
|
||||
{/* ... (Isi Card Log tetap sama seperti sebelumnya) ... */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`text-3xl ${style.color} flex-shrink-0`}>
|
||||
{style.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-lg font-bold ${style.color}`}>
|
||||
{formatActionName(log.action)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400 mt-1">
|
||||
<span className="text-white font-medium">
|
||||
{log.user_name || "System"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{Helpers.formatDateTime(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{log.details && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mt-3">
|
||||
<p className="text-sm text-slate-300">
|
||||
{log.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && filteredAuditLogs.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">📜</div>
|
||||
<p>Tidak ada data log.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ ADDED: Pagination Component */}
|
||||
{!loading && filteredAuditLogs.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={10} // Sesuai limit backend
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.AuditLogTab = AuditLogTab;
|
||||
@@ -0,0 +1,240 @@
|
||||
// assets/js/pages/admin/tabs/CategoriesTab.js
|
||||
|
||||
const CategoriesTab = ({ state, handlers }) => {
|
||||
const {
|
||||
categories,
|
||||
setCategories,
|
||||
showCreateCategoryModal,
|
||||
setShowCreateCategoryModal,
|
||||
showEditCategoryModal,
|
||||
setShowEditCategoryModal,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
} = state;
|
||||
|
||||
const { handleCreateCategory, handleUpdateCategory, handleDeleteCategory } =
|
||||
handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🏷️ Kelola Kategori
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateCategoryModal(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"
|
||||
>
|
||||
➕ Tambah Kategori
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ Kelola kategori yang digunakan untuk mengklasifikasikan barang
|
||||
hilang dan temuan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-slate-600 rounded-xl p-5 hover:border-blue-500 hover:shadow-xl hover:shadow-blue-500/20 transition-all"
|
||||
>
|
||||
{/* 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">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Value:{" "}
|
||||
<span className="text-blue-400">{category.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-2xl">🏷️</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<div className="text-sm text-slate-300">
|
||||
<strong className="text-blue-400">ID:</strong> {category.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description if available */}
|
||||
{category.description && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(category);
|
||||
setShowEditCategoryModal(true);
|
||||
}}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus kategori "${category.name}"?\n\nPeringatan: Barang dengan kategori ini mungkin terpengaruh!`
|
||||
)
|
||||
) {
|
||||
handleDeleteCategory(category.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"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p>Belum ada kategori</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Category Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateCategoryModal}
|
||||
onClose={() => setShowCreateCategoryModal(false)}
|
||||
title="➕ Tambah Kategori Baru"
|
||||
>
|
||||
<form onSubmit={handleCreateCategory} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Kategori *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Contoh: Elektronik"
|
||||
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">
|
||||
Value (Identifier) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
required
|
||||
placeholder="Contoh: elektronik (huruf kecil, tanpa spasi)"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Value digunakan sebagai identifier. Gunakan huruf kecil, tanpa
|
||||
spasi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Deskripsi singkat tentang kategori ini..."
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.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"
|
||||
>
|
||||
{state.loading ? "⏳ Menyimpan..." : "✅ Simpan Kategori"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Category Modal */}
|
||||
<Modal
|
||||
isOpen={showEditCategoryModal}
|
||||
onClose={() => {
|
||||
setShowEditCategoryModal(false);
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
title="✏️ Edit Kategori"
|
||||
>
|
||||
{selectedCategory && (
|
||||
<form onSubmit={handleUpdateCategory} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Kategori *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={selectedCategory.name}
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Value (Identifier) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
required
|
||||
defaultValue={selectedCategory.value}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Hati-hati mengubah value, dapat mempengaruhi data existing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
defaultValue={selectedCategory.description || ""}
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.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"
|
||||
>
|
||||
{state.loading ? "⏳ Menyimpan..." : "✅ Update Kategori"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
// assets/js/pages/admin/tabs/ClaimsTabAdmin.js - WITH FULL CRUD
|
||||
|
||||
const ClaimsTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
claims,
|
||||
filteredClaims,
|
||||
claimStatusFilter,
|
||||
setClaimStatusFilter,
|
||||
claimSearchTerm,
|
||||
setClaimSearchTerm,
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewClaimDetail,
|
||||
handleDeleteClaim,
|
||||
handleEditClaimClick,
|
||||
loadClaims,
|
||||
} = handlers;
|
||||
|
||||
// Filter claims locally
|
||||
React.useEffect(() => {
|
||||
let filtered = claims.filter((claim) => {
|
||||
const matchesSearch =
|
||||
claim.item_name.toLowerCase().includes(claimSearchTerm.toLowerCase()) ||
|
||||
claim.user_name.toLowerCase().includes(claimSearchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
!claimStatusFilter || claim.status === claimStatusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
state.setFilteredClaims(filtered);
|
||||
}, [claimSearchTerm, claimStatusFilter, claims]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Kelola Klaim</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateClaimModal(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"
|
||||
>
|
||||
➕ Tambah Klaim Manual
|
||||
</button>
|
||||
<button
|
||||
onClick={loadClaims}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari klaim..."
|
||||
value={claimSearchTerm}
|
||||
onChange={(e) => setClaimSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={claimStatusFilter}
|
||||
onChange={(e) => setClaimStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-yellow-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "pending").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Pending</div>
|
||||
</div>
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "approved").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Approved</div>
|
||||
</div>
|
||||
<div className="bg-red-500/10 border border-red-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-red-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "rejected").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredClaims.map((claim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-slate-600 rounded-xl p-5 hover:border-blue-500 hover:shadow-xl hover:shadow-blue-500/20 transition-all"
|
||||
>
|
||||
{/* 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">
|
||||
{claim.item_name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pengklaim:{" "}
|
||||
<span className="text-white font-medium">
|
||||
{claim.user_name}
|
||||
</span>{" "}
|
||||
• {claim.contact}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${Helpers.getStatusBadgeClass(
|
||||
claim.status
|
||||
)}`}
|
||||
>
|
||||
{claim.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match Percentage */}
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`mb-4 p-3 rounded-lg text-center ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-yellow-500/10 border border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-slate-400 mb-1">
|
||||
Similarity Match
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<strong className="text-sm text-slate-300">
|
||||
Deskripsi Klaim:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-400 mt-1 line-clamp-3">
|
||||
{claim.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-1 text-xs text-slate-400 mb-4">
|
||||
<div>
|
||||
📅 Diajukan: {Helpers.formatDateTime(claim.created_at)}
|
||||
</div>
|
||||
{claim.verified_at && (
|
||||
<div>
|
||||
✅ Diverifikasi: {Helpers.formatDateTime(claim.verified_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.verified_by_name && (
|
||||
<div>👤 Oleh: {claim.verified_by_name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{claim.notes && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border-2 mb-4 ${
|
||||
claim.status === "approved"
|
||||
? "bg-green-500/10 border-green-500/30"
|
||||
: "bg-red-500/10 border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-sm text-slate-300">
|
||||
Catatan Manager:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-400 mt-1">{claim.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
{claim.status === "approved" && claim.berita_acara_no && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{claim.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
{claim.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(claim.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.case_closed_by_name && (
|
||||
<div>Oleh: {claim.case_closed_by_name}</div>
|
||||
)}
|
||||
</div>
|
||||
{claim.bukti_serah_terima && (
|
||||
<a
|
||||
href={claim.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewClaimDetail(claim)}
|
||||
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"
|
||||
>
|
||||
📋 Detail
|
||||
</button>
|
||||
|
||||
{/* Edit button - only for pending claims */}
|
||||
{claim.status === "pending" && (
|
||||
<button
|
||||
onClick={() => handleEditClaimClick(claim)}
|
||||
className="px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus klaim dari "${claim.user_name}"?\n\nBarang: ${claim.item_name}`
|
||||
)
|
||||
) {
|
||||
handleDeleteClaim(claim.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"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredClaims.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🤝</div>
|
||||
<p>
|
||||
{claimSearchTerm || claimStatusFilter
|
||||
? "Tidak ada klaim yang sesuai filter"
|
||||
: "Belum ada klaim"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
// assets/js/pages/admin/tabs/ItemsTab.js
|
||||
|
||||
const ItemsTab = ({ state, handlers }) => {
|
||||
const {
|
||||
showExpired,
|
||||
setShowExpired,
|
||||
itemSearchTerm,
|
||||
setItemSearchTerm,
|
||||
itemStatusFilter,
|
||||
setItemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
setItemCategoryFilter,
|
||||
filteredItems,
|
||||
items,
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
photoPreview,
|
||||
itemPage,
|
||||
itemTotalPages,
|
||||
itemTotalRecords,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewItemDetail,
|
||||
handleCreateItem,
|
||||
handleEditItemClick,
|
||||
handleDeleteItem,
|
||||
handlePhotoChange,
|
||||
loadItems,
|
||||
} = handlers;
|
||||
|
||||
// ✅ Gunakan useEffect untuk reload data saat Filter / Page berubah
|
||||
// Kita menggunakan debounce (tunda) untuk search agar tidak spam API
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// Reset ke halaman 1 jika filter berubah
|
||||
// Jika hanya halaman yang berubah, load halaman tersebut
|
||||
loadItems(itemPage);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
itemPage,
|
||||
itemSearchTerm,
|
||||
itemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
showExpired,
|
||||
]);
|
||||
|
||||
// Reset page ke 1 jika filter berubah (kecuali itemPage itu sendiri)
|
||||
React.useEffect(() => {
|
||||
state.setItemPage(1);
|
||||
}, [itemSearchTerm, itemStatusFilter, itemCategoryFilter, showExpired]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{showExpired ? "📦 Barang Expired" : "📦 Kelola Barang"}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateItemModal(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"
|
||||
>
|
||||
➕ Tambah Barang
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari barang (nama/lokasi)..."
|
||||
value={itemSearchTerm}
|
||||
onChange={(e) => setItemSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={itemStatusFilter}
|
||||
onChange={(e) => setItemStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="pending_claim">Pending Claim</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="case_closed">Case Closed</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<select
|
||||
value={itemCategoryFilter}
|
||||
onChange={(e) => setItemCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* ✅ PERUBAHAN UTAMA:
|
||||
Kita me-map 'items' secara langsung.
|
||||
Tidak perlu filteredItems.filter() lagi karena backend sudah mengirimkan data
|
||||
yang sudah terfilter (hanya 10 biji) sesuai halaman aktif.
|
||||
*/}
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl overflow-hidden hover:shadow-2xl transition-all ${
|
||||
item.status === "expired"
|
||||
? "border-red-500/50 hover:border-red-500"
|
||||
: "border-slate-600 hover:border-blue-500 hover:shadow-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{item.status === "expired" && (
|
||||
<div className="bg-red-600/90 text-white text-center py-1 text-xs font-bold">
|
||||
⚠️ EXPIRED
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={
|
||||
item.photo_url ||
|
||||
"https://via.placeholder.com/280x200?text=No+Image"
|
||||
}
|
||||
alt={item.name}
|
||||
className={`w-full h-48 object-cover cursor-pointer ${
|
||||
item.status === "expired" ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={() => handleViewItemDetail(item)}
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/280x200?text=No+Image")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1 text-sm text-slate-400 mb-3">
|
||||
<span>📍 {item.location}</span>
|
||||
<span>📅 {Helpers.formatDate(item.date_found)}</span>
|
||||
<span>👤 {item.reporter_name}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-3 ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewItemDetail(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"
|
||||
>
|
||||
📋 Detail
|
||||
</button>
|
||||
|
||||
{/* Tombol Edit/Hapus hanya muncul jika bukan case_closed */}
|
||||
{item.status !== "case_closed" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditItemClick(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus "${item.name}"?`
|
||||
)
|
||||
) {
|
||||
handleDeleteItem(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"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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"
|
||||
: "Tidak ada barang ditemukan"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ KOMPONEN PAGINATION */}
|
||||
{items.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={itemPage}
|
||||
totalPages={itemTotalPages}
|
||||
totalRecords={itemTotalRecords}
|
||||
onPageChange={(page) => state.setItemPage(page)}
|
||||
itemsPerPage={10}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateItemModal state={state} handlers={handlers} />
|
||||
<EditItemModal state={state} handlers={handlers} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
const LostItemsTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
lostItems,
|
||||
lostItemSearchTerm,
|
||||
setLostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
setLostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
setLostItemCategoryFilter,
|
||||
lostItemPage,
|
||||
lostItemTotalPages,
|
||||
lostItemTotalRecords,
|
||||
categories,
|
||||
loading,
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewLostItemDetail,
|
||||
handleEditLostItemClick,
|
||||
handleDeleteLostItem,
|
||||
loadLostItems,
|
||||
} = handlers;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadLostItems(lostItemPage);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
lostItemPage,
|
||||
lostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
state.setLostItemPage(1);
|
||||
}, [lostItemSearchTerm, lostItemStatusFilter, lostItemCategoryFilter]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🔍 Kelola Laporan Barang Hilang
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateLostItemModal(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"
|
||||
>
|
||||
➕ Tambah Laporan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari laporan (nama/deskripsi)..."
|
||||
value={lostItemSearchTerm}
|
||||
onChange={(e) => setLostItemSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={lostItemStatusFilter}
|
||||
onChange={(e) => setLostItemStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_verification">Pending Verification</option>
|
||||
<option value="claimed">Claimed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<select
|
||||
value={lostItemCategoryFilter}
|
||||
onChange={(e) => setLostItemCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
ID
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Nama Barang
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Kategori
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Pelapor
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Lokasi
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Tanggal Hilang
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lostItems &&
|
||||
lostItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-slate-700/50 hover:bg-slate-700/30 transition"
|
||||
>
|
||||
<td className="py-3 px-4 text-slate-300">#{item.id}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.color && (
|
||||
<div className="text-xs text-slate-400">
|
||||
🎨 {item.color}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{item.category}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white">{item.user_name}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
ID: {item.user_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{item.location || "-"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{Helpers.formatDateShort(item.date_lost)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
{item.direct_claim_status && (
|
||||
<div className="text-xs text-yellow-400 mt-1">
|
||||
📋 Claim: {item.direct_claim_status}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewLostItemDetail(item)}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
title="Detail"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditLostItemClick(item)}
|
||||
className="px-3 py-1 bg-yellow-600 text-white text-xs rounded hover:bg-yellow-700 transition"
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus laporan "${item.name}"?`
|
||||
)
|
||||
) {
|
||||
handleDeleteLostItem(item.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition"
|
||||
title="Hapus"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(!lostItems || lostItems.length === 0) && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<p>Tidak ada laporan barang hilang</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lostItems && lostItems.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={lostItemPage}
|
||||
totalPages={lostItemTotalPages}
|
||||
totalRecords={lostItemTotalRecords}
|
||||
onPageChange={(page) => state.setLostItemPage(page)}
|
||||
itemsPerPage={10}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
const MyClaimsTab = ({ state, handlers }) => {
|
||||
const { myLostItems, myClaims, loading } = state;
|
||||
|
||||
// Lost items dengan pending claims (untuk OWNER perspective)
|
||||
const lostItemsWithClaims = myLostItems.filter(
|
||||
(item) =>
|
||||
item.direct_claim_id &&
|
||||
item.direct_claim_status &&
|
||||
item.status !== "completed" &&
|
||||
item.direct_claim_status !== "completed"
|
||||
);
|
||||
|
||||
const regularClaims = myClaims || [];
|
||||
|
||||
// Status badge untuk OWNER perspective (di section "Barang Hilang Saya")
|
||||
const getOwnerStatusBadge = (status) => {
|
||||
const badges = {
|
||||
active: { color: "bg-blue-500", text: "🔍 Masih Hilang", emoji: "😢" },
|
||||
claimed: {
|
||||
color: "bg-yellow-500",
|
||||
text: "⏳ Menunggu Persetujuan Anda",
|
||||
emoji: "🤔",
|
||||
},
|
||||
found: { color: "bg-green-500", text: "✅ Ditemukan", emoji: "🎉" },
|
||||
completed: {
|
||||
color: "bg-purple-500",
|
||||
text: "✅ Selesai - Barang Diterima",
|
||||
emoji: "🎉",
|
||||
},
|
||||
waiting_owner: {
|
||||
color: "bg-orange-500",
|
||||
text: "⏳ Menunggu Keputusan Anda", // Owner yang harus decide
|
||||
emoji: "🔔",
|
||||
},
|
||||
verified: {
|
||||
color: "bg-green-600",
|
||||
text: "✅ Terverifikasi",
|
||||
emoji: "✅",
|
||||
},
|
||||
};
|
||||
return (
|
||||
badges[status] || {
|
||||
color: "bg-gray-500",
|
||||
text: status,
|
||||
emoji: "❓",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Status badge untuk CLAIMANT perspective (di section "Klaim yang Saya Ajukan")
|
||||
const getClaimantStatusBadge = (status) => {
|
||||
const badges = {
|
||||
pending: {
|
||||
color: "bg-yellow-500",
|
||||
text: "⏳ Menunggu Verifikasi Admin",
|
||||
emoji: "⏳",
|
||||
},
|
||||
approved: {
|
||||
color: "bg-green-500",
|
||||
text: "✅ Disetujui",
|
||||
emoji: "✅",
|
||||
},
|
||||
rejected: {
|
||||
color: "bg-red-500",
|
||||
text: "❌ Ditolak",
|
||||
emoji: "❌",
|
||||
},
|
||||
waiting_owner: {
|
||||
color: "bg-orange-500",
|
||||
text: "⏳ Menunggu Keputusan Pemilik", // Claimant menunggu owner
|
||||
emoji: "💤",
|
||||
},
|
||||
verified: {
|
||||
color: "bg-green-600",
|
||||
text: "✅ Terverifikasi",
|
||||
emoji: "✅",
|
||||
},
|
||||
completed: {
|
||||
color: "bg-purple-500",
|
||||
text: "🎉 Selesai",
|
||||
emoji: "🎉",
|
||||
},
|
||||
};
|
||||
return (
|
||||
badges[status] || {
|
||||
color: "bg-gray-500",
|
||||
text: status,
|
||||
emoji: "❓",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="animate-spin text-4xl mb-4">⏳</div>
|
||||
<p>Memuat data klaim...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* SECTION 1: OWNER PERSPECTIVE - Barang Hilang Saya yang Ada Klaimnya */}
|
||||
{lostItemsWithClaims.length > 0 && (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700 p-6">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<span className="text-3xl">😢</span>
|
||||
Barang Hilang Saya
|
||||
<span className="bg-orange-500/20 text-orange-400 text-sm px-3 py-1 rounded-full border border-orange-500/30">
|
||||
{lostItemsWithClaims.length} item dengan klaim pending
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 mb-6">
|
||||
Ada yang menemukan barang Anda! Silakan approve atau reject klaim
|
||||
mereka.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{lostItemsWithClaims.map((lostItem) => {
|
||||
const statusBadge = getOwnerStatusBadge(
|
||||
lostItem.direct_claim_status || lostItem.status
|
||||
);
|
||||
const isWaitingOwner =
|
||||
lostItem.direct_claim_status === "waiting_owner";
|
||||
const isVerified =
|
||||
lostItem.direct_claim_status === "verified" ||
|
||||
lostItem.status === "found";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lostItem.id}
|
||||
className={`bg-slate-900/80 rounded-xl border ${
|
||||
isWaitingOwner
|
||||
? "border-orange-500/50 shadow-lg shadow-orange-500/20"
|
||||
: "border-slate-700"
|
||||
} p-6 hover:shadow-xl transition-all`}
|
||||
>
|
||||
{/* Header dengan status */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="text-xl font-bold text-white">
|
||||
{lostItem.name}
|
||||
</h4>
|
||||
<span
|
||||
className={`${statusBadge.color} text-white text-xs px-3 py-1 rounded-full font-medium`}
|
||||
>
|
||||
{statusBadge.emoji} {statusBadge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-400">
|
||||
<p>
|
||||
<span className="font-medium">📦 Kategori:</span>{" "}
|
||||
{lostItem.category}
|
||||
</p>
|
||||
{lostItem.color && (
|
||||
<p>
|
||||
<span className="font-medium">🎨 Warna:</span>{" "}
|
||||
{lostItem.color}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">📍 Lokasi Hilang:</span>{" "}
|
||||
{lostItem.location}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
📅 Tanggal Hilang:
|
||||
</span>{" "}
|
||||
{new Date(lostItem.date_lost).toLocaleDateString(
|
||||
"id-ID"
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-medium">📝 Deskripsi:</span>{" "}
|
||||
{lostItem.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Area untuk Owner */}
|
||||
{isWaitingOwner && (
|
||||
<div className="bg-orange-900/30 border border-orange-500/30 rounded-lg p-4 mt-4">
|
||||
<p className="text-orange-300 font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="text-2xl">🔔</span>
|
||||
Ada yang mengaku menemukan barang ini!
|
||||
</p>
|
||||
<p className="text-sm text-slate-300 mb-4">
|
||||
Seseorang telah mengajukan klaim untuk barang ini.
|
||||
Silakan putuskan apakah ini benar barang Anda.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserRespondClaim(
|
||||
lostItem.direct_claim_id,
|
||||
"approve"
|
||||
)
|
||||
}
|
||||
className="flex-1 bg-gradient-to-r from-green-600 to-green-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg shadow-green-500/30"
|
||||
>
|
||||
✅ Approve (Ini barang saya!)
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserRespondClaim(
|
||||
lostItem.direct_claim_id,
|
||||
"reject"
|
||||
)
|
||||
}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-red-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-red-700 hover:to-red-800 transition shadow-lg shadow-red-500/30"
|
||||
>
|
||||
❌ Reject (Bukan barang saya)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVerified && (
|
||||
<div className="bg-green-900/30 border border-green-500/30 rounded-lg p-4 mt-4">
|
||||
<p className="text-green-300 font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="text-2xl">✅</span>
|
||||
Klaim Disetujui!
|
||||
</p>
|
||||
<p className="text-sm text-slate-300 mb-4">
|
||||
Anda telah menyetujui bahwa penemu menemukan barang
|
||||
Anda. Koordinasikan pengambilan barang, lalu konfirmasi
|
||||
setelah menerima barang.
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserCompleteCase(
|
||||
lostItem.direct_claim_id
|
||||
)
|
||||
}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-purple-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-purple-700 hover:to-purple-800 transition shadow-lg shadow-purple-500/30"
|
||||
>
|
||||
🎉 Konfirmasi: Saya Sudah Terima Barang
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION 2: CLAIMANT PERSPECTIVE - Klaim yang Saya Ajukan */}
|
||||
{regularClaims.length > 0 && (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700 p-6">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<span className="text-3xl">🤝</span>
|
||||
Klaim Barang yang Saya Ajukan
|
||||
<span className="bg-blue-500/20 text-blue-400 text-sm px-3 py-1 rounded-full border border-blue-500/30">
|
||||
{regularClaims.length} klaim
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{regularClaims.map((claim) => {
|
||||
const statusBadge = getClaimantStatusBadge(claim.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="bg-slate-900/80 rounded-xl border border-slate-700 p-5 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h4 className="text-lg font-bold text-white flex-1">
|
||||
{claim.item_name}
|
||||
</h4>
|
||||
<span
|
||||
className={`${statusBadge.color} text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ml-2`}
|
||||
>
|
||||
{statusBadge.emoji} {statusBadge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-400">
|
||||
<p>
|
||||
<span className="font-medium">📝 Deskripsi:</span>{" "}
|
||||
{claim.description}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">📞 Kontak:</span>{" "}
|
||||
{claim.contact}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">📅 Tanggal Klaim:</span>{" "}
|
||||
{new Date(claim.created_at).toLocaleDateString("id-ID")}
|
||||
</p>
|
||||
{claim.notes && (
|
||||
<p className="text-yellow-400 mt-2">
|
||||
<span className="font-medium">💬 Catatan:</span>{" "}
|
||||
{claim.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{lostItemsWithClaims.length === 0 && regularClaims.length === 0 && (
|
||||
<div className="text-center py-16 bg-slate-800/30 rounded-2xl border border-slate-700">
|
||||
<div className="text-6xl mb-4">🔭</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Belum Ada Klaim</h3>
|
||||
<p className="text-slate-400">
|
||||
Anda belum memiliki klaim atau barang hilang dengan klaim pending.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.MyClaimsTab = MyClaimsTab;
|
||||
@@ -0,0 +1,55 @@
|
||||
// assets/js/pages/admin/tabs/ReportsTab.js
|
||||
|
||||
const ReportsTab = ({ handlers, loading }) => {
|
||||
const { handleExport } = 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">Export Laporan</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Periode
|
||||
</label>
|
||||
<select
|
||||
name="period"
|
||||
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="month">Bulan Ini</option>
|
||||
<option value="semester">Semester Ini</option>
|
||||
<option value="year">Tahun Ini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tipe Laporan
|
||||
</label>
|
||||
<select
|
||||
name="report_type"
|
||||
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="items">Barang Ditemukan</option>
|
||||
<option value="archives">Arsip</option>
|
||||
<option value="claims">Klaim</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 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 ? "⏳ Exporting..." : "📄 Export PDF"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("excel")}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 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 ? "⏳ Exporting..." : "📊 Export Excel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
const RolesTab = ({ state, handlers }) => {
|
||||
// ✅ PERBAIKAN: Ambil setShowCreateRoleModal dari state
|
||||
const { roles, loading, setShowCreateRoleModal } = state;
|
||||
|
||||
// handlers hanya berisi fungsi logika (API calls)
|
||||
const { handleEditRole, handleDeleteRole } = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🔑 Kelola Role & Hak Akses
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateRoleModal(true)} // Sekarang fungsi ini sudah didefinisikan
|
||||
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"
|
||||
>
|
||||
➕ Tambah Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="bg-slate-700/30 border border-slate-600 rounded-xl p-5 hover:border-blue-500 transition"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white capitalize">
|
||||
{role.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">{role.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEditRole(role)}
|
||||
className="p-2 bg-yellow-600/20 text-yellow-400 rounded hover:bg-yellow-600/40"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{/* Jangan izinkan hapus admin/user/manager default jika perlu */}
|
||||
{role.name !== "admin" && role.name !== "user" && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.id)}
|
||||
className="p-2 bg-red-600/20 text-red-400 rounded hover:bg-red-600/40"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<strong className="text-xs text-slate-500 uppercase block mb-2">
|
||||
Hak Akses (Permissions):
|
||||
</strong>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{role.permissions && role.permissions.length > 0 ? (
|
||||
role.permissions.map((perm) => (
|
||||
<span
|
||||
key={perm.id}
|
||||
className="px-2 py-1 bg-blue-500/10 text-blue-300 border border-blue-500/20 rounded text-xs"
|
||||
>
|
||||
{perm.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-slate-500 text-xs italic">
|
||||
Tidak ada permission khusus
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.RolesTab = RolesTab;
|
||||
@@ -0,0 +1,111 @@
|
||||
// assets/js/pages/admin/tabs/UsersTab.js
|
||||
|
||||
const UsersTab = ({ state, handlers }) => {
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
roleFilter,
|
||||
setRoleFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
filteredUsers,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
setCurrentPage,
|
||||
} = state;
|
||||
|
||||
const { handleEditUser, handleBlockUser, handleUnblockUser } = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-4 text-white">Daftar User</h2>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari user..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Role</option>
|
||||
<option value="user">User</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-700">
|
||||
<thead className="bg-slate-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Nama
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
NRP
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gradient-to-br from-slate-800 to-slate-900 divide-y divide-slate-700">
|
||||
{filteredUsers.map((u) => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
user={u}
|
||||
onEdit={handleEditUser}
|
||||
onBlock={handleBlockUser}
|
||||
onUnblock={handleUnblockUser}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
itemsPerPage={CONFIG.PAGINATION.LIMIT}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">👥</div>
|
||||
<p>Tidak ada user ditemukan</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const useAdminState = () => {
|
||||
// Existing states
|
||||
const [user, setUser] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
const [stats, setStats] = useState({});
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Items states
|
||||
const [items, setItems] = useState([]);
|
||||
const [filteredItems, setFilteredItems] = useState([]);
|
||||
const [showExpired, setShowExpired] = useState(false);
|
||||
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
||||
const [itemStatusFilter, setItemStatusFilter] = useState("");
|
||||
const [itemCategoryFilter, setItemCategoryFilter] = useState("");
|
||||
const [selectedItemDetail, setSelectedItemDetail] = useState(null);
|
||||
const [showItemDetailModal, setShowItemDetailModal] = useState(false);
|
||||
const [showCreateItemModal, setShowCreateItemModal] = useState(false);
|
||||
const [showEditItemModal, setShowEditItemModal] = useState(false);
|
||||
const [selectedItemToEdit, setSelectedItemToEdit] = useState(null);
|
||||
const [photoPreview, setPhotoPreview] = useState(null);
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemTotalPages, setItemTotalPages] = useState(1);
|
||||
const [itemTotalRecords, setItemTotalRecords] = useState(0);
|
||||
|
||||
// NEW: Lost Items states
|
||||
const [lostItems, setLostItems] = useState([]);
|
||||
const [filteredLostItems, setFilteredLostItems] = useState([]);
|
||||
const [lostItemSearchTerm, setLostItemSearchTerm] = useState("");
|
||||
const [lostItemStatusFilter, setLostItemStatusFilter] = useState("");
|
||||
const [lostItemCategoryFilter, setLostItemCategoryFilter] = useState("");
|
||||
const [selectedLostItemDetail, setSelectedLostItemDetail] = useState(null);
|
||||
const [showLostItemDetailModal, setShowLostItemDetailModal] = useState(false);
|
||||
const [showCreateLostItemModal, setShowCreateLostItemModal] = useState(false);
|
||||
const [showEditLostItemModal, setShowEditLostItemModal] = useState(false);
|
||||
const [selectedLostItemToEdit, setSelectedLostItemToEdit] = useState(null);
|
||||
const [lostItemPage, setLostItemPage] = useState(1);
|
||||
const [lostItemTotalPages, setLostItemTotalPages] = useState(1);
|
||||
const [lostItemTotalRecords, setLostItemTotalRecords] = useState(0);
|
||||
|
||||
// Claims states
|
||||
const [claims, setClaims] = useState([]);
|
||||
const [filteredClaims, setFilteredClaims] = useState([]);
|
||||
const [claimStatusFilter, setClaimStatusFilter] = useState("");
|
||||
const [claimSearchTerm, setClaimSearchTerm] = useState("");
|
||||
const [selectedClaim, setSelectedClaim] = useState(null);
|
||||
const [showClaimDetailModal, setShowClaimDetailModal] = useState(false);
|
||||
const [showCreateClaimModal, setShowCreateClaimModal] = useState(false);
|
||||
const [showEditClaimModal, setShowEditClaimModal] = useState(false);
|
||||
const [selectedClaimToEdit, setSelectedClaimToEdit] = useState(null);
|
||||
|
||||
// Archives states
|
||||
const [archives, setArchives] = useState([]);
|
||||
const [filteredArchives, setFilteredArchives] = useState([]);
|
||||
const [archiveSearchTerm, setArchiveSearchTerm] = useState("");
|
||||
const [archiveCategoryFilter, setArchiveCategoryFilter] = useState("");
|
||||
const [selectedArchive, setSelectedArchive] = useState(null);
|
||||
const [showArchiveDetailModal, setShowArchiveDetailModal] = useState(false);
|
||||
|
||||
// Audit logs states
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [filteredAuditLogs, setFilteredAuditLogs] = useState([]);
|
||||
const [auditLogSearchTerm, setAuditLogSearchTerm] = useState("");
|
||||
const [auditLogActionFilter, setAuditLogActionFilter] = useState("");
|
||||
const [auditLogUserFilter, setAuditLogUserFilter] = useState("");
|
||||
const [auditLogDateFilter, setAuditLogDateFilter] = useState("");
|
||||
|
||||
// Categories states
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||
const [showEditCategoryModal, setShowEditCategoryModal] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
|
||||
// Roles states
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [showCreateRoleModal, setShowCreateRoleModal] = useState(false);
|
||||
const [showEditRoleModal, setShowEditRoleModal] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
|
||||
return {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
setStats,
|
||||
users,
|
||||
setUsers,
|
||||
filteredUsers,
|
||||
setFilteredUsers,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
totalPages,
|
||||
setTotalPages,
|
||||
totalRecords,
|
||||
setTotalRecords,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
roleFilter,
|
||||
setRoleFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
setLoading,
|
||||
items,
|
||||
setItems,
|
||||
filteredItems,
|
||||
setFilteredItems,
|
||||
showExpired,
|
||||
setShowExpired,
|
||||
itemSearchTerm,
|
||||
setItemSearchTerm,
|
||||
itemStatusFilter,
|
||||
setItemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
setItemCategoryFilter,
|
||||
selectedItemDetail,
|
||||
setSelectedItemDetail,
|
||||
showItemDetailModal,
|
||||
setShowItemDetailModal,
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
itemPage,
|
||||
setItemPage,
|
||||
itemTotalPages,
|
||||
setItemTotalPages,
|
||||
itemTotalRecords,
|
||||
setItemTotalRecords,
|
||||
// Lost Items
|
||||
lostItems,
|
||||
setLostItems,
|
||||
filteredLostItems,
|
||||
setFilteredLostItems,
|
||||
lostItemSearchTerm,
|
||||
setLostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
setLostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
setLostItemCategoryFilter,
|
||||
selectedLostItemDetail,
|
||||
setSelectedLostItemDetail,
|
||||
showLostItemDetailModal,
|
||||
setShowLostItemDetailModal,
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
showEditLostItemModal,
|
||||
setShowEditLostItemModal,
|
||||
selectedLostItemToEdit,
|
||||
setSelectedLostItemToEdit,
|
||||
lostItemPage,
|
||||
setLostItemPage,
|
||||
lostItemTotalPages,
|
||||
setLostItemTotalPages,
|
||||
lostItemTotalRecords,
|
||||
setLostItemTotalRecords,
|
||||
// Claims
|
||||
claims,
|
||||
setClaims,
|
||||
filteredClaims,
|
||||
setFilteredClaims,
|
||||
claimStatusFilter,
|
||||
setClaimStatusFilter,
|
||||
claimSearchTerm,
|
||||
setClaimSearchTerm,
|
||||
selectedClaim,
|
||||
setSelectedClaim,
|
||||
showClaimDetailModal,
|
||||
setShowClaimDetailModal,
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
selectedClaimToEdit,
|
||||
setSelectedClaimToEdit,
|
||||
// Archives
|
||||
archives,
|
||||
setArchives,
|
||||
filteredArchives,
|
||||
setFilteredArchives,
|
||||
archiveSearchTerm,
|
||||
setArchiveSearchTerm,
|
||||
archiveCategoryFilter,
|
||||
setArchiveCategoryFilter,
|
||||
selectedArchive,
|
||||
setSelectedArchive,
|
||||
showArchiveDetailModal,
|
||||
setShowArchiveDetailModal,
|
||||
// Audit Logs
|
||||
auditLogs,
|
||||
setAuditLogs,
|
||||
filteredAuditLogs,
|
||||
setFilteredAuditLogs,
|
||||
auditLogSearchTerm,
|
||||
setAuditLogSearchTerm,
|
||||
auditLogActionFilter,
|
||||
setAuditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
setAuditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
setAuditLogDateFilter,
|
||||
// Categories
|
||||
categories,
|
||||
setCategories,
|
||||
showCreateCategoryModal,
|
||||
setShowCreateCategoryModal,
|
||||
showEditCategoryModal,
|
||||
setShowEditCategoryModal,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
// Roles
|
||||
roles,
|
||||
setRoles,
|
||||
permissions,
|
||||
setPermissions,
|
||||
showCreateRoleModal,
|
||||
setShowCreateRoleModal,
|
||||
showEditRoleModal,
|
||||
setShowEditRoleModal,
|
||||
selectedRole,
|
||||
setSelectedRole,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user