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
+202
View File
@@ -0,0 +1,202 @@
const AIChatbot = () => {
const { useState, useEffect, useRef } = React;
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => {
if (isOpen && messages.length === 0) {
loadHistory();
}
}, [isOpen]);
const loadHistory = async () => {
try {
const data = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.AI.HISTORY}?limit=10`
);
if (data.data && data.data.history) {
const formatted = data.data.history.reverse().flatMap((chat) => [
{ text: chat.message, sender: "user", time: chat.created_at },
{ text: chat.response, sender: "ai", time: chat.created_at },
]);
setMessages(formatted);
}
} catch (error) {
console.error("Failed to load history:", error);
}
};
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMessage = { text: input, sender: "user", time: new Date() };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setLoading(true);
try {
const data = await ApiUtils.post(CONFIG.API_ENDPOINTS.AI.CHAT, {
message: input,
history: messages.slice(-10).map((msg) => ({
text: msg.text,
sender: msg.sender,
})),
});
const aiMessage = {
text:
data.data.message?.content ||
data.data.response ||
"Maaf, tidak ada respons.",
sender: "ai",
time: new Date(),
};
setMessages((prev) => [...prev, aiMessage]);
} catch (error) {
console.error("AI Chat error:", error);
const errorMessage = {
text: "Maaf, terjadi kesalahan. Silakan coba lagi.",
sender: "ai",
time: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setLoading(false);
}
};
const clearHistory = async () => {
if (!confirm("Hapus semua riwayat chat?")) return;
try {
await ApiUtils.delete(CONFIG.API_ENDPOINTS.AI.HISTORY);
setMessages([]);
} catch (error) {
console.error("Failed to clear history:", error);
alert("Gagal menghapus riwayat");
}
};
return (
<>
{/* Floating Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-6 right-6 w-16 h-16 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-full shadow-2xl hover:scale-110 transition-transform z-50 flex items-center justify-center"
>
{isOpen ? (
<span className="text-2xl">✕</span>
) : (
<span className="text-3xl">🤖</span>
)}
</button>
{/* Chat Window - REDUCED HEIGHT */}
{isOpen && (
<div className="fixed bottom-24 right-6 w-96 h-[500px] bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl shadow-2xl border border-slate-700 flex flex-col z-50">
{/* Header - REDUCED PADDING */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-3 rounded-t-2xl flex justify-between items-center">
<div>
<h3 className="text-white font-bold text-base">
🤖 AI Assistant
</h3>
<p className="text-blue-100 text-xs">
Tanya apa saja tentang Lost & Found
</p>
</div>
<button
onClick={clearHistory}
className="text-white hover:text-blue-200 text-sm"
title="Clear History"
>
🗑️
</button>
</div>
{/* Messages Area - ADJUSTED HEIGHT */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{messages.length === 0 && (
<div className="text-center text-slate-400 mt-6">
<p className="text-3xl mb-2">👋</p>
<p className="text-sm">Halo! Ada yang bisa saya bantu?</p>
<div className="mt-3 text-xs space-y-1">
<p>💡 Coba tanya:</p>
<p>"Ada dompet yang ditemukan?"</p>
<p>"Bagaimana cara lapor kehilangan?"</p>
</div>
</div>
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${
msg.sender === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] p-2.5 rounded-2xl text-sm ${
msg.sender === "user"
? "bg-blue-600 text-white rounded-br-none"
: "bg-slate-700 text-slate-100 rounded-bl-none"
}`}
>
<p className="whitespace-pre-wrap">{msg.text}</p>
{msg.intent && (
<span className="text-xs opacity-70 mt-1 block">
🏷️ {msg.intent}
</span>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-slate-700 text-slate-100 p-2.5 rounded-2xl rounded-bl-none">
<span className="inline-block animate-pulse text-sm">
💭 Sedang berpikir...
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area - REDUCED PADDING */}
<div className="p-3 border-t border-slate-700">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && sendMessage()}
placeholder="Ketik pesan..."
className="flex-1 px-3 py-2 bg-slate-700 text-white text-sm rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim()}
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
📤
</button>
</div>
</div>
</div>
)}
</>
);
};
+258
View File
@@ -0,0 +1,258 @@
const ClaimCard = ({
claim,
onViewDetails,
onVerify,
onReopen,
onCancelApproval,
onUserRespond,
isOwnerView = false,
currentUserId,
}) => {
const getStatusClass = (status) => {
switch (status) {
case "approved":
case "verified":
return "bg-green-600 text-white border-green-500";
case "rejected":
return "bg-red-600 text-white border-red-500";
case "waiting_owner":
return "bg-yellow-600 text-white border-yellow-500";
default:
return "bg-slate-700 text-slate-300 border-slate-600";
}
};
const getStatusText = (status) => {
if (status === "waiting_owner") {
// Jika yang login adalah pemilik barang yang hilang
if (
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id
) {
return "🔔 MENUNGGU ANDA";
}
// Jika yang login adalah penemu yang mengajukan klaim
if (claim.user_id && currentUserId === claim.user_id) {
return "⏳ MENUNGGU OWNER"; // Updated: Sesuai permintaan
}
return onUserRespond ? "🔔 MENUNGGU PEMILIK" : "⏳ MENUNGGU OWNER";
}
return status.toUpperCase();
};
const ActorBadge = ({ label, name, icon, color }) => (
<div
className={`flex items-center gap-1 text-xs ${color} bg-slate-950/30 px-2 py-1 rounded border border-slate-700`}
>
<span>{icon}</span>
<span className="opacity-70">{label}:</span>
<span className="font-semibold">{name}</span>
</div>
);
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border-2 border-slate-700 rounded-xl p-6 hover:shadow-2xl hover:shadow-blue-500/20 hover:border-blue-500 transition-all group flex flex-col h-full">
<div className="flex justify-between items-start mb-4 border-b border-slate-700 pb-3">
<div>
<h3 className="text-lg font-bold text-white group-hover:text-blue-400 transition">
{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>
<div className="text-right">
<span
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border ${getStatusClass(
claim.status
)}`}
>
{getStatusText(claim.status)}
</span>
{claim.match_percentage && (
<div
className={`text-xs font-bold mt-2 ${
claim.match_percentage >= 70
? "text-green-400"
: "text-yellow-400"
}`}
>
Match: {claim.match_percentage}%
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 flex-grow">
<div className="bg-slate-950/50 p-3 rounded-lg border border-slate-600 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-yellow-500"></div>
<p className="text-xs text-slate-500 mb-1 font-bold uppercase">
Ciri Rahasia (Dari Penemu)
</p>
<p className="text-sm text-slate-300 italic">
"{claim.item_secret_details || "Tidak ada data"}"
</p>
</div>
<div className="bg-slate-950/50 p-3 rounded-lg border border-slate-600 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-blue-500"></div>
<p className="text-xs text-slate-500 mb-1 font-bold uppercase">
Deskripsi Pengklaim
</p>
<p className="text-sm text-slate-300">"{claim.description}"</p>
</div>
</div>
{claim.status === "waiting_owner" &&
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id && (
<div className="bg-blue-500/20 border border-blue-500/50 p-3 rounded-lg mb-4 text-center">
<p className="text-sm text-blue-200 font-semibold">
🔔 Seseorang menemukan barang ini dan mencocokkannya dengan Anda.
</p>
<p className="text-xs text-blue-300 mt-1">
Apakah benar ini barang Anda?
</p>
</div>
)}
{claim.status === "waiting_owner" &&
claim.user_id &&
currentUserId === claim.user_id && (
<div className="bg-yellow-500/20 border border-yellow-500/50 p-3 rounded-lg mb-4 text-center">
<p className="text-sm text-yellow-200 font-semibold">
⏳ Menunggu keputusan dari pemilik barang
</p>
<p className="text-xs text-yellow-300 mt-1">
Pemilik sedang memverifikasi apakah ini barang mereka
</p>
</div>
)}
<div className="space-y-2 mb-4 pt-3 border-t border-slate-700 border-dashed">
<div className="flex justify-between items-center">
<ActorBadge
label="Penemu"
name={claim.reporter_name || "Anonim"}
icon="📦"
color="text-slate-300"
/>
<span className="text-[10px] text-slate-500">
Ditemukan: {Helpers.formatDate(claim.created_at)}
</span>
</div>
{claim.verifier_name && (
<div className="flex justify-between items-center">
<ActorBadge
label={
claim.status === "rejected"
? "Ditolak Oleh"
: "Diverifikasi Oleh"
}
name={claim.verifier_name}
icon={claim.status === "rejected" ? "❌" : "✅"}
color={
claim.status === "rejected" ? "text-red-300" : "text-green-300"
}
/>
<span className="text-[10px] text-slate-500">
{Helpers.formatDateTime(claim.verified_at)}
</span>
</div>
)}
{claim.case_closed_by_name && (
<div className="flex justify-between items-center bg-purple-500/10 p-1 rounded mt-1">
<ActorBadge
label="Closed By"
name={claim.case_closed_by_name}
icon="🔒"
color="text-purple-300"
/>
<div className="text-right">
<span className="block text-[10px] text-purple-300/70 font-bold">
BA: {claim.berita_acara_no}
</span>
<span className="block text-[10px] text-purple-300/50">
{Helpers.formatDateTime(claim.case_closed_at)}
</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-auto pt-2 border-t border-slate-700">
{onViewDetails && (
<button
onClick={onViewDetails}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded transition"
>
📋 Detail
</button>
)}
{claim.status === "waiting_owner" &&
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id &&
onUserRespond && (
<div className="flex gap-2 w-full">
<button
onClick={() => onUserRespond(claim.id, "reject")}
className="flex-1 px-4 py-2 border border-red-500 text-red-400 hover:bg-red-500/10 rounded transition"
>
❌ Bukan Punya Saya
</button>
<button
onClick={() => onUserRespond(claim.id, "approve")}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded transition shadow-lg"
>
✅ Ya, Benar
</button>
</div>
)}
{claim.status === "pending" && onVerify && (
<button
onClick={() => onVerify(claim)}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔍</span> Verifikasi
</button>
)}
{claim.status === "approved" && !claim.berita_acara_no && (
<>
{onCancelApproval && (
<button
onClick={() => {
if (confirm("Yakin ingin membatalkan status Approved?"))
onCancelApproval(claim.id);
}}
className="px-3 py-2 border border-red-500 text-red-400 hover:bg-red-500/10 text-sm rounded transition"
title="Batalkan Approval"
>
🚫 Batal
</button>
)}
{onVerify && (
<button
onClick={() => onVerify(claim)}
className="flex-1 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔒</span> Close Case
</button>
)}
</>
)}
{claim.status === "approved" && claim.berita_acara_no && onReopen && (
<button
onClick={() => onReopen(claim)}
className="flex-1 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔄</span> Reopen Case
</button>
)}
</div>
</div>
);
};
+144
View File
@@ -0,0 +1,144 @@
const DirectClaimModal = ({ isOpen, onClose, onSubmit, loading, lostItem }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80 backdrop-blur-sm p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-r from-blue-900 to-slate-900 p-6 border-b border-slate-700 z-10">
<h3 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-3xl">⚡</span>
Klaim Langsung ke Pemilik
</h3>
<p className="text-slate-300 mt-2 text-sm">
Formulir ini akan langsung dikirim ke pemilik barang untuk
disetujui/ditolak
</p>
</div>
{lostItem && (
<div className="p-6 bg-blue-900/20 border-b border-slate-700">
<h4 className="text-lg font-bold text-white mb-3">
📦 Barang yang Akan Diklaim:
</h4>
<div className="bg-slate-800/50 rounded-lg p-4 space-y-2 text-sm">
<p className="text-white">
<span className="font-semibold">Nama:</span> {lostItem.name}
</p>
<p className="text-slate-300">
<span className="font-semibold">Kategori:</span>{" "}
{lostItem.category}
</p>
{lostItem.color && (
<p className="text-slate-300">
<span className="font-semibold">Warna:</span> {lostItem.color}
</p>
)}
<p className="text-slate-300">
<span className="font-semibold">Lokasi Hilang:</span>{" "}
{lostItem.location}
</p>
<p className="text-slate-300">
<span className="font-semibold">Deskripsi:</span>{" "}
{lostItem.description}
</p>
</div>
</div>
)}
<form onSubmit={onSubmit} className="p-6 space-y-6">
<div className="bg-orange-900/20 border border-orange-500/30 rounded-lg p-4">
<p className="text-orange-300 text-sm flex items-center gap-2">
<span className="text-xl">⚠️</span>
<span>
<strong>Penting:</strong> Pastikan ini adalah barang yang
benar-benar Anda temukan. Pemilik akan langsung menerima
notifikasi.
</span>
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📝 Deskripsi Barang yang Anda Temukan *
</label>
<textarea
name="description"
required
rows="5"
placeholder="Jelaskan detail barang yang Anda temukan (warna, kondisi, ciri khusus, dll)"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
<p className="text-xs text-slate-500 mt-1">
Berikan detail yang cukup agar pemilik yakin ini barangnya
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📞 Nomor Kontak Anda *
</label>
<input
type="text"
name="contact"
required
placeholder="Contoh: 081234567890"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Pemilik akan menghubungi Anda untuk koordinasi pengambilan
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📎 URL Bukti (Opsional)
</label>
<input
type="text"
name="proof_url"
placeholder="https://example.com/foto-barang.jpg"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Link foto atau dokumen pendukung (jika ada)
</p>
</div>
<div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4">
<h5 className="font-bold text-green-300 mb-2 flex items-center gap-2">
<span className="text-xl">✅</span>
Alur Setelah Submit:
</h5>
<ol className="text-sm text-slate-300 space-y-1 list-decimal list-inside">
<li>Notifikasi langsung terkirim ke pemilik</li>
<li>Pemilik akan melihat detail klaim Anda</li>
<li>Pemilik bisa Approve/Reject klaim</li>
<li>Jika Approved, Anda koordinasi pengambilan</li>
<li>Setelah serah terima, pemilik konfirmasi case closed</li>
</ol>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-700">
<button
type="button"
onClick={onClose}
disabled={loading}
className="flex-1 px-6 py-3 bg-slate-800 text-slate-300 rounded-lg font-semibold hover:bg-slate-700 transition disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-6 py-3 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 disabled:opacity-50 shadow-lg shadow-blue-500/30"
>
{loading ? "⏳ Mengirim..." : "⚡ Kirim ke Pemilik"}
</button>
</div>
</form>
</div>
</div>
);
};
window.DirectClaimModal = DirectClaimModal;
+76
View File
@@ -0,0 +1,76 @@
// web/js/components/FoundOptionModal.js
const FoundOptionModal = ({ isOpen, onClose, onSelectOption }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80 backdrop-blur-sm p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 shadow-2xl max-w-lg w-full overflow-hidden animate-fade-in-up">
{/* Header Modal */}
<div className="p-6 border-b border-slate-800">
<h3 className="text-xl font-bold text-white text-center">
🧐 Bagaimana proses pengembaliannya?
</h3>
<p className="text-slate-400 text-center text-sm mt-2">
Pilih metode yang Anda inginkan untuk memproses barang temuan ini.
</p>
</div>
{/* Pilihan Opsi */}
<div className="p-6 space-y-4">
{/* OPSI 1: Langsung ke Pemilik (Direct) */}
<button
onClick={() => onSelectOption("direct")}
className="w-full group relative flex items-center p-4 bg-gradient-to-r from-blue-900/40 to-slate-900 border-2 border-blue-500/30 hover:border-blue-500 rounded-xl transition-all hover:shadow-lg hover:shadow-blue-500/20 text-left"
>
<div className="bg-blue-500/20 p-3 rounded-full mr-4 group-hover:bg-blue-500 group-hover:text-white transition-colors text-blue-400">
⚡
</div>
<div>
<h4 className="font-bold text-white group-hover:text-blue-400 transition">
Langsung ke Pemilik (Cepat)
</h4>
<p className="text-xs text-slate-400 mt-1">
Anda mengisi form, notifikasi langsung ke pemilik untuk
<span className="text-green-400 font-bold ml-1">
Approve/Reject.
</span>
</p>
<span className="text-[10px] text-blue-300 font-mono mt-1 block">
*Tanpa perantara Manager
</span>
</div>
</button>
{/* OPSI 2: Via Manager (Standard) */}
<button
onClick={() => onSelectOption("manager")}
className="w-full group relative flex items-center p-4 bg-gradient-to-r from-purple-900/40 to-slate-900 border-2 border-purple-500/30 hover:border-purple-500 rounded-xl transition-all hover:shadow-lg hover:shadow-purple-500/20 text-left"
>
<div className="bg-purple-500/20 p-3 rounded-full mr-4 group-hover:bg-purple-500 group-hover:text-white transition-colors text-purple-400">
🛡️
</div>
<div>
<h4 className="font-bold text-white group-hover:text-purple-400 transition">
Verifikasi Manager (Aman)
</h4>
<p className="text-xs text-slate-400 mt-1">
Laporan diperiksa Manager/Posko dulu sebelum diteruskan ke
pemilik.
</p>
</div>
</button>
</div>
{/* Footer / Tombol Batal */}
<div className="p-4 bg-slate-950/50 border-t border-slate-800 text-center">
<button
onClick={onClose}
className="text-slate-500 hover:text-white text-sm font-medium transition px-4 py-2 hover:bg-slate-800 rounded-lg"
>
Batal
</button>
</div>
</div>
</div>
);
};
+188
View File
@@ -0,0 +1,188 @@
// web/js/components/ItemCard.js
const ItemCard = ({
item,
onViewDetail,
onClaim,
onEdit,
onDelete,
onManualClaim,
currentUserId,
showActions = true,
isManager = false,
}) => {
const isOwnItem = currentUserId && item.reporter_id === currentUserId;
// ✅ FIX: Status logic yang lebih akurat
const isCaseClosed = item.status === "case_closed";
const isExpired = item.status === "expired";
const isVerified = item.status === "verified" || item.status === "completed";
const isPendingClaim = item.status === "pending_claim";
const isUnclaimed = item.status === "unclaimed";
// ✅ User hanya bisa claim jika unclaimed DAN bukan barang sendiri
const canClaim = isUnclaimed && !isOwnItem;
// ✅ Edit/Delete logic
let canEditOrDelete = false;
if (!isCaseClosed) {
if (isManager) {
canEditOrDelete = true;
} else {
canEditOrDelete = !isExpired;
}
}
const showEditButton = (isOwnItem || isManager) && canEditOrDelete;
// ✅ Improved status display text
const getStatusText = (status) => {
const statusMap = {
unclaimed: "Belum Diklaim",
pending_claim: "Sedang Diklaim",
verified: "Sudah Diklaim",
completed: "Selesai",
case_closed: "Kasus Ditutup",
expired: "Kadaluarsa",
};
return statusMap[status] || status;
};
return (
<div
className={`bg-gradient-to-br from-slate-800 to-slate-900 border-2 rounded-xl overflow-hidden hover:shadow-2xl transition-all cursor-pointer ${
isExpired
? "border-red-500/50 hover:border-red-500"
: "border-slate-700 hover:border-blue-500 hover:shadow-blue-500/20 hover:-translate-y-1"
}`}
>
{/* Expired Banner */}
{isExpired && (
<div className="absolute top-0 left-0 right-0 bg-red-600/90 text-white text-center py-1 text-xs font-bold z-10">
⚠️ EXPIRED - Manager Only View
</div>
)}
{/* Completed Banner - TAMBAHKAN INI */}
{isVerified && (
<div className="absolute top-0 left-0 right-0 bg-green-600/90 text-white text-center py-1 text-xs font-bold z-10">
✅ SUDAH DIKLAIM
</div>
)}
<img
src={
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}
alt={item.name}
className={`w-full h-48 object-cover ${isExpired ? "opacity-50" : ""} ${
isVerified ? "opacity-80" : ""
}`}
onClick={() => onViewDetail && onViewDetail(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>
</div>
<div className="flex items-center justify-between mb-3">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{getStatusText(item.status)} {/* ✅ Better display */}
</span>
{isOwnItem && (
<span className="px-3 py-1 bg-green-500/20 text-green-400 text-xs font-semibold rounded-full border border-green-500/50">
Barang Anda
</span>
)}
</div>
{showActions && (
<div className="flex gap-2 flex-wrap">
{/* Detail Button */}
{onViewDetail && (
<button
onClick={(e) => {
e.stopPropagation();
onViewDetail(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>
)}
{/* Manual Claim (Manager) */}
{onManualClaim && isUnclaimed && (
<button
onClick={(e) => {
e.stopPropagation();
onManualClaim(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
🤝 Klaim
</button>
)}
{/* User Claim - ✅ HANYA SHOW JIKA UNCLAIMED */}
{onClaim && canClaim && (
<button
onClick={(e) => {
e.stopPropagation();
onClaim(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
Klaim
</button>
)}
{/* Info untuk status pending */}
{isPendingClaim && !isOwnItem && (
<div className="flex-1 px-3 py-2 bg-yellow-600/20 text-yellow-400 text-xs rounded-lg border border-yellow-600/50 text-center">
⏳ Sedang diproses
</div>
)}
{/* Edit Button */}
{onEdit && showEditButton && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit(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>
)}
{/* Delete Button */}
{onDelete && canEditOrDelete && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(`Yakin hapus "${item.name}"?`)) onDelete(item.id);
}}
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
title="Hapus Barang"
>
🗑️
</button>
)}
</div>
)}
</div>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
// assets/js/components/Modal.js
const Modal = ({ isOpen, onClose, title, children, size = "default" }) => {
if (!isOpen) return null;
const sizeClasses = {
default: "max-w-2xl",
large: "max-w-4xl",
small: "max-w-md",
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
onClick={onClose}
>
<div
className={`bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl ${sizeClasses[size]}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
<h3 className="text-xl font-semibold text-white">{title}</h3>
<button
onClick={onClose}
className="text-2xl text-slate-400 hover:text-white transition"
>
×
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
};
+60
View File
@@ -0,0 +1,60 @@
// assets/js/components/Navbar.js
const Navbar = ({ user, onLogout, userType = "user" }) => {
const colorMap = {
admin: "from-red-500 to-red-600",
manager: "from-yellow-500 to-yellow-600",
user: "from-blue-500 to-blue-600",
};
const labelMap = {
admin: "Admin",
manager: "Manager",
user: "User",
};
const textColorMap = {
admin: "text-red-400",
manager: "text-yellow-400",
user: "text-blue-400",
};
return (
<nav className="bg-gradient-to-r from-slate-800 to-slate-900 shadow-xl sticky top-0 z-50 border-b border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="text-2xl font-bold text-blue-400">
🔍 Lost & Found
</div>
<div className="flex items-center gap-4">
<NotificationDropdown />
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-full bg-gradient-to-br ${
colorMap[userType] || colorMap.user
} text-white flex items-center justify-center font-semibold shadow-lg`}
>
{Helpers.getInitials(user?.name)}
</div>
<div className="flex flex-col">
<span className="font-medium text-white">{user?.name}</span>
{userType !== "user" && (
<span
className={`text-xs ${textColorMap[userType]} font-semibold`}
>
{labelMap[userType]}
</span>
)}
</div>
</div>
<button
onClick={onLogout}
className="px-4 py-2 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition shadow-lg"
>
Logout
</button>
</div>
</div>
</div>
</nav>
);
};
+130
View File
@@ -0,0 +1,130 @@
// web/js/components/NotificationDropdown.js
const NotificationDropdown = () => {
const [notifications, setNotifications] = React.useState([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
// Load notifications periodically
React.useEffect(() => {
loadNotifications();
const interval = setInterval(loadNotifications, 30000); // Refresh tiap 30 detik
return () => clearInterval(interval);
}, []);
const loadNotifications = async () => {
try {
const response = await fetch(
`${CONFIG.API_URL}${CONFIG.API_ENDPOINTS.NOTIFICATIONS}?limit=5`,
{
headers: { Authorization: `Bearer ${AuthUtils.getToken()}` },
}
);
if (response.ok) {
const result = await response.json();
setNotifications(result.data.notifications || []);
setUnreadCount(result.data.unread_count || 0);
}
} catch (error) {
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (id) => {
try {
await fetch(
`${CONFIG.API_URL}${CONFIG.API_ENDPOINTS.NOTIFICATIONS}/${id}/read`,
{
method: "PATCH",
headers: { Authorization: `Bearer ${AuthUtils.getToken()}` },
}
);
loadNotifications();
} catch (error) {
console.error("Error marking as read", error);
}
};
const handleItemClick = (notif) => {
handleMarkAsRead(notif.id);
// Redirect logic based on type
if (notif.entity_type === "claim") {
// Trigger event custom agar UserApp bisa membuka tab Claims
window.dispatchEvent(new CustomEvent("open-my-claims"));
}
};
return (
<div className="relative">
{/* Bell Icon */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-slate-300 hover:text-white transition"
>
<span className="text-2xl">🔔</span>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full border-2 border-slate-900 animate-pulse">
{unreadCount}
</span>
)}
</button>
{/* Dropdown Content */}
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
></div>
<div className="absolute right-0 mt-2 w-80 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden animate-fade-in">
<div className="p-3 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
<h3 className="font-semibold text-white text-sm">Notifikasi</h3>
<button
onClick={loadNotifications}
className="text-xs text-blue-400 hover:text-blue-300"
>
Refresh
</button>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-slate-500 text-sm">
Belum ada notifikasi
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
onClick={() => handleItemClick(notif)}
className={`p-3 border-b border-slate-700 cursor-pointer hover:bg-slate-700 transition ${
!notif.is_read
? "bg-slate-700/30 border-l-4 border-l-blue-500"
: ""
}`}
>
<div className="flex justify-between items-start mb-1">
<strong
className={`text-sm ${
!notif.is_read ? "text-blue-400" : "text-slate-300"
}`}
>
{notif.title}
</strong>
<span className="text-[10px] text-slate-500">
{new Date(notif.created_at).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-slate-400 line-clamp-2">
{notif.message}
</p>
</div>
))
)}
</div>
</div>
</>
)}
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
// assets/js/components/Pagination.js
const Pagination = ({
currentPage,
totalPages,
totalRecords,
onPageChange,
itemsPerPage = 10,
}) => {
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
pages.push(1, 2, 3, 4, "...", totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(
1,
"...",
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages
);
} else {
pages.push(
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages
);
}
}
return pages;
};
const startRecord = Math.min(
(currentPage - 1) * itemsPerPage + 1,
totalRecords
);
const endRecord = Math.min(currentPage * itemsPerPage, totalRecords);
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 px-6 py-4 bg-slate-900/50 rounded-xl border border-slate-700">
<div className="text-slate-400 text-sm">
Menampilkan {startRecord} - {endRecord} dari {totalRecords} data
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition disabled:bg-slate-900 disabled:text-slate-600 disabled:cursor-not-allowed border border-slate-700"
>
← Prev
</button>
{getPageNumbers().map((page, index) =>
page === "..." ? (
<span key={`dots-${index}`} className="px-3 py-2 text-slate-400">
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-4 py-2 rounded-lg transition border ${
currentPage === page
? "bg-blue-600 text-white border-blue-500 font-semibold shadow-lg shadow-blue-500/50"
: "bg-slate-800 text-white hover:bg-slate-700 border-slate-700"
}`}
>
{page}
</button>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition disabled:bg-slate-900 disabled:text-slate-600 disabled:cursor-not-allowed border border-slate-700"
>
Next →
</button>
</div>
</div>
);
};
+12
View File
@@ -0,0 +1,12 @@
// assets/js/components/StatCard.js
const StatCard = ({ title, value, icon, colorClass = "text-blue-400" }) => (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 p-6 rounded-xl shadow-lg border border-slate-700 hover:border-blue-500 transition-all">
<div className="flex items-center justify-between">
<div>
<h3 className="text-slate-400 text-sm mb-2">{title}</h3>
<div className={`text-3xl font-bold ${colorClass}`}>{value}</div>
</div>
<div className="text-4xl">{icon}</div>
</div>
</div>
);
+26
View File
@@ -0,0 +1,26 @@
// assets/js/components/Toast.js
const Toast = ({ message, type, onClose }) => {
const { useEffect } = React;
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const typeClasses = {
success: "bg-green-100 text-green-800 border-2 border-green-500",
error: "bg-red-100 text-red-800 border-2 border-red-500",
info: "bg-blue-100 text-blue-800 border-2 border-blue-500",
warning: "bg-yellow-100 text-yellow-800 border-2 border-yellow-500",
};
return (
<div
className={`fixed top-20 right-4 px-6 py-4 rounded-xl shadow-lg z-50 animate-slide-in ${
typeClasses[type] || typeClasses.info
}`}
>
{message}
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
// assets/js/components/UserRow.js
const UserRow = ({ user, onEdit, onBlock, onUnblock }) => (
<tr className="hover:bg-slate-700/50 transition">
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-white">{user.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-slate-300">{user.email}</td>
<td className="px-6 py-4 whitespace-nowrap text-slate-300">{user.nrp}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getRoleBadgeClass(
user.role
)}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
user.status || "active"
)}`}
>
{user.status || "active"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<button
onClick={() => onEdit(user)}
className="px-3 py-1 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
>
Edit
</button>
{user.status === "active" || !user.status ? (
<button
onClick={() => onBlock(user.id)}
className="px-3 py-1 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded hover:from-red-700 hover:to-red-800 transition shadow-lg"
>
Block
</button>
) : (
<button
onClick={() => onUnblock(user.id)}
className="px-3 py-1 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
Unblock
</button>
)}
</div>
</td>
</tr>
);
+38
View File
@@ -0,0 +1,38 @@
// assets/js/config.js
const CONFIG = {
API_URL: "http://localhost:8080",
API_ENDPOINTS: {
ROLES: "/api/admin/roles",
PERMISSIONS: "/api/admin/permissions",
LOGIN: "/api/login",
REGISTER: "/api/register",
CATEGORIES: "/api/categories",
ITEMS: "/api/items",
CLAIMS: "/api/claims",
LOST_ITEMS: "/api/lost-items",
UPLOAD: "/api/upload/item-image",
NOTIFICATIONS: "/api/notifications",
AI: {
CHAT: "/api/ai/chat",
HISTORY: "/api/ai/history",
},
ADMIN: {
DASHBOARD: "/api/admin/dashboard",
USERS: "/api/admin/users",
EXPORT: "/api/reports/export",
AUDIT_LOGS: "/api/admin/audit-logs",
},
MANAGER: {
DASHBOARD: "/api/manager/dashboard",
},
USER: {
STATS: "/api/user/stats",
ITEMS: "/api/user/items",
LOST_ITEMS: "/api/user/lost-items",
CLAIMS: "/api/user/claims",
},
},
PAGINATION: {
LIMIT: 10,
},
};
+105
View File
@@ -0,0 +1,105 @@
const LostItemsTab = () => {
// State untuk modal
const [showOptionModal, setShowOptionModal] = React.useState(false);
const [showFormModal, setShowFormModal] = React.useState(false);
const [selectedLostItem, setSelectedLostItem] = React.useState(null);
const [selectedMethod, setSelectedMethod] = React.useState(null); // 'direct' or 'manager'
// 1. Handler saat tombol "Saya Menemukan" ditekan
const handleSayaMenemukanClick = (lostItem) => {
setSelectedLostItem(lostItem);
setShowOptionModal(true); // Tampilkan Modal Opsi
};
// 2. Handler saat Opsi dipilih di Modal
const handleOptionSelect = (method) => {
// method akan bernilai "direct" atau "manager"
console.log("Metode dipilih:", method);
// 1. Simpan metode pilihan user
setSelectedMethod(method);
// 2. Tutup modal opsi
setShowOptionModal(false);
// 3. Langsung buka modal Form Pengisian Data (Form Penemu)
// Form ini akan sama untuk kedua opsi, tapi cara handling submit-nya nanti beda
setShowFormModal(true);
};
// 3. Handler Submit Form Barang Ditemukan
const handleFormSubmit = async (formData) => {
try {
// Siapkan payload data
const payload = {
...formData, // Data dari form (foto, lokasi, dll)
lost_item_id: selectedLostItem.id, // ID barang hilang yang diklaim
// LOGIKA KUNCI:
// Jika "direct", set flag is_direct_to_owner = true.
// Backend harus membaca flag ini untuk bypass status manager verification.
is_direct_to_owner: selectedMethod === "direct",
};
console.log("Submitting payload:", payload);
// Kirim ke endpoint (Pastikan backend Anda menangani field is_direct_to_owner)
await ApiUtils.post("/api/items/found-linked", payload);
// Berikan feedback yang sesuai ke user
if (selectedMethod === "direct") {
alert("Berhasil! Laporan dikirim LANGSUNG ke pemilik untuk approval.");
} else {
alert("Berhasil! Laporan dikirim ke MANAGER untuk verifikasi.");
}
// Reset dan tutup modal
setShowFormModal(false);
// Refresh data list jika perlu
// loadLostItems();
} catch (error) {
console.error("Error submitting found report:", error);
alert("Gagal mengirim laporan: " + error.message);
}
};
return (
<div>
{/* List Barang Hilang */}
{lostItems.map((item) => (
<div key={item.id} className="card">
<h3>{item.name}</h3>
<button
onClick={() => handleSayaMenemukanClick(item)}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
✋ Saya Menemukan
</button>
</div>
))}
{/* Modal Pilihan Opsi */}
<FoundOptionModal
isOpen={showFoundOptionModal}
onClose={() => setShowFoundOptionModal(false)}
onSelectOption={handleSelectFoundOption}
/>
<ReportFoundModal
isOpen={state.showReportFoundModal}
onClose={() => state.setShowReportFoundModal(false)}
onSubmit={handlers.submitReportFound} // Gunakan handler yang baru
// Opsional: Kirim data barang hilang untuk pre-fill deskripsi otomatis
linkedLostItem={selectedLostItemForFound}
/>
{/* Modal Form Pengisian (Gunakan komponen CreateItemModal yang sudah ada tapi dimodifikasi prop onSubmitnya) */}
<CreateFoundItemModal
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
onSubmit={handleFormSubmit}
targetItemName={selectedLostItem?.name}
/>
</div>
);
};
+560
View File
@@ -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>
);
};
+220
View File
@@ -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>
);
};
+127
View File
@@ -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;
+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>
);
};
File diff suppressed because it is too large Load Diff
+247
View File
@@ -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,
};
};
+165
View File
@@ -0,0 +1,165 @@
// assets/js/pages/auth/LoginApp.js
const { useState, useEffect } = React;
const LoginApp = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState(null);
const [errors, setErrors] = useState({});
const state = { setErrors, setLoading, setToast };
const handlers = useLoginHandlers(state);
useEffect(() => {
const user = AuthUtils.getCurrentUser();
if (AuthUtils.isAuthenticated() && user?.role) {
AuthUtils.redirectBasedOnRole(user);
}
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 p-5">
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-3xl shadow-2xl overflow-hidden max-w-md w-full border border-slate-700">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 via-blue-700 to-indigo-700 text-white p-10 text-center relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImdyaWQiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHBhdGggZD0iTSAxMCAwIEwgMCAwIDAgMTAiIGZpbGw9Im5vbmUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS1vcGFjaXR5PSIwLjEiIHN0cm9rZS13aWR0aD0iMSIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0idXJsKCNncmlkKSIvPjwvc3ZnPg==')] opacity-20"></div>
<h1 className="text-4xl font-bold mb-2 relative z-10">🔍 Login</h1>
<p className="text-blue-100 relative z-10">
Masuk ke Lost & Found System
</p>
</div>
{/* Body */}
<div className="p-10">
<form
onSubmit={(e) => handlers.handleSubmit(e, email, password)}
className="space-y-6"
>
{/* Email */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="mahasiswa@example.com"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.email
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.email && (
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
)}
</div>
{/* Password */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="********"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.password
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.password && (
<p className="text-red-400 text-sm mt-1">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold rounded-xl hover:from-blue-700 hover:to-blue-800 transition transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-blue-500/50 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed disabled:transform-none"
>
{loading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin h-5 w-5 mr-3"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Logging in...
</span>
) : (
"Login"
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-slate-800 text-slate-400">atau</span>
</div>
</div>
{/* Register Link */}
<div className="text-center">
<p className="text-slate-300">
Belum punya akun?{" "}
<a
href="/register"
className="text-blue-400 font-semibold hover:text-blue-300 hover:underline"
>
Register disini
</a>
</p>
</div>
{/* Back to Home */}
<div className="text-center mt-4">
<a
href="/"
className="text-slate-400 text-sm hover:text-blue-400 transition"
>
← Kembali ke Beranda
</a>
</div>
</div>
</div>
{/* Toast */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
};
ReactDOM.render(<LoginApp />, document.getElementById("root"));
+301
View File
@@ -0,0 +1,301 @@
// assets/js/pages/auth/RegisterApp.js
const { useState, useEffect } = React;
const RegisterApp = () => {
const [formData, setFormData] = useState({
name: "",
email: "",
nrp: "",
phone: "",
password: "",
confirmPassword: "",
});
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState(null);
const [errors, setErrors] = useState({});
const [passwordStrength, setPasswordStrength] = useState("");
const state = { setErrors, setLoading, setToast };
const handlers = useRegisterHandlers(state);
useEffect(() => {
const user = AuthUtils.getCurrentUser();
if (AuthUtils.isAuthenticated() && user?.role) {
AuthUtils.redirectBasedOnRole(user);
}
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (name === "password") {
setPasswordStrength(Helpers.checkPasswordStrength(value));
}
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const getPasswordStrengthColor = () => {
if (passwordStrength === "weak") return "text-red-400";
if (passwordStrength === "medium") return "text-yellow-400";
if (passwordStrength === "strong") return "text-green-400";
return "";
};
const getPasswordStrengthText = () => {
if (passwordStrength === "weak") return "⚠️ Password lemah";
if (passwordStrength === "medium") return "✓ Password sedang";
if (passwordStrength === "strong") return "✓ Password kuat";
return "";
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 p-5">
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-3xl shadow-2xl overflow-hidden max-w-lg w-full my-8 border border-slate-700">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 via-blue-700 to-indigo-700 text-white p-10 text-center relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImdyaWQiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHBhdGggZD0iTSAxMCAwIEwgMCAwIDAgMTAiIGZpbGw9Im5vbmUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS1vcGFjaXR5PSIwLjEiIHN0cm9rZS13aWR0aD0iMSIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0idXJsKCNncmlkKSIvPjwvc3ZnPg==')] opacity-20"></div>
<h1 className="text-4xl font-bold mb-2 relative z-10">🔍 Register</h1>
<p className="text-blue-100 relative z-10">
Buat akun Lost & Found System
</p>
</div>
{/* Body */}
<div className="p-10">
<form
onSubmit={(e) => handlers.handleSubmit(e, formData)}
className="space-y-5"
>
{/* Name */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Nama Lengkap
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="John Doe"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.name
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.name && (
<p className="text-red-400 text-sm mt-1">{errors.name}</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Email
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="mahasiswa@example.com"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.email
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.email && (
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
)}
</div>
{/* NRP & Phone */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-2">
NRP
</label>
<input
type="text"
name="nrp"
value={formData.nrp}
onChange={handleChange}
placeholder="5026211234"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.nrp
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.nrp && (
<p className="text-red-400 text-sm mt-1">{errors.nrp}</p>
)}
</div>
<div>
<label className="block text-slate-300 font-semibold mb-2">
No. Telepon
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="08123456789"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.phone
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.phone && (
<p className="text-red-400 text-sm mt-1">{errors.phone}</p>
)}
</div>
</div>
{/* Password */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Password
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="********"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.password
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{passwordStrength && (
<p className={`text-sm mt-1 ${getPasswordStrengthColor()}`}>
{getPasswordStrengthText()}
</p>
)}
{errors.password && (
<p className="text-red-400 text-sm mt-1">{errors.password}</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="block text-slate-300 font-semibold mb-2">
Konfirmasi Password
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="********"
className={`w-full px-4 py-3 bg-slate-700 border-2 rounded-xl text-white placeholder-slate-400 focus:outline-none transition ${
errors.confirmPassword
? "border-red-500 focus:border-red-500"
: "border-slate-600 focus:border-blue-500"
}`}
required
/>
{errors.confirmPassword && (
<p className="text-red-400 text-sm mt-1">
{errors.confirmPassword}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold rounded-xl hover:from-blue-700 hover:to-blue-800 transition transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-blue-500/50 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed disabled:transform-none mt-2"
>
{loading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin h-5 w-5 mr-3"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Registering...
</span>
) : (
"Register"
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-slate-800 text-slate-400">atau</span>
</div>
</div>
{/* Login Link */}
<div className="text-center">
<p className="text-slate-300">
Sudah punya akun?{" "}
<a
href="/login"
className="text-blue-400 font-semibold hover:text-blue-300 hover:underline"
>
Login disini
</a>
</p>
</div>
{/* Back to Home */}
<div className="text-center mt-4">
<a
href="/"
className="text-slate-400 text-sm hover:text-blue-400 transition"
>
← Kembali ke Beranda
</a>
</div>
</div>
</div>
{/* Toast */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</div>
);
};
ReactDOM.render(<RegisterApp />, document.getElementById("root"));
+51
View File
@@ -0,0 +1,51 @@
// assets/js/pages/auth/useLoginHandlers.js
const useLoginHandlers = (state) => {
const { setErrors, setLoading, setToast } = state;
const showToast = (message, type = "info") => {
setToast({ message, type });
};
const handleSubmit = async (e, email, password) => {
e.preventDefault();
setErrors({});
let newErrors = {};
if (!Helpers.validateEmail(email)) {
newErrors.email = "Email tidak valid";
}
if (password.length < 6) {
newErrors.password = "Password minimal 6 karakter";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setLoading(true);
try {
const data = await ApiUtils.post(CONFIG.API_ENDPOINTS.LOGIN, {
email,
password,
});
AuthUtils.setToken(data.data.token);
AuthUtils.setCurrentUser(data.data.user);
showToast("Login berhasil! Mengalihkan...", "success");
setTimeout(() => {
AuthUtils.redirectBasedOnRole(data.data.user);
}, 500);
} catch (error) {
showToast(error.message || "Email atau password salah!", "error");
} finally {
setLoading(false);
}
};
return { handleSubmit };
};
+63
View File
@@ -0,0 +1,63 @@
// assets/js/pages/auth/useRegisterHandlers.js
const useRegisterHandlers = (state) => {
const { setErrors, setLoading, setToast } = state;
const showToast = (message, type = "info") => {
setToast({ message, type });
};
const handleSubmit = async (e, formData) => {
e.preventDefault();
setErrors({});
let newErrors = {};
if (formData.name.length < 3) {
newErrors.name = "Nama minimal 3 karakter";
}
if (!Helpers.validateEmail(formData.email)) {
newErrors.email = "Email tidak valid";
}
if (formData.nrp.length < 10) {
newErrors.nrp = "NRP minimal 10 digit";
}
if (!Helpers.validatePhone(formData.phone)) {
newErrors.phone = "Nomor tidak valid";
}
if (formData.password.length < 8) {
newErrors.password = "Password minimal 8 karakter";
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "Password tidak cocok";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setLoading(true);
try {
await ApiUtils.post(CONFIG.API_ENDPOINTS.REGISTER, {
name: formData.name,
email: formData.email,
nrp: formData.nrp,
phone: formData.phone,
password: formData.password,
});
showToast("Registrasi berhasil! Mengalihkan ke login...", "success");
setTimeout(() => {
window.location.href = "/login";
}, 2000);
} catch (error) {
showToast(error.message || "Registrasi gagal!", "error");
} finally {
setLoading(false);
}
};
return { handleSubmit };
};
+239
View File
@@ -0,0 +1,239 @@
// assets/js/pages/manager/ManagerApp.js
const { useState, useEffect } = React;
const ManagerApp = () => {
const state = useManagerState();
const handlers = useManagerHandlers(state);
const {
user,
setUser,
activeTab,
setActiveTab,
stats,
showDetailModal,
setShowDetailModal,
selectedItem,
showVerifyModal,
setShowVerifyModal,
selectedClaim,
toast,
setToast,
showCloseCaseModal,
setShowCloseCaseModal,
closeCaseData,
setCloseCaseData,
loading,
showReportFoundModal,
setShowReportFoundModal,
photoPreview,
setPhotoPreview,
showManualClaimModal,
setShowManualClaimModal,
selectedItemForClaim,
setSelectedItemForClaim,
showApproveModal,
setShowApproveModal,
} = state;
const {
loadData,
handleApproveClaim,
handleRejectClaim,
handleCloseCase,
submitCloseCase,
submitReportFound,
handlePhotoChange,
handleLogout,
submitApproveClaim,
} = handlers;
useEffect(() => {
if (!AuthUtils.checkAuthAndRedirect("manager")) return;
const currentUser = AuthUtils.getCurrentUser();
setUser(currentUser);
loadData();
}, []);
useEffect(() => {
if (activeTab === "claims") {
handlers.loadClaims();
}
}, [activeTab]);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
<Navbar user={user} onLogout={handleLogout} userType="manager" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">
Dashboard Manager
</h1>
<p className="text-slate-400">
Kelola barang temuan dan verifikasi klaim
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatCard
title="Total Barang"
value={stats.total_items || 0}
icon="📦"
/>
<StatCard
title="Pending Claim"
value={stats.pending_claims || 0}
icon="⏳"
colorClass="text-yellow-400"
/>
<StatCard
title="Verified"
value={stats.verified || 0}
icon="✅"
colorClass="text-green-400"
/>
<StatCard
title="Expired"
value={stats.expired || 0}
icon="⚠️"
colorClass="text-red-400"
/>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setActiveTab("items")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "items"
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
}`}
>
📦 Kelola Barang
</button>
<button
onClick={() => {
setActiveTab("lost-items");
handlers.loadLostItems();
}}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "lost-items"
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
}`}
>
😢 Kelola Barang Hilang
</button>
<button
onClick={() => setActiveTab("claims")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "claims"
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
}`}
>
🤝 Verifikasi Klaim
</button>
</div>
{/* Tab Content */}
{activeTab === "items" && (
<ItemsTabManager state={state} handlers={handlers} />
)}
{activeTab === "lost-items" && (
<LostItemsTabManager state={state} handlers={handlers} />
)}
{activeTab === "claims" && (
<ClaimsTabManager state={state} handlers={handlers} />
)}
{/* Modals */}
<DetailModal
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
item={selectedItem}
/>
<VerifyClaimModal
isOpen={showVerifyModal}
onClose={() => setShowVerifyModal(false)}
claim={selectedClaim}
onApprove={handleApproveClaim}
onReject={handleRejectClaim}
onCloseCase={handleCloseCase}
/>
<CloseCaseModal
isOpen={showCloseCaseModal}
onClose={() => setShowCloseCaseModal(false)}
claim={selectedClaim}
closeCaseData={closeCaseData}
setCloseCaseData={setCloseCaseData}
onSubmit={submitCloseCase}
loading={loading}
/>
<ManagerReportFoundModal
isOpen={showReportFoundModal}
onClose={() => {
setShowReportFoundModal(false);
setPhotoPreview(null);
}}
categories={state.categories}
onSubmit={submitReportFound}
loading={loading}
photoPreview={photoPreview}
onPhotoChange={(e) => handlePhotoChange(e)}
/>
<MatchLostItemModal
isOpen={state.showMatchLostItemModal}
onClose={() => {
state.setShowMatchLostItemModal(false);
state.setSelectedLostItem(null);
}}
lostItem={state.selectedLostItem}
items={state.items}
onSubmit={handlers.submitMatchLostItem}
loading={loading}
/>
<ApproveClaimModal
isOpen={showApproveModal}
onClose={() => setShowApproveModal(false)}
onSubmit={submitApproveClaim}
loading={loading}
/>
<ManagerManualClaimModal
isOpen={showManualClaimModal}
onClose={() => {
setShowManualClaimModal(false);
setSelectedItemForClaim(null);
}}
item={selectedItemForClaim}
onSubmit={handlers.submitManualClaim}
loading={loading}
/>
<EditItemModal state={state} handlers={handlers} />
{/* Toast */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
<AIChatbot />
</div>
</div>
);
};
ReactDOM.render(<ManagerApp />, document.getElementById("root"));
@@ -0,0 +1,305 @@
// web/js/pages/manager/ManagerManualClaimModal.js
// Modal untuk manager input klaim manual ketika user datang langsung (offline)
const ManagerManualClaimModal = ({
isOpen,
onClose,
onSubmit,
loading,
item,
}) => {
const [users, setUsers] = React.useState([]);
const [loadingUsers, setLoadingUsers] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState(null);
// State untuk input manual
const [claimerName, setClaimerName] = React.useState("");
const [claimerContact, setClaimerContact] = React.useState("");
const [description, setDescription] = React.useState("");
const [managerNotes, setManagerNotes] = React.useState("");
// Load users when modal opens
React.useEffect(() => {
if (isOpen) {
loadUsers();
resetForm();
}
}, [isOpen]);
const resetForm = () => {
setSelectedUser(null);
setClaimerName("");
setClaimerContact("");
setDescription("");
setManagerNotes("");
};
const loadUsers = async () => {
try {
setLoadingUsers(true);
const token = localStorage.getItem("token");
const response = await fetch(
`http://localhost:8080/api/manager/users?page=1&limit=1000`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setUsers(data.data || []);
}
} catch (error) {
console.error("Failed to load users:", error);
} finally {
setLoadingUsers(false);
}
};
const handleUserSelect = (e) => {
const userId = e.target.value;
if (!userId) {
setSelectedUser(null);
setClaimerName("");
setClaimerContact("");
return;
}
const user = users.find((u) => u.id === parseInt(userId));
if (user) {
setSelectedUser(user);
setClaimerName(user.name);
setClaimerContact(user.phone || "");
}
};
const handleSubmit = () => {
// Validasi
if (!claimerName || !claimerContact || !description) {
alert("Nama pengklaim, kontak, dan deskripsi wajib diisi!");
return;
}
// Prepare data
const formData = {
item_id: item?.id,
user_id: selectedUser?.id || null,
claimer_name: claimerName,
contact: claimerContact,
description: description,
manager_notes: managerNotes,
};
onSubmit(formData);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
onClick={onClose}
>
<div
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
<h3 className="text-xl font-semibold text-white">
🤝 Input Klaim Manual (Offline)
</h3>
<button
onClick={onClose}
className="text-2xl text-slate-400 hover:text-white transition"
>
×
</button>
</div>
{/* Body */}
<div className="p-6 space-y-4">
{/* Info Box */}
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
<div className="flex items-start gap-3">
<span className="text-2xl">ℹ️</span>
<div className="flex-1">
<strong className="text-blue-400 block mb-1">
Klaim Offline
</strong>
<p className="text-sm text-slate-300">
Fitur ini untuk mencatat klaim yang disampaikan langsung oleh
user kepada manager. Jika pengklaim memiliki akun, pilih di
bawah. Jika tidak, isi data manual.
</p>
</div>
</div>
</div>
{/* Item Info */}
{item && (
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
<strong className="text-green-400 block mb-2">
📦 Barang yang Diklaim:
</strong>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
<div>
<strong className="text-slate-200">Nama:</strong> {item.name}
</div>
<div>
<strong className="text-slate-200">Lokasi:</strong>{" "}
{item.location}
</div>
<div>
<strong className="text-slate-200">Tanggal:</strong>{" "}
{new Date(item.date_found).toLocaleDateString("id-ID")}
</div>
<div>
<strong className="text-slate-200">Status:</strong>{" "}
{item.status}
</div>
</div>
</div>
)}
{/* User Selection */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Pilih User (Pengklaim){" "}
<span className="text-slate-500 font-normal text-sm">
(Opsional)
</span>
</label>
<select
onChange={handleUserSelect}
disabled={loadingUsers}
value={selectedUser?.id || ""}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
>
<option value="">-- Tamu / Tidak Terdaftar --</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} - {user.email} ({user.nrp})
</option>
))}
</select>
<p className="text-xs text-slate-400 mt-1">
Pilih jika pengklaim sudah punya akun. Jika belum, biarkan kosong
dan isi manual.
</p>
</div>
{/* Selected User Info */}
{selectedUser && (
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
<strong className="text-green-400 block mb-2">
✅ User Terpilih:
</strong>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
<div>
<strong className="text-slate-200">Nama:</strong>{" "}
{selectedUser.name}
</div>
<div>
<strong className="text-slate-200">Email:</strong>{" "}
{selectedUser.email}
</div>
<div>
<strong className="text-slate-200">NRP:</strong>{" "}
{selectedUser.nrp}
</div>
<div>
<strong className="text-slate-200">Telepon:</strong>{" "}
{selectedUser.phone || "-"}
</div>
</div>
</div>
)}
<div className="border-t border-slate-700 pt-4">
<h3 className="text-lg font-semibold text-white mb-4">
Detail Klaim
</h3>
</div>
{/* Claimer Name */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Nama Pengklaim *
</label>
<input
type="text"
value={claimerName}
onChange={(e) => setClaimerName(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Nama lengkap pengklaim"
/>
<p className="text-xs text-slate-400 mt-1">
Otomatis terisi jika memilih user, atau isi manual
</p>
</div>
{/* Contact */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Kontak Pengklaim *
</label>
<input
type="text"
value={claimerContact}
onChange={(e) => setClaimerContact(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="08123456789"
/>
</div>
{/* Description */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Deskripsi Klaim (Ciri-ciri yang Disebutkan Pengklaim) *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Tuliskan ciri-ciri barang yang disebutkan oleh pengklaim..."
/>
<p className="text-xs text-yellow-400 mt-1">
⚠️ Pastikan mencatat detail yang disebutkan pengklaim untuk
verifikasi
</p>
</div>
{/* Manager Notes */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Catatan Manager (Opsional)
</label>
<textarea
value={managerNotes}
onChange={(e) => setManagerNotes(e.target.value)}
rows={2}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Catatan internal untuk referensi..."
/>
</div>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={loading}
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
>
{loading ? "⏳ Memproses..." : "✅ Submit Klaim"}
</button>
</div>
</div>
</div>
);
};
// Export ke window untuk digunakan di file lain
window.ManagerManualClaimModal = ManagerManualClaimModal;
+469
View File
@@ -0,0 +1,469 @@
// assets/js/pages/manager/ManagerModals.js
const DetailModal = ({ isOpen, onClose, item }) => (
<Modal isOpen={isOpen} onClose={onClose} title="Detail Barang">
{item && (
<div>
<img
src={item.photo_url || "https://via.placeholder.com/600x400"}
alt={item.name}
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
/>
<h3 className="text-2xl font-bold mb-4 text-white">{item.name}</h3>
<div className="space-y-3 text-slate-300">
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Kategori:</strong>
<span className="ml-2">
{item.category ||
Helpers.getCategoryName(item.category_id) ||
"Tidak ada data"}
</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Lokasi:</strong>
<span className="ml-2">{item.location || "Tidak ada data"}</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Tanggal Ditemukan:</strong>
<span className="ml-2">
{Helpers.formatDate(item.date_found) || "Tidak ada data"}
</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Status:</strong>
<span
className={`ml-2 px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{item.status || "Tidak ada data"}
</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Deskripsi Umum:</strong>
<p className="text-slate-300 mt-2">
{item.description || "Tidak ada deskripsi"}
</p>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Pelapor:</strong>
<span className="ml-2">
{item.reporter_name || "Tidak ada data"}
</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Kontak Pelapor:</strong>
<span className="ml-2">
{item.reporter_contact || "Tidak ada data"}
</span>
</div>
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">🔒</span>
<strong className="text-yellow-400 text-lg">
Ciri Khusus Rahasia (Untuk Verifikasi)
</strong>
</div>
<p className="text-slate-300 leading-relaxed">
{item.secret_details || "Tidak ada deskripsi rahasia"}
</p>
<p className="text-xs text-yellow-400 mt-2">
⚠️ Info ini RAHASIA - gunakan untuk verifikasi klaim
</p>
</div>
</div>
</div>
)}
</Modal>
);
const VerifyClaimModal = ({
isOpen,
onClose,
claim,
onApprove,
onReject,
onCloseCase,
}) => (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Verifikasi Klaim"
size="large"
>
{claim && (
<div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-blue-500/10 p-4 rounded-xl border border-blue-500/30">
<h4 className="font-semibold mb-3 text-blue-400">
Deskripsi Asli Barang (Rahasia)
</h4>
<p className="text-sm text-slate-300 font-medium">
{claim.item_secret_details || "Tidak ada ciri khusus rahasia"}
</p>
<div className="mt-3 pt-3 border-t border-slate-600/50">
<span className="text-xs text-slate-500 uppercase">
Deskripsi Umum:
</span>
<p className="text-xs text-slate-400">{claim.item_description}</p>
</div>
</div>
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
<h4 className="font-semibold mb-3 text-yellow-400">
Deskripsi dari Pengklaim
</h4>
<p className="text-sm text-slate-300">{claim.description}</p>
</div>
</div>
{claim.match_percentage && (
<div
className={`p-6 rounded-xl text-center mb-6 ${
claim.match_percentage >= 70
? "bg-green-500/10 border border-green-500/30"
: "bg-yellow-500/10 border border-yellow-500/30"
}`}
>
<strong className="text-slate-300">Similarity Match:</strong>
<div
className={`text-4xl font-bold mt-2 ${
claim.match_percentage >= 70
? "text-green-400"
: "text-yellow-400"
}`}
>
{claim.match_percentage}%
</div>
</div>
)}
<div className="bg-slate-900/50 p-4 rounded-xl mb-6 border border-slate-700">
<strong className="text-slate-300">Info Pengklaim:</strong>
<div className="mt-2 space-y-1 text-slate-400">
<div>Nama: {claim.user_name}</div>
<div>Kontak: {claim.contact}</div>
</div>
</div>
<div className="flex gap-4">
{claim.status === "pending" && (
<>
<button
onClick={onApprove}
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg"
>
✓ Approve Klaim
</button>
<button
onClick={onReject}
className="flex-1 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-xl hover:from-red-700 hover:to-red-800 transition font-semibold shadow-lg"
>
✗ Reject Klaim
</button>
</>
)}
</div>
{claim.status === "approved" && !claim.berita_acara_no && (
<button
onClick={() => {
onClose();
onCloseCase(claim);
}}
className="w-full mt-4 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg"
>
📋 Close Case (Serah Terima)
</button>
)}
{claim.status === "approved" && claim.berita_acara_no && (
<div className="mt-4 p-4 bg-green-500/10 border-2 border-green-500/30 rounded-xl">
<div className="text-green-400 font-semibold text-center mb-2">
✓ Case Already Closed
</div>
<div className="text-sm text-slate-300 text-center">
No. BA: <strong>{claim.berita_acara_no}</strong>
</div>
</div>
)}
</div>
)}
</Modal>
);
const CloseCaseModal = ({
isOpen,
onClose,
claim,
closeCaseData,
setCloseCaseData,
onSubmit,
loading,
}) => (
<Modal
isOpen={isOpen}
onClose={() => {
onClose();
setCloseCaseData({
berita_acara_no: "",
bukti_serah_terima: "",
notes: "",
});
}}
title="Close Case - Serah Terima Barang"
>
<form onSubmit={onSubmit} className="space-y-4">
<div className="bg-blue-500/10 p-4 rounded-lg border border-blue-500/30 mb-4">
<strong className="text-blue-400">Barang:</strong>
<p className="text-white mt-1">{claim?.item_name}</p>
<strong className="text-blue-400 mt-2 block">Penerima:</strong>
<p className="text-white">{claim?.user_name}</p>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
No. Berita Acara *
</label>
<input
type="text"
value={closeCaseData.berita_acara_no}
onChange={(e) =>
setCloseCaseData({
...closeCaseData,
berita_acara_no: e.target.value,
})
}
placeholder="BA/LNF/2025/001"
required
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Bukti Serah Terima (Foto/PDF)
</label>
<input
type="file"
name="bukti_file"
accept="image/*,application/pdf"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Catatan (Opsional)
</label>
<textarea
value={closeCaseData.notes}
onChange={(e) =>
setCloseCaseData({ ...closeCaseData, notes: e.target.value })
}
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Catatan tambahan..."
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
>
{loading ? "Memproses..." : "✓ Close Case"}
</button>
</form>
</Modal>
);
const LostItemDetailModal = ({ isOpen, onClose, item }) => (
<Modal isOpen={isOpen} onClose={onClose} title="😢 Detail Barang Hilang">
{item && (
<div className="space-y-6">
{/* Header Section */}
<div className="bg-gradient-to-r from-slate-800 to-slate-900 p-5 rounded-xl border-l-4 border-yellow-500 shadow-lg">
<h3 className="text-2xl font-bold text-white mb-1">{item.name}</h3>
<div className="flex items-center gap-2 text-sm text-slate-400">
<span className="bg-slate-700 px-2 py-1 rounded text-xs text-white border border-slate-600">
{item.status === "active" ? "Aktif" : "Ditemukan"}
</span>
<span>•</span>
<span>ID #{item.id}</span>
</div>
</div>
{/* Grid Informasi Utama */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
Kategori
</p>
<p className="text-white flex items-center gap-2">
🏷️ {item.category || "-"}
</p>
</div>
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
Tanggal Hilang
</p>
<p className="text-white flex items-center gap-2">
📅 {Helpers.formatDate(item.date_lost)}
</p>
</div>
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
Warna
</p>
<p className="text-white flex items-center gap-2">
🎨 {item.color || "-"}
</p>
</div>
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
Lokasi
</p>
<p className="text-white flex items-center gap-2">
📍 {item.location || "-"}
</p>
</div>
</div>
{/* Deskripsi */}
<div className="bg-slate-900/50 p-5 rounded-xl border border-slate-600">
<h4 className="text-yellow-400 font-semibold mb-2 flex items-center gap-2">
<span>📝</span> Deskripsi Lengkap
</h4>
<p className="text-slate-300 leading-relaxed text-sm">
"{item.description}"
</p>
</div>
{/* Informasi Pelapor */}
<div className="bg-blue-500/10 border border-blue-500/30 p-5 rounded-xl">
<h4 className="text-blue-400 font-semibold mb-3 flex items-center gap-2">
<span>👤</span> Informasi Pelapor
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-400">Nama Pelapor</p>
<p className="text-white font-medium">{item.user_name}</p>
</div>
<div>
<p className="text-xs text-slate-400">Kontak</p>
<div className="flex items-center gap-2">
<span className="text-white font-medium">
{item.user_contact || "-"}
</span>
{item.user_contact && (
<a
href={`https://wa.me/${item.user_contact
.replace(/^0/, "62")
.replace(/\D/g, "")}`}
target="_blank"
className="text-xs bg-green-600 px-2 py-1 rounded text-white hover:bg-green-700 transition"
>
Chat WA
</a>
)}
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex justify-end pt-4 border-t border-slate-700">
<button
onClick={onClose}
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
>
Tutup
</button>
</div>
</div>
)}
</Modal>
);
const ApproveClaimModal = ({ isOpen, onClose, onSubmit, loading }) => {
const [notes, setNotes] = React.useState("");
// Reset input saat modal dibuka
React.useEffect(() => {
if (isOpen) setNotes("");
}, [isOpen]);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(notes); // Kirim notes ke handler submitApproveClaim
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="✅ Konfirmasi Approval"
size="small"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
<p className="text-sm text-green-200">
Apakah Anda yakin ingin menyetujui klaim ini? Status barang akan
berubah menjadi <strong>Approved</strong>.
</p>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Catatan Approval (Opsional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-green-500 focus:outline-none transition"
placeholder="Contoh: Identitas telah diverifikasi..."
autoFocus
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={loading}
className="flex-1 px-4 py-3 bg-slate-700 text-white rounded-xl hover:bg-slate-600 transition font-semibold"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:opacity-50"
>
{loading ? "Memproses..." : "Ya, Approve"}
</button>
</div>
</form>
</Modal>
);
};
// Jangan lupa export ke window
window.LostItemDetailModal = LostItemDetailModal;
window.DetailModal = DetailModal;
window.VerifyClaimModal = VerifyClaimModal;
window.CloseCaseModal = CloseCaseModal;
window.LostItemDetailModal = LostItemDetailModal;
window.ApproveClaimModal = ApproveClaimModal;
@@ -0,0 +1,320 @@
const ManagerReportFoundModal = ({
isOpen,
onClose,
onSubmit,
loading,
photoPreview,
onPhotoChange,
categories,
}) => {
const [users, setUsers] = React.useState([]);
const [loadingUsers, setLoadingUsers] = React.useState(false);
// ✅ ADD: State untuk menyimpan user yang dipilih
const [selectedUser, setSelectedUser] = React.useState(null);
// ✅ ADD: State untuk input manual
const [reporterName, setReporterName] = React.useState("");
const [reporterContact, setReporterContact] = React.useState("");
// Load users when modal opens
React.useEffect(() => {
if (isOpen) {
loadUsers();
// Reset form saat modal dibuka
setSelectedUser(null);
setReporterName("");
setReporterContact("");
}
}, [isOpen]);
const loadUsers = async () => {
try {
setLoadingUsers(true);
const response = await fetch(
`${CONFIG.API_URL}/api/manager/users?page=1&limit=1000`,
{
headers: {
Authorization: `Bearer ${AuthUtils.getToken()}`,
},
}
);
if (response.ok) {
const data = await response.json();
setUsers(data.data || []);
}
} catch (error) {
console.error("Failed to load users:", error);
} finally {
setLoadingUsers(false);
}
};
// ✅ UPDATE: Handle user selection
const handleUserSelect = (e) => {
const userId = e.target.value;
if (!userId) {
setSelectedUser(null);
return;
}
const user = users.find((u) => u.id === parseInt(userId));
if (user) {
setSelectedUser(user);
setReporterName(user.name);
setReporterContact(user.phone || "");
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="📦 Laporan Barang Ditemukan (Offline)"
size="large"
>
<form onSubmit={onSubmit} className="space-y-4">
{/* Info Box */}
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
<div className="flex items-start gap-3">
<span className="text-2xl">ℹ️</span>
<div className="flex-1">
<strong className="text-blue-400 block mb-1">
Laporan Offline
</strong>
<p className="text-sm text-slate-300">
Fitur ini untuk mencatat barang yang diserahkan langsung kepada
manager. Jika pelapor memiliki akun, silakan pilih di bawah.
Jika tidak, cukup isi nama manual.
</p>
</div>
</div>
</div>
{/* User Selection */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Pilih User (Penemu Barang){" "}
<span className="text-slate-500 font-normal text-sm">
(Opsional)
</span>
</label>
<select
name="reporter_user_id"
onChange={handleUserSelect}
disabled={loadingUsers}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
>
<option value="">-- Tamu / Tidak Terdaftar --</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} - {user.email} ({user.nrp})
</option>
))}
</select>
<p className="text-xs text-slate-400 mt-1">
Pilih jika pelapor sudah punya akun. Jika belum, biarkan kosong.
</p>
</div>
{/* ✅ FIXED: Selected User Info - sekarang selectedUser sudah didefinisikan */}
{selectedUser && (
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
<strong className="text-green-400 block mb-2">
✅ User Terpilih:
</strong>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
<div>
<strong className="text-slate-200">Nama:</strong>{" "}
{selectedUser.name}
</div>
<div>
<strong className="text-slate-200">Email:</strong>{" "}
{selectedUser.email}
</div>
<div>
<strong className="text-slate-200">NRP:</strong>{" "}
{selectedUser.nrp}
</div>
<div>
<strong className="text-slate-200">Telepon:</strong>{" "}
{selectedUser.phone || "-"}
</div>
</div>
</div>
)}
<div className="border-t border-slate-700 pt-4">
<h3 className="text-lg font-semibold text-white mb-4">
Detail Barang Ditemukan
</h3>
</div>
{/* Item Details */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Nama Barang *
</label>
<input
type="text"
name="name"
required
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Contoh: Dompet Kulit Coklat"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Foto Barang
</label>
<input
type="file"
name="photo"
accept="image/*"
onChange={onPhotoChange}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
/>
{photoPreview && (
<div className="mt-3">
<img
src={photoPreview}
alt="Preview"
className="w-full h-48 object-cover rounded-xl border-2 border-blue-500"
/>
</div>
)}
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Kategori *
</label>
<select
name="category_id"
required
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
>
<option value="">Pilih Kategori</option>
{/* PERBAIKAN: Gunakan 'categories' langsung, bukan 'state.categories' */}
{categories &&
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Lokasi Ditemukan *
</label>
<input
type="text"
name="location"
required
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Contoh: Perpustakaan Lantai 2"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Deskripsi Umum *
</label>
<textarea
name="description"
required
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Deskripsi umum barang yang akan ditampilkan..."
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Ciri Khusus Rahasia * 🔒
</label>
<textarea
name="secret_details"
required
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Jelaskan ciri khusus yang HANYA pemilik asli yang tahu (contoh: ada goresan di bagian belakang, ada tulisan nama di dalam, dll)"
/>
<p className="text-xs text-yellow-400 mt-1">
⚠️ Info ini RAHASIA dan hanya digunakan untuk verifikasi klaim
</p>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Tanggal Ditemukan *
</label>
<input
type="date"
name="date_found"
required
max={new Date().toISOString().split("T")[0]}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
/>
</div>
{/* ✅ INPUT MANUAL dengan controlled state */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Nama Pelapor *
</label>
<input
type="text"
name="reporter_name"
required
value={reporterName}
onChange={(e) => setReporterName(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Nama lengkap penemu barang"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Kontak Pelapor *
</label>
<input
type="text"
name="reporter_contact"
required
value={reporterContact}
onChange={(e) => setReporterContact(e.target.value)}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="08123456789"
/>
</div>
<div>
<label className="block font-semibold mb-2 text-slate-300">
Catatan Manager (Opsional)
</label>
<textarea
name="manager_notes"
rows="2"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Catatan internal dari manager..."
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
>
{loading ? "⏳ Menyimpan..." : "✅ Submit Laporan"}
</button>
</form>
</Modal>
);
};
window.ManagerReportFoundModal = ManagerReportFoundModal;
+108
View File
@@ -0,0 +1,108 @@
// assets/js/pages/manager/ManagerTabs.js - FIXED
const ItemsTabManager = ({ state, handlers }) => {
const { showExpired, setShowExpired, items, expiredItems, stats } = state;
const {
handleViewDetail,
handleManualClaimClick,
handleEditItemClick,
handleDeleteItem,
} = handlers;
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-white">
{showExpired ? "📦 Barang Expired" : "📦 Daftar Barang Ditemukan"}
</h2>
<div className="flex gap-3">
{/* ✅ TOMBOL BARU */}
<button
onClick={() => state.setShowReportFoundModal(true)}
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
📝 Lapor Barang Ditemukan (Offline)
</button>
<button
onClick={() => setShowExpired(false)}
className={`px-4 py-2 rounded-lg font-semibold transition ${
!showExpired
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg"
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
}`}
>
📦 Aktif ({items.length})
</button>
<button
onClick={() => setShowExpired(true)}
className={`px-4 py-2 rounded-lg font-semibold transition ${
showExpired
? "bg-gradient-to-r from-red-600 to-red-700 text-white shadow-lg"
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
}`}
>
⚠️ Expired ({stats.expired})
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{(showExpired ? expiredItems : items).map((item) => (
<ItemCard
key={item.id}
item={item}
onViewDetail={handleViewDetail}
onManualClaim={handleManualClaimClick}
// ✅ TAMBAHKAN 3 PROPS INI:
isManager={true} // Agar logika di ItemCard jalan
onEdit={handleEditItemClick} // Fungsi ketika tombol diklik
onDelete={handleDeleteItem} // (Opsional) Jika manager boleh hapus
showActions={true}
/>
))}
</div>
{(showExpired ? expiredItems : items).length === 0 && (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">{showExpired ? "⚠️" : "📦"}</div>
<p>{showExpired ? "Belum ada barang expired" : "Belum ada barang"}</p>
</div>
)}
</div>
);
};
const ClaimsTabManager = ({ state, handlers }) => {
const { claims } = state;
const { handleVerifyClaim, handleReopenCase, handleCancelApproval } =
handlers;
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<h2 className="text-xl font-semibold mb-6 text-white">Daftar Klaim</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{claims.map((claim) => (
<ClaimCard
key={claim.id}
claim={claim}
onVerify={handleVerifyClaim} // Ini mentrigger modal verify/close
onReopen={handleReopenCase}
onCancelApproval={handleCancelApproval} // ✅ Pass props baru
/>
))}
</div>
{claims.length === 0 && (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🤝</div>
<p>Belum ada klaim</p>
</div>
)}
</div>
);
};
window.ItemsTabManager = ItemsTabManager;
window.ClaimsTabManager = ClaimsTabManager;
@@ -0,0 +1,448 @@
// web/js/pages/manager/tabs/LostItemsTabManager.js
const LostItemsTabManager = ({ state, handlers }) => {
const { lostItems, items, loading } = state;
const {
showToast,
loadLostItems,
handleDeleteLostItem,
handleMatchLostItem,
handleViewLostItemDetail,
} = handlers;
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-semibold text-white">
Kelola Laporan Barang Hilang
</h2>
<p className="text-slate-400 text-sm mt-1">
Lihat semua laporan barang hilang dan hubungkan dengan barang
ditemukan
</p>
</div>
<button
onClick={() => loadLostItems()}
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
>
🔄 Refresh
</button>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-yellow-500/10 border border-yellow-500/30 p-4 rounded-lg text-center">
<div className="text-yellow-400 font-bold text-2xl">
{lostItems.filter((i) => i.status === "active").length}
</div>
<div className="text-slate-400 text-sm">Aktif</div>
</div>
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-lg text-center">
<div className="text-green-400 font-bold text-2xl">
{lostItems.filter((i) => i.status === "found").length}
</div>
<div className="text-slate-400 text-sm">Ditemukan</div>
</div>
<div className="bg-slate-500/10 border border-slate-500/30 p-4 rounded-lg text-center">
<div className="text-slate-400 font-bold text-2xl">
{lostItems.length}
</div>
<div className="text-slate-400 text-sm">Total</div>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">⏳</div>
<p>Memuat data...</p>
</div>
) : lostItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{lostItems.map((item) => (
<div
key={item.id}
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl p-5 hover:shadow-xl transition-all ${
item.status === "found"
? "border-green-500/50 hover:border-green-500"
: "border-slate-600 hover:border-blue-500 hover:shadow-blue-500/20"
}`}
>
{/* Header */}
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
<div className="flex-1">
<h3 className="text-lg font-bold text-white">{item.name}</h3>
<p className="text-xs text-slate-400 mt-1">
👤 {item.user_name}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{item.status === "active" ? "😢 Hilang" : "✅ Ditemukan"}
</span>
</div>
{/* Details */}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">🏷️</span>
<span>{item.category || item.category_name}</span>
</div>
{item.color && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">🎨</span>
<span>{item.color}</span>
</div>
)}
{item.location && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📍</span>
<span>{item.location}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📅</span>
<span>{Helpers.formatDate(item.date_lost)}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📞</span>
<span>{item.user_contact || "Tidak ada kontak"}</span>
</div>
</div>
{/* Description */}
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
<strong className="text-xs text-slate-400 uppercase block mb-1">
Deskripsi:
</strong>
<p className="text-sm text-slate-300 line-clamp-3">
{item.description}
</p>
</div>
{/* Matched Item (if found) */}
{item.status === "found" && item.matched_item_id && (
<div className="bg-green-500/10 border-2 border-green-500/30 p-3 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">🎉</span>
<strong className="text-green-400">Sudah Ditemukan</strong>
</div>
<p className="text-xs text-slate-300">
Dihubungkan dengan barang:{" "}
<strong>
{item.matched_item_name || "ID #" + item.matched_item_id}
</strong>
</p>
{item.matched_at && (
<p className="text-xs text-slate-400 mt-1">
Pada: {Helpers.formatDateTime(item.matched_at)}
</p>
)}
</div>
)}
{/* AI Suggestions - Description Based */}
{item.status === "active" &&
item.ai_suggestions &&
item.ai_suggestions.length > 0 && (
<div className="bg-blue-500/10 border border-blue-500/30 p-3 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🤖</span>
<strong className="text-blue-400 text-sm">
AI Suggestions (Description Similarity)
</strong>
</div>
<p className="text-[10px] text-slate-400 mb-2">
Berdasarkan kesamaan deskripsi barang hilang dengan ciri
khusus barang ditemukan
</p>
<div className="space-y-2">
{item.ai_suggestions
.slice(0, 3)
.map((suggestion, idx) => (
<div
key={idx}
className="text-xs text-slate-300 bg-slate-900/50 p-2 rounded"
>
<div className="flex justify-between items-center mb-1">
<span className="font-semibold">
{suggestion.item_name}
</span>
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold ${
suggestion.match_score >= 70
? "bg-green-500/20 text-green-400"
: suggestion.match_score >= 50
? "bg-yellow-500/20 text-yellow-400"
: "bg-orange-500/20 text-orange-400"
}`}
>
{suggestion.match_score}% Match
</span>
</div>
<p className="text-slate-400 text-[10px] mb-1">
📍 {suggestion.location} • 📅{" "}
{Helpers.formatDateShort(suggestion.date_found)}
</p>
{suggestion.preview_text && (
<p className="text-slate-500 text-[10px] italic">
"{suggestion.preview_text}"
</p>
)}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{item.status === "active" && (
<button
onClick={() => handleMatchLostItem(item)}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
🔗 Hubungkan
</button>
)}
<button
onClick={() => handleViewLostItemDetail(item)}
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg flex items-center justify-center gap-1"
>
<span>📋</span> Detail
</button>
<button
onClick={() => {
if (
confirm(
`⚠️ Yakin ingin menghapus laporan "${item.name}" dari ${item.user_name}?\n\nTindakan ini tidak dapat dibatalkan.`
)
) {
handleDeleteLostItem(item.id);
}
}}
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
title="Hapus Laporan"
>
🗑️
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">😢</div>
<p>Belum ada laporan barang hilang</p>
</div>
)}
<LostItemDetailModal
isOpen={state.showLostDetailModal}
onClose={() => state.setShowLostDetailModal(false)}
item={state.selectedLostDetail}
/>
</div>
);
};
// Modal untuk menghubungkan barang hilang dengan barang ditemukan
const MatchLostItemModal = ({
isOpen,
onClose,
lostItem,
items,
onSubmit,
loading,
}) => {
const [selectedItemId, setSelectedItemId] = React.useState("");
const [searchTerm, setSearchTerm] = React.useState("");
const filteredItems = items.filter(
(item) =>
item.status === "unclaimed" &&
(item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.location.toLowerCase().includes(searchTerm.toLowerCase()))
);
const handleSubmit = (e) => {
e.preventDefault();
if (!selectedItemId) {
alert("Pilih barang yang ditemukan!");
return;
}
onSubmit(lostItem.id, parseInt(selectedItemId));
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
onClick={onClose}
>
<div
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-3xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
<h3 className="text-xl font-semibold text-white">
🔗 Hubungkan Barang Hilang dengan Barang Ditemukan
</h3>
<button
onClick={onClose}
className="text-2xl text-slate-400 hover:text-white transition"
>
×
</button>
</div>
{/* Body */}
<div className="p-6">
{/* Lost Item Info */}
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl mb-6">
<strong className="text-yellow-400 block mb-2">
😢 Barang Hilang:
</strong>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
<div>
<strong className="text-slate-200">Nama:</strong>{" "}
{lostItem.name}
</div>
<div>
<strong className="text-slate-200">Kategori:</strong>{" "}
{lostItem.category || "-"}
</div>
<div>
<strong className="text-slate-200">Warna:</strong>{" "}
{lostItem.color || "-"}
</div>
<div>
<strong className="text-slate-200">Lokasi:</strong>{" "}
{lostItem.location || "-"}
</div>
<div className="col-span-2">
<strong className="text-slate-200">Deskripsi:</strong>{" "}
{lostItem.description}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Search */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Cari Barang Ditemukan
</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Cari berdasarkan nama atau lokasi..."
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Items List */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Pilih Barang yang Sesuai *
</label>
<div className="max-h-96 overflow-y-auto space-y-2 bg-slate-900/50 p-3 rounded-xl border border-slate-600">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item.id}
onClick={() => setSelectedItemId(item.id.toString())}
className={`p-4 rounded-lg cursor-pointer transition border-2 ${
selectedItemId === item.id.toString()
? "bg-blue-500/20 border-blue-500"
: "bg-slate-800 border-slate-700 hover:border-blue-400"
}`}
>
<div className="flex items-start gap-3">
{/* Radio */}
<input
type="radio"
name="item_id"
value={item.id}
checked={selectedItemId === item.id.toString()}
onChange={(e) => setSelectedItemId(e.target.value)}
className="mt-1"
/>
{/* Photo */}
{item.photo_url && (
<img
src={item.photo_url}
alt={item.name}
className="w-16 h-16 object-cover rounded-lg border border-slate-600"
onError={(e) =>
(e.target.src =
"https://via.placeholder.com/64?text=No+Image")
}
/>
)}
{/* Info */}
<div className="flex-1">
<h4 className="text-white font-semibold">
{item.name}
</h4>
<div className="text-xs text-slate-400 space-y-1 mt-1">
<div>🏷️ {item.category || "-"}</div>
<div>📍 {item.location}</div>
<div>📅 {Helpers.formatDate(item.date_found)}</div>
<div>👤 Ditemukan oleh: {item.reporter_name}</div>
</div>
{/* Show secret details preview for matching */}
{item.secret_details && (
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded">
<div className="text-[10px] text-yellow-400 font-bold mb-1">
🔒 Ciri Khusus:
</div>
<p className="text-[10px] text-slate-300 italic line-clamp-2">
"{item.secret_details}"
</p>
</div>
)}
</div>
</div>
</div>
))
) : (
<div className="text-center py-8 text-slate-400">
<p>Tidak ada barang ditemukan yang sesuai</p>
</div>
)}
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading || !selectedItemId}
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
>
{loading ? "⏳ Menghubungkan..." : "✅ Hubungkan Barang"}
</button>
</form>
</div>
</div>
</div>
);
};
// Export
window.LostItemsTabManager = LostItemsTabManager;
window.MatchLostItemModal = MatchLostItemModal;
+677
View File
@@ -0,0 +1,677 @@
// assets/js/pages/manager/useManagerHandlers.js
const useManagerHandlers = (state) => {
const {
setToast,
setLoading,
setStats,
setItems,
setClaims,
setExpiredItems,
setSelectedItem,
setShowDetailModal,
setSelectedClaim,
setShowVerifyModal,
setShowCloseCaseModal,
setCloseCaseData,
selectedClaim,
closeCaseData,
setShowReportFoundModal,
setPhotoPreview,
setShowEditItemModal,
setSelectedItemToEdit,
selectedItemToEdit,
} = state;
const showToast = (message, type = "info") => {
setToast({ message, type });
};
const loadData = async () => {
try {
const [itemsResponse, claimsResponse, expiredResponse] =
await Promise.all([
ApiUtils.get(`${CONFIG.API_ENDPOINTS.ITEMS}?page=1&limit=1000`),
ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS),
ApiUtils.get(
`${CONFIG.API_ENDPOINTS.ITEMS}?status=expired&page=1&limit=1000`
),
]);
const allItems = itemsResponse.data || [];
const allClaims = claimsResponse.data || [];
const allExpired = expiredResponse.data || [];
const calculatedStats = {
total_items: allItems.length,
pending_claims: allClaims.filter((c) => c.status === "pending").length,
verified: allItems.filter((i) => i.status === "verified").length,
expired: allExpired.length,
};
setStats(calculatedStats);
setItems(allItems);
setClaims(allClaims);
setExpiredItems(allExpired);
} catch (error) {
showToast("Gagal memuat data: " + error.message, "error");
}
};
const handlePhotoChange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPhotoPreview(reader.result);
};
reader.readAsDataURL(file);
}
};
// ✅ ADD: Handle Edit Click
const handleEditItemClick = async (item) => {
try {
// Reset preview foto agar tidak nyangkut foto sebelumnya
setPhotoPreview(null); // ✅ TAMBAHKAN BARIS INI
const response = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
);
setSelectedItemToEdit(response.data || response);
setShowEditItemModal(true);
} catch (error) {
showToast("Gagal memuat data barang", "error");
}
};
// ✅ ADD: Handle Update Item
const handleUpdateItem = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
setLoading(true);
showToast("Memproses update barang...", "info");
// ✅ LOGIKA BARU: Upload Foto jika ada
let photoUrl = state.selectedItemToEdit.photo_url; // Default ke foto lama
const photoFile = formData.get("photo");
if (photoFile && photoFile.size > 0) {
showToast("Mengupload foto baru...", "info");
const uploadFormData = new FormData();
uploadFormData.append("image", photoFile);
const uploadData = await ApiUtils.uploadFile(
CONFIG.API_ENDPOINTS.UPLOAD,
uploadFormData
);
photoUrl = uploadData.data.url;
}
// ✅ SELESAI LOGIKA UPLOAD
const dateFound = formData.get("date_found");
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
const itemData = {
name: formData.get("name"),
category_id: parseInt(formData.get("category_id")),
photo_url: photoUrl, // ✅ Masukkan URL foto ke payload
location: formData.get("location"),
description: formData.get("description"),
secret_details: formData.get("secret_details"),
date_found: dateFoundISO,
reporter_name: formData.get("reporter_name"),
reporter_contact: formData.get("reporter_contact"),
status: formData.get("status"), // ✅ KIRIM STATUS BARU
reason: formData.get("reason") || "Admin/Manager update",
};
await ApiUtils.put(
// ✅ GUNAKAN PUT
`${CONFIG.API_ENDPOINTS.ITEMS}/${state.selectedItemToEdit.id}`,
itemData // ✅ Kirim data langsung
);
showToast("Barang berhasil diupdate!", "success");
state.setShowEditItemModal(false);
state.setSelectedItemToEdit(null);
setPhotoPreview(null);
loadData();
} catch (error) {
showToast("Gagal update barang: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
const loadLostItems = async () => {
try {
setLoading(true);
const response = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}?page=1&limit=1000`
);
// Enhance with AI suggestions (optional - can be done by backend)
const lostItemsWithSuggestions = await Promise.all(
(response.data || []).map(async (lostItem) => {
if (lostItem.status === "active") {
// Get potential matches based on category and date
const potentialMatches = state.items
.filter(
(item) =>
item.status === "unclaimed" &&
item.category_id === lostItem.category_id &&
Math.abs(
new Date(item.date_found) - new Date(lostItem.date_lost)
) <
7 * 24 * 60 * 60 * 1000 // Within 7 days
)
.map((item) => ({
item_id: item.id,
item_name: item.name,
location: item.location,
date_found: item.date_found,
match_score: calculateMatchScore(lostItem, item),
}))
.sort((a, b) => b.match_score - a.match_score)
.slice(0, 5);
return {
...lostItem,
ai_suggestions: potentialMatches,
};
}
return lostItem;
})
);
state.setLostItems(lostItemsWithSuggestions);
} catch (error) {
showToast("Gagal memuat laporan barang hilang", "error");
} finally {
setLoading(false);
}
};
// Simple match score calculator
const calculateMatchScore = (lostItem, foundItem) => {
// Helper function untuk hitung skor teks sederhana
const getKeywordScore = (sourceText, targetText, minWordLength = 3) => {
if (!sourceText || !targetText) return 0;
const source = sourceText.toLowerCase();
const target = targetText.toLowerCase();
// 1. Cocok Sempurna
if (source === target) return 100;
// 2. Target mengandung source utuh
if (target.includes(source)) return 95;
// 3. Keyword Matching
const keywords = source
.split(/[\s,.]+/)
.filter((w) => w.length >= minWordLength);
if (keywords.length === 0) return 0;
let matchCount = 0;
keywords.forEach((word) => {
if (target.includes(word)) {
matchCount++;
}
});
return (matchCount / keywords.length) * 100;
};
// --- HITUNG SKOR ---
// 1. Skor Nama Barang (Bobot 50%)
// Membandingkan Nama di laporan hilang vs Nama di barang temuan
const nameScore = getKeywordScore(lostItem.name, foundItem.name, 2);
// 2. Skor Secret Details (Bobot 50%)
// Membandingkan Deskripsi User vs Secret Details Admin
const secretScore = getKeywordScore(
lostItem.description,
foundItem.secret_details,
3
);
// --- TOTAL SKOR ---
// Menggabungkan kedua skor dengan rata-rata
const totalScore = nameScore * 0.5 + secretScore * 0.5;
return Math.round(totalScore);
};
const handleMatchLostItem = (lostItem) => {
state.setSelectedLostItem(lostItem);
state.setShowMatchLostItemModal(true);
};
const submitMatchLostItem = async (lostItemId, foundItemId) => {
try {
setLoading(true);
showToast("Menghubungkan barang...", "info");
// Call backend API to match
await ApiUtils.post(
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}/match`,
{
item_id: foundItemId,
}
);
showToast("Barang berhasil dihubungkan!", "success");
state.setShowMatchLostItemModal(false);
state.setSelectedLostItem(null);
loadLostItems();
loadData();
} catch (error) {
showToast("Gagal menghubungkan barang: " + error.message, "error");
} finally {
setLoading(false);
}
};
const handleDeleteLostItem = async (lostItemId) => {
try {
setLoading(true);
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}`);
showToast("Laporan barang hilang berhasil dihapus!", "success");
loadLostItems();
} catch (error) {
showToast("Gagal menghapus laporan: " + error.message, "error");
} finally {
setLoading(false);
}
};
// ✅ ADD: Handle Delete Item
const handleDeleteItem = async (itemId) => {
try {
state.setLoading(true);
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.ITEMS}/${itemId}`);
showToast("Barang berhasil dihapus!", "success");
loadData();
} catch (error) {
showToast("Gagal menghapus barang: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
const submitReportFound = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
setLoading(true);
showToast("Memproses laporan...", "info");
// Upload photo if exists
let photoUrl = "";
const photoFile = formData.get("photo");
if (photoFile && photoFile.size > 0) {
showToast("Mengupload foto...", "info");
const uploadFormData = new FormData();
uploadFormData.append("image", photoFile);
const uploadData = await ApiUtils.uploadFile(
CONFIG.API_ENDPOINTS.UPLOAD,
uploadFormData
);
photoUrl = uploadData.data.url;
}
// Prepare data
const dateFound = formData.get("date_found");
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
const reporterUserIdRaw = formData.get("reporter_user_id");
const reporterUserId = reporterUserIdRaw
? parseInt(reporterUserIdRaw)
: 0;
const itemData = {
name: formData.get("name"),
category_id: parseInt(formData.get("category_id")),
photo_url: photoUrl,
location: formData.get("location"),
description: formData.get("description"),
secret_details: formData.get("secret_details"),
date_found: dateFoundISO,
reporter_name: formData.get("reporter_name"),
reporter_contact: formData.get("reporter_contact"),
reporter_user_id: reporterUserId,
manager_notes: formData.get("manager_notes") || "",
};
await ApiUtils.post(CONFIG.API_ENDPOINTS.ITEMS, itemData);
showToast("Laporan berhasil disimpan!", "success");
setShowReportFoundModal(false); // ✅ Now this works
setPhotoPreview(null); // ✅ Now this works
loadData();
} catch (error) {
showToast("Gagal menyimpan laporan: " + error.message, "error");
} finally {
setLoading(false);
}
};
const handleViewLostItemDetail = (item) => {
state.setSelectedLostDetail(item);
state.setShowLostDetailModal(true);
};
const handleManualClaimClick = (item) => {
state.setSelectedItemForClaim(item);
state.setShowManualClaimModal(true);
};
const submitManualClaim = async (formData) => {
try {
state.setLoading(true);
showToast("Memproses klaim manual...", "info");
const payload = {
item_id: parseInt(formData.item_id),
user_id: formData.user_id ? parseInt(formData.user_id) : null,
description: formData.description,
contact: formData.contact,
claimer_name: formData.claimer_name,
manager_notes: formData.manager_notes || "",
};
await ApiUtils.post(CONFIG.API_ENDPOINTS.CLAIMS, payload);
showToast("Klaim manual berhasil dicatat!", "success");
state.setShowManualClaimModal(false);
state.setSelectedItemForClaim(null);
loadData();
loadClaims();
} catch (error) {
showToast("Gagal mencatat klaim: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
const loadClaims = async () => {
try {
const response = await ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS);
setClaims(response.data || []);
} catch (error) {
showToast("Gagal memuat klaim", "error");
}
};
const handleLogout = () => {
if (Helpers.showConfirm("Apakah Anda yakin ingin logout?")) {
AuthUtils.clearAuth();
window.location.href = "/login";
}
};
const handleViewDetail = async (item) => {
try {
const response = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
);
let itemData = response.data || response;
const finalData = {
...itemData,
reporter_name: itemData.reporter_name || "Data tidak tersedia",
reporter_contact: itemData.reporter_contact || "Data tidak tersedia",
description: itemData.description || "Tidak ada deskripsi",
secret_details:
itemData.secret_details || "Tidak ada deskripsi rahasia",
};
setSelectedItem(finalData);
setShowDetailModal(true);
} catch (error) {
showToast("Gagal memuat detail: " + error.message, "error");
}
};
const handleVerifyClaim = async (claim) => {
try {
const detailData = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}`
);
if (!detailData || !detailData.data) {
throw new Error("Invalid claim data");
}
setSelectedClaim(detailData.data);
setShowVerifyModal(true);
} catch (error) {
showToast("Gagal memuat klaim: " + error.message, "error");
}
};
const handleApproveClaim = () => {
// Tutup modal verifikasi detail agar tidak tumpuk (opsional, tergantung preferensi UX)
state.setShowVerifyModal(false);
// Buka modal konfirmasi approve
state.setShowApproveModal(true);
};
// 2. Fungsi eksekusi: Melakukan request API (Dipanggil saat tombol "Ya, Approve" di modal diklik)
const submitApproveClaim = async (notes) => {
// Validasi sederhana
if (!state.selectedClaim || !state.selectedClaim.id) {
showToast("Data klaim tidak valid!", "error");
return;
}
try {
setLoading(true); // Aktifkan loading state
// Request ke API
await ApiUtils.post(
`${CONFIG.API_ENDPOINTS.CLAIMS}/${state.selectedClaim.id}/verify`,
{
status: "approved",
notes: notes || "", // Menggunakan notes dari input Modal
}
);
showToast("Klaim berhasil diapprove!", "success");
// Tutup modal dan refresh data
state.setShowApproveModal(false);
loadClaims();
loadData();
} catch (error) {
showToast("Gagal approve klaim: " + error.message, "error");
} finally {
setLoading(false); // Matikan loading state
}
};
const handleRejectClaim = async () => {
if (!selectedClaim || !selectedClaim.id) {
showToast("Data klaim tidak valid!", "error");
return;
}
const notes = prompt("Alasan penolakan (wajib):");
if (!notes) {
showToast("Alasan penolakan harus diisi!", "error");
return;
}
try {
await ApiUtils.post(
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/verify`,
{
status: "rejected",
notes,
}
);
showToast("Klaim berhasil ditolak!", "success");
setShowVerifyModal(false);
loadClaims();
loadData();
} catch (error) {
showToast("Gagal reject klaim: " + error.message, "error");
}
};
const handleCloseCase = async (claim) => {
setSelectedClaim(claim);
setShowCloseCaseModal(true);
};
const submitCloseCase = async (e) => {
e.preventDefault();
if (!closeCaseData.berita_acara_no) {
showToast("No. Berita Acara wajib diisi!", "error");
return;
}
try {
setLoading(true);
let buktiUrl = closeCaseData.bukti_serah_terima;
const buktiFile = document.querySelector('input[name="bukti_file"]')
?.files[0];
if (buktiFile) {
showToast("Mengupload bukti...", "info");
const uploadFormData = new FormData();
uploadFormData.append("proof", buktiFile);
const uploadResponse = await ApiUtils.uploadFile(
"/api/upload/claim-proof",
uploadFormData
);
buktiUrl = uploadResponse.data.url;
}
await ApiUtils.post(
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/close`,
{
berita_acara_no: closeCaseData.berita_acara_no,
bukti_serah_terima: buktiUrl,
notes: closeCaseData.notes,
}
);
showToast("Case berhasil ditutup!", "success");
setShowCloseCaseModal(false);
setCloseCaseData({
berita_acara_no: "",
bukti_serah_terima: "",
notes: "",
});
loadClaims();
loadData();
} catch (error) {
showToast("Gagal close case: " + error.message, "error");
} finally {
setLoading(false);
}
};
const handleCancelApproval = async (claimId) => {
try {
setLoading(true);
// Kita panggil endpoint baru (perlu dibuat di backend)
// Atau gunakan endpoint verify dengan status khusus jika backend mendukung
await ApiUtils.post(
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claimId}/cancel-approval`,
{}
);
showToast(
"Status Approved berhasil dibatalkan. Klaim kembali ke Pending.",
"success"
);
loadClaims();
loadData(); // Refresh stats
} catch (error) {
showToast("Gagal membatalkan approval: " + error.message, "error");
} finally {
setLoading(false);
}
};
const handleReopenCase = async (claim) => {
const reason = prompt("Alasan Reopen Case (WAJIB):");
if (!reason || reason.trim() === "") {
showToast("Alasan reopen wajib diisi!", "error");
return;
}
if (
!confirm(
`Yakin reopen case untuk "${claim.item_name}"?\n\nAlasan: ${reason}`
)
) {
return;
}
try {
setLoading(true);
await ApiUtils.post(`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}/reopen`, {
reason,
});
showToast("Case berhasil dibuka kembali!", "success");
loadClaims();
loadData();
} catch (error) {
showToast("Gagal reopen case: " + error.message, "error");
} finally {
setLoading(false);
}
};
return {
showToast,
loadData,
loadClaims,
handleLogout,
handleViewDetail,
handleVerifyClaim,
handleApproveClaim,
handleRejectClaim,
handleCloseCase,
submitCloseCase,
handleReopenCase,
handlePhotoChange, // ✅ Pastikan ini ada
submitReportFound,
// ✅ REMOVED: loadAuditLogs (not needed in manager)
handleManualClaimClick,
submitManualClaim,
handleCancelApproval,
handleEditItemClick,
handleUpdateItem,
handleDeleteItem,
loadLostItems,
handleMatchLostItem,
submitMatchLostItem,
handleDeleteLostItem,
handleViewLostItemDetail,
handleApproveClaim, // Fungsi pemicu (dihubungkan ke tombol di VerifyModal)
submitApproveClaim, // Fungsi submit (dihubungkan ke form di ApproveClaimModal)
};
};
window.useManagerHandlers = useManagerHandlers;
+100
View File
@@ -0,0 +1,100 @@
const { useState } = React;
const useManagerState = () => {
const [user, setUser] = useState(null);
const [activeTab, setActiveTab] = useState("items");
const [stats, setStats] = useState({
total_items: 0,
pending_claims: 0,
verified: 0,
expired: 0,
});
const [items, setItems] = useState([]);
const [claims, setClaims] = useState([]);
const [expiredItems, setExpiredItems] = useState([]);
const [selectedItem, setSelectedItem] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedClaim, setSelectedClaim] = useState(null);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [toast, setToast] = useState(null);
const [loading, setLoading] = useState(false);
const [showCloseCaseModal, setShowCloseCaseModal] = useState(false);
const [closeCaseData, setCloseCaseData] = useState({
berita_acara_no: "",
bukti_serah_terima: "",
notes: "",
});
const [showReportFoundModal, setShowReportFoundModal] = useState(false);
const [photoPreview, setPhotoPreview] = useState(null);
const [categories, setCategories] = useState([]);
const [showManualClaimModal, setShowManualClaimModal] = useState(false);
const [selectedItemForClaim, setSelectedItemForClaim] = useState(null);
const [showApproveModal, setShowApproveModal] = useState(false);
const [showEditItemModal, setShowEditItemModal] = useState(false);
const [selectedItemToEdit, setSelectedItemToEdit] = useState(null);
const [lostItems, setLostItems] = useState([]);
const [showMatchLostItemModal, setShowMatchLostItemModal] = useState(false);
const [selectedLostItem, setSelectedLostItem] = useState(null);
const [showLostDetailModal, setShowLostDetailModal] = useState(false);
const [selectedLostDetail, setSelectedLostDetail] = useState(null);
return {
user,
setUser,
activeTab,
setActiveTab,
stats,
setStats,
items,
setItems,
claims,
setClaims,
expiredItems,
setExpiredItems,
selectedItem,
setSelectedItem,
showDetailModal,
setShowDetailModal,
selectedClaim,
setSelectedClaim,
showVerifyModal,
setShowVerifyModal,
toast,
setToast,
loading,
setLoading,
showCloseCaseModal,
setShowCloseCaseModal,
closeCaseData,
setCloseCaseData,
showReportFoundModal,
setShowReportFoundModal,
photoPreview,
setPhotoPreview,
categories,
setCategories,
showManualClaimModal,
setShowManualClaimModal,
selectedItemForClaim,
setSelectedItemForClaim,
showApproveModal,
setShowApproveModal,
showEditItemModal,
setShowEditItemModal,
selectedItemToEdit,
setSelectedItemToEdit,
lostItems,
setLostItems,
showMatchLostItemModal,
setShowMatchLostItemModal,
selectedLostItem,
setSelectedLostItem,
showLostDetailModal,
setShowLostDetailModal,
selectedLostDetail,
setSelectedLostDetail,
};
};
// Export ke global scope
window.useManagerState = useManagerState;
+237
View File
@@ -0,0 +1,237 @@
const { useState, useEffect } = React;
const UserApp = () => {
const state = useUserState();
const handlers = useUserHandlers(state);
const {
user,
setUser,
activeTab,
setActiveTab,
stats,
items,
setFilteredItems,
searchTerm,
categoryFilter,
showDetailModal,
setShowDetailModal,
selectedItem,
showClaimModal,
setShowClaimModal,
showReportLostModal,
setShowReportLostModal,
showReportFoundModal,
setShowReportFoundModal,
toast,
setToast,
loading,
photoPreview,
setPhotoPreview,
showSelectLostItemModal,
setShowSelectLostItemModal,
suggestedLostItems,
claimInitialData,
categories,
} = state;
useEffect(() => {
if (!AuthUtils.checkAuthAndRedirect("user")) return;
const currentUser = AuthUtils.getCurrentUser();
setUser(currentUser);
handlers.loadData();
}, []);
useEffect(() => {
filterItems();
}, [searchTerm, categoryFilter, items]);
const filterItems = () => {
let filtered = items.filter((item) => {
const matchesSearch =
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.location.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory =
!categoryFilter ||
Helpers.getCategoryValue(item.category_id) === categoryFilter;
return matchesSearch && matchesCategory;
});
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="user" />
<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 User</h1>
<p className="text-slate-400">
Selamat datang di Lost & Found System
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="Barang Hilang Saya"
value={stats.lost_items || 0}
icon="😢"
/>
<StatCard
title="Barang yang Saya Temukan"
value={stats.found_items || 0}
icon="🎉"
/>
<StatCard title="Klaim Saya" value={stats.claims || 0} icon="🤝" />
</div>
<div className="flex gap-2 mb-6 flex-wrap">
<button
onClick={() => setActiveTab("browse")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "browse"
? "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"
}`}
>
📦 Barang Ditemukan
</button>
<button
onClick={() => setActiveTab("public-lost")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "public-lost"
? "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"
}`}
>
😢 Barang Hilang
</button>
<button
onClick={() => setActiveTab("lost")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "lost"
? "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"
}`}
>
😢 Barang Hilang Saya
</button>
<button
onClick={() => setActiveTab("found")}
className={`px-6 py-3 rounded-xl font-semibold transition ${
activeTab === "found"
? "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"
}`}
>
🎉 Barang yang Saya Temukan
</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"
}`}
>
🤝 Klaim Saya
</button>
</div>
{activeTab === "browse" && (
<BrowseTab state={state} handlers={handlers} />
)}
{activeTab === "public-lost" && (
<PublicLostItemsTab state={state} handlers={handlers} />
)}
{activeTab === "lost" && (
<MyLostItemsTab
state={state}
handlers={handlers}
myClaims={state.myClaims}
/>
)}
{activeTab === "found" && (
<MyFoundItemsTab state={state} handlers={handlers} />
)}
{activeTab === "claims" && (
<MyClaimsTab state={state} handlers={handlers} />
)}
</div>
<SelectLostItemModal
isOpen={showSelectLostItemModal}
onClose={() => state.setShowSelectLostItemModal(false)}
lostItems={suggestedLostItems}
onSelect={handlers.handleSelectLostItemForClaim}
onManual={handlers.handleManualClaimEntry}
/>
<FoundOptionModal
isOpen={state.showFoundOptionModal}
onClose={() => state.setShowFoundOptionModal(false)}
onSelectOption={handlers.handleSelectFoundOption}
/>
<DirectClaimModal
isOpen={state.showDirectClaimModal}
onClose={() => state.setShowDirectClaimModal(false)}
onSubmit={handlers.handleDirectClaimSubmit}
loading={loading}
lostItem={state.selectedLostItemForFound}
/>
<DetailModalUser
isOpen={state.showDetailModal}
onClose={() => state.setShowDetailModal(false)}
item={state.selectedItem}
user={state.user}
onClaim={handlers.handleClaim}
/>
<ClaimModalUser
isOpen={showClaimModal}
onClose={() => state.setShowClaimModal(false)}
onSubmit={handlers.submitClaim}
loading={loading}
initialData={claimInitialData}
categories={categories}
/>
<ReportLostModalUser
isOpen={showReportLostModal}
onClose={() => state.setShowReportLostModal(false)}
onSubmit={handlers.submitReportLost}
loading={loading}
categories={categories}
/>
<ReportFoundModalUser
isOpen={showReportFoundModal}
onClose={() => state.setShowReportFoundModal(false)}
onSubmit={handlers.submitReportFound}
loading={loading}
photoPreview={photoPreview}
onPhotoChange={handlers.handlePhotoChange}
user={user}
categories={categories}
/>
{state.toast && (
<Toast
message={state.toast.message}
type={state.toast.type}
onClose={() => state.setToast(null)}
/>
)}
<AIChatbot />
</div>
);
};
ReactDOM.render(<UserApp />, document.getElementById("root"));
+523
View File
@@ -0,0 +1,523 @@
// assets/js/pages/user/UserModals.js
// ✅ [BARU] Modal untuk memilih Laporan Hilang yang cocok
const SelectLostItemModal = ({
isOpen,
onClose,
lostItems,
onSelect,
onManual,
}) => (
<Modal isOpen={isOpen} onClose={onClose} title="💡 Ditemukan Laporan Cocok">
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-xl">
<p className="text-sm text-blue-200">
Kami menemukan laporan <strong>Barang Hilang Anda</strong> dengan
kategori yang sama. Pilih salah satu untuk mengisi form klaim secara
otomatis.
</p>
</div>
<div className="space-y-3 max-h-80 overflow-y-auto pr-1">
{lostItems.map((item) => (
<div
key={item.id}
onClick={() => onSelect(item)}
className="bg-slate-800 border border-slate-600 hover:border-blue-500 hover:bg-slate-700/50 p-4 rounded-xl cursor-pointer transition group"
>
<div className="flex justify-between items-start">
<div>
<h4 className="font-bold text-white group-hover:text-blue-400 transition">
{item.name}
</h4>
<p className="text-xs text-slate-400 mt-1">
📅 {Helpers.formatDate(item.date_lost)} • 📍{" "}
{item.location || "?"}
</p>
</div>
<span className="text-2xl opacity-50 group-hover:opacity-100 transition">
👉
</span>
</div>
<p className="text-xs text-slate-300 mt-2 line-clamp-2 italic">
"{item.description}"
</p>
</div>
))}
</div>
<div className="pt-4 border-t border-slate-700">
<button
onClick={onManual}
className="w-full py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-semibold transition"
>
📝 Tidak, Saya Ingin Input Manual
</button>
</div>
</div>
</Modal>
);
const DetailModalUser = ({ isOpen, onClose, item, user, onClaim }) => (
<Modal isOpen={isOpen} onClose={onClose} title="Detail Barang">
{item && (
<div>
<img
src={item.photo_url || "https://via.placeholder.com/600x400"}
alt={item.name}
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
/>
<h3 className="text-2xl font-bold mb-4 text-white">{item.name}</h3>
<div className="space-y-3 text-slate-200">
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Kategori:</strong>
<span className="ml-2">
{item.category || Helpers.getCategoryName(item.category_id)}
</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Lokasi:</strong>
<span className="ml-2">{item.location}</span>
</div>
<div className="bg-slate-700/50 p-3 rounded-lg">
<strong className="text-blue-400">Tanggal:</strong>
<span className="ml-2">{Helpers.formatDate(item.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(
item.status
)}`}
>
{item.status}
</span>
</div>
</div>
{item.status === "unclaimed" && item.reporter_id !== user?.id && (
<button
onClick={() => {
onClose();
onClaim(item);
}}
className="w-full mt-6 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"
>
Klaim Barang Ini
</button>
)}
{item.reporter_id === user?.id && (
<div className="mt-6 p-4 bg-green-500/10 border-2 border-green-500/30 rounded-xl text-center">
<div className="text-green-400 font-semibold text-lg mb-2">
✓ Ini adalah barang yang Anda laporkan
</div>
<p className="text-slate-400 text-sm">
Anda tidak dapat mengklaim barang yang Anda sendiri temukan
</p>
</div>
)}
</div>
)}
</Modal>
);
const ClaimModalUser = ({
isOpen,
onClose,
onSubmit,
loading,
initialData,
categories, // Pastikan prop ini diterima
}) => {
const [formData, setFormData] = React.useState({
description: "",
contact: "",
});
React.useEffect(() => {
if (isOpen) {
if (initialData) {
setFormData({
description: initialData.description || "",
contact: initialData.contact || "",
});
} else {
setFormData({ description: "", contact: "" });
}
}
}, [isOpen, initialData]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Klaim Barang">
<form onSubmit={onSubmit} className="space-y-4">
{initialData && (
<div className="bg-green-500/10 border border-green-500/30 p-3 rounded-lg mb-2">
<p className="text-xs text-green-300">
✅ Data terisi otomatis dari laporan hilang Anda. Silakan lengkapi
bukti.
</p>
</div>
)}
{/* Deskripsi */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Deskripsi Barang *
</label>
<textarea
name="description"
required
rows="4"
value={formData.description}
onChange={handleChange}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Jelaskan ciri khusus barang yang hanya Anda tahu..."
/>
</div>
{/* Kontak */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Kontak yang bisa dihubungi *
</label>
<input
type="text"
name="contact"
required
value={formData.contact}
onChange={handleChange}
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="No HP / WhatsApp / Line"
/>
</div>
{/* Bukti */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Bukti Pendukung (Opsional)
</label>
<input
type="file"
name="proof"
accept="image/*,.pdf"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 transition"
/>
<p className="text-xs text-slate-400 mt-1">
Foto struk, foto lama barang, atau bukti kepemilikan lainnya.
</p>
</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 ? "Mengirim..." : "Ajukan Klaim"}
</button>
</form>
</Modal>
);
};
const ReportLostModalUser = ({
isOpen,
onClose,
onSubmit,
loading,
categories,
}) => {
if (!isOpen) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title="📢 Lapor Kehilangan Barang">
<form onSubmit={onSubmit} className="space-y-4">
{/* Nama Barang */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Nama Barang Hilang *
</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 Hitam Merk X"
/>
</div>
{/* Kategori Dropdown */}
<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>
{categories && categories.length > 0 ? (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))
) : (
<option value="" disabled>
Gagal memuat kategori (Cek Database)
</option>
)}
</select>
</div>
{/* Warna (Opsional) */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Warna Dominan
</label>
<input
type="text"
name="color"
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: Hitam"
/>
</div>
{/* Lokasi */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Perkiraan Lokasi Hilang
</label>
<input
type="text"
name="location"
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: Kantin, Lab Komputer"
/>
</div>
{/* Deskripsi */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Ciri-ciri Detail *
</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="Jelaskan ciri-ciri unik, isi dompet, stiker laptop, dll..."
/>
</div>
{/* Tanggal Hilang */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Tanggal Hilang *
</label>
<input
type="date"
name="date_lost"
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>
<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 ? "Mengirim..." : "Submit Laporan"}
</button>
</form>
</Modal>
);
};
const ReportFoundModalUser = ({
isOpen,
onClose,
onSubmit,
loading,
photoPreview,
onPhotoChange,
user,
categories,
}) => (
<Modal isOpen={isOpen} onClose={onClose} title="Lapor Barang Temuan">
<form onSubmit={onSubmit} className="space-y-4">
{/* Input Nama Barang */}
<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: Botol Minum Biru"
/>
</div>
{/* Input Kategori - BAGIAN YANG DIPERBAIKI */}
<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>
{categories &&
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Input Foto Barang */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Foto Barang
</label>
<input
type="file"
name="photo"
accept="image/*"
onChange={onPhotoChange}
className="w-full text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 transition"
/>
{photoPreview && (
<div className="mt-3">
<p className="text-xs text-slate-400 mb-1">Preview:</p>
<img
src={photoPreview}
alt="Preview"
className="w-full h-48 object-cover rounded-xl border-2 border-blue-500"
/>
</div>
)}
</div>
{/* Input Lokasi */}
<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>
{/* Input Deskripsi */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Deskripsi Umum *
</label>
<textarea
name="description"
required
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Deskripsi umum barang yang akan ditampilkan..."
/>
</div>
{/* Input Ciri Rahasia */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Ciri Khusus Rahasia * 🔒
</label>
<textarea
name="secret_details"
required
rows="3"
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
placeholder="Jelaskan ciri khusus yang HANYA pemilik asli yang tahu (contoh: ada goresan di bagian belakang, ada tulisan nama di dalam, dll)"
/>
<p className="text-xs text-yellow-400 mt-1">
⚠️ Info ini RAHASIA dan hanya digunakan untuk verifikasi klaim
</p>
</div>
{/* Input Tanggal */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Tanggal Ditemukan *
</label>
<input
type="date"
name="date_found"
required
max={new Date().toISOString().split("T")[0]}
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Input Pelapor (Read Only) */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Nama Pelapor *
</label>
<input
type="text"
name="reporter_name"
required
defaultValue={user?.name}
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"
readOnly
/>
</div>
{/* Input Kontak */}
<div>
<label className="block font-semibold mb-2 text-slate-300">
Kontak Pelapor *
</label>
<input
type="text"
name="reporter_contact"
required
defaultValue={user?.phone}
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>
{/* Tombol Submit */}
<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 ? "Mengirim..." : "Submit Laporan"}
</button>
</form>
</Modal>
);
// Export ke window
window.DetailModalUser = DetailModalUser;
window.ClaimModalUser = ClaimModalUser;
window.SelectLostItemModal = SelectLostItemModal;
window.ReportLostModalUser = ReportLostModalUser; // Pastikan ini ada
window.ReportFoundModalUser = ReportFoundModalUser;
+350
View File
@@ -0,0 +1,350 @@
// web/js/pages/user/UserTabsBrowse.js
// ✅ 1. Komponen BrowseTab (Barang Ditemukan)
const BrowseTab = ({ state, handlers }) => {
const {
filteredItems,
loading,
searchTerm,
setSearchTerm,
categoryFilter,
setCategoryFilter,
categories,
user,
} = state;
const { handleViewDetail, handleClaim } = handlers;
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="p-6 border-b border-slate-700">
<h2 className="text-xl font-semibold mb-4 text-white">
📦 Barang Ditemukan
</h2>
<div className="flex gap-3 flex-wrap">
<input
type="text"
placeholder="Cari barang (nama/lokasi)..."
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={categoryFilter}
onChange={(e) => setCategoryFilter(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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredItems.map((item) => (
<ItemCard
key={item.id}
item={item}
currentUserId={user?.id}
onViewDetail={handleViewDetail}
onClaim={handleClaim}
showActions={true}
/>
))}
</div>
{filteredItems.length === 0 && (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">📦</div>
<p>Tidak ada barang ditemukan sesuai pencarian.</p>
</div>
)}
</>
)}
</div>
</div>
);
};
// ✅ 2. Komponen PublicLostItemsTab (Barang Hilang Publik)
const PublicLostItemsTab = ({ state, handlers }) => {
const { publicLostItems, loading, user } = state;
const [showContactModal, setShowContactModal] = React.useState(false);
const [selectedLostItem, setSelectedLostItem] = React.useState(null);
const [showDetailModal, setShowDetailModal] = React.useState(false);
const [selectedDetailItem, setSelectedDetailItem] = React.useState(null);
const handleShowContact = (item) => {
setSelectedLostItem(item);
setShowContactModal(true);
};
const handleShowDetail = (item) => {
setSelectedDetailItem(item);
setShowDetailModal(true);
};
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-white">
Semua Laporan Barang Hilang
</h2>
<div className="text-slate-400 text-sm">
{publicLostItems.length} laporan
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">⏳</div>
<p>Memuat data...</p>
</div>
) : publicLostItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{publicLostItems.map((item) => {
const isMine = user && user.id === item.user_id;
return (
<div
key={item.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-3">
<h3 className="text-lg font-semibold text-white flex-1">
{item.name}
</h3>
{/* ✅ Tampilkan Badge jika barang sendiri */}
{isMine ? (
<span className="px-2 py-1 rounded text-[10px] font-bold bg-blue-500/20 text-blue-400 border border-blue-500/50">
BARANG SAYA
</span>
) : (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{item.status}
</span>
)}
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">🏷️</span>
<span>{item.category || item.category_name}</span>
</div>
{item.location && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📍</span>
<span>{item.location}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">👤</span>
<span>{item.user_name}</span>
</div>
</div>
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mb-4">
<p className="text-sm text-slate-300 line-clamp-3">
{item.description}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleShowDetail(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>
{item.status === "active" && !isMine && (
<button
onClick={() => handlers.handleOpenFoundOption(item)}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
✅ Saya Menemukan
</button>
)}
{isMine && (
<div className="flex-1 px-3 py-2 text-center text-xs text-slate-400 border border-slate-600 rounded-lg">
Kelola di tab "Barang Hilang Saya"
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">😢</div>
<p>Belum ada laporan barang hilang</p>
</div>
)}
{/* Modal Detail Barang Hilang */}
<Modal
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
title="📋 Detail Barang Hilang"
>
{selectedDetailItem && (
<div className="space-y-4">
<div className="bg-gradient-to-r from-slate-700 to-slate-800 p-4 rounded-xl border border-slate-600">
<h3 className="text-xl font-bold text-white mb-1">
{selectedDetailItem.name}
</h3>
<div className="flex flex-wrap gap-2 text-sm">
<span className="text-blue-400 font-medium">
🏷️{" "}
{selectedDetailItem.category ||
selectedDetailItem.category_name ||
"-"}
</span>
<span className="text-slate-500">•</span>
<span className="text-slate-300">
📅 {Helpers.formatDate(selectedDetailItem.date_lost)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-700/30 p-3 rounded-lg border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-bold mb-1">
📍 Lokasi Hilang
</p>
<p className="text-white text-sm">
{selectedDetailItem.location || "-"}
</p>
</div>
<div className="bg-slate-700/30 p-3 rounded-lg border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-bold mb-1">
🎨 Warna/Ciri
</p>
<p className="text-white text-sm">
{selectedDetailItem.color || "-"}
</p>
</div>
</div>
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600">
<p className="text-xs text-slate-400 uppercase font-bold mb-2">
📝 Deskripsi Lengkap
</p>
<p className="text-slate-300 text-sm leading-relaxed italic">
"{selectedDetailItem.description}"
</p>
</div>
<div className="bg-blue-500/10 p-3 rounded-lg border border-blue-500/20 flex items-center gap-3">
<div className="bg-blue-600 w-10 h-10 rounded-full flex items-center justify-center text-lg shadow">
👤
</div>
<div>
<p className="text-xs text-blue-300 font-bold uppercase">
Dilaporkan Oleh
</p>
<p className="text-white font-medium">
{selectedDetailItem.user_name}
</p>
</div>
</div>
<button
onClick={() => setShowDetailModal(false)}
className="w-full py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-semibold transition"
>
Tutup
</button>
</div>
)}
</Modal>
{/* Modal Kontak (Jika diperlukan) */}
<Modal
isOpen={showContactModal}
onClose={() => setShowContactModal(false)}
title="👋 Anda Menemukan Barang Ini?"
>
{selectedLostItem && (
<div className="text-center space-y-6">
<div className="bg-slate-700/50 p-6 rounded-2xl border border-slate-600">
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full mx-auto flex items-center justify-center text-3xl shadow-lg mb-4">
👤
</div>
<p className="text-slate-400 mb-1">Silakan hubungi pemilik:</p>
<h3 className="text-2xl font-bold text-white mb-2">
{selectedLostItem.user_name}
</h3>
{selectedLostItem.user_contact ? (
<a
href={`https://wa.me/${selectedLostItem.user_contact
.replace(/^0/, "62")
.replace(/\D/g, "")}`}
target="_blank"
className="inline-flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-full font-semibold transition shadow-lg"
>
<span>📞</span> Hubungi via WhatsApp
</a>
) : (
<div className="text-yellow-400 text-sm bg-yellow-400/10 py-2 px-4 rounded-lg inline-block">
Kontak tidak ditampilkan secara publik
</div>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-slate-800 text-slate-400">Atau</span>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-xl text-left flex gap-4">
<div className="text-3xl">📝</div>
<div>
<h4 className="font-bold text-blue-400 mb-1">
Laporkan Temuan
</h4>
<p className="text-sm text-slate-300 mb-2">
Agar lebih aman, Anda bisa melaporkan barang ini sebagai
"Ditemukan" di sistem kami.
</p>
<button
onClick={() => {
setShowContactModal(false);
}}
className="text-xs text-white bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded transition"
>
Buka Menu "Barang yang Saya Temukan"
</button>
</div>
</div>
</div>
)}
</Modal>
</div>
);
};
// Export ke window agar bisa diakses oleh UserApp
window.BrowseTab = BrowseTab;
window.PublicLostItemsTab = PublicLostItemsTab;
+284
View File
@@ -0,0 +1,284 @@
// assets/js/pages/user/UserTabsMy.js
const MyLostItemsTab = ({ state, handlers, myClaims }) => {
const { myLostItems, loading } = state;
const { handleUserRespondClaim, handleUserCompleteCase, loadData } = handlers;
// ✅ PERBAIKAN: Gunakan lost_item_id yang dikirim backend, jangan pakai Regex deskripsi
const findMatchingClaim = (lostItemId) => {
if (!myClaims || myClaims.length === 0) return null;
// Cari claim yang memiliki lost_item_id sama dengan item ini
// DAN statusnya relevan (menunggu owner, approved/verified)
return myClaims.find((c) => c.lost_item_id === lostItemId);
};
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-white">Barang Hilang Saya</h2>
<div className="flex gap-2">
<button
onClick={() => loadData()}
className="px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition border border-slate-600"
>
🔄 Refresh
</button>
<button
onClick={() => state.setShowReportLostModal(true)}
className="px-4 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"
>
+ Lapor Kehilangan
</button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4 animate-pulse">⏳</div>Memuat data...
</div>
) : myLostItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myLostItems.map((item) => {
// ✅ PERBAIKAN UTAMA: Ambil klaim langsung dari objek item (dikirim backend)
// Bukan mencari dari list myClaims
const matchingClaim = item.direct_claim;
return (
<div
key={item.id}
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl p-4 transition-all ${
matchingClaim
? "border-yellow-500 shadow-yellow-500/20"
: "border-slate-600"
}`}
>
<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">
{/* Tampilkan Badge Status Item */}
<span
className={`px-2 py-1 rounded text-xs font-bold w-fit ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{item.status.replace("_", " ").toUpperCase()}
</span>
</div>
{/* --- LOGIKA TOMBOL APPROVE/REJECT --- */}
{matchingClaim && (
<div className="mt-3 p-3 rounded-lg border bg-slate-900/50 border-slate-600">
{/* KASUS 1: STATUS WAITING_OWNER (Munculkan Tombol) */}
{matchingClaim.status === "waiting_owner" && (
<>
<div className="mb-3">
<p className="text-yellow-400 text-xs font-bold flex items-center gap-1 mb-1">
🔔 DITEMUKAN OLEH:{" "}
{matchingClaim.reporter_name || "Seseorang"}
</p>
<p className="text-xs text-slate-300 italic">
"{matchingClaim.description}"
</p>
</div>
<div className="flex gap-2">
<button
onClick={() =>
handleUserRespondClaim(matchingClaim.id, "reject")
}
className="flex-1 py-2 bg-red-900/30 text-red-400 border border-red-500/30 rounded hover:bg-red-600 hover:text-white text-xs font-bold transition"
>
Tolak
</button>
<button
onClick={() =>
handleUserRespondClaim(
matchingClaim.id,
"approve"
)
}
className="flex-1 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-xs font-bold shadow-lg transition"
>
Terima Barang
</button>
</div>
</>
)}
{/* KASUS 2: STATUS VERIFIED (Munculkan Tombol Selesai) */}
{(matchingClaim.status === "verified" ||
matchingClaim.status === "approved") && (
<>
<p className="text-green-400 text-xs font-bold mb-2">
✅ SUDAH ANDA SETUJUI
</p>
<p className="text-xs text-slate-400 mb-3">
Hubungi: {matchingClaim.contact}
</p>
<button
onClick={() =>
handleUserCompleteCase(matchingClaim.id)
}
className="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-bold transition"
>
📦 Barang Sudah Diterima (Selesai)
</button>
</>
)}
{/* KASUS 3: STATUS REJECTED */}
{matchingClaim.status === "rejected" && (
<p className="text-red-400 text-xs font-bold text-center">
❌ KLAIM DITOLAK
</p>
)}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center text-slate-400 py-8">
Belum ada laporan kehilangan.
</div>
)}
</div>
);
};
const MyFoundItemsTab = ({ state, handlers }) => {
const { myFoundItems, loading, user } = state;
const { handleViewDetail } = handlers;
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-white">
Barang yang Saya Temukan
</h2>
<button
onClick={() => state.setShowReportFoundModal(true)}
className="px-4 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"
>
+ Lapor Penemuan
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4 animate-pulse">⏳</div>
<p>Memuat data...</p>
</div>
) : myFoundItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myFoundItems.map((item) => (
<div key={item.id} className="relative">
<ItemCard
item={item}
currentUserId={user?.id}
onViewDetail={handleViewDetail}
showActions={true}
/>
{/* --- TAMBAHAN VISUAL UNTUK STATUS --- */}
{/* Jika status Pending Claim (Menunggu Pemilik/Admin) */}
{item.status === "pending_claim" && (
<div className="mt-2 bg-yellow-500/10 border border-yellow-500/30 p-2 rounded-lg text-center">
<p className="text-xs text-yellow-400 font-semibold">
⏳ Menunggu Konfirmasi
</p>
<p className="text-[10px] text-slate-400">
Menunggu pemilik asli atau admin memverifikasi.
</p>
</div>
)}
{/* Jika status Verified (Sudah Diapprove) */}
{item.status === "verified" && (
<div className="mt-2 bg-green-500/10 border border-green-500/30 p-2 rounded-lg text-center">
<p className="text-xs text-green-400 font-semibold">
✅ Terverifikasi
</p>
<p className="text-[10px] text-slate-400">
Pemilik telah mengonfirmasi. Silakan serahkan barang.
</p>
</div>
)}
{/* Jika status Case Closed (Selesai) */}
{item.status === "case_closed" && (
<div className="mt-2 bg-blue-500/10 border border-blue-500/30 p-2 rounded-lg text-center">
<p className="text-xs text-blue-400 font-semibold">
🏁 Kasus Selesai
</p>
<p className="text-[10px] text-slate-400">
Barang telah dikembalikan. Terima kasih!
</p>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🎉</div>
<p>Anda belum melaporkan barang temuan</p>
<button
onClick={() => state.setShowReportFoundModal(true)}
className="mt-4 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"
>
Lapor Sekarang
</button>
</div>
)}
</div>
);
};
const MyClaimsTab = ({ state, handlers }) => {
const { myClaims, loading } = state;
const { handleUserRespondClaim } = 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">
Klaim Saya & Konfirmasi
</h2>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">⏳</div>
<p>Memuat data...</p>
</div>
) : myClaims.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myClaims.map((claim) => (
<ClaimCard
key={claim.id}
claim={claim}
onUserRespond={handleUserRespondClaim}
/>
))}
</div>
) : (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">🤝</div>
<p>Anda belum memiliki riwayat klaim</p>
<button
onClick={() => state.setActiveTab("browse")}
className="mt-4 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"
>
Lihat Barang Ditemukan
</button>
</div>
)}
</div>
);
};
window.MyLostItemsTab = MyLostItemsTab;
window.MyFoundItemsTab = MyFoundItemsTab;
window.MyClaimsTab = MyClaimsTab;
+266
View File
@@ -0,0 +1,266 @@
// ==========================================
// UPDATE MyClaimsTab.js untuk pass currentUserId
// ==========================================
const MyClaimsTab = ({ state, handlers }) => {
const { myLostItems, myClaims, loading, user } = state; // ⭐ Ambil user dari state
const lostItemsWithClaims = myLostItems.filter(
(item) =>
item.direct_claim_id &&
item.direct_claim_status &&
item.status !== "completed" &&
item.direct_claim_status !== "completed"
);
const regularClaims = myClaims || [];
const getStatusBadge = (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: "🎉",
},
pending: {
color: "bg-yellow-500",
text: "⏳ Menunggu Verifikasi",
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 Anda",
emoji: "👤",
},
verified: {
color: "bg-green-600",
text: "✅ Terverifikasi",
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">
{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 = getStatusBadge(
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`}
>
<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>
{/* ⭐ Display claim card with proper context */}
{lostItem.direct_claim && (
<div className="mt-4">
<ClaimCard
claim={{
...lostItem.direct_claim,
lost_item_user_id: lostItem.user_id, // ⭐ Pass owner ID
}}
currentUserId={user?.id} // ⭐ Pass current user ID
onUserRespond={handlers.handleUserRespondClaim}
/>
</div>
)}
{/* Fallback untuk direct claim tanpa detail */}
{!lostItem.direct_claim && 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>
)}
{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) => (
<ClaimCard
key={claim.id}
claim={{
...claim,
user_id: claim.user_id, // Penemu
lost_item_user_id: claim.lost_item?.user_id, // Pemilik
}}
currentUserId={user?.id} // ⭐ Pass current user ID
/>
))}
</div>
</div>
)}
{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;
+308
View File
@@ -0,0 +1,308 @@
const MyFoundItemsTab = ({ state, handlers }) => {
const { myFoundItems, loading, user } = state;
const { showToast, loadData } = handlers;
const handleApproveItem = async (item) => {
if (!item.direct_claim_id) {
showToast("⚠️ Tidak ada klaim untuk item ini", "error");
return;
}
const confirmMsg = `✅ Konfirmasi Penerimaan Barang
Apakah Anda yakin barang "${item.name}" ini adalah milik Anda?
Jika Ya:
- Status akan berubah menjadi "verified"
- Anda bisa koordinasi dengan penemu untuk pengambilan
- Kasus akan dilanjutkan
⚠️ Pastikan ini benar-benar barang Anda!`;
if (!confirm(confirmMsg)) return;
try {
state.setLoading(true);
// ✅ ENDPOINT YANG BENAR - menggunakan claim ID
await ApiUtils.post(`/api/user/claims/${item.direct_claim_id}/respond`, {
action: "approve",
});
showToast(
"✅ Berhasil! Silakan koordinasi pengambilan dengan penemu.",
"success"
);
loadData();
} catch (error) {
console.error("Error approving item:", error);
showToast("Gagal menerima: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
const handleRejectItem = async (item) => {
if (!item.direct_claim_id) {
showToast("⚠️ Tidak ada klaim untuk item ini", "error");
return;
}
const reason = prompt(
"❌ Alasan Penolakan (opsional):\n\nContoh: Bukan barang saya, warna berbeda, dll."
);
if (reason === null) return; // User cancelled
const confirmMsg = `❌ Konfirmasi Penolakan
Yakin menolak barang "${item.name}"?
${reason ? `Alasan: ${reason}` : "(Tanpa alasan)"}
Barang akan kembali dalam status pencarian.`;
if (!confirm(confirmMsg)) return;
try {
state.setLoading(true);
// ✅ ENDPOINT YANG BENAR - menggunakan claim ID
await ApiUtils.post(`/api/user/claims/${item.direct_claim_id}/respond`, {
action: "reject",
});
showToast(
"❌ Barang ditolak. Status kembali dalam pencarian.",
"success"
);
loadData();
} catch (error) {
console.error("Error rejecting item:", error);
showToast("Gagal menolak: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-xl font-semibold text-white">
📦 Barang yang Dilaporkan Orang Lain Menemukan
</h2>
<p className="text-slate-400 text-sm mt-1">
Barang hilang Anda yang dilaporkan orang lain menemukan - butuh
konfirmasi Anda
</p>
</div>
<button
onClick={() => loadData()}
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
>
🔄 Refresh
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-yellow-500/10 border border-yellow-500/30 p-4 rounded-lg text-center">
<div className="text-yellow-400 font-bold text-2xl">
{
myFoundItems.filter((i) => i.status === "pending_verification")
.length
}
</div>
<div className="text-slate-400 text-sm">Menunggu Konfirmasi</div>
</div>
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-lg text-center">
<div className="text-green-400 font-bold text-2xl">
{myFoundItems.filter((i) => i.status === "verified").length}
</div>
<div className="text-slate-400 text-sm">Diverifikasi</div>
</div>
<div className="bg-slate-500/10 border border-slate-500/30 p-4 rounded-lg text-center">
<div className="text-slate-400 font-bold text-2xl">
{myFoundItems.length}
</div>
<div className="text-slate-400 text-sm">Total</div>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">⏳</div>
<p>Memuat data...</p>
</div>
) : myFoundItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myFoundItems.map((item) => {
const isPendingVerification =
item.status === "pending_verification";
const isVerified = item.status === "verified";
return (
<div
key={item.id}
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl p-5 hover:shadow-xl transition-all ${
isPendingVerification
? "border-yellow-500/50 hover:border-yellow-500 shadow-yellow-500/20"
: isVerified
? "border-green-500/50 hover:border-green-500"
: "border-slate-600 hover:border-blue-500"
}`}
>
{/* 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>
{item.category_name && (
<p className="text-xs text-slate-400 mt-1">
🏷️ {item.category_name}
</p>
)}
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${
isPendingVerification
? "bg-yellow-500/20 text-yellow-400 border border-yellow-500/50 animate-pulse"
: isVerified
? "bg-green-500/20 text-green-400 border border-green-500/50"
: Helpers.getStatusBadgeClass(item.status)
}`}
>
{isPendingVerification
? "⏳ Menunggu"
: isVerified
? "✅ Verified"
: item.status}
</span>
</div>
{/* Item Details */}
<div className="space-y-2 mb-4">
{item.color && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">🎨</span>
<span>Warna: {item.color}</span>
</div>
)}
{item.location && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📍</span>
<span>{item.location}</span>
</div>
)}
{item.date_found && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">📅</span>
<span>{Helpers.formatDate(item.date_found)}</span>
</div>
)}
{item.reporter_name && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="text-blue-400">👤</span>
<span>Ditemukan oleh: {item.reporter_name}</span>
</div>
)}
</div>
{/* Description */}
{item.description && (
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
<strong className="text-xs text-slate-400 uppercase block mb-1">
Deskripsi:
</strong>
<p className="text-sm text-slate-300 line-clamp-3">
{item.description}
</p>
</div>
)}
{/* Pending Verification Alert */}
{isPendingVerification && (
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-3 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">⚠️</span>
<strong className="text-yellow-400">
Konfirmasi Diperlukan
</strong>
</div>
<p className="text-xs text-slate-300">
Seseorang menemukan barang yang sesuai dengan laporan
Anda. Silakan konfirmasi apakah ini benar barang Anda.
</p>
</div>
)}
{/* Verified Alert */}
{isVerified && (
<div className="bg-green-500/10 border-2 border-green-500/30 p-3 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">✅</span>
<strong className="text-green-400">
Sudah Diverifikasi
</strong>
</div>
<p className="text-xs text-slate-300">
Anda telah mengonfirmasi ini barang Anda. Koordinasikan
pengambilan.
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
{isPendingVerification && (
<>
{/* Approve Button */}
<button
onClick={() => handleApproveItem(item)}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg font-semibold flex items-center justify-center gap-1"
>
<span>✅</span> Terima
</button>
{/* Reject Button */}
<button
onClick={() => handleRejectItem(item)}
className="flex-1 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 font-semibold flex items-center justify-center gap-1"
>
<span>❌</span> Tolak
</button>
</>
)}
{/* Detail Button */}
<button
onClick={() =>
handlers.handleViewDetail &&
handlers.handleViewDetail(item)
}
className={`${
isPendingVerification ? "px-3" : "flex-1"
} py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg flex items-center justify-center gap-1`}
>
<span>📋</span> {isPendingVerification ? "" : "Detail"}
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-slate-400">
<div className="text-6xl mb-4">📭</div>
<p>Belum ada barang yang dilaporkan ditemukan</p>
<p className="text-sm mt-2">
Ketika seseorang menemukan barang Anda, akan muncul di sini
</p>
</div>
)}
</div>
);
};
window.MyFoundItemsTab = MyFoundItemsTab;
+321
View File
@@ -0,0 +1,321 @@
const MyLostItemsTab = ({ state, handlers }) => {
const { myLostItems, myClaims, loading } = state;
const lostItemsWithClaims = myLostItems.filter((item) => {
if (!item.direct_claim_id || !item.direct_claim_status) {
return false;
}
const excludedStatuses = ["completed", "resolved", "case_closed"];
if (excludedStatuses.includes(item.status)) {
return false;
}
if (excludedStatuses.includes(item.direct_claim_status)) {
return false;
}
const pendingStatuses = ["waiting_owner", "verified", "claimed", "pending"];
return pendingStatuses.includes(item.direct_claim_status);
});
const regularClaims = myClaims || [];
// UPDATED: Menambahkan parameter isMyLostItem (default true)
const getStatusBadge = (status, isMyLostItem = true) => {
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-600",
text: "✅ Selesai - Barang Diterima",
emoji: "🎉",
},
resolved: {
color: "bg-purple-600",
text: "✅ Selesai - Dikembalikan",
emoji: "🎉",
},
pending: {
color: "bg-yellow-500",
text: "⏳ Menunggu Verifikasi",
emoji: "⏳",
},
approved: { color: "bg-green-500", text: "✅ Disetujui", emoji: "✅" },
rejected: { color: "bg-red-500", text: "❌ Ditolak", emoji: "❌" },
waiting_owner: {
color: "bg-orange-500",
// LOGIC FIX: Membedakan teks berdasarkan kepemilikan
text: isMyLostItem ? "⏳ Menunggu Anda" : "⏳ Menunggu Owner",
emoji: "👤",
},
verified: {
color: "bg-green-600",
text: "✅ Terverifikasi",
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">
{/* Header Section */}
<div className="flex justify-between items-center bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl p-4 shadow-lg">
<div>
<h3 className="text-xl font-bold text-white">
😢 Barang Hilang Saya
</h3>
<p className="text-blue-100 text-sm">
Kelola laporan kehilangan barang Anda
</p>
</div>
<button
onClick={() => state.setShowReportLostModal(true)}
className="px-6 py-3 bg-white text-blue-600 rounded-lg font-bold hover:bg-blue-50 transition shadow-xl hover:scale-105 transform flex items-center gap-2"
>
<span className="text-xl">➕</span>
Lapor Kehilangan
</button>
</div>
{/* SECTION 1: Barang Hilang Saya (Anda sebagai Owner) */}
{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) => {
// Context: True (Ini barang saya)
const statusBadge = getStatusBadge(
lostItem.direct_claim_status || lostItem.status,
true
);
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`}
>
<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>
{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: Klaim Barang yang Saya Ajukan (Anda sebagai Penemu) */}
{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) => {
// Context: False (Ini klaim saya ke orang lain/owner)
// UPDATED: Pass false agar text menjadi "Menunggu Owner"
const statusBadge = getStatusBadge(claim.status, false);
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>
)}
{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 mb-6">
Anda belum memiliki klaim atau barang hilang dengan klaim pending.
</p>
<button
onClick={() => state.setShowReportLostModal(true)}
className="px-6 py-3 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"
>
➕ Lapor Kehilangan Sekarang
</button>
</div>
)}
</div>
);
};
window.MyLostItemsTab = MyLostItemsTab;
@@ -0,0 +1,131 @@
const PublicLostItemsTab = ({ state, handlers }) => {
const { publicLostItems, user } = state;
const getStatusDisplay = (lostItem) => {
const statusMap = {
active: { label: "Aktif", color: "bg-green-600 text-white" },
claimed: { label: "Sedang Diklaim", color: "bg-yellow-600 text-white" },
resolved: { label: "Selesai", color: "bg-blue-600 text-white" },
};
return (
statusMap[lostItem.status] || {
label: lostItem.status,
color: "bg-gray-600 text-white",
}
);
};
const shouldShowFoundButton = (lostItem) => {
return lostItem.user_id !== user.id && lostItem.status === "active";
};
const shouldShowClaimedInfo = (lostItem) => {
return lostItem.status === "claimed" || lostItem.status === "resolved";
};
return (
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
<h3 className="text-2xl font-bold text-white mb-4">
😢 Barang Hilang (Dari Pengguna Lain)
</h3>
<p className="text-slate-400 mb-6">
Jika Anda menemukan barang yang cocok, klik "Saya Menemukan!" untuk
memberitahu pemiliknya
</p>
{publicLostItems.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p className="text-6xl mb-4">🔭</p>
<p className="text-xl">Tidak ada barang hilang saat ini</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{publicLostItems.map((lostItem) => {
const statusDisplay = getStatusDisplay(lostItem);
const showFoundButton = shouldShowFoundButton(lostItem);
const showClaimedInfo = shouldShowClaimedInfo(lostItem);
return (
<div
key={lostItem.id}
className={`bg-slate-900/50 rounded-lg p-5 border transition ${
lostItem.status === "resolved"
? "border-blue-500/50 opacity-75"
: "border-slate-700 hover:border-blue-500"
}`}
>
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-bold text-white text-lg">
{lostItem.name}
</h4>
<p className="text-sm text-slate-400">
{lostItem.category}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded-full ${statusDisplay.color}`}
>
{statusDisplay.label}
</span>
</div>
<div className="space-y-2 mb-4 text-sm text-slate-300">
<p>
<strong>📍 Lokasi:</strong>{" "}
{lostItem.location || "Tidak disebutkan"}
</p>
<p>
<strong>🎨 Warna:</strong>{" "}
{lostItem.color || "Tidak disebutkan"}
</p>
<p>
<strong>📅 Tanggal Hilang:</strong>{" "}
{new Date(lostItem.date_lost).toLocaleDateString("id-ID")}
</p>
<p>
<strong>📝 Deskripsi:</strong> {lostItem.description}
</p>
</div>
{/* PERBAIKAN: Ubah dari handleOpenDirectClaimModal ke handleOpenFoundOption */}
{showFoundButton && (
<button
onClick={() => handlers.handleOpenFoundOption(lostItem)}
className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-3 rounded-lg font-semibold transition shadow-lg"
>
🎉 Saya Menemukan Barang Ini!
</button>
)}
{lostItem.status === "claimed" && (
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg p-3 text-center">
<p className="text-yellow-400 text-sm">
⏳ Sedang menunggu konfirmasi pemilik
</p>
</div>
)}
{lostItem.status === "resolved" && (
<div className="bg-blue-900/30 border border-blue-700/50 rounded-lg p-3 text-center">
<p className="text-blue-400 text-sm font-semibold">
✅ Barang sudah dikembalikan ke pemilik
</p>
</div>
)}
{lostItem.user_id === user.id && (
<div className="bg-purple-900/30 border border-purple-700/50 rounded-lg p-3 text-center">
<p className="text-purple-400 text-sm">
📋 Ini laporan kehilangan Anda
</p>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
+429
View File
@@ -0,0 +1,429 @@
const useUserHandlers = (state) => {
const {
setToast,
setLoading,
setStats,
setItems,
setMyFoundItems,
setMyLostItems,
setPublicLostItems,
setMyClaims,
setSelectedItem,
setShowDetailModal,
setShowClaimModal,
setShowReportFoundModal,
setPhotoPreview,
setSuggestedLostItems,
setShowSelectLostItemModal,
setClaimInitialData,
myLostItems,
} = state;
const showToast = (message, type = "info") => {
setToast({ message, type });
};
const loadData = async () => {
try {
state.setLoading(true);
const [
itemsResponse,
myFoundResponse,
myLostResponse,
publicLostResponse,
myClaimsResponse,
categoriesResponse,
] = await Promise.all([
ApiUtils.get(`${CONFIG.API_ENDPOINTS.ITEMS}?page=1&limit=100`),
ApiUtils.get(CONFIG.API_ENDPOINTS.USER.ITEMS).catch(() => ({
data: [],
})),
ApiUtils.get(CONFIG.API_ENDPOINTS.USER.LOST_ITEMS).catch(() => ({
data: [],
})),
ApiUtils.get(CONFIG.API_ENDPOINTS.LOST_ITEMS).catch(() => ({
data: [],
})),
ApiUtils.get(CONFIG.API_ENDPOINTS.USER.CLAIMS).catch(() => ({
data: [],
})),
ApiUtils.get(
CONFIG.API_ENDPOINTS.CATEGORIES || "/api/categories"
).catch(() => ({ data: [] })),
]);
state.setStats({
lost_items: myLostResponse.data?.length || 0,
found_items: myFoundResponse.data?.length || 0,
claims: myClaimsResponse.data?.length || 0,
});
state.setItems(itemsResponse.data || []);
state.setMyFoundItems(myFoundResponse.data || []);
state.setMyLostItems(myLostResponse.data || []);
state.setPublicLostItems(publicLostResponse.data || []);
state.setMyClaims(myClaimsResponse.data || []);
if (state.setCategories) {
state.setCategories(categoriesResponse.data || []);
}
} catch (error) {
console.error("Error loading data:", error);
showToast("Gagal memuat data. Cek koneksi database.", "error");
} finally {
state.setLoading(false);
}
};
const handleLogout = () => {
if (Helpers.showConfirm("Apakah Anda yakin ingin logout?")) {
AuthUtils.clearAuth();
window.location.href = "/login";
}
};
const handleViewDetail = async (item) => {
try {
const response = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
);
setSelectedItem(response.data || response);
setShowDetailModal(true);
} catch (error) {
showToast("Gagal memuat detail", "error");
}
};
const handleUserRespondClaim = async (claimId, action) => {
const confirmMsg =
action === "approve"
? '✅ Yakin ini barang Anda?\n\nJika approve:\n- Status akan menjadi "Verified"\n- Anda bisa koordinasi pengambilan dengan penemu\n- Setelah terima barang, konfirmasi untuk case closed'
: "❌ Yakin menolak?\n\nJika reject:\n- Klaim akan ditolak\n- Barang kembali dalam status pencarian\n- Orang lain bisa mengajukan klaim";
if (!confirm(confirmMsg)) return;
try {
state.setLoading(true);
// FIXED: URL endpoint sesuai controller
await ApiUtils.post(`/api/claims/${claimId}/respond`, {
action: action,
});
showToast(
action === "approve"
? "✅ Berhasil! Silakan koordinasi pengambilan dengan penemu."
: "❌ Klaim ditolak. Barang kembali dalam pencarian.",
"success"
);
// Reload data agar tampilan tombol berubah
await loadData();
} catch (error) {
console.error("Error responding:", error);
showToast(
"Gagal memproses: " + (error.response?.data?.message || error.message),
"error"
);
} finally {
state.setLoading(false);
}
};
const handleOpenDirectClaimModal = (lostItem) => {
console.log("Membuka direct claim modal untuk:", lostItem.name);
state.setSelectedLostItemForFound(lostItem);
state.setShowDirectClaimModal(true);
};
const handleClaim = (item) => {
setSelectedItem(item);
const matches = myLostItems.filter(
(lostItem) =>
lostItem.status === "active" &&
lostItem.category_id === item.category_id
);
if (matches.length > 0) {
setSuggestedLostItems(matches);
setShowSelectLostItemModal(true);
} else {
setClaimInitialData(null);
setShowClaimModal(true);
}
};
const handleSelectLostItemForClaim = (lostItem) => {
const initialData = {
description: `[Berdasarkan Laporan Hilang #${lostItem.id} - ${lostItem.name}]\n${lostItem.description}`,
contact: state.user?.phone || "",
};
setClaimInitialData(initialData);
setShowSelectLostItemModal(false);
setShowClaimModal(true);
};
const handleManualClaimEntry = () => {
setClaimInitialData(null);
setShowSelectLostItemModal(false);
setShowClaimModal(true);
};
const submitClaim = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
if (!formData.get("description") || !formData.get("contact")) {
showToast("Deskripsi dan Kontak harus diisi", "error");
return;
}
try {
setLoading(true);
showToast("Memproses klaim...", "info");
let proofUrl = "";
const proofFile = formData.get("proof");
if (proofFile && proofFile.size > 0) {
showToast("Mengupload bukti...", "info");
const uploadFormData = new FormData();
uploadFormData.append("proof", proofFile);
const uploadResponse = await ApiUtils.uploadFile(
"/api/upload/claim-proof",
uploadFormData
);
proofUrl = uploadResponse.data.url;
}
const payload = {
item_id: parseInt(state.selectedItem.id),
description: formData.get("description"),
contact: formData.get("contact"),
proof_url: proofUrl,
};
await ApiUtils.post(CONFIG.API_ENDPOINTS.CLAIMS, payload);
showToast("Klaim berhasil disubmit!", "success");
state.setShowClaimModal(false);
loadData();
} catch (error) {
showToast(error.message || "Gagal submit klaim", "error");
} finally {
setLoading(false);
}
};
const submitReportLost = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
setLoading(true);
const dateLost = formData.get("date_lost");
const dateLostISO = new Date(dateLost + "T00:00:00Z").toISOString();
const data = {
name: formData.get("name"),
category_id: parseInt(formData.get("category_id")),
color: formData.get("color") || "",
location: formData.get("location") || "",
description: formData.get("description"),
date_lost: dateLostISO,
};
await ApiUtils.post(CONFIG.API_ENDPOINTS.LOST_ITEMS, data);
showToast("Laporan kehilangan berhasil!", "success");
state.setShowReportLostModal(false);
loadData();
} catch (error) {
showToast("Gagal submit laporan: " + error.message, "error");
} finally {
setLoading(false);
}
};
const submitReportFound = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
setLoading(true);
let photoUrl = "";
const photoFile = formData.get("photo");
if (photoFile && photoFile.size > 0) {
showToast("Mengupload foto...", "info");
const uploadFormData = new FormData();
uploadFormData.append("image", photoFile);
const uploadData = await ApiUtils.uploadFile(
CONFIG.API_ENDPOINTS.UPLOAD,
uploadFormData
);
photoUrl = uploadData.data.url;
}
const dateFound = formData.get("date_found");
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
const payload = {
name: formData.get("name"),
category_id: parseInt(formData.get("category_id")),
photo_url: photoUrl,
location: formData.get("location"),
description: formData.get("description"),
secret_details: formData.get("secret_details"),
date_found: dateFoundISO,
reporter_name: formData.get("reporter_name"),
reporter_contact: formData.get("reporter_contact"),
};
if (
state.selectedLostItemForFound &&
state.foundReportMethod === "direct"
) {
payload.lost_item_id = state.selectedLostItemForFound.id;
payload.verification_flow = "direct";
payload.is_direct_to_owner = true;
}
await ApiUtils.post(CONFIG.API_ENDPOINTS.ITEMS, payload);
if (
state.foundReportMethod === "direct" &&
state.selectedLostItemForFound
) {
showToast("Laporan terkirim ke PEMILIK untuk disetujui!", "success");
} else {
showToast("Laporan terkirim ke MANAGER untuk verifikasi.", "success");
}
setShowReportFoundModal(false);
setPhotoPreview(null);
state.setSelectedLostItemForFound(null);
loadData();
} catch (error) {
showToast("Gagal mengirim laporan: " + error.message, "error");
} finally {
setLoading(false);
}
};
const handleOpenFoundOption = (lostItem) => {
console.log("Membuka opsi penemuan untuk:", lostItem.name);
state.setSelectedLostItemForFound(lostItem);
state.setShowFoundOptionModal(true);
};
const handleSelectFoundOption = (method) => {
console.log("Metode dipilih:", method);
state.setFoundReportMethod(method);
state.setShowFoundOptionModal(false);
if (method === "direct") {
handleDirectClaimFlow();
} else {
state.setShowReportFoundModal(true);
}
};
const handleDirectClaimFlow = () => {
if (!state.selectedLostItemForFound) {
showToast("Tidak ada lost item yang dipilih", "error");
return;
}
state.setShowDirectClaimModal(true);
};
const handlePhotoChange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setPhotoPreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const handleUserCompleteCase = async (claimId) => {
const confirmMsg =
'🎉 Konfirmasi Penerimaan Barang\n\nApakah Anda sudah menerima barang fisik ini?\n\n✅ Jika Ya:\n- Status akan menjadi "Completed"\n- Kasus ditutup secara permanen\n- Tidak bisa dibatalkan\n\n⚠️ Pastikan barang sudah di tangan Anda!';
if (!confirm(confirmMsg)) return;
try {
state.setLoading(true);
// FIXED: Gunakan /api/claims/:id/complete (bukan /api/user/claims/:id/complete)
await ApiUtils.post(`/api/claims/${claimId}/complete`, {});
showToast(
"🎉 Selamat! Barang Anda telah kembali. Kasus selesai.",
"success"
);
loadData();
} catch (error) {
console.error("Error completing case:", error);
showToast("Gagal menyelesaikan: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
const handleDirectClaimSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
if (!state.selectedLostItemForFound) {
showToast("Tidak ada lost item yang dipilih", "error");
return;
}
try {
state.setLoading(true);
const payload = {
description: formData.get("description"),
contact: formData.get("contact"),
proof_url: formData.get("proof_url") || "",
};
await ApiUtils.post(
`/api/lost-items/${state.selectedLostItemForFound.id}/direct-claim`,
payload
);
showToast("✅ Klaim terkirim ke pemilik untuk persetujuan!", "success");
state.setShowDirectClaimModal(false);
state.setSelectedLostItemForFound(null);
loadData();
} catch (error) {
console.error("Direct claim error:", error);
showToast("Gagal mengirim klaim: " + error.message, "error");
} finally {
state.setLoading(false);
}
};
return {
showToast,
loadData,
handleLogout,
handleViewDetail,
handleClaim,
submitClaim,
submitReportLost,
submitReportFound,
handlePhotoChange,
handleSelectLostItemForClaim,
handleManualClaimEntry,
handleOpenFoundOption,
handleSelectFoundOption,
handleUserRespondClaim,
handleUserCompleteCase,
handleDirectClaimSubmit,
handleDirectClaimFlow,
handleOpenDirectClaimModal,
};
};
window.useUserHandlers = useUserHandlers;
+97
View File
@@ -0,0 +1,97 @@
// assets/js/pages/user/useUserState.js
const { useState } = React;
const useUserState = () => {
const [user, setUser] = useState(null);
const [activeTab, setActiveTab] = useState("browse");
const [stats, setStats] = useState({
lost_items: 0,
found_items: 0,
claims: 0,
});
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [selectedItem, setSelectedItem] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showClaimModal, setShowClaimModal] = useState(false);
const [showReportLostModal, setShowReportLostModal] = useState(false);
const [showReportFoundModal, setShowReportFoundModal] = useState(false);
const [toast, setToast] = useState(null);
const [loading, setLoading] = useState(false);
const [myFoundItems, setMyFoundItems] = useState([]);
const [myLostItems, setMyLostItems] = useState([]);
const [publicLostItems, setPublicLostItems] = useState([]);
const [photoPreview, setPhotoPreview] = useState(null);
const [myClaims, setMyClaims] = useState([]);
const [showSelectLostItemModal, setShowSelectLostItemModal] = useState(false);
const [suggestedLostItems, setSuggestedLostItems] = useState([]);
const [claimInitialData, setClaimInitialData] = useState(null);
const [categories, setCategories] = useState([]);
const [showFoundOptionModal, setShowFoundOptionModal] = useState(false);
const [selectedLostItemForFound, setSelectedLostItemForFound] =
useState(null);
const [foundReportMethod, setFoundReportMethod] = useState(null);
const [showDirectClaimModal, setShowDirectClaimModal] = useState(false);
return {
user,
setUser,
activeTab,
setActiveTab,
stats,
setStats,
items,
setItems,
filteredItems,
setFilteredItems,
searchTerm,
setSearchTerm,
categoryFilter,
setCategoryFilter,
selectedItem,
setSelectedItem,
showDetailModal,
setShowDetailModal,
showClaimModal,
setShowClaimModal,
showReportLostModal,
setShowReportLostModal,
showReportFoundModal,
setShowReportFoundModal,
toast,
setToast,
loading,
setLoading,
myFoundItems,
setMyFoundItems,
myLostItems,
setMyLostItems,
publicLostItems,
setPublicLostItems,
photoPreview,
setPhotoPreview,
myClaims,
setMyClaims,
showSelectLostItemModal,
setShowSelectLostItemModal,
suggestedLostItems,
setSuggestedLostItems,
claimInitialData,
setClaimInitialData,
categories,
setCategories,
showFoundOptionModal,
setShowFoundOptionModal,
selectedLostItemForFound,
setSelectedLostItemForFound,
foundReportMethod,
setFoundReportMethod,
showDirectClaimModal,
setShowDirectClaimModal,
};
};
// Export to window
window.useUserState = useUserState;
+105
View File
@@ -0,0 +1,105 @@
// assets/js/utils/api.js
const ApiUtils = {
call: async (endpoint, options = {}) => {
const token = AuthUtils.getToken();
const defaultOptions = {
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
};
const finalOptions = { ...defaultOptions, ...options };
if (options.headers) {
finalOptions.headers = { ...defaultOptions.headers, ...options.headers };
}
try {
const response = await fetch(
`${CONFIG.API_URL}${endpoint}`,
finalOptions
);
// 1. Cek jika Unauthorized (401)
if (response.status === 401) {
AuthUtils.clearAuth();
window.location.href = "/login";
return;
}
// 2. Cek apakah response valid JSON
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
// Jika bukan JSON (misal text "200 OK" atau HTML error page)
const text = await response.text();
console.error("API Error (Non-JSON Response):", text);
throw new Error("Server returned invalid response format");
}
// 3. Parse JSON dengan aman
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || "Request failed");
}
return data;
} catch (error) {
console.error("Fetch error:", error);
throw error;
}
},
get: async (endpoint) => {
return ApiUtils.call(endpoint, { method: "GET" });
},
post: async (endpoint, body) => {
return ApiUtils.call(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
},
put: async (endpoint, body) => {
return ApiUtils.call(endpoint, {
method: "PUT",
body: JSON.stringify(body),
});
},
patch: async (endpoint, body) => {
return ApiUtils.call(endpoint, {
method: "PATCH",
body: JSON.stringify(body),
});
},
delete: async (endpoint) => {
return ApiUtils.call(endpoint, { method: "DELETE" });
},
uploadFile: async (endpoint, formData) => {
const token = AuthUtils.getToken();
const response = await fetch(`${CONFIG.API_URL}${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
AuthUtils.clearAuth();
window.location.href = "/login";
}
throw new Error(data.error || "Upload failed");
}
return data;
},
};
+65
View File
@@ -0,0 +1,65 @@
// assets/js/utils/auth.js
const AuthUtils = {
getToken: () => localStorage.getItem("token"),
setToken: (token) => localStorage.setItem("token", token),
removeToken: () => localStorage.removeItem("token"),
getCurrentUser: () => {
const user = localStorage.getItem("user");
return user ? JSON.parse(user) : null;
},
setCurrentUser: (user) => {
localStorage.setItem("user", JSON.stringify(user));
},
removeCurrentUser: () => {
localStorage.removeItem("user");
},
clearAuth: () => {
localStorage.clear();
},
isAuthenticated: () => {
return !!AuthUtils.getToken();
},
hasRole: (role) => {
const user = AuthUtils.getCurrentUser();
return user && user.role === role;
},
redirectBasedOnRole: (user) => {
if (!user || !user.role) {
window.location.href = "/login";
return;
}
const redirectMap = {
admin: "/admin",
manager: "/manager",
user: "/user",
};
window.location.href = redirectMap[user.role] || "/login";
},
checkAuthAndRedirect: (requiredRole = null) => {
const user = AuthUtils.getCurrentUser();
if (!AuthUtils.isAuthenticated() || !user) {
window.location.href = "/login";
return false;
}
if (requiredRole && user.role !== requiredRole) {
AuthUtils.redirectBasedOnRole(user);
return false;
}
return true;
},
};
+115
View File
@@ -0,0 +1,115 @@
// assets/js/utils/helpers.js
const Helpers = {
formatDate: (dateString) => {
return new Date(dateString).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
day: "numeric",
});
},
formatDateShort: (dateString) => {
return new Date(dateString).toLocaleDateString("id-ID", {
year: "numeric",
month: "short",
day: "numeric",
});
},
formatDateTime: (dateString) => {
return new Date(dateString).toLocaleString("id-ID", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
},
validateEmail: (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
validatePhone: (phone) => {
const re = /^(08|62)[0-9]{9,12}$/;
return re.test(phone);
},
checkPasswordStrength: (password) => {
let strength = 0;
if (password.length >= 8) strength++;
if (password.match(/[a-z]+/)) strength++;
if (password.match(/[A-Z]+/)) strength++;
if (password.match(/[0-9]+/)) strength++;
if (password.match(/[$@#&!]+/)) strength++;
if (password.length < 8) {
return "";
} else if (strength <= 2) {
return "weak";
} else if (strength <= 4) {
return "medium";
} else {
return "strong";
}
},
debounce: (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
truncateText: (text, maxLength) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
},
getStatusBadgeClass: (status) => {
const statusClasses = {
unclaimed: "bg-blue-500/20 text-blue-400 border border-blue-500/50",
verified: "bg-green-500/20 text-green-400 border border-green-500/50",
pending_claim:
"bg-yellow-500/20 text-yellow-400 border border-yellow-500/50",
case_closed: "bg-slate-500/20 text-slate-400 border border-slate-500/50",
expired: "bg-red-500/20 text-red-400 border border-red-500/50",
active: "bg-green-500/20 text-green-400 border border-green-500/50",
blocked: "bg-red-500/20 text-red-400 border border-red-500/50",
pending: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/50",
approved: "bg-green-500/20 text-green-400 border border-green-500/50",
rejected: "bg-red-500/20 text-red-400 border border-red-500/50",
};
return (
statusClasses[status] ||
"bg-slate-500/20 text-slate-400 border border-slate-500/50"
);
},
getRoleBadgeClass: (role) => {
const roleClasses = {
admin: "bg-red-500/20 text-red-400 border border-red-500/50",
manager: "bg-yellow-500/20 text-yellow-400 border border-yellow-500/50",
user: "bg-blue-500/20 text-blue-400 border border-blue-500/50",
};
return (
roleClasses[role] ||
"bg-slate-500/20 text-slate-400 border border-slate-500/50"
);
},
getInitials: (name) => {
if (!name) return "?";
return name.charAt(0).toUpperCase();
},
showConfirm: (message) => {
return confirm(message);
},
};