Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1
2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions
+240
View File
@@ -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>
);
};
+310
View File
@@ -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;
+240
View File
@@ -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>
);
};
+284
View File
@@ -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>
);
};
+266
View File
@@ -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>
);
};
+320
View File
@@ -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;
+55
View File
@@ -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>
);
};
+81
View File
@@ -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;
+111
View File
@@ -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>
);
};