Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,560 @@
|
||||
// assets/js/pages/admin/AdminApp.js - WITH CATEGORIES TAB
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const AdminApp = () => {
|
||||
const state = useAdminState();
|
||||
const handlers = useAdminHandlers(state);
|
||||
|
||||
const {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
users,
|
||||
setFilteredUsers,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
roleFilter,
|
||||
statusFilter,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
selectedUser,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
showItemDetailModal,
|
||||
setShowItemDetailModal,
|
||||
selectedItemDetail,
|
||||
showClaimDetailModal,
|
||||
setShowClaimDetailModal,
|
||||
selectedClaim,
|
||||
showArchiveDetailModal,
|
||||
setShowArchiveDetailModal,
|
||||
selectedArchive,
|
||||
} = state;
|
||||
|
||||
const { loadData, loadItems, showToast, handleUpdateUser } = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthUtils.checkAuthAndRedirect("admin")) return;
|
||||
const currentUser = AuthUtils.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
loadData();
|
||||
handlers.loadCategories(); // ✅ Load categories on mount
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadData(); // Ini memuat Users menggunakan currentPage (sudah benar di handler)
|
||||
loadItems();
|
||||
handlers.loadClaims();
|
||||
handlers.loadArchives();
|
||||
|
||||
// ✅ PERBAIKAN: Kirim currentPage agar tidak reset ke 1
|
||||
handlers.loadAuditLogs(currentPage);
|
||||
}
|
||||
}, [user, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 1) {
|
||||
state.setCurrentPage(1);
|
||||
} else {
|
||||
filterUsers();
|
||||
}
|
||||
}, [searchTerm, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
filterUsers();
|
||||
}, [users]);
|
||||
|
||||
useEffect(() => {
|
||||
filterItemsData();
|
||||
}, [
|
||||
state.itemSearchTerm,
|
||||
state.itemStatusFilter,
|
||||
state.itemCategoryFilter,
|
||||
state.items,
|
||||
]);
|
||||
|
||||
const filterUsers = () => {
|
||||
let filtered = users.filter((u) => {
|
||||
const matchesSearch =
|
||||
u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.nrp.includes(searchTerm);
|
||||
const matchesRole = !roleFilter || u.role === roleFilter;
|
||||
const matchesStatus =
|
||||
!statusFilter || (u.status || "active") === statusFilter;
|
||||
return matchesSearch && matchesRole && matchesStatus;
|
||||
});
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const filterItemsData = () => {
|
||||
let filtered = state.items.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.name.toLowerCase().includes(state.itemSearchTerm.toLowerCase()) ||
|
||||
item.location
|
||||
.toLowerCase()
|
||||
.includes(state.itemSearchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
!state.itemStatusFilter || item.status === state.itemStatusFilter;
|
||||
const matchesCategory =
|
||||
!state.itemCategoryFilter ||
|
||||
Helpers.getCategoryValue(item.category_id) === state.itemCategoryFilter;
|
||||
return matchesSearch && matchesStatus && matchesCategory;
|
||||
});
|
||||
state.setFilteredItems(filtered);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
|
||||
<Navbar user={user} onLogout={handlers.handleLogout} userType="admin" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
Dashboard Admin
|
||||
</h1>
|
||||
<p className="text-slate-400">Kelola sistem Lost & Found</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Total User"
|
||||
value={stats.users?.total || 0}
|
||||
icon="👥"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Barang"
|
||||
value={stats.items?.total || 0}
|
||||
icon="📦"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Klaim"
|
||||
value={stats.claims?.total || 0}
|
||||
icon="🤝"
|
||||
/>
|
||||
<StatCard
|
||||
title="Kategori"
|
||||
value={state.categories?.length || 0}
|
||||
icon="🏷️"
|
||||
colorClass="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Di Arsip"
|
||||
value={stats.archives?.total || 0}
|
||||
icon="📂"
|
||||
colorClass="text-green-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Log"
|
||||
value={stats.audit_logs?.total || 0}
|
||||
icon="📜"
|
||||
colorClass="text-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveTab("users")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "users"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
👥 Kelola User
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("roles");
|
||||
handlers.loadRolesAndPermissions();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "roles"
|
||||
? "bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🔑 Role & Akses
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("items")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "items"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📦 Kelola Barang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("lostitems")}
|
||||
className={`px-6 py-3 rounded-t-xl font-semibold transition ${
|
||||
activeTab === "lostitems"
|
||||
? "bg-gradient-to-br from-slate-800 to-slate-900 text-white border-t-2 border-x-2 border-slate-700"
|
||||
: "bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
🔍 Laporan Hilang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("claims")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "claims"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🤝 Kelola Klaim
|
||||
</button>
|
||||
|
||||
{/* ✅ NEW: Categories Tab */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("categories");
|
||||
handlers.loadCategories();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "categories"
|
||||
? "bg-gradient-to-r from-yellow-600 to-yellow-700 text-white shadow-lg shadow-yellow-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🏷️ Kelola Kategori
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("archives");
|
||||
handlers.loadArchives();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "archives"
|
||||
? "bg-gradient-to-r from-green-600 to-green-700 text-white shadow-lg shadow-green-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📂 Kelola Arsip
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("audit-log");
|
||||
handlers.loadAuditLogs();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "audit-log"
|
||||
? "bg-gradient-to-r from-purple-600 to-purple-700 text-white shadow-lg shadow-purple-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📜 Audit Log
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("reports")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "reports"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📊 Laporan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "users" && window.UsersTab && (
|
||||
<UsersTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "roles" && window.RolesTab && (
|
||||
<RolesTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "items" && window.ItemsTab && (
|
||||
<ItemsTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "lostitems" && window.LostItemsTabAdmin && (
|
||||
<>
|
||||
<LostItemsTabAdmin state={state} handlers={handlers} />
|
||||
{window.CreateLostItemModal && (
|
||||
<CreateLostItemModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.EditLostItemModal && (
|
||||
<EditLostItemModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.LostItemDetailModal && (
|
||||
<LostItemDetailModal state={state} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeTab === "claims" && window.ClaimsTabAdmin && (
|
||||
<ClaimsTabAdmin state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "categories" && window.CategoriesTab && (
|
||||
<CategoriesTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "archives" && window.ArchivesTabAdmin && (
|
||||
<ArchivesTabAdmin state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "audit-log" && window.AuditLogTab && (
|
||||
<AuditLogTab state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "reports" && window.ReportsTab && (
|
||||
<ReportsTab handlers={handlers} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{window.ClaimDetailModalAdmin && (
|
||||
<ClaimDetailModalAdmin
|
||||
isOpen={showClaimDetailModal}
|
||||
onClose={() => setShowClaimDetailModal(false)}
|
||||
claim={selectedClaim}
|
||||
/>
|
||||
)}
|
||||
{window.ArchiveDetailModal && (
|
||||
<ArchiveDetailModal
|
||||
isOpen={showArchiveDetailModal}
|
||||
onClose={() => setShowArchiveDetailModal(false)}
|
||||
archive={selectedArchive}
|
||||
/>
|
||||
)}
|
||||
{window.RoleModal && (
|
||||
<>
|
||||
<RoleModal
|
||||
isOpen={state.showCreateRoleModal}
|
||||
onClose={() => state.setShowCreateRoleModal(false)}
|
||||
allPermissions={state.permissions}
|
||||
onSubmit={handlers.handleCreateRole}
|
||||
loading={loading}
|
||||
/>
|
||||
<RoleModal
|
||||
isOpen={state.showEditRoleModal}
|
||||
onClose={() => {
|
||||
state.setShowEditRoleModal(false);
|
||||
state.setSelectedRole(null);
|
||||
}}
|
||||
role={state.selectedRole}
|
||||
allPermissions={state.permissions}
|
||||
onSubmit={handlers.handleUpdateRole}
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{window.CreateClaimModal && (
|
||||
<CreateClaimModal state={state} handlers={handlers} />
|
||||
)}
|
||||
{window.EditClaimModal && (
|
||||
<EditClaimModal state={state} handlers={handlers} />
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<Modal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
title="Edit User"
|
||||
>
|
||||
{selectedUser && (
|
||||
<form onSubmit={handleUpdateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedUser.name}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={selectedUser.email}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Role *
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selectedUser.role}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold disabled:from-slate-600 disabled:to-slate-700 shadow-lg"
|
||||
>
|
||||
{loading ? "Menyimpan..." : "Update User"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Item Detail Modal */}
|
||||
<Modal
|
||||
isOpen={showItemDetailModal}
|
||||
onClose={() => setShowItemDetailModal(false)}
|
||||
title="Detail Barang"
|
||||
>
|
||||
{selectedItemDetail && (
|
||||
<div>
|
||||
<img
|
||||
src={
|
||||
selectedItemDetail.photo_url ||
|
||||
"https://via.placeholder.com/600x400"
|
||||
}
|
||||
alt={selectedItemDetail.name}
|
||||
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold mb-4 text-white">
|
||||
{selectedItemDetail.name}
|
||||
</h3>
|
||||
<div className="space-y-3 text-slate-300">
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kategori:</strong>
|
||||
<span className="ml-2">
|
||||
{selectedItemDetail.category ||
|
||||
Helpers.getCategoryName(selectedItemDetail.category_id)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Lokasi:</strong>
|
||||
<span className="ml-2">{selectedItemDetail.location}</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Tanggal Ditemukan:</strong>
|
||||
<span className="ml-2">
|
||||
{Helpers.formatDate(selectedItemDetail.date_found)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Status:</strong>
|
||||
<span
|
||||
className={`ml-2 px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
selectedItemDetail.status
|
||||
)}`}
|
||||
>
|
||||
{selectedItemDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Deskripsi Umum:</strong>
|
||||
<p className="text-slate-300 mt-2">
|
||||
{selectedItemDetail.description || "Tidak ada deskripsi"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{selectedItemDetail.reporter_name || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kontak Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{selectedItemDetail.reporter_contact || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<strong className="text-yellow-400 text-lg">
|
||||
Ciri Khusus Rahasia (Untuk Verifikasi)
|
||||
</strong>
|
||||
</div>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
{selectedItemDetail.secret_details ||
|
||||
"Tidak ada deskripsi rahasia"}
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400 mt-2">
|
||||
⚠️ Info ini RAHASIA - gunakan untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
{selectedItemDetail.case_closed_at && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
{selectedItemDetail.berita_acara_no && (
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{selectedItemDetail.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Ditutup:{" "}
|
||||
{Helpers.formatDateTime(
|
||||
selectedItemDetail.case_closed_at
|
||||
)}
|
||||
</div>
|
||||
{selectedItemDetail.case_closed_by_name && (
|
||||
<div>Oleh: {selectedItemDetail.case_closed_by_name}</div>
|
||||
)}
|
||||
{selectedItemDetail.case_closed_notes && (
|
||||
<div className="mt-2 pt-2 border-t border-green-500/30">
|
||||
Catatan: {selectedItemDetail.case_closed_notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedItemDetail.bukti_serah_terima && (
|
||||
<a
|
||||
href={selectedItemDetail.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Chatbot */}
|
||||
<AIChatbot />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Render
|
||||
ReactDOM.render(<AdminApp />, document.getElementById("root"));
|
||||
@@ -0,0 +1,187 @@
|
||||
// assets/js/pages/admin/modals/ArchiveDetailModal.js
|
||||
|
||||
const ArchiveDetailModal = ({ isOpen, onClose, archive }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Arsip" size="large">
|
||||
{archive && (
|
||||
<div>
|
||||
{/* Image */}
|
||||
<img
|
||||
src={
|
||||
archive.photo_url ||
|
||||
"https://via.placeholder.com/600x400?text=No+Image"
|
||||
}
|
||||
alt={archive.name}
|
||||
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src = "https://via.placeholder.com/600x400?text=No+Image")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">{archive.name}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-green-400">Status:</strong>{" "}
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 border border-green-500/50">
|
||||
✅ CASE CLOSED
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Kategori:</strong>{" "}
|
||||
{archive.category || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Lokasi Ditemukan:</strong>{" "}
|
||||
{archive.location}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-400">Tanggal Ditemukan:</strong>{" "}
|
||||
{Helpers.formatDate(archive.date_found)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Sections */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">📝</span>
|
||||
<strong className="text-blue-400">Deskripsi Umum</strong>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{archive.description || "Tidak ada deskripsi"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🔒</span>
|
||||
<strong className="text-yellow-400">Ciri Khusus Rahasia</strong>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{archive.secret_details || "Tidak ada deskripsi rahasia"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reporter Info */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600 mb-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Informasi Pelapor:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Nama:</strong>{" "}
|
||||
{archive.reporter_name || "Tidak ada data"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Kontak:</strong>{" "}
|
||||
{archive.reporter_contact || "Tidak ada data"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case Closed Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-3xl">📋</span>
|
||||
<strong className="text-green-400 text-lg">
|
||||
Informasi Penutupan Case
|
||||
</strong>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<div>
|
||||
No. Berita Acara:{" "}
|
||||
<strong className="text-white text-lg">
|
||||
{archive.berita_acara_no || "-"}
|
||||
</strong>
|
||||
</div>
|
||||
{archive.case_closed_at && (
|
||||
<div>
|
||||
Tanggal Ditutup:{" "}
|
||||
<strong>
|
||||
{Helpers.formatDateTime(archive.case_closed_at)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_by_name && (
|
||||
<div>
|
||||
Ditutup Oleh: <strong>{archive.case_closed_by_name}</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.claimer_name && (
|
||||
<div>
|
||||
Diterima Oleh (Penerima):{" "}
|
||||
<strong className="text-green-400">
|
||||
{archive.claimer_name}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.claimer_contact && (
|
||||
<div>
|
||||
Kontak Penerima: <strong>{archive.claimer_contact}</strong>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_notes && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/30">
|
||||
<strong className="block mb-1">Catatan Penutupan:</strong>
|
||||
<p className="text-slate-300 bg-slate-900/50 p-3 rounded-lg">
|
||||
{archive.case_closed_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{archive.bukti_serah_terima && (
|
||||
<a
|
||||
href={archive.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition shadow-lg"
|
||||
>
|
||||
📄 Lihat Bukti Serah Terima
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-600">
|
||||
<strong className="text-slate-300 block mb-3">Timeline:</strong>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-blue-400 mt-1">📅</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Ditemukan:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.date_found)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{archive.created_at && (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-blue-400 mt-1">📝</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Dilaporkan:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{archive.case_closed_at && (
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-green-400 mt-1">✅</span>
|
||||
<div>
|
||||
<strong className="text-slate-300">Ditutup:</strong>
|
||||
<p className="text-slate-400">
|
||||
{Helpers.formatDateTime(archive.case_closed_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,171 @@
|
||||
// assets/js/pages/admin/modals/ClaimDetailModalAdmin.js
|
||||
|
||||
const ClaimDetailModalAdmin = ({ isOpen, onClose, claim }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Klaim" size="large">
|
||||
{claim && (
|
||||
<div>
|
||||
{/* Header Info */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
{claim.item_name}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Pengklaim:</strong>{" "}
|
||||
{claim.user_name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Kontak:</strong> {claim.contact}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Status:</strong>{" "}
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
claim.status
|
||||
)}`}
|
||||
>
|
||||
{claim.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Tanggal:</strong>{" "}
|
||||
{Helpers.formatDateTime(claim.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Section */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<h4 className="font-semibold mb-3 text-yellow-400 flex items-center gap-2">
|
||||
<span>🔒</span>
|
||||
Deskripsi Rahasia Barang
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300 font-medium">
|
||||
{claim.item_secret_details || "Tidak ada ciri khusus rahasia"}
|
||||
</p>
|
||||
<div className="mt-3 pt-3 border-t border-yellow-500/30">
|
||||
<span className="text-xs text-slate-500 uppercase">
|
||||
Deskripsi Umum:
|
||||
</span>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{claim.item_description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 p-4 rounded-xl border border-blue-500/30">
|
||||
<h4 className="font-semibold mb-3 text-blue-400 flex items-center gap-2">
|
||||
<span>💬</span>
|
||||
Deskripsi dari Pengklaim
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300">{claim.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Percentage */}
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`p-6 rounded-xl text-center mb-6 ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border-2 border-green-500/30"
|
||||
: "bg-yellow-500/10 border-2 border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-slate-300">Similarity Match:</strong>
|
||||
<div
|
||||
className={`text-5xl font-bold mt-2 ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Info */}
|
||||
{claim.verified_at && (
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl border border-slate-700 mb-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Informasi Verifikasi:
|
||||
</strong>
|
||||
<div className="space-y-1 text-sm text-slate-400">
|
||||
<div>
|
||||
✅ Diverifikasi: {Helpers.formatDateTime(claim.verified_at)}
|
||||
</div>
|
||||
{claim.verified_by_name && (
|
||||
<div>👤 Oleh: {claim.verified_by_name}</div>
|
||||
)}
|
||||
{claim.notes && (
|
||||
<div className="mt-2 pt-2 border-t border-slate-700">
|
||||
<strong className="text-slate-300">Catatan:</strong>
|
||||
<p className="text-slate-400 mt-1">{claim.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
{claim.berita_acara_no && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-3xl">📋</span>
|
||||
<strong className="text-green-400 text-lg">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white text-lg">
|
||||
{claim.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
{claim.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(claim.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.case_closed_by_name && (
|
||||
<div>Oleh: {claim.case_closed_by_name}</div>
|
||||
)}
|
||||
{claim.case_closed_notes && (
|
||||
<div className="mt-2 pt-2 border-t border-green-500/30">
|
||||
<strong>Catatan:</strong>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{claim.case_closed_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{claim.bukti_serah_terima && (
|
||||
<a
|
||||
href={claim.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-3 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition shadow-lg"
|
||||
>
|
||||
📄 Lihat Bukti Serah Terima
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proof Image */}
|
||||
{claim.proof_url && (
|
||||
<div className="mt-6">
|
||||
<strong className="text-slate-300 block mb-2">
|
||||
Bukti Pendukung:
|
||||
</strong>
|
||||
<img
|
||||
src={claim.proof_url}
|
||||
alt="Bukti Klaim"
|
||||
className="w-full h-64 object-cover rounded-xl border-2 border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
// assets/js/pages/admin/modals/CreateClaimModal.js
|
||||
|
||||
const CreateClaimModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
items,
|
||||
users,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleCreateClaim } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateClaimModal}
|
||||
onClose={() => setShowCreateClaimModal(false)}
|
||||
title="➕ Tambah Klaim Manual"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateClaim} className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg mb-4">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ Fitur ini untuk menambahkan klaim manual oleh admin, misalnya
|
||||
jika user melaporkan secara offline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih Barang *
|
||||
</label>
|
||||
<select
|
||||
name="item_id"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Pilih Barang --</option>
|
||||
{items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" || item.status === "pending_claim"
|
||||
)
|
||||
.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} - {item.location} (
|
||||
{Helpers.formatDate(item.date_found)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User Pengklaim *
|
||||
</label>
|
||||
<select
|
||||
name="user_id"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Pilih User --</option>
|
||||
{users &&
|
||||
users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
User yang akan menjadi pengklaim barang ini
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan ciri-ciri yang diklaim oleh user..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Kontak *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="contact"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Admin (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="admin_notes"
|
||||
rows="2"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan internal untuk admin..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Simpan Klaim"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
// assets/js/pages/admin/modals/CreateItemModal.js
|
||||
|
||||
const CreateItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
photoPreview,
|
||||
loading,
|
||||
categories,
|
||||
} = state;
|
||||
const { handleCreateItem, handlePhotoChange } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateItemModal}
|
||||
onClose={() => {
|
||||
setShowCreateItemModal(false);
|
||||
state.setPhotoPreview(null);
|
||||
}}
|
||||
title="➕ Tambah Barang Baru"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateItem} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Barang *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Dompet Kulit Coklat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kategori *
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
|
||||
{/* UBAH BAGIAN INI */}
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option disabled>Memuat kategori...</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Foto Barang
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
{photoPreview && (
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 w-full h-48 object-cover rounded-xl border-2 border-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Lokasi Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Perpustakaan Lantai 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Umum *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Deskripsi umum barang..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Ciri Khusus Rahasia * 🔒
|
||||
</label>
|
||||
<textarea
|
||||
name="secret_details"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Ciri khusus untuk verifikasi klaim..."
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Info ini RAHASIA untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tanggal Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_found"
|
||||
required
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_name"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Nama lengkap pelapor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Simpan Barang"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
const CreateLostItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
categories,
|
||||
users,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const { handleCreateLostItem } = handlers;
|
||||
|
||||
if (!showCreateLostItemModal) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCreateLostItemModal}
|
||||
onClose={() => setShowCreateLostItemModal(false)}
|
||||
title="➕ Tambah Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleCreateLostItem} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
User ID (Pelapor) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="user_id"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih User</option>
|
||||
{users &&
|
||||
users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} (#{user.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih user yang melaporkan kehilangan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Nama Barang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: HP Samsung Galaxy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Kategori <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Warna
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Hitam, Biru"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Tanggal Hilang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_lost"
|
||||
required
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Lokasi Hilang
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Perpustakaan Lantai 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Deskripsi <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan ciri-ciri barang yang hilang secara detail..."
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Deskripsi ini akan digunakan untuk auto-matching dengan barang
|
||||
yang ditemukan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ <strong>Info:</strong> Setelah laporan dibuat, sistem akan
|
||||
otomatis mencari kecocokan dengan barang yang telah ditemukan. User
|
||||
akan mendapat notifikasi jika ada barang yang cocok.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateLostItemModal(false)}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Memproses..." : "➕ Tambah Laporan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// assets/js/pages/admin/modals/EditClaimModal.js
|
||||
|
||||
const EditClaimModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
selectedClaimToEdit,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleUpdateClaim } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditClaimModal}
|
||||
onClose={() => setShowEditClaimModal(false)}
|
||||
title="✏️ Edit Klaim"
|
||||
size="large"
|
||||
>
|
||||
{selectedClaimToEdit && (
|
||||
<form onSubmit={handleUpdateClaim} className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-blue-400">Barang:</strong>{" "}
|
||||
{selectedClaimToEdit.item_name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-blue-400">Pengklaim:</strong>{" "}
|
||||
{selectedClaimToEdit.user_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedClaimToEdit.description}
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Kontak *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="contact"
|
||||
defaultValue={selectedClaimToEdit.contact}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Alasan Edit *
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
required
|
||||
rows="2"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan alasan edit klaim..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Update Klaim"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
// assets/js/pages/admin/modals/EditItemModal.js
|
||||
|
||||
const EditItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
loading,
|
||||
photoPreview,
|
||||
setPhotoPreview, // <--- Pastikan mengambil setPhotoPreview dari state
|
||||
} = state;
|
||||
const { handleUpdateItem, handlePhotoChange } = handlers;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditItemModal}
|
||||
onClose={() => {
|
||||
setShowEditItemModal(false);
|
||||
setSelectedItemToEdit(null);
|
||||
setPhotoPreview(null);
|
||||
}}
|
||||
title="✏️ Edit Barang"
|
||||
size="large"
|
||||
>
|
||||
{selectedItemToEdit && (
|
||||
<form onSubmit={handleUpdateItem} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Barang *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={selectedItemToEdit.name}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kategori *
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
defaultValue={selectedItemToEdit.category_id}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ BAGIAN BARU: STATUS DROPDOWN */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Status Barang *
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={selectedItemToEdit.status}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="unclaimed">Unclaimed (Aktif/Belum Diklaim)</option>
|
||||
<option value="pending_claim">
|
||||
Pending Claim (Sedang Diverifikasi)
|
||||
</option>
|
||||
<option value="verified">Verified (Terverifikasi)</option>
|
||||
<option value="expired">Expired (Kadaluarsa)</option>
|
||||
<option value="case_closed">
|
||||
Case Closed (Selesai/Diarsipkan)
|
||||
</option>
|
||||
</select>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
ℹ️ Ubah status ke <strong>Unclaimed</strong> jika ingin
|
||||
mengaktifkan kembali barang Expired agar bisa diklaim user.
|
||||
</p>
|
||||
</div>
|
||||
{/* ✅ AKHIR BAGIAN BARU */}
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Update Foto Barang (Opsional)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
{/* Tampilkan preview foto baru ATAU foto lama */}
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-slate-400 mb-1">Preview:</p>
|
||||
<img
|
||||
src={photoPreview || selectedItemToEdit.photo_url}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-cover rounded-xl border-2 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Lokasi *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={selectedItemToEdit.location}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Umum *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedItemToEdit.description}
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Ciri Khusus Rahasia * 🔒
|
||||
</label>
|
||||
<textarea
|
||||
name="secret_details"
|
||||
defaultValue={selectedItemToEdit.secret_details}
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tanggal Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_found"
|
||||
defaultValue={selectedItemToEdit.date_found?.split("T")[0]}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
defaultValue={selectedItemToEdit.reporter_contact}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_name"
|
||||
defaultValue={selectedItemToEdit.reporter_name}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Alasan Edit *
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
required
|
||||
rows="2"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan alasan edit..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Update Barang"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
window.EditItemModal = EditItemModal;
|
||||
@@ -0,0 +1,178 @@
|
||||
const EditLostItemModal = ({ state, handlers }) => {
|
||||
const {
|
||||
selectedLostItemToEdit,
|
||||
showEditLostItemModal,
|
||||
setShowEditLostItemModal,
|
||||
categories,
|
||||
loading,
|
||||
} = state;
|
||||
const { handleUpdateLostItem } = handlers;
|
||||
|
||||
// ✅ DEBUG: Log data saat modal dibuka
|
||||
React.useEffect(() => {
|
||||
if (selectedLostItemToEdit) {
|
||||
console.log("📋 Selected Lost Item Data:", selectedLostItemToEdit);
|
||||
console.log("🏷️ Category ID:", selectedLostItemToEdit.category_id);
|
||||
console.log("📦 Available Categories:", categories);
|
||||
}
|
||||
}, [selectedLostItemToEdit, categories]);
|
||||
|
||||
if (!showEditLostItemModal || !selectedLostItemToEdit) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showEditLostItemModal}
|
||||
onClose={() => setShowEditLostItemModal(false)}
|
||||
title="✏️ Edit Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={handleUpdateLostItem} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Nama Barang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
defaultValue={selectedLostItemToEdit.name}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: HP Samsung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Kategori <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{/* ✅ PERBAIKAN: Gunakan category_id, bukan category */}
|
||||
<select
|
||||
name="category_id"
|
||||
defaultValue={selectedLostItemToEdit.category_id || ""}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
onChange={(e) => {
|
||||
console.log("🔄 Category changed to:", e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* ✅ DEBUGGING: Tampilkan nilai saat ini */}
|
||||
<small className="text-slate-400 text-xs mt-1 block">
|
||||
Current: {selectedLostItemToEdit.category} (ID:{" "}
|
||||
{selectedLostItemToEdit.category_id})
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Warna
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
defaultValue={selectedLostItemToEdit.color || ""}
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Hitam, Biru"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Tanggal Hilang <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_lost"
|
||||
defaultValue={selectedLostItemToEdit.date_lost?.split("T")[0]}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Lokasi Hilang
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={selectedLostItemToEdit.location || ""}
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Perpustakaan Lantai 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Deskripsi <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={selectedLostItemToEdit.description}
|
||||
required
|
||||
rows="4"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan ciri-ciri barang yang hilang..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Status <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={selectedLostItemToEdit.status}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_verification">Pending Verification</option>
|
||||
<option value="claimed">Claimed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-slate-300 mb-2 font-semibold">
|
||||
Alasan Update
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
rows="2"
|
||||
className="w-full px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Alasan melakukan update (opsional)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditLostItemModal(false)}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Memproses..." : "💾 Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
const LostItemDetailModal = ({ state }) => {
|
||||
const {
|
||||
selectedLostItemDetail,
|
||||
showLostItemDetailModal,
|
||||
setShowLostItemDetailModal,
|
||||
} = state;
|
||||
|
||||
if (!showLostItemDetailModal || !selectedLostItemDetail) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showLostItemDetailModal}
|
||||
onClose={() => setShowLostItemDetailModal(false)}
|
||||
title="📋 Detail Laporan Barang Hilang"
|
||||
size="large"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Info Barang */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
🔍 Informasi Barang
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">ID Laporan</label>
|
||||
<p className="text-white font-semibold">
|
||||
#{selectedLostItemDetail.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Nama Barang</label>
|
||||
<p className="text-white font-semibold">
|
||||
{selectedLostItemDetail.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Kategori</label>
|
||||
<p className="text-white">{selectedLostItemDetail.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Warna</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.color || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Lokasi Hilang</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.location || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Tanggal Hilang</label>
|
||||
<p className="text-white">
|
||||
{Helpers.formatDate(selectedLostItemDetail.date_lost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Status</label>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
selectedLostItemDetail.status
|
||||
)}`}
|
||||
>
|
||||
{selectedLostItemDetail.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Dibuat</label>
|
||||
<p className="text-white">
|
||||
{Helpers.formatDateTime(selectedLostItemDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-3">
|
||||
📝 Deskripsi
|
||||
</h4>
|
||||
<p className="text-slate-300 whitespace-pre-wrap">
|
||||
{selectedLostItemDetail.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Pelapor */}
|
||||
<div className="bg-slate-700/50 rounded-xl p-6 border border-slate-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
👤 Informasi Pelapor
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">User ID</label>
|
||||
<p className="text-white">#{selectedLostItemDetail.user_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Nama</label>
|
||||
<p className="text-white font-semibold">
|
||||
{selectedLostItemDetail.user_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direct Claim Info (jika ada) */}
|
||||
{selectedLostItemDetail.direct_claim && (
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/50 rounded-xl p-6">
|
||||
<h4 className="text-lg font-semibold text-yellow-400 mb-4 flex items-center gap-2">
|
||||
🎯 Direct Claim
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Claim ID</label>
|
||||
<p className="text-white">
|
||||
#{selectedLostItemDetail.direct_claim.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Status Claim</label>
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
selectedLostItemDetail.direct_claim.status
|
||||
)}`}
|
||||
>
|
||||
{selectedLostItemDetail.direct_claim.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Penemu</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.direct_claim.user_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-sm">Kontak</label>
|
||||
<p className="text-white">
|
||||
{selectedLostItemDetail.direct_claim.contact}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-slate-400 text-sm">
|
||||
Deskripsi Claim
|
||||
</label>
|
||||
<p className="text-white mt-1">
|
||||
{selectedLostItemDetail.direct_claim.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
const RoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
role,
|
||||
allPermissions,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => {
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: "",
|
||||
description: "",
|
||||
permission_ids: [],
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (role) {
|
||||
// Edit Mode
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permission_ids: role.permissions
|
||||
? role.permissions.map((p) => p.id)
|
||||
: [],
|
||||
});
|
||||
} else {
|
||||
// Create Mode
|
||||
setFormData({ name: "", description: "", permission_ids: [] });
|
||||
}
|
||||
}, [role, isOpen]);
|
||||
|
||||
const handlePermissionToggle = (permId) => {
|
||||
setFormData((prev) => {
|
||||
const exists = prev.permission_ids.includes(permId);
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
permission_ids: prev.permission_ids.filter((id) => id !== permId),
|
||||
};
|
||||
} else {
|
||||
return { ...prev, permission_ids: [...prev.permission_ids, permId] };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={role ? "✏️ Edit Role" : "➕ Buat Role Baru"}
|
||||
size="large"
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Role
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
disabled={role && (role.name === "admin" || role.name === "user")}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Permissions (Hak Akses)
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-60 overflow-y-auto bg-slate-900/50 p-4 rounded-xl border border-slate-700">
|
||||
{allPermissions.map((perm) => (
|
||||
<label
|
||||
key={perm.id}
|
||||
className="flex items-start gap-2 cursor-pointer p-2 hover:bg-slate-700 rounded transition"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={formData.permission_ids.includes(perm.id)}
|
||||
onChange={() => handlePermissionToggle(perm.id)}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{perm.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{perm.slug}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
{loading ? "Menyimpan..." : "Simpan Role"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
window.RoleModal = RoleModal;
|
||||
@@ -0,0 +1,240 @@
|
||||
// assets/js/pages/admin/tabs/ArchivesTabAdmin.js
|
||||
|
||||
const ArchivesTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
archives,
|
||||
filteredArchives,
|
||||
archiveSearchTerm,
|
||||
setArchiveSearchTerm,
|
||||
archiveCategoryFilter,
|
||||
setArchiveCategoryFilter,
|
||||
} = state;
|
||||
|
||||
const { handleViewArchiveDetail } = handlers;
|
||||
|
||||
// Filter archives locally
|
||||
React.useEffect(() => {
|
||||
let filtered = archives.filter((item) => {
|
||||
const matchesSearch =
|
||||
item.name.toLowerCase().includes(archiveSearchTerm.toLowerCase()) ||
|
||||
item.location.toLowerCase().includes(archiveSearchTerm.toLowerCase()) ||
|
||||
(item.berita_acara_no &&
|
||||
item.berita_acara_no
|
||||
.toLowerCase()
|
||||
.includes(archiveSearchTerm.toLowerCase()));
|
||||
const matchesCategory =
|
||||
!archiveCategoryFilter ||
|
||||
Helpers.getCategoryValue(item.category_id) === archiveCategoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
state.setFilteredArchives(filtered);
|
||||
}, [archiveSearchTerm, archiveCategoryFilter, archives]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
📂 Kelola Arsip (Case Closed)
|
||||
</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handlers.loadArchives()}
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari arsip (nama, lokasi, no. BA)..."
|
||||
value={archiveSearchTerm}
|
||||
onChange={(e) => setArchiveSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={archiveCategoryFilter}
|
||||
onChange={(e) => setArchiveCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{archives.length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Total Arsip</div>
|
||||
</div>
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-blue-400 font-bold text-2xl">
|
||||
{
|
||||
archives.filter(
|
||||
(a) =>
|
||||
new Date(a.case_closed_at).getMonth() ===
|
||||
new Date().getMonth()
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Bulan Ini</div>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 border border-purple-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-purple-400 font-bold text-2xl">
|
||||
{
|
||||
archives.filter(
|
||||
(a) =>
|
||||
new Date(a.case_closed_at).getFullYear() ===
|
||||
new Date().getFullYear()
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Tahun Ini</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredArchives.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-green-500/30 rounded-xl p-5 hover:border-green-500 hover:shadow-xl hover:shadow-green-500/20 transition-all"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-white">{item.name}</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
📍 {item.location} • 📅{" "}
|
||||
{Helpers.formatDate(item.date_found)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-green-500/20 text-green-400 border border-green-500/50">
|
||||
✅ CLOSED
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{item.photo_url && (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt={item.name}
|
||||
className="w-full h-40 object-cover rounded-lg border border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/400x200?text=No+Image")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{item.berita_acara_no || "-"}
|
||||
</strong>
|
||||
</div>
|
||||
{item.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(item.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{item.case_closed_by_name && (
|
||||
<div>Oleh: {item.case_closed_by_name}</div>
|
||||
)}
|
||||
{item.claimer_name && (
|
||||
<div>
|
||||
Penerima:{" "}
|
||||
<strong className="text-white">
|
||||
{item.claimer_name}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.bukti_serah_terima && (
|
||||
<a
|
||||
href={item.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reporter Info */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-400">
|
||||
<div>
|
||||
<strong className="text-slate-300">Pelapor:</strong>
|
||||
<br />
|
||||
{item.reporter_name || "N/A"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-300">Kontak:</strong>
|
||||
<br />
|
||||
{item.reporter_contact || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-slate-400">
|
||||
🏷️ {item.category || "-"}
|
||||
</span>
|
||||
{item.case_closed_notes && (
|
||||
<span className="text-xs text-blue-400">📝 Ada catatan</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewArchiveDetail(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
📋 Detail Lengkap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredArchives.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">📂</div>
|
||||
<p>
|
||||
{archiveSearchTerm || archiveCategoryFilter
|
||||
? "Tidak ada arsip yang sesuai filter"
|
||||
: "Belum ada arsip"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
// assets/js/pages/admin/tabs/AuditLogTab.js
|
||||
|
||||
const AuditLogTab = ({ state, handlers }) => {
|
||||
const {
|
||||
filteredAuditLogs, // Data yang ditampilkan (sekarang langsung dari API)
|
||||
auditLogSearchTerm,
|
||||
setAuditLogSearchTerm,
|
||||
auditLogActionFilter,
|
||||
setAuditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
setAuditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
setAuditLogDateFilter,
|
||||
// State Pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const { loadAuditLogs } = handlers;
|
||||
|
||||
// ✅ UPDATED: Trigger load data saat halaman atau filter berubah
|
||||
// Menggunakan debounce untuk search text agar tidak spam API
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// Reset ke halaman 1 jika filter berubah, tapi tetap di halaman x jika hanya navigasi
|
||||
loadAuditLogs(currentPage);
|
||||
}, 500); // Delay 500ms
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
currentPage,
|
||||
auditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
]);
|
||||
// Catatan: auditLogSearchTerm bisa dimasukkan dependency jika backend support text search
|
||||
|
||||
// Handler ganti halaman
|
||||
const handlePageChange = (newPage) => {
|
||||
state.setCurrentPage(newPage);
|
||||
// useEffect di atas akan otomatis memanggil API karena currentPage berubah
|
||||
};
|
||||
|
||||
// Helper styles (Tetap sama seperti sebelumnya)
|
||||
const getActionStyle = (action) => {
|
||||
const styles = {
|
||||
login: {
|
||||
icon: "🔐",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
},
|
||||
logout: {
|
||||
icon: "🚪",
|
||||
color: "text-gray-400",
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/30",
|
||||
},
|
||||
create_item: {
|
||||
icon: "➕",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
update_item: {
|
||||
icon: "✏️",
|
||||
color: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/30",
|
||||
},
|
||||
delete_item: {
|
||||
icon: "🗑️",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
create_claim: {
|
||||
icon: "🤝",
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/30",
|
||||
},
|
||||
verify_claim: {
|
||||
icon: "✅",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
reject_claim: {
|
||||
icon: "❌",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
close_case: {
|
||||
icon: "📋",
|
||||
color: "text-purple-400",
|
||||
bg: "bg-purple-500/10",
|
||||
border: "border-purple-500/30",
|
||||
},
|
||||
reopen_case: {
|
||||
icon: "🔄",
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-orange-500/30",
|
||||
},
|
||||
update_user_role: {
|
||||
icon: "👤",
|
||||
color: "text-yellow-400",
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/30",
|
||||
},
|
||||
block_user: {
|
||||
icon: "🚫",
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/30",
|
||||
},
|
||||
unblock_user: {
|
||||
icon: "✅",
|
||||
color: "text-green-400",
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/30",
|
||||
},
|
||||
export_report: {
|
||||
icon: "📊",
|
||||
color: "text-cyan-400",
|
||||
bg: "bg-cyan-500/10",
|
||||
border: "border-cyan-500/30",
|
||||
},
|
||||
};
|
||||
return (
|
||||
styles[action] || {
|
||||
icon: "📌",
|
||||
color: "text-slate-400",
|
||||
bg: "bg-slate-500/10",
|
||||
border: "border-slate-500/30",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const formatActionName = (action) => {
|
||||
return action
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
// Daftar aksi unik untuk dropdown (bisa hardcode atau ambil dari API/konstanta jika perlu)
|
||||
// Karena pagination server-side, kita tidak bisa ambil unique dari 'all data' di frontend.
|
||||
// Sebaiknya sediakan list statis atau endpoint khusus. Disini kita pakai statis umum.
|
||||
const actionOptions = [
|
||||
"login",
|
||||
"logout",
|
||||
"create_item",
|
||||
"update_item",
|
||||
"delete_item",
|
||||
"create_claim",
|
||||
"verify_claim",
|
||||
"reject_claim",
|
||||
"close_case",
|
||||
"reopen_case",
|
||||
"update_user_role",
|
||||
"block_user",
|
||||
"unblock_user",
|
||||
"export_report",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">📜 Audit Log</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => loadAuditLogs(1)} // Reset ke halaman 1 saat refresh manual
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
{/* Note: Search text frontend mungkin tidak jalan sempurna jika backend belum support LIKE query global */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari aktivitas..."
|
||||
value={auditLogSearchTerm}
|
||||
onChange={(e) => setAuditLogSearchTerm(e.target.value)}
|
||||
disabled // Sementara disable jika backend belum support full-text search global
|
||||
className="px-4 py-2 bg-slate-800 border-2 border-slate-600 rounded-xl text-slate-500 cursor-not-allowed"
|
||||
title="Pencarian teks belum aktif"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={auditLogActionFilter}
|
||||
onChange={(e) => {
|
||||
setAuditLogActionFilter(e.target.value);
|
||||
state.setCurrentPage(1); // Reset page saat filter berubah
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Aksi</option>
|
||||
{actionOptions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{formatActionName(action)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter User ID..."
|
||||
value={auditLogUserFilter}
|
||||
onChange={(e) => {
|
||||
setAuditLogUserFilter(e.target.value);
|
||||
// state.setCurrentPage(1); // Optional: reset page immediately
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={auditLogDateFilter}
|
||||
onChange={(e) => setAuditLogDateFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredAuditLogs.map((log, index) => {
|
||||
const style = getActionStyle(log.action);
|
||||
return (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`${style.bg} border-2 ${style.border} rounded-xl p-4 hover:shadow-xl transition-all relative`}
|
||||
>
|
||||
{/* ... (Isi Card Log tetap sama seperti sebelumnya) ... */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`text-3xl ${style.color} flex-shrink-0`}>
|
||||
{style.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-lg font-bold ${style.color}`}>
|
||||
{formatActionName(log.action)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400 mt-1">
|
||||
<span className="text-white font-medium">
|
||||
{log.user_name || "System"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{Helpers.formatDateTime(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{log.details && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-700 mt-3">
|
||||
<p className="text-sm text-slate-300">
|
||||
{log.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && filteredAuditLogs.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">📜</div>
|
||||
<p>Tidak ada data log.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ ADDED: Pagination Component */}
|
||||
{!loading && filteredAuditLogs.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={10} // Sesuai limit backend
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.AuditLogTab = AuditLogTab;
|
||||
@@ -0,0 +1,240 @@
|
||||
// assets/js/pages/admin/tabs/CategoriesTab.js
|
||||
|
||||
const CategoriesTab = ({ state, handlers }) => {
|
||||
const {
|
||||
categories,
|
||||
setCategories,
|
||||
showCreateCategoryModal,
|
||||
setShowCreateCategoryModal,
|
||||
showEditCategoryModal,
|
||||
setShowEditCategoryModal,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
} = state;
|
||||
|
||||
const { handleCreateCategory, handleUpdateCategory, handleDeleteCategory } =
|
||||
handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🏷️ Kelola Kategori
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateCategoryModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
➕ Tambah Kategori
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-300">
|
||||
ℹ️ Kelola kategori yang digunakan untuk mengklasifikasikan barang
|
||||
hilang dan temuan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-slate-600 rounded-xl p-5 hover:border-blue-500 hover:shadow-xl hover:shadow-blue-500/20 transition-all"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Value:{" "}
|
||||
<span className="text-blue-400">{category.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-2xl">🏷️</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<div className="text-sm text-slate-300">
|
||||
<strong className="text-blue-400">ID:</strong> {category.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description if available */}
|
||||
{category.description && (
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(category);
|
||||
setShowEditCategoryModal(true);
|
||||
}}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus kategori "${category.name}"?\n\nPeringatan: Barang dengan kategori ini mungkin terpengaruh!`
|
||||
)
|
||||
) {
|
||||
handleDeleteCategory(category.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🏷️</div>
|
||||
<p>Belum ada kategori</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Category Modal */}
|
||||
<Modal
|
||||
isOpen={showCreateCategoryModal}
|
||||
onClose={() => setShowCreateCategoryModal(false)}
|
||||
title="➕ Tambah Kategori Baru"
|
||||
>
|
||||
<form onSubmit={handleCreateCategory} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Kategori *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Contoh: Elektronik"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Value (Identifier) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
required
|
||||
placeholder="Contoh: elektronik (huruf kecil, tanpa spasi)"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Value digunakan sebagai identifier. Gunakan huruf kecil, tanpa
|
||||
spasi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
placeholder="Deskripsi singkat tentang kategori ini..."
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{state.loading ? "⏳ Menyimpan..." : "✅ Simpan Kategori"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Category Modal */}
|
||||
<Modal
|
||||
isOpen={showEditCategoryModal}
|
||||
onClose={() => {
|
||||
setShowEditCategoryModal(false);
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
title="✏️ Edit Kategori"
|
||||
>
|
||||
{selectedCategory && (
|
||||
<form onSubmit={handleUpdateCategory} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Kategori *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={selectedCategory.name}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Value (Identifier) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
required
|
||||
defaultValue={selectedCategory.value}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Hati-hati mengubah value, dapat mempengaruhi data existing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="3"
|
||||
defaultValue={selectedCategory.description || ""}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state.loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{state.loading ? "⏳ Menyimpan..." : "✅ Update Kategori"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
// assets/js/pages/admin/tabs/ClaimsTabAdmin.js - WITH FULL CRUD
|
||||
|
||||
const ClaimsTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
claims,
|
||||
filteredClaims,
|
||||
claimStatusFilter,
|
||||
setClaimStatusFilter,
|
||||
claimSearchTerm,
|
||||
setClaimSearchTerm,
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewClaimDetail,
|
||||
handleDeleteClaim,
|
||||
handleEditClaimClick,
|
||||
loadClaims,
|
||||
} = handlers;
|
||||
|
||||
// Filter claims locally
|
||||
React.useEffect(() => {
|
||||
let filtered = claims.filter((claim) => {
|
||||
const matchesSearch =
|
||||
claim.item_name.toLowerCase().includes(claimSearchTerm.toLowerCase()) ||
|
||||
claim.user_name.toLowerCase().includes(claimSearchTerm.toLowerCase());
|
||||
const matchesStatus =
|
||||
!claimStatusFilter || claim.status === claimStatusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
state.setFilteredClaims(filtered);
|
||||
}, [claimSearchTerm, claimStatusFilter, claims]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">Kelola Klaim</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateClaimModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
➕ Tambah Klaim Manual
|
||||
</button>
|
||||
<button
|
||||
onClick={loadClaims}
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari klaim..."
|
||||
value={claimSearchTerm}
|
||||
onChange={(e) => setClaimSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={claimStatusFilter}
|
||||
onChange={(e) => setClaimStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-yellow-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "pending").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Pending</div>
|
||||
</div>
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "approved").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Approved</div>
|
||||
</div>
|
||||
<div className="bg-red-500/10 border border-red-500/30 p-3 rounded-lg text-center">
|
||||
<div className="text-red-400 font-bold text-2xl">
|
||||
{claims.filter((c) => c.status === "rejected").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Rejected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredClaims.map((claim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="bg-gradient-to-br from-slate-700 to-slate-800 border-2 border-slate-600 rounded-xl p-5 hover:border-blue-500 hover:shadow-xl hover:shadow-blue-500/20 transition-all"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{claim.item_name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pengklaim:{" "}
|
||||
<span className="text-white font-medium">
|
||||
{claim.user_name}
|
||||
</span>{" "}
|
||||
• {claim.contact}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${Helpers.getStatusBadgeClass(
|
||||
claim.status
|
||||
)}`}
|
||||
>
|
||||
{claim.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match Percentage */}
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`mb-4 p-3 rounded-lg text-center ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-yellow-500/10 border border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-slate-400 mb-1">
|
||||
Similarity Match
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<strong className="text-sm text-slate-300">
|
||||
Deskripsi Klaim:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-400 mt-1 line-clamp-3">
|
||||
{claim.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-1 text-xs text-slate-400 mb-4">
|
||||
<div>
|
||||
📅 Diajukan: {Helpers.formatDateTime(claim.created_at)}
|
||||
</div>
|
||||
{claim.verified_at && (
|
||||
<div>
|
||||
✅ Diverifikasi: {Helpers.formatDateTime(claim.verified_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.verified_by_name && (
|
||||
<div>👤 Oleh: {claim.verified_by_name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{claim.notes && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border-2 mb-4 ${
|
||||
claim.status === "approved"
|
||||
? "bg-green-500/10 border-green-500/30"
|
||||
: "bg-red-500/10 border-red-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-sm text-slate-300">
|
||||
Catatan Manager:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-400 mt-1">{claim.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Case Closed Info */}
|
||||
{claim.status === "approved" && claim.berita_acara_no && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
<strong className="text-green-400">Case Closed</strong>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-300">
|
||||
<div>
|
||||
No. BA:{" "}
|
||||
<strong className="text-white">
|
||||
{claim.berita_acara_no}
|
||||
</strong>
|
||||
</div>
|
||||
{claim.case_closed_at && (
|
||||
<div>
|
||||
Ditutup: {Helpers.formatDateTime(claim.case_closed_at)}
|
||||
</div>
|
||||
)}
|
||||
{claim.case_closed_by_name && (
|
||||
<div>Oleh: {claim.case_closed_by_name}</div>
|
||||
)}
|
||||
</div>
|
||||
{claim.bukti_serah_terima && (
|
||||
<a
|
||||
href={claim.bukti_serah_terima}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
📄 Lihat Bukti
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewClaimDetail(claim)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
📋 Detail
|
||||
</button>
|
||||
|
||||
{/* Edit button - only for pending claims */}
|
||||
{claim.status === "pending" && (
|
||||
<button
|
||||
onClick={() => handleEditClaimClick(claim)}
|
||||
className="px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus klaim dari "${claim.user_name}"?\n\nBarang: ${claim.item_name}`
|
||||
)
|
||||
) {
|
||||
handleDeleteClaim(claim.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredClaims.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🤝</div>
|
||||
<p>
|
||||
{claimSearchTerm || claimStatusFilter
|
||||
? "Tidak ada klaim yang sesuai filter"
|
||||
: "Belum ada klaim"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
// assets/js/pages/admin/tabs/ItemsTab.js
|
||||
|
||||
const ItemsTab = ({ state, handlers }) => {
|
||||
const {
|
||||
showExpired,
|
||||
setShowExpired,
|
||||
itemSearchTerm,
|
||||
setItemSearchTerm,
|
||||
itemStatusFilter,
|
||||
setItemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
setItemCategoryFilter,
|
||||
filteredItems,
|
||||
items,
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
photoPreview,
|
||||
itemPage,
|
||||
itemTotalPages,
|
||||
itemTotalRecords,
|
||||
loading,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewItemDetail,
|
||||
handleCreateItem,
|
||||
handleEditItemClick,
|
||||
handleDeleteItem,
|
||||
handlePhotoChange,
|
||||
loadItems,
|
||||
} = handlers;
|
||||
|
||||
// ✅ Gunakan useEffect untuk reload data saat Filter / Page berubah
|
||||
// Kita menggunakan debounce (tunda) untuk search agar tidak spam API
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// Reset ke halaman 1 jika filter berubah
|
||||
// Jika hanya halaman yang berubah, load halaman tersebut
|
||||
loadItems(itemPage);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
itemPage,
|
||||
itemSearchTerm,
|
||||
itemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
showExpired,
|
||||
]);
|
||||
|
||||
// Reset page ke 1 jika filter berubah (kecuali itemPage itu sendiri)
|
||||
React.useEffect(() => {
|
||||
state.setItemPage(1);
|
||||
}, [itemSearchTerm, itemStatusFilter, itemCategoryFilter, showExpired]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{showExpired ? "📦 Barang Expired" : "📦 Kelola Barang"}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateItemModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
➕ Tambah Barang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(false)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
!showExpired
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
📦 Aktif
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(true)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
showExpired
|
||||
? "bg-gradient-to-r from-red-600 to-red-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
⚠️ Expired
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari barang (nama/lokasi)..."
|
||||
value={itemSearchTerm}
|
||||
onChange={(e) => setItemSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={itemStatusFilter}
|
||||
onChange={(e) => setItemStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="pending_claim">Pending Claim</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="case_closed">Case Closed</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<select
|
||||
value={itemCategoryFilter}
|
||||
onChange={(e) => setItemCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{state.categories &&
|
||||
state.categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{" "}
|
||||
{/* Sesuaikan value dengan slug/id dari DB */}
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* ✅ PERUBAHAN UTAMA:
|
||||
Kita me-map 'items' secara langsung.
|
||||
Tidak perlu filteredItems.filter() lagi karena backend sudah mengirimkan data
|
||||
yang sudah terfilter (hanya 10 biji) sesuai halaman aktif.
|
||||
*/}
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl overflow-hidden hover:shadow-2xl transition-all ${
|
||||
item.status === "expired"
|
||||
? "border-red-500/50 hover:border-red-500"
|
||||
: "border-slate-600 hover:border-blue-500 hover:shadow-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{item.status === "expired" && (
|
||||
<div className="bg-red-600/90 text-white text-center py-1 text-xs font-bold">
|
||||
⚠️ EXPIRED
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={
|
||||
item.photo_url ||
|
||||
"https://via.placeholder.com/280x200?text=No+Image"
|
||||
}
|
||||
alt={item.name}
|
||||
className={`w-full h-48 object-cover cursor-pointer ${
|
||||
item.status === "expired" ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={() => handleViewItemDetail(item)}
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/280x200?text=No+Image")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1 text-sm text-slate-400 mb-3">
|
||||
<span>📍 {item.location}</span>
|
||||
<span>📅 {Helpers.formatDate(item.date_found)}</span>
|
||||
<span>👤 {item.reporter_name}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-3 ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewItemDetail(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
📋 Detail
|
||||
</button>
|
||||
|
||||
{/* Tombol Edit/Hapus hanya muncul jika bukan case_closed */}
|
||||
{item.status !== "case_closed" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditItemClick(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus "${item.name}"?`
|
||||
)
|
||||
) {
|
||||
handleDeleteItem(item.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">{showExpired ? "⚠️" : "📦"}</div>
|
||||
<p>
|
||||
{showExpired
|
||||
? "Belum ada barang expired"
|
||||
: "Tidak ada barang ditemukan"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ KOMPONEN PAGINATION */}
|
||||
{items.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={itemPage}
|
||||
totalPages={itemTotalPages}
|
||||
totalRecords={itemTotalRecords}
|
||||
onPageChange={(page) => state.setItemPage(page)}
|
||||
itemsPerPage={10}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateItemModal state={state} handlers={handlers} />
|
||||
<EditItemModal state={state} handlers={handlers} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
const LostItemsTabAdmin = ({ state, handlers }) => {
|
||||
const {
|
||||
lostItems,
|
||||
lostItemSearchTerm,
|
||||
setLostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
setLostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
setLostItemCategoryFilter,
|
||||
lostItemPage,
|
||||
lostItemTotalPages,
|
||||
lostItemTotalRecords,
|
||||
categories,
|
||||
loading,
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
handleViewLostItemDetail,
|
||||
handleEditLostItemClick,
|
||||
handleDeleteLostItem,
|
||||
loadLostItems,
|
||||
} = handlers;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadLostItems(lostItemPage);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
lostItemPage,
|
||||
lostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
state.setLostItemPage(1);
|
||||
}, [lostItemSearchTerm, lostItemStatusFilter, lostItemCategoryFilter]);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🔍 Kelola Laporan Barang Hilang
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateLostItemModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
➕ Tambah Laporan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari laporan (nama/deskripsi)..."
|
||||
value={lostItemSearchTerm}
|
||||
onChange={(e) => setLostItemSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={lostItemStatusFilter}
|
||||
onChange={(e) => setLostItemStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending_verification">Pending Verification</option>
|
||||
<option value="claimed">Claimed</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<select
|
||||
value={lostItemCategoryFilter}
|
||||
onChange={(e) => setLostItemCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Kategori</option>
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug || cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4 animate-pulse">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
ID
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Nama Barang
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Kategori
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Pelapor
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Lokasi
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Tanggal Hilang
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-slate-300 font-semibold">
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lostItems &&
|
||||
lostItems.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-slate-700/50 hover:bg-slate-700/30 transition"
|
||||
>
|
||||
<td className="py-3 px-4 text-slate-300">#{item.id}</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.color && (
|
||||
<div className="text-xs text-slate-400">
|
||||
🎨 {item.color}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{item.category}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="text-white">{item.user_name}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
ID: {item.user_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{item.location || "-"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{Helpers.formatDateShort(item.date_lost)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
{item.direct_claim_status && (
|
||||
<div className="text-xs text-yellow-400 mt-1">
|
||||
📋 Claim: {item.direct_claim_status}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleViewLostItemDetail(item)}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
|
||||
title="Detail"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditLostItemClick(item)}
|
||||
className="px-3 py-1 bg-yellow-600 text-white text-xs rounded hover:bg-yellow-700 transition"
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus laporan "${item.name}"?`
|
||||
)
|
||||
) {
|
||||
handleDeleteLostItem(item.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition"
|
||||
title="Hapus"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(!lostItems || lostItems.length === 0) && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<p>Tidak ada laporan barang hilang</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lostItems && lostItems.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={lostItemPage}
|
||||
totalPages={lostItemTotalPages}
|
||||
totalRecords={lostItemTotalRecords}
|
||||
onPageChange={(page) => state.setLostItemPage(page)}
|
||||
itemsPerPage={10}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
const MyClaimsTab = ({ state, handlers }) => {
|
||||
const { myLostItems, myClaims, loading } = state;
|
||||
|
||||
// Lost items dengan pending claims (untuk OWNER perspective)
|
||||
const lostItemsWithClaims = myLostItems.filter(
|
||||
(item) =>
|
||||
item.direct_claim_id &&
|
||||
item.direct_claim_status &&
|
||||
item.status !== "completed" &&
|
||||
item.direct_claim_status !== "completed"
|
||||
);
|
||||
|
||||
const regularClaims = myClaims || [];
|
||||
|
||||
// Status badge untuk OWNER perspective (di section "Barang Hilang Saya")
|
||||
const getOwnerStatusBadge = (status) => {
|
||||
const badges = {
|
||||
active: { color: "bg-blue-500", text: "🔍 Masih Hilang", emoji: "😢" },
|
||||
claimed: {
|
||||
color: "bg-yellow-500",
|
||||
text: "⏳ Menunggu Persetujuan Anda",
|
||||
emoji: "🤔",
|
||||
},
|
||||
found: { color: "bg-green-500", text: "✅ Ditemukan", emoji: "🎉" },
|
||||
completed: {
|
||||
color: "bg-purple-500",
|
||||
text: "✅ Selesai - Barang Diterima",
|
||||
emoji: "🎉",
|
||||
},
|
||||
waiting_owner: {
|
||||
color: "bg-orange-500",
|
||||
text: "⏳ Menunggu Keputusan Anda", // Owner yang harus decide
|
||||
emoji: "🔔",
|
||||
},
|
||||
verified: {
|
||||
color: "bg-green-600",
|
||||
text: "✅ Terverifikasi",
|
||||
emoji: "✅",
|
||||
},
|
||||
};
|
||||
return (
|
||||
badges[status] || {
|
||||
color: "bg-gray-500",
|
||||
text: status,
|
||||
emoji: "❓",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Status badge untuk CLAIMANT perspective (di section "Klaim yang Saya Ajukan")
|
||||
const getClaimantStatusBadge = (status) => {
|
||||
const badges = {
|
||||
pending: {
|
||||
color: "bg-yellow-500",
|
||||
text: "⏳ Menunggu Verifikasi Admin",
|
||||
emoji: "⏳",
|
||||
},
|
||||
approved: {
|
||||
color: "bg-green-500",
|
||||
text: "✅ Disetujui",
|
||||
emoji: "✅",
|
||||
},
|
||||
rejected: {
|
||||
color: "bg-red-500",
|
||||
text: "❌ Ditolak",
|
||||
emoji: "❌",
|
||||
},
|
||||
waiting_owner: {
|
||||
color: "bg-orange-500",
|
||||
text: "⏳ Menunggu Keputusan Pemilik", // Claimant menunggu owner
|
||||
emoji: "💤",
|
||||
},
|
||||
verified: {
|
||||
color: "bg-green-600",
|
||||
text: "✅ Terverifikasi",
|
||||
emoji: "✅",
|
||||
},
|
||||
completed: {
|
||||
color: "bg-purple-500",
|
||||
text: "🎉 Selesai",
|
||||
emoji: "🎉",
|
||||
},
|
||||
};
|
||||
return (
|
||||
badges[status] || {
|
||||
color: "bg-gray-500",
|
||||
text: status,
|
||||
emoji: "❓",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="animate-spin text-4xl mb-4">⏳</div>
|
||||
<p>Memuat data klaim...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* SECTION 1: OWNER PERSPECTIVE - Barang Hilang Saya yang Ada Klaimnya */}
|
||||
{lostItemsWithClaims.length > 0 && (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700 p-6">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<span className="text-3xl">😢</span>
|
||||
Barang Hilang Saya
|
||||
<span className="bg-orange-500/20 text-orange-400 text-sm px-3 py-1 rounded-full border border-orange-500/30">
|
||||
{lostItemsWithClaims.length} item dengan klaim pending
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-slate-400 mb-6">
|
||||
Ada yang menemukan barang Anda! Silakan approve atau reject klaim
|
||||
mereka.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{lostItemsWithClaims.map((lostItem) => {
|
||||
const statusBadge = getOwnerStatusBadge(
|
||||
lostItem.direct_claim_status || lostItem.status
|
||||
);
|
||||
const isWaitingOwner =
|
||||
lostItem.direct_claim_status === "waiting_owner";
|
||||
const isVerified =
|
||||
lostItem.direct_claim_status === "verified" ||
|
||||
lostItem.status === "found";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lostItem.id}
|
||||
className={`bg-slate-900/80 rounded-xl border ${
|
||||
isWaitingOwner
|
||||
? "border-orange-500/50 shadow-lg shadow-orange-500/20"
|
||||
: "border-slate-700"
|
||||
} p-6 hover:shadow-xl transition-all`}
|
||||
>
|
||||
{/* Header dengan status */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="text-xl font-bold text-white">
|
||||
{lostItem.name}
|
||||
</h4>
|
||||
<span
|
||||
className={`${statusBadge.color} text-white text-xs px-3 py-1 rounded-full font-medium`}
|
||||
>
|
||||
{statusBadge.emoji} {statusBadge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-slate-400">
|
||||
<p>
|
||||
<span className="font-medium">📦 Kategori:</span>{" "}
|
||||
{lostItem.category}
|
||||
</p>
|
||||
{lostItem.color && (
|
||||
<p>
|
||||
<span className="font-medium">🎨 Warna:</span>{" "}
|
||||
{lostItem.color}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">📍 Lokasi Hilang:</span>{" "}
|
||||
{lostItem.location}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
📅 Tanggal Hilang:
|
||||
</span>{" "}
|
||||
{new Date(lostItem.date_lost).toLocaleDateString(
|
||||
"id-ID"
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-medium">📝 Deskripsi:</span>{" "}
|
||||
{lostItem.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Area untuk Owner */}
|
||||
{isWaitingOwner && (
|
||||
<div className="bg-orange-900/30 border border-orange-500/30 rounded-lg p-4 mt-4">
|
||||
<p className="text-orange-300 font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="text-2xl">🔔</span>
|
||||
Ada yang mengaku menemukan barang ini!
|
||||
</p>
|
||||
<p className="text-sm text-slate-300 mb-4">
|
||||
Seseorang telah mengajukan klaim untuk barang ini.
|
||||
Silakan putuskan apakah ini benar barang Anda.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserRespondClaim(
|
||||
lostItem.direct_claim_id,
|
||||
"approve"
|
||||
)
|
||||
}
|
||||
className="flex-1 bg-gradient-to-r from-green-600 to-green-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg shadow-green-500/30"
|
||||
>
|
||||
✅ Approve (Ini barang saya!)
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserRespondClaim(
|
||||
lostItem.direct_claim_id,
|
||||
"reject"
|
||||
)
|
||||
}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-red-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-red-700 hover:to-red-800 transition shadow-lg shadow-red-500/30"
|
||||
>
|
||||
❌ Reject (Bukan barang saya)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVerified && (
|
||||
<div className="bg-green-900/30 border border-green-500/30 rounded-lg p-4 mt-4">
|
||||
<p className="text-green-300 font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="text-2xl">✅</span>
|
||||
Klaim Disetujui!
|
||||
</p>
|
||||
<p className="text-sm text-slate-300 mb-4">
|
||||
Anda telah menyetujui bahwa penemu menemukan barang
|
||||
Anda. Koordinasikan pengambilan barang, lalu konfirmasi
|
||||
setelah menerima barang.
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
handlers.handleUserCompleteCase(
|
||||
lostItem.direct_claim_id
|
||||
)
|
||||
}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-purple-700 text-white px-4 py-3 rounded-lg font-semibold hover:from-purple-700 hover:to-purple-800 transition shadow-lg shadow-purple-500/30"
|
||||
>
|
||||
🎉 Konfirmasi: Saya Sudah Terima Barang
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION 2: CLAIMANT PERSPECTIVE - Klaim yang Saya Ajukan */}
|
||||
{regularClaims.length > 0 && (
|
||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700 p-6">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<span className="text-3xl">🤝</span>
|
||||
Klaim Barang yang Saya Ajukan
|
||||
<span className="bg-blue-500/20 text-blue-400 text-sm px-3 py-1 rounded-full border border-blue-500/30">
|
||||
{regularClaims.length} klaim
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{regularClaims.map((claim) => {
|
||||
const statusBadge = getClaimantStatusBadge(claim.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="bg-slate-900/80 rounded-xl border border-slate-700 p-5 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h4 className="text-lg font-bold text-white flex-1">
|
||||
{claim.item_name}
|
||||
</h4>
|
||||
<span
|
||||
className={`${statusBadge.color} text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ml-2`}
|
||||
>
|
||||
{statusBadge.emoji} {statusBadge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-400">
|
||||
<p>
|
||||
<span className="font-medium">📝 Deskripsi:</span>{" "}
|
||||
{claim.description}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">📞 Kontak:</span>{" "}
|
||||
{claim.contact}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">📅 Tanggal Klaim:</span>{" "}
|
||||
{new Date(claim.created_at).toLocaleDateString("id-ID")}
|
||||
</p>
|
||||
{claim.notes && (
|
||||
<p className="text-yellow-400 mt-2">
|
||||
<span className="font-medium">💬 Catatan:</span>{" "}
|
||||
{claim.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{lostItemsWithClaims.length === 0 && regularClaims.length === 0 && (
|
||||
<div className="text-center py-16 bg-slate-800/30 rounded-2xl border border-slate-700">
|
||||
<div className="text-6xl mb-4">🔭</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Belum Ada Klaim</h3>
|
||||
<p className="text-slate-400">
|
||||
Anda belum memiliki klaim atau barang hilang dengan klaim pending.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.MyClaimsTab = MyClaimsTab;
|
||||
@@ -0,0 +1,55 @@
|
||||
// assets/js/pages/admin/tabs/ReportsTab.js
|
||||
|
||||
const ReportsTab = ({ handlers, loading }) => {
|
||||
const { handleExport } = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-6 text-white">Export Laporan</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Periode
|
||||
</label>
|
||||
<select
|
||||
name="period"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="month">Bulan Ini</option>
|
||||
<option value="semester">Semester Ini</option>
|
||||
<option value="year">Tahun Ini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tipe Laporan
|
||||
</label>
|
||||
<select
|
||||
name="report_type"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="items">Barang Ditemukan</option>
|
||||
<option value="archives">Arsip</option>
|
||||
<option value="claims">Klaim</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Exporting..." : "📄 Export PDF"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport("excel")}
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Exporting..." : "📊 Export Excel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
const RolesTab = ({ state, handlers }) => {
|
||||
// ✅ PERBAIKAN: Ambil setShowCreateRoleModal dari state
|
||||
const { roles, loading, setShowCreateRoleModal } = state;
|
||||
|
||||
// handlers hanya berisi fungsi logika (API calls)
|
||||
const { handleEditRole, handleDeleteRole } = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
🔑 Kelola Role & Hak Akses
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateRoleModal(true)} // Sekarang fungsi ini sudah didefinisikan
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
➕ Tambah Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="bg-slate-700/30 border border-slate-600 rounded-xl p-5 hover:border-blue-500 transition"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white capitalize">
|
||||
{role.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">{role.description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEditRole(role)}
|
||||
className="p-2 bg-yellow-600/20 text-yellow-400 rounded hover:bg-yellow-600/40"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{/* Jangan izinkan hapus admin/user/manager default jika perlu */}
|
||||
{role.name !== "admin" && role.name !== "user" && (
|
||||
<button
|
||||
onClick={() => handleDeleteRole(role.id)}
|
||||
className="p-2 bg-red-600/20 text-red-400 rounded hover:bg-red-600/40"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<strong className="text-xs text-slate-500 uppercase block mb-2">
|
||||
Hak Akses (Permissions):
|
||||
</strong>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{role.permissions && role.permissions.length > 0 ? (
|
||||
role.permissions.map((perm) => (
|
||||
<span
|
||||
key={perm.id}
|
||||
className="px-2 py-1 bg-blue-500/10 text-blue-300 border border-blue-500/20 rounded text-xs"
|
||||
>
|
||||
{perm.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-slate-500 text-xs italic">
|
||||
Tidak ada permission khusus
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
window.RolesTab = RolesTab;
|
||||
@@ -0,0 +1,111 @@
|
||||
// assets/js/pages/admin/tabs/UsersTab.js
|
||||
|
||||
const UsersTab = ({ state, handlers }) => {
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
roleFilter,
|
||||
setRoleFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
filteredUsers,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
setCurrentPage,
|
||||
} = state;
|
||||
|
||||
const { handleEditUser, handleBlockUser, handleUnblockUser } = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl shadow-xl border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-4 text-white">Daftar User</h2>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari user..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 min-w-[200px] px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Role</option>
|
||||
<option value="user">User</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Semua Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-700">
|
||||
<thead className="bg-slate-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Nama
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
NRP
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gradient-to-br from-slate-800 to-slate-900 divide-y divide-slate-700">
|
||||
{filteredUsers.map((u) => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
user={u}
|
||||
onEdit={handleEditUser}
|
||||
onBlock={handleBlockUser}
|
||||
onUnblock={handleUnblockUser}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
itemsPerPage={CONFIG.PAGINATION.LIMIT}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">👥</div>
|
||||
<p>Tidak ada user ditemukan</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const useAdminState = () => {
|
||||
// Existing states
|
||||
const [user, setUser] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
const [stats, setStats] = useState({});
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Items states
|
||||
const [items, setItems] = useState([]);
|
||||
const [filteredItems, setFilteredItems] = useState([]);
|
||||
const [showExpired, setShowExpired] = useState(false);
|
||||
const [itemSearchTerm, setItemSearchTerm] = useState("");
|
||||
const [itemStatusFilter, setItemStatusFilter] = useState("");
|
||||
const [itemCategoryFilter, setItemCategoryFilter] = useState("");
|
||||
const [selectedItemDetail, setSelectedItemDetail] = useState(null);
|
||||
const [showItemDetailModal, setShowItemDetailModal] = useState(false);
|
||||
const [showCreateItemModal, setShowCreateItemModal] = useState(false);
|
||||
const [showEditItemModal, setShowEditItemModal] = useState(false);
|
||||
const [selectedItemToEdit, setSelectedItemToEdit] = useState(null);
|
||||
const [photoPreview, setPhotoPreview] = useState(null);
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemTotalPages, setItemTotalPages] = useState(1);
|
||||
const [itemTotalRecords, setItemTotalRecords] = useState(0);
|
||||
|
||||
// NEW: Lost Items states
|
||||
const [lostItems, setLostItems] = useState([]);
|
||||
const [filteredLostItems, setFilteredLostItems] = useState([]);
|
||||
const [lostItemSearchTerm, setLostItemSearchTerm] = useState("");
|
||||
const [lostItemStatusFilter, setLostItemStatusFilter] = useState("");
|
||||
const [lostItemCategoryFilter, setLostItemCategoryFilter] = useState("");
|
||||
const [selectedLostItemDetail, setSelectedLostItemDetail] = useState(null);
|
||||
const [showLostItemDetailModal, setShowLostItemDetailModal] = useState(false);
|
||||
const [showCreateLostItemModal, setShowCreateLostItemModal] = useState(false);
|
||||
const [showEditLostItemModal, setShowEditLostItemModal] = useState(false);
|
||||
const [selectedLostItemToEdit, setSelectedLostItemToEdit] = useState(null);
|
||||
const [lostItemPage, setLostItemPage] = useState(1);
|
||||
const [lostItemTotalPages, setLostItemTotalPages] = useState(1);
|
||||
const [lostItemTotalRecords, setLostItemTotalRecords] = useState(0);
|
||||
|
||||
// Claims states
|
||||
const [claims, setClaims] = useState([]);
|
||||
const [filteredClaims, setFilteredClaims] = useState([]);
|
||||
const [claimStatusFilter, setClaimStatusFilter] = useState("");
|
||||
const [claimSearchTerm, setClaimSearchTerm] = useState("");
|
||||
const [selectedClaim, setSelectedClaim] = useState(null);
|
||||
const [showClaimDetailModal, setShowClaimDetailModal] = useState(false);
|
||||
const [showCreateClaimModal, setShowCreateClaimModal] = useState(false);
|
||||
const [showEditClaimModal, setShowEditClaimModal] = useState(false);
|
||||
const [selectedClaimToEdit, setSelectedClaimToEdit] = useState(null);
|
||||
|
||||
// Archives states
|
||||
const [archives, setArchives] = useState([]);
|
||||
const [filteredArchives, setFilteredArchives] = useState([]);
|
||||
const [archiveSearchTerm, setArchiveSearchTerm] = useState("");
|
||||
const [archiveCategoryFilter, setArchiveCategoryFilter] = useState("");
|
||||
const [selectedArchive, setSelectedArchive] = useState(null);
|
||||
const [showArchiveDetailModal, setShowArchiveDetailModal] = useState(false);
|
||||
|
||||
// Audit logs states
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [filteredAuditLogs, setFilteredAuditLogs] = useState([]);
|
||||
const [auditLogSearchTerm, setAuditLogSearchTerm] = useState("");
|
||||
const [auditLogActionFilter, setAuditLogActionFilter] = useState("");
|
||||
const [auditLogUserFilter, setAuditLogUserFilter] = useState("");
|
||||
const [auditLogDateFilter, setAuditLogDateFilter] = useState("");
|
||||
|
||||
// Categories states
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||
const [showEditCategoryModal, setShowEditCategoryModal] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
|
||||
// Roles states
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [showCreateRoleModal, setShowCreateRoleModal] = useState(false);
|
||||
const [showEditRoleModal, setShowEditRoleModal] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState(null);
|
||||
|
||||
return {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
setStats,
|
||||
users,
|
||||
setUsers,
|
||||
filteredUsers,
|
||||
setFilteredUsers,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
totalPages,
|
||||
setTotalPages,
|
||||
totalRecords,
|
||||
setTotalRecords,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
roleFilter,
|
||||
setRoleFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
setLoading,
|
||||
items,
|
||||
setItems,
|
||||
filteredItems,
|
||||
setFilteredItems,
|
||||
showExpired,
|
||||
setShowExpired,
|
||||
itemSearchTerm,
|
||||
setItemSearchTerm,
|
||||
itemStatusFilter,
|
||||
setItemStatusFilter,
|
||||
itemCategoryFilter,
|
||||
setItemCategoryFilter,
|
||||
selectedItemDetail,
|
||||
setSelectedItemDetail,
|
||||
showItemDetailModal,
|
||||
setShowItemDetailModal,
|
||||
showCreateItemModal,
|
||||
setShowCreateItemModal,
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
itemPage,
|
||||
setItemPage,
|
||||
itemTotalPages,
|
||||
setItemTotalPages,
|
||||
itemTotalRecords,
|
||||
setItemTotalRecords,
|
||||
// Lost Items
|
||||
lostItems,
|
||||
setLostItems,
|
||||
filteredLostItems,
|
||||
setFilteredLostItems,
|
||||
lostItemSearchTerm,
|
||||
setLostItemSearchTerm,
|
||||
lostItemStatusFilter,
|
||||
setLostItemStatusFilter,
|
||||
lostItemCategoryFilter,
|
||||
setLostItemCategoryFilter,
|
||||
selectedLostItemDetail,
|
||||
setSelectedLostItemDetail,
|
||||
showLostItemDetailModal,
|
||||
setShowLostItemDetailModal,
|
||||
showCreateLostItemModal,
|
||||
setShowCreateLostItemModal,
|
||||
showEditLostItemModal,
|
||||
setShowEditLostItemModal,
|
||||
selectedLostItemToEdit,
|
||||
setSelectedLostItemToEdit,
|
||||
lostItemPage,
|
||||
setLostItemPage,
|
||||
lostItemTotalPages,
|
||||
setLostItemTotalPages,
|
||||
lostItemTotalRecords,
|
||||
setLostItemTotalRecords,
|
||||
// Claims
|
||||
claims,
|
||||
setClaims,
|
||||
filteredClaims,
|
||||
setFilteredClaims,
|
||||
claimStatusFilter,
|
||||
setClaimStatusFilter,
|
||||
claimSearchTerm,
|
||||
setClaimSearchTerm,
|
||||
selectedClaim,
|
||||
setSelectedClaim,
|
||||
showClaimDetailModal,
|
||||
setShowClaimDetailModal,
|
||||
showCreateClaimModal,
|
||||
setShowCreateClaimModal,
|
||||
showEditClaimModal,
|
||||
setShowEditClaimModal,
|
||||
selectedClaimToEdit,
|
||||
setSelectedClaimToEdit,
|
||||
// Archives
|
||||
archives,
|
||||
setArchives,
|
||||
filteredArchives,
|
||||
setFilteredArchives,
|
||||
archiveSearchTerm,
|
||||
setArchiveSearchTerm,
|
||||
archiveCategoryFilter,
|
||||
setArchiveCategoryFilter,
|
||||
selectedArchive,
|
||||
setSelectedArchive,
|
||||
showArchiveDetailModal,
|
||||
setShowArchiveDetailModal,
|
||||
// Audit Logs
|
||||
auditLogs,
|
||||
setAuditLogs,
|
||||
filteredAuditLogs,
|
||||
setFilteredAuditLogs,
|
||||
auditLogSearchTerm,
|
||||
setAuditLogSearchTerm,
|
||||
auditLogActionFilter,
|
||||
setAuditLogActionFilter,
|
||||
auditLogUserFilter,
|
||||
setAuditLogUserFilter,
|
||||
auditLogDateFilter,
|
||||
setAuditLogDateFilter,
|
||||
// Categories
|
||||
categories,
|
||||
setCategories,
|
||||
showCreateCategoryModal,
|
||||
setShowCreateCategoryModal,
|
||||
showEditCategoryModal,
|
||||
setShowEditCategoryModal,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
// Roles
|
||||
roles,
|
||||
setRoles,
|
||||
permissions,
|
||||
setPermissions,
|
||||
showCreateRoleModal,
|
||||
setShowCreateRoleModal,
|
||||
showEditRoleModal,
|
||||
setShowEditRoleModal,
|
||||
selectedRole,
|
||||
setSelectedRole,
|
||||
};
|
||||
};
|
||||
@@ -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"));
|
||||
@@ -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"));
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
// assets/js/pages/manager/ManagerApp.js
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const ManagerApp = () => {
|
||||
const state = useManagerState();
|
||||
const handlers = useManagerHandlers(state);
|
||||
|
||||
const {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
showDetailModal,
|
||||
setShowDetailModal,
|
||||
selectedItem,
|
||||
showVerifyModal,
|
||||
setShowVerifyModal,
|
||||
selectedClaim,
|
||||
toast,
|
||||
setToast,
|
||||
showCloseCaseModal,
|
||||
setShowCloseCaseModal,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
loading,
|
||||
showReportFoundModal,
|
||||
setShowReportFoundModal,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
showManualClaimModal,
|
||||
setShowManualClaimModal,
|
||||
selectedItemForClaim,
|
||||
setSelectedItemForClaim,
|
||||
showApproveModal,
|
||||
setShowApproveModal,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
loadData,
|
||||
handleApproveClaim,
|
||||
handleRejectClaim,
|
||||
handleCloseCase,
|
||||
submitCloseCase,
|
||||
submitReportFound,
|
||||
handlePhotoChange,
|
||||
handleLogout,
|
||||
submitApproveClaim,
|
||||
} = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!AuthUtils.checkAuthAndRedirect("manager")) return;
|
||||
const currentUser = AuthUtils.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "claims") {
|
||||
handlers.loadClaims();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
|
||||
<Navbar user={user} onLogout={handleLogout} userType="manager" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
Dashboard Manager
|
||||
</h1>
|
||||
<p className="text-slate-400">
|
||||
Kelola barang temuan dan verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Total Barang"
|
||||
value={stats.total_items || 0}
|
||||
icon="📦"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Claim"
|
||||
value={stats.pending_claims || 0}
|
||||
icon="⏳"
|
||||
colorClass="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Verified"
|
||||
value={stats.verified || 0}
|
||||
icon="✅"
|
||||
colorClass="text-green-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Expired"
|
||||
value={stats.expired || 0}
|
||||
icon="⚠️"
|
||||
colorClass="text-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveTab("items")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "items"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
📦 Kelola Barang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab("lost-items");
|
||||
handlers.loadLostItems();
|
||||
}}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "lost-items"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
😢 Kelola Barang Hilang
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("claims")}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition ${
|
||||
activeTab === "claims"
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg shadow-blue-500/50"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700 border border-slate-700"
|
||||
}`}
|
||||
>
|
||||
🤝 Verifikasi Klaim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "items" && (
|
||||
<ItemsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "lost-items" && (
|
||||
<LostItemsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
{activeTab === "claims" && (
|
||||
<ClaimsTabManager state={state} handlers={handlers} />
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<DetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedItem}
|
||||
/>
|
||||
|
||||
<VerifyClaimModal
|
||||
isOpen={showVerifyModal}
|
||||
onClose={() => setShowVerifyModal(false)}
|
||||
claim={selectedClaim}
|
||||
onApprove={handleApproveClaim}
|
||||
onReject={handleRejectClaim}
|
||||
onCloseCase={handleCloseCase}
|
||||
/>
|
||||
|
||||
<CloseCaseModal
|
||||
isOpen={showCloseCaseModal}
|
||||
onClose={() => setShowCloseCaseModal(false)}
|
||||
claim={selectedClaim}
|
||||
closeCaseData={closeCaseData}
|
||||
setCloseCaseData={setCloseCaseData}
|
||||
onSubmit={submitCloseCase}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ManagerReportFoundModal
|
||||
isOpen={showReportFoundModal}
|
||||
onClose={() => {
|
||||
setShowReportFoundModal(false);
|
||||
setPhotoPreview(null);
|
||||
}}
|
||||
categories={state.categories}
|
||||
onSubmit={submitReportFound}
|
||||
loading={loading}
|
||||
photoPreview={photoPreview}
|
||||
onPhotoChange={(e) => handlePhotoChange(e)}
|
||||
/>
|
||||
|
||||
<MatchLostItemModal
|
||||
isOpen={state.showMatchLostItemModal}
|
||||
onClose={() => {
|
||||
state.setShowMatchLostItemModal(false);
|
||||
state.setSelectedLostItem(null);
|
||||
}}
|
||||
lostItem={state.selectedLostItem}
|
||||
items={state.items}
|
||||
onSubmit={handlers.submitMatchLostItem}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ApproveClaimModal
|
||||
isOpen={showApproveModal}
|
||||
onClose={() => setShowApproveModal(false)}
|
||||
onSubmit={submitApproveClaim}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<ManagerManualClaimModal
|
||||
isOpen={showManualClaimModal}
|
||||
onClose={() => {
|
||||
setShowManualClaimModal(false);
|
||||
setSelectedItemForClaim(null);
|
||||
}}
|
||||
item={selectedItemForClaim}
|
||||
onSubmit={handlers.submitManualClaim}
|
||||
loading={loading}
|
||||
/>
|
||||
<EditItemModal state={state} handlers={handlers} />
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
<AIChatbot />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<ManagerApp />, document.getElementById("root"));
|
||||
@@ -0,0 +1,305 @@
|
||||
// web/js/pages/manager/ManagerManualClaimModal.js
|
||||
// Modal untuk manager input klaim manual ketika user datang langsung (offline)
|
||||
|
||||
const ManagerManualClaimModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
item,
|
||||
}) => {
|
||||
const [users, setUsers] = React.useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = React.useState(false);
|
||||
const [selectedUser, setSelectedUser] = React.useState(null);
|
||||
|
||||
// State untuk input manual
|
||||
const [claimerName, setClaimerName] = React.useState("");
|
||||
const [claimerContact, setClaimerContact] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [managerNotes, setManagerNotes] = React.useState("");
|
||||
|
||||
// Load users when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
resetForm();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedUser(null);
|
||||
setClaimerName("");
|
||||
setClaimerContact("");
|
||||
setDescription("");
|
||||
setManagerNotes("");
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`http://localhost:8080/api/manager/users?page=1&limit=1000`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSelect = (e) => {
|
||||
const userId = e.target.value;
|
||||
if (!userId) {
|
||||
setSelectedUser(null);
|
||||
setClaimerName("");
|
||||
setClaimerContact("");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.find((u) => u.id === parseInt(userId));
|
||||
if (user) {
|
||||
setSelectedUser(user);
|
||||
setClaimerName(user.name);
|
||||
setClaimerContact(user.phone || "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Validasi
|
||||
if (!claimerName || !claimerContact || !description) {
|
||||
alert("Nama pengklaim, kontak, dan deskripsi wajib diisi!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const formData = {
|
||||
item_id: item?.id,
|
||||
user_id: selectedUser?.id || null,
|
||||
claimer_name: claimerName,
|
||||
contact: claimerContact,
|
||||
description: description,
|
||||
manager_notes: managerNotes,
|
||||
};
|
||||
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
🤝 Input Klaim Manual (Offline)
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl text-slate-400 hover:text-white transition"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="text-blue-400 block mb-1">
|
||||
Klaim Offline
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300">
|
||||
Fitur ini untuk mencatat klaim yang disampaikan langsung oleh
|
||||
user kepada manager. Jika pengklaim memiliki akun, pilih di
|
||||
bawah. Jika tidak, isi data manual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
{item && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
📦 Barang yang Diklaim:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong> {item.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Lokasi:</strong>{" "}
|
||||
{item.location}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Tanggal:</strong>{" "}
|
||||
{new Date(item.date_found).toLocaleDateString("id-ID")}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Status:</strong>{" "}
|
||||
{item.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Selection */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User (Pengklaim){" "}
|
||||
<span className="text-slate-500 font-normal text-sm">
|
||||
(Opsional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
onChange={handleUserSelect}
|
||||
disabled={loadingUsers}
|
||||
value={selectedUser?.id || ""}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Tamu / Tidak Terdaftar --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} - {user.email} ({user.nrp})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih jika pengklaim sudah punya akun. Jika belum, biarkan kosong
|
||||
dan isi manual.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selected User Info */}
|
||||
{selectedUser && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
✅ User Terpilih:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{selectedUser.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Email:</strong>{" "}
|
||||
{selectedUser.email}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">NRP:</strong>{" "}
|
||||
{selectedUser.nrp}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Telepon:</strong>{" "}
|
||||
{selectedUser.phone || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Detail Klaim
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Claimer Name */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pengklaim *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claimerName}
|
||||
onChange={(e) => setClaimerName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Nama lengkap pengklaim"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Otomatis terisi jika memilih user, atau isi manual
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pengklaim *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claimerContact}
|
||||
onChange={(e) => setClaimerContact(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Klaim (Ciri-ciri yang Disebutkan Pengklaim) *
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Tuliskan ciri-ciri barang yang disebutkan oleh pengklaim..."
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Pastikan mencatat detail yang disebutkan pengklaim untuk
|
||||
verifikasi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manager Notes */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Manager (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={managerNotes}
|
||||
onChange={(e) => setManagerNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan internal untuk referensi..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Memproses..." : "✅ Submit Klaim"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export ke window untuk digunakan di file lain
|
||||
window.ManagerManualClaimModal = ManagerManualClaimModal;
|
||||
@@ -0,0 +1,469 @@
|
||||
// assets/js/pages/manager/ManagerModals.js
|
||||
|
||||
const DetailModal = ({ isOpen, onClose, item }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Detail Barang">
|
||||
{item && (
|
||||
<div>
|
||||
<img
|
||||
src={item.photo_url || "https://via.placeholder.com/600x400"}
|
||||
alt={item.name}
|
||||
className="w-full h-64 object-cover rounded-xl mb-4 border-2 border-slate-600"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold mb-4 text-white">{item.name}</h3>
|
||||
|
||||
<div className="space-y-3 text-slate-300">
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kategori:</strong>
|
||||
<span className="ml-2">
|
||||
{item.category ||
|
||||
Helpers.getCategoryName(item.category_id) ||
|
||||
"Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Lokasi:</strong>
|
||||
<span className="ml-2">{item.location || "Tidak ada data"}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Tanggal Ditemukan:</strong>
|
||||
<span className="ml-2">
|
||||
{Helpers.formatDate(item.date_found) || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Status:</strong>
|
||||
<span
|
||||
className={`ml-2 px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Deskripsi Umum:</strong>
|
||||
<p className="text-slate-300 mt-2">
|
||||
{item.description || "Tidak ada deskripsi"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{item.reporter_name || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 p-3 rounded-lg">
|
||||
<strong className="text-blue-400">Kontak Pelapor:</strong>
|
||||
<span className="ml-2">
|
||||
{item.reporter_contact || "Tidak ada data"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<strong className="text-yellow-400 text-lg">
|
||||
Ciri Khusus Rahasia (Untuk Verifikasi)
|
||||
</strong>
|
||||
</div>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
{item.secret_details || "Tidak ada deskripsi rahasia"}
|
||||
</p>
|
||||
<p className="text-xs text-yellow-400 mt-2">
|
||||
⚠️ Info ini RAHASIA - gunakan untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const VerifyClaimModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
claim,
|
||||
onApprove,
|
||||
onReject,
|
||||
onCloseCase,
|
||||
}) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Verifikasi Klaim"
|
||||
size="large"
|
||||
>
|
||||
{claim && (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-blue-500/10 p-4 rounded-xl border border-blue-500/30">
|
||||
<h4 className="font-semibold mb-3 text-blue-400">
|
||||
Deskripsi Asli Barang (Rahasia)
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300 font-medium">
|
||||
{claim.item_secret_details || "Tidak ada ciri khusus rahasia"}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-600/50">
|
||||
<span className="text-xs text-slate-500 uppercase">
|
||||
Deskripsi Umum:
|
||||
</span>
|
||||
<p className="text-xs text-slate-400">{claim.item_description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/30">
|
||||
<h4 className="font-semibold mb-3 text-yellow-400">
|
||||
Deskripsi dari Pengklaim
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300">{claim.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{claim.match_percentage && (
|
||||
<div
|
||||
className={`p-6 rounded-xl text-center mb-6 ${
|
||||
claim.match_percentage >= 70
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-yellow-500/10 border border-yellow-500/30"
|
||||
}`}
|
||||
>
|
||||
<strong className="text-slate-300">Similarity Match:</strong>
|
||||
<div
|
||||
className={`text-4xl font-bold mt-2 ${
|
||||
claim.match_percentage >= 70
|
||||
? "text-green-400"
|
||||
: "text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{claim.match_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-900/50 p-4 rounded-xl mb-6 border border-slate-700">
|
||||
<strong className="text-slate-300">Info Pengklaim:</strong>
|
||||
<div className="mt-2 space-y-1 text-slate-400">
|
||||
<div>Nama: {claim.user_name}</div>
|
||||
<div>Kontak: {claim.contact}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{claim.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={onApprove}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
✓ Approve Klaim
|
||||
</button>
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-xl hover:from-red-700 hover:to-red-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
✗ Reject Klaim
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claim.status === "approved" && !claim.berita_acara_no && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onCloseCase(claim);
|
||||
}}
|
||||
className="w-full mt-4 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg"
|
||||
>
|
||||
📋 Close Case (Serah Terima)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{claim.status === "approved" && claim.berita_acara_no && (
|
||||
<div className="mt-4 p-4 bg-green-500/10 border-2 border-green-500/30 rounded-xl">
|
||||
<div className="text-green-400 font-semibold text-center mb-2">
|
||||
✓ Case Already Closed
|
||||
</div>
|
||||
<div className="text-sm text-slate-300 text-center">
|
||||
No. BA: <strong>{claim.berita_acara_no}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const CloseCaseModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
claim,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setCloseCaseData({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
}}
|
||||
title="Close Case - Serah Terima Barang"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="bg-blue-500/10 p-4 rounded-lg border border-blue-500/30 mb-4">
|
||||
<strong className="text-blue-400">Barang:</strong>
|
||||
<p className="text-white mt-1">{claim?.item_name}</p>
|
||||
<strong className="text-blue-400 mt-2 block">Penerima:</strong>
|
||||
<p className="text-white">{claim?.user_name}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
No. Berita Acara *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={closeCaseData.berita_acara_no}
|
||||
onChange={(e) =>
|
||||
setCloseCaseData({
|
||||
...closeCaseData,
|
||||
berita_acara_no: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="BA/LNF/2025/001"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Bukti Serah Terima (Foto/PDF)
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="bukti_file"
|
||||
accept="image/*,application/pdf"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={closeCaseData.notes}
|
||||
onChange={(e) =>
|
||||
setCloseCaseData({ ...closeCaseData, notes: e.target.value })
|
||||
}
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan tambahan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "Memproses..." : "✓ Close Case"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const LostItemDetailModal = ({ isOpen, onClose, item }) => (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="😢 Detail Barang Hilang">
|
||||
{item && (
|
||||
<div className="space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-slate-800 to-slate-900 p-5 rounded-xl border-l-4 border-yellow-500 shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-white mb-1">{item.name}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className="bg-slate-700 px-2 py-1 rounded text-xs text-white border border-slate-600">
|
||||
{item.status === "active" ? "Aktif" : "Ditemukan"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>ID #{item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Informasi Utama */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Kategori
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
🏷️ {item.category || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Tanggal Hilang
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
📅 {Helpers.formatDate(item.date_lost)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Warna
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
🎨 {item.color || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-700/30 p-4 rounded-xl border border-slate-600">
|
||||
<p className="text-xs text-slate-400 uppercase font-semibold mb-1">
|
||||
Lokasi
|
||||
</p>
|
||||
<p className="text-white flex items-center gap-2">
|
||||
📍 {item.location || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<div className="bg-slate-900/50 p-5 rounded-xl border border-slate-600">
|
||||
<h4 className="text-yellow-400 font-semibold mb-2 flex items-center gap-2">
|
||||
<span>📝</span> Deskripsi Lengkap
|
||||
</h4>
|
||||
<p className="text-slate-300 leading-relaxed text-sm">
|
||||
"{item.description}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Informasi Pelapor */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-5 rounded-xl">
|
||||
<h4 className="text-blue-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<span>👤</span> Informasi Pelapor
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Nama Pelapor</p>
|
||||
<p className="text-white font-medium">{item.user_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Kontak</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">
|
||||
{item.user_contact || "-"}
|
||||
</span>
|
||||
{item.user_contact && (
|
||||
<a
|
||||
href={`https://wa.me/${item.user_contact
|
||||
.replace(/^0/, "62")
|
||||
.replace(/\D/g, "")}`}
|
||||
target="_blank"
|
||||
className="text-xs bg-green-600 px-2 py-1 rounded text-white hover:bg-green-700 transition"
|
||||
>
|
||||
Chat WA
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex justify-end pt-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const ApproveClaimModal = ({ isOpen, onClose, onSubmit, loading }) => {
|
||||
const [notes, setNotes] = React.useState("");
|
||||
|
||||
// Reset input saat modal dibuka
|
||||
React.useEffect(() => {
|
||||
if (isOpen) setNotes("");
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(notes); // Kirim notes ke handler submitApproveClaim
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="✅ Konfirmasi Approval"
|
||||
size="small"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<p className="text-sm text-green-200">
|
||||
Apakah Anda yakin ingin menyetujui klaim ini? Status barang akan
|
||||
berubah menjadi <strong>Approved</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Approval (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-green-500 focus:outline-none transition"
|
||||
placeholder="Contoh: Identitas telah diverifikasi..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 bg-slate-700 text-white rounded-xl hover:bg-slate-600 transition font-semibold"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Memproses..." : "Ya, Approve"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Jangan lupa export ke window
|
||||
window.LostItemDetailModal = LostItemDetailModal;
|
||||
|
||||
window.DetailModal = DetailModal;
|
||||
window.VerifyClaimModal = VerifyClaimModal;
|
||||
window.CloseCaseModal = CloseCaseModal;
|
||||
window.LostItemDetailModal = LostItemDetailModal;
|
||||
window.ApproveClaimModal = ApproveClaimModal;
|
||||
@@ -0,0 +1,320 @@
|
||||
const ManagerReportFoundModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
photoPreview,
|
||||
onPhotoChange,
|
||||
categories,
|
||||
}) => {
|
||||
const [users, setUsers] = React.useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = React.useState(false);
|
||||
|
||||
// ✅ ADD: State untuk menyimpan user yang dipilih
|
||||
const [selectedUser, setSelectedUser] = React.useState(null);
|
||||
|
||||
// ✅ ADD: State untuk input manual
|
||||
const [reporterName, setReporterName] = React.useState("");
|
||||
const [reporterContact, setReporterContact] = React.useState("");
|
||||
|
||||
// Load users when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadUsers();
|
||||
// Reset form saat modal dibuka
|
||||
setSelectedUser(null);
|
||||
setReporterName("");
|
||||
setReporterContact("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const response = await fetch(
|
||||
`${CONFIG.API_URL}/api/manager/users?page=1&limit=1000`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${AuthUtils.getToken()}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ UPDATE: Handle user selection
|
||||
const handleUserSelect = (e) => {
|
||||
const userId = e.target.value;
|
||||
if (!userId) {
|
||||
setSelectedUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.find((u) => u.id === parseInt(userId));
|
||||
if (user) {
|
||||
setSelectedUser(user);
|
||||
setReporterName(user.name);
|
||||
setReporterContact(user.phone || "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="📦 Laporan Barang Ditemukan (Offline)"
|
||||
size="large"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border-2 border-blue-500/30 p-4 rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="text-blue-400 block mb-1">
|
||||
Laporan Offline
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300">
|
||||
Fitur ini untuk mencatat barang yang diserahkan langsung kepada
|
||||
manager. Jika pelapor memiliki akun, silakan pilih di bawah.
|
||||
Jika tidak, cukup isi nama manual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Selection */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih User (Penemu Barang){" "}
|
||||
<span className="text-slate-500 font-normal text-sm">
|
||||
(Opsional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
name="reporter_user_id"
|
||||
onChange={handleUserSelect}
|
||||
disabled={loadingUsers}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Tamu / Tidak Terdaftar --</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} - {user.email} ({user.nrp})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pilih jika pelapor sudah punya akun. Jika belum, biarkan kosong.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ✅ FIXED: Selected User Info - sekarang selectedUser sudah didefinisikan */}
|
||||
{selectedUser && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-xl">
|
||||
<strong className="text-green-400 block mb-2">
|
||||
✅ User Terpilih:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{selectedUser.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Email:</strong>{" "}
|
||||
{selectedUser.email}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">NRP:</strong>{" "}
|
||||
{selectedUser.nrp}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Telepon:</strong>{" "}
|
||||
{selectedUser.phone || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-700 pt-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Detail Barang Ditemukan
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Item Details */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Barang *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Dompet Kulit Coklat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Foto Barang
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
onChange={onPhotoChange}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 focus:outline-none"
|
||||
/>
|
||||
{photoPreview && (
|
||||
<div className="mt-3">
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Preview"
|
||||
className="w-full h-48 object-cover rounded-xl border-2 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kategori *
|
||||
</label>
|
||||
<select
|
||||
name="category_id"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Pilih Kategori</option>
|
||||
{/* PERBAIKAN: Gunakan 'categories' langsung, bukan 'state.categories' */}
|
||||
{categories &&
|
||||
categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Lokasi Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Contoh: Perpustakaan Lantai 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Deskripsi Umum *
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Deskripsi umum barang yang akan ditampilkan..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Ciri Khusus Rahasia * 🔒
|
||||
</label>
|
||||
<textarea
|
||||
name="secret_details"
|
||||
required
|
||||
rows="3"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Jelaskan ciri khusus yang HANYA pemilik asli yang tahu (contoh: ada goresan di bagian belakang, ada tulisan nama di dalam, dll)"
|
||||
/>
|
||||
<p className="text-xs text-yellow-400 mt-1">
|
||||
⚠️ Info ini RAHASIA dan hanya digunakan untuk verifikasi klaim
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Tanggal Ditemukan *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="date_found"
|
||||
required
|
||||
max={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ✅ INPUT MANUAL dengan controlled state */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Nama Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_name"
|
||||
required
|
||||
value={reporterName}
|
||||
onChange={(e) => setReporterName(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Nama lengkap penemu barang"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Kontak Pelapor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="reporter_contact"
|
||||
required
|
||||
value={reporterContact}
|
||||
onChange={(e) => setReporterContact(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="08123456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Catatan Manager (Opsional)
|
||||
</label>
|
||||
<textarea
|
||||
name="manager_notes"
|
||||
rows="2"
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Catatan internal dari manager..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700"
|
||||
>
|
||||
{loading ? "⏳ Menyimpan..." : "✅ Submit Laporan"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
window.ManagerReportFoundModal = ManagerReportFoundModal;
|
||||
@@ -0,0 +1,108 @@
|
||||
// assets/js/pages/manager/ManagerTabs.js - FIXED
|
||||
|
||||
const ItemsTabManager = ({ state, handlers }) => {
|
||||
const { showExpired, setShowExpired, items, expiredItems, stats } = state;
|
||||
const {
|
||||
handleViewDetail,
|
||||
handleManualClaimClick,
|
||||
handleEditItemClick,
|
||||
handleDeleteItem,
|
||||
} = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{showExpired ? "📦 Barang Expired" : "📦 Daftar Barang Ditemukan"}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* ✅ TOMBOL BARU */}
|
||||
<button
|
||||
onClick={() => state.setShowReportFoundModal(true)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
📝 Lapor Barang Ditemukan (Offline)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(false)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
!showExpired
|
||||
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
📦 Aktif ({items.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowExpired(true)}
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||
showExpired
|
||||
? "bg-gradient-to-r from-red-600 to-red-700 text-white shadow-lg"
|
||||
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
}`}
|
||||
>
|
||||
⚠️ Expired ({stats.expired})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{(showExpired ? expiredItems : items).map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onViewDetail={handleViewDetail}
|
||||
onManualClaim={handleManualClaimClick}
|
||||
// ✅ TAMBAHKAN 3 PROPS INI:
|
||||
isManager={true} // Agar logika di ItemCard jalan
|
||||
onEdit={handleEditItemClick} // Fungsi ketika tombol diklik
|
||||
onDelete={handleDeleteItem} // (Opsional) Jika manager boleh hapus
|
||||
showActions={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(showExpired ? expiredItems : items).length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">{showExpired ? "⚠️" : "📦"}</div>
|
||||
<p>{showExpired ? "Belum ada barang expired" : "Belum ada barang"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClaimsTabManager = ({ state, handlers }) => {
|
||||
const { claims } = state;
|
||||
const { handleVerifyClaim, handleReopenCase, handleCancelApproval } =
|
||||
handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-6 text-white">Daftar Klaim</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{claims.map((claim) => (
|
||||
<ClaimCard
|
||||
key={claim.id}
|
||||
claim={claim}
|
||||
onVerify={handleVerifyClaim} // Ini mentrigger modal verify/close
|
||||
onReopen={handleReopenCase}
|
||||
onCancelApproval={handleCancelApproval} // ✅ Pass props baru
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{claims.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">🤝</div>
|
||||
<p>Belum ada klaim</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
window.ItemsTabManager = ItemsTabManager;
|
||||
window.ClaimsTabManager = ClaimsTabManager;
|
||||
@@ -0,0 +1,448 @@
|
||||
// web/js/pages/manager/tabs/LostItemsTabManager.js
|
||||
|
||||
const LostItemsTabManager = ({ state, handlers }) => {
|
||||
const { lostItems, items, loading } = state;
|
||||
const {
|
||||
showToast,
|
||||
loadLostItems,
|
||||
handleDeleteLostItem,
|
||||
handleMatchLostItem,
|
||||
handleViewLostItemDetail,
|
||||
} = handlers;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-6 shadow-xl border border-slate-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Kelola Laporan Barang Hilang
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Lihat semua laporan barang hilang dan hubungkan dengan barang
|
||||
ditemukan
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => loadLostItems()}
|
||||
className="px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-yellow-400 font-bold text-2xl">
|
||||
{lostItems.filter((i) => i.status === "active").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Aktif</div>
|
||||
</div>
|
||||
<div className="bg-green-500/10 border border-green-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-green-400 font-bold text-2xl">
|
||||
{lostItems.filter((i) => i.status === "found").length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Ditemukan</div>
|
||||
</div>
|
||||
<div className="bg-slate-500/10 border border-slate-500/30 p-4 rounded-lg text-center">
|
||||
<div className="text-slate-400 font-bold text-2xl">
|
||||
{lostItems.length}
|
||||
</div>
|
||||
<div className="text-slate-400 text-sm">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">⏳</div>
|
||||
<p>Memuat data...</p>
|
||||
</div>
|
||||
) : lostItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{lostItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`bg-gradient-to-br from-slate-700 to-slate-800 border-2 rounded-xl p-5 hover:shadow-xl transition-all ${
|
||||
item.status === "found"
|
||||
? "border-green-500/50 hover:border-green-500"
|
||||
: "border-slate-600 hover:border-blue-500 hover:shadow-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4 border-b border-slate-600 pb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-white">{item.name}</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
👤 {item.user_name}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${Helpers.getStatusBadgeClass(
|
||||
item.status
|
||||
)}`}
|
||||
>
|
||||
{item.status === "active" ? "😢 Hilang" : "✅ Ditemukan"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">🏷️</span>
|
||||
<span>{item.category || item.category_name}</span>
|
||||
</div>
|
||||
|
||||
{item.color && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">🎨</span>
|
||||
<span>{item.color}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📍</span>
|
||||
<span>{item.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📅</span>
|
||||
<span>{Helpers.formatDate(item.date_lost)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<span className="text-blue-400">📞</span>
|
||||
<span>{item.user_contact || "Tidak ada kontak"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-slate-900/50 p-3 rounded-lg border border-slate-600 mb-4">
|
||||
<strong className="text-xs text-slate-400 uppercase block mb-1">
|
||||
Deskripsi:
|
||||
</strong>
|
||||
<p className="text-sm text-slate-300 line-clamp-3">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Matched Item (if found) */}
|
||||
{item.status === "found" && item.matched_item_id && (
|
||||
<div className="bg-green-500/10 border-2 border-green-500/30 p-3 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🎉</span>
|
||||
<strong className="text-green-400">Sudah Ditemukan</strong>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300">
|
||||
Dihubungkan dengan barang:{" "}
|
||||
<strong>
|
||||
{item.matched_item_name || "ID #" + item.matched_item_id}
|
||||
</strong>
|
||||
</p>
|
||||
{item.matched_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Pada: {Helpers.formatDateTime(item.matched_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions - Description Based */}
|
||||
{item.status === "active" &&
|
||||
item.ai_suggestions &&
|
||||
item.ai_suggestions.length > 0 && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 p-3 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<strong className="text-blue-400 text-sm">
|
||||
AI Suggestions (Description Similarity)
|
||||
</strong>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mb-2">
|
||||
Berdasarkan kesamaan deskripsi barang hilang dengan ciri
|
||||
khusus barang ditemukan
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{item.ai_suggestions
|
||||
.slice(0, 3)
|
||||
.map((suggestion, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs text-slate-300 bg-slate-900/50 p-2 rounded"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="font-semibold">
|
||||
{suggestion.item_name}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-bold ${
|
||||
suggestion.match_score >= 70
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: suggestion.match_score >= 50
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-orange-500/20 text-orange-400"
|
||||
}`}
|
||||
>
|
||||
{suggestion.match_score}% Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-[10px] mb-1">
|
||||
📍 {suggestion.location} • 📅{" "}
|
||||
{Helpers.formatDateShort(suggestion.date_found)}
|
||||
</p>
|
||||
{suggestion.preview_text && (
|
||||
<p className="text-slate-500 text-[10px] italic">
|
||||
"{suggestion.preview_text}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{item.status === "active" && (
|
||||
<button
|
||||
onClick={() => handleMatchLostItem(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
|
||||
>
|
||||
🔗 Hubungkan
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleViewLostItemDetail(item)}
|
||||
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg flex items-center justify-center gap-1"
|
||||
>
|
||||
<span>📋</span> Detail
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`⚠️ Yakin ingin menghapus laporan "${item.name}" dari ${item.user_name}?\n\nTindakan ini tidak dapat dibatalkan.`
|
||||
)
|
||||
) {
|
||||
handleDeleteLostItem(item.id);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
|
||||
title="Hapus Laporan"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<div className="text-6xl mb-4">😢</div>
|
||||
<p>Belum ada laporan barang hilang</p>
|
||||
</div>
|
||||
)}
|
||||
<LostItemDetailModal
|
||||
isOpen={state.showLostDetailModal}
|
||||
onClose={() => state.setShowLostDetailModal(false)}
|
||||
item={state.selectedLostDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Modal untuk menghubungkan barang hilang dengan barang ditemukan
|
||||
const MatchLostItemModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
lostItem,
|
||||
items,
|
||||
onSubmit,
|
||||
loading,
|
||||
}) => {
|
||||
const [selectedItemId, setSelectedItemId] = React.useState("");
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" &&
|
||||
(item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.location.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedItemId) {
|
||||
alert("Pilih barang yang ditemukan!");
|
||||
return;
|
||||
}
|
||||
onSubmit(lostItem.id, parseInt(selectedItemId));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl max-w-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
🔗 Hubungkan Barang Hilang dengan Barang Ditemukan
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl text-slate-400 hover:text-white transition"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
{/* Lost Item Info */}
|
||||
<div className="bg-yellow-500/10 border-2 border-yellow-500/30 p-4 rounded-xl mb-6">
|
||||
<strong className="text-yellow-400 block mb-2">
|
||||
😢 Barang Hilang:
|
||||
</strong>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<strong className="text-slate-200">Nama:</strong>{" "}
|
||||
{lostItem.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Kategori:</strong>{" "}
|
||||
{lostItem.category || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Warna:</strong>{" "}
|
||||
{lostItem.color || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-slate-200">Lokasi:</strong>{" "}
|
||||
{lostItem.location || "-"}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<strong className="text-slate-200">Deskripsi:</strong>{" "}
|
||||
{lostItem.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Cari Barang Ditemukan
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Cari berdasarkan nama atau lokasi..."
|
||||
className="w-full px-4 py-3 bg-slate-700 border-2 border-slate-600 rounded-xl text-white placeholder-slate-400 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div>
|
||||
<label className="block font-semibold mb-2 text-slate-300">
|
||||
Pilih Barang yang Sesuai *
|
||||
</label>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 bg-slate-900/50 p-3 rounded-xl border border-slate-600">
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItemId(item.id.toString())}
|
||||
className={`p-4 rounded-lg cursor-pointer transition border-2 ${
|
||||
selectedItemId === item.id.toString()
|
||||
? "bg-blue-500/20 border-blue-500"
|
||||
: "bg-slate-800 border-slate-700 hover:border-blue-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Radio */}
|
||||
<input
|
||||
type="radio"
|
||||
name="item_id"
|
||||
value={item.id}
|
||||
checked={selectedItemId === item.id.toString()}
|
||||
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
{/* Photo */}
|
||||
{item.photo_url && (
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt={item.name}
|
||||
className="w-16 h-16 object-cover rounded-lg border border-slate-600"
|
||||
onError={(e) =>
|
||||
(e.target.src =
|
||||
"https://via.placeholder.com/64?text=No+Image")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-semibold">
|
||||
{item.name}
|
||||
</h4>
|
||||
<div className="text-xs text-slate-400 space-y-1 mt-1">
|
||||
<div>🏷️ {item.category || "-"}</div>
|
||||
<div>📍 {item.location}</div>
|
||||
<div>📅 {Helpers.formatDate(item.date_found)}</div>
|
||||
<div>👤 Ditemukan oleh: {item.reporter_name}</div>
|
||||
</div>
|
||||
|
||||
{/* Show secret details preview for matching */}
|
||||
{item.secret_details && (
|
||||
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded">
|
||||
<div className="text-[10px] text-yellow-400 font-bold mb-1">
|
||||
🔒 Ciri Khusus:
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-300 italic line-clamp-2">
|
||||
"{item.secret_details}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<p>Tidak ada barang ditemukan yang sesuai</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !selectedItemId}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-xl hover:from-green-700 hover:to-green-800 transition font-semibold shadow-lg disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "⏳ Menghubungkan..." : "✅ Hubungkan Barang"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export
|
||||
window.LostItemsTabManager = LostItemsTabManager;
|
||||
window.MatchLostItemModal = MatchLostItemModal;
|
||||
@@ -0,0 +1,677 @@
|
||||
// assets/js/pages/manager/useManagerHandlers.js
|
||||
const useManagerHandlers = (state) => {
|
||||
const {
|
||||
setToast,
|
||||
setLoading,
|
||||
setStats,
|
||||
setItems,
|
||||
setClaims,
|
||||
setExpiredItems,
|
||||
setSelectedItem,
|
||||
setShowDetailModal,
|
||||
setSelectedClaim,
|
||||
setShowVerifyModal,
|
||||
setShowCloseCaseModal,
|
||||
setCloseCaseData,
|
||||
selectedClaim,
|
||||
closeCaseData,
|
||||
setShowReportFoundModal,
|
||||
setPhotoPreview,
|
||||
setShowEditItemModal,
|
||||
setSelectedItemToEdit,
|
||||
selectedItemToEdit,
|
||||
} = state;
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [itemsResponse, claimsResponse, expiredResponse] =
|
||||
await Promise.all([
|
||||
ApiUtils.get(`${CONFIG.API_ENDPOINTS.ITEMS}?page=1&limit=1000`),
|
||||
ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS),
|
||||
ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}?status=expired&page=1&limit=1000`
|
||||
),
|
||||
]);
|
||||
|
||||
const allItems = itemsResponse.data || [];
|
||||
const allClaims = claimsResponse.data || [];
|
||||
const allExpired = expiredResponse.data || [];
|
||||
|
||||
const calculatedStats = {
|
||||
total_items: allItems.length,
|
||||
pending_claims: allClaims.filter((c) => c.status === "pending").length,
|
||||
verified: allItems.filter((i) => i.status === "verified").length,
|
||||
expired: allExpired.length,
|
||||
};
|
||||
|
||||
setStats(calculatedStats);
|
||||
setItems(allItems);
|
||||
setClaims(allClaims);
|
||||
setExpiredItems(allExpired);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat data: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPhotoPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Edit Click
|
||||
const handleEditItemClick = async (item) => {
|
||||
try {
|
||||
// Reset preview foto agar tidak nyangkut foto sebelumnya
|
||||
setPhotoPreview(null); // ✅ TAMBAHKAN BARIS INI
|
||||
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
|
||||
);
|
||||
setSelectedItemToEdit(response.data || response);
|
||||
setShowEditItemModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat data barang", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Update Item
|
||||
const handleUpdateItem = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Memproses update barang...", "info");
|
||||
|
||||
// ✅ LOGIKA BARU: Upload Foto jika ada
|
||||
let photoUrl = state.selectedItemToEdit.photo_url; // Default ke foto lama
|
||||
const photoFile = formData.get("photo");
|
||||
|
||||
if (photoFile && photoFile.size > 0) {
|
||||
showToast("Mengupload foto baru...", "info");
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("image", photoFile);
|
||||
|
||||
const uploadData = await ApiUtils.uploadFile(
|
||||
CONFIG.API_ENDPOINTS.UPLOAD,
|
||||
uploadFormData
|
||||
);
|
||||
photoUrl = uploadData.data.url;
|
||||
}
|
||||
// ✅ SELESAI LOGIKA UPLOAD
|
||||
|
||||
const dateFound = formData.get("date_found");
|
||||
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
|
||||
|
||||
const itemData = {
|
||||
name: formData.get("name"),
|
||||
category_id: parseInt(formData.get("category_id")),
|
||||
photo_url: photoUrl, // ✅ Masukkan URL foto ke payload
|
||||
location: formData.get("location"),
|
||||
description: formData.get("description"),
|
||||
secret_details: formData.get("secret_details"),
|
||||
date_found: dateFoundISO,
|
||||
reporter_name: formData.get("reporter_name"),
|
||||
reporter_contact: formData.get("reporter_contact"),
|
||||
status: formData.get("status"), // ✅ KIRIM STATUS BARU
|
||||
reason: formData.get("reason") || "Admin/Manager update",
|
||||
};
|
||||
|
||||
await ApiUtils.put(
|
||||
// ✅ GUNAKAN PUT
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${state.selectedItemToEdit.id}`,
|
||||
itemData // ✅ Kirim data langsung
|
||||
);
|
||||
|
||||
showToast("Barang berhasil diupdate!", "success");
|
||||
state.setShowEditItemModal(false);
|
||||
state.setSelectedItemToEdit(null);
|
||||
setPhotoPreview(null);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal update barang: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLostItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}?page=1&limit=1000`
|
||||
);
|
||||
|
||||
// Enhance with AI suggestions (optional - can be done by backend)
|
||||
const lostItemsWithSuggestions = await Promise.all(
|
||||
(response.data || []).map(async (lostItem) => {
|
||||
if (lostItem.status === "active") {
|
||||
// Get potential matches based on category and date
|
||||
const potentialMatches = state.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "unclaimed" &&
|
||||
item.category_id === lostItem.category_id &&
|
||||
Math.abs(
|
||||
new Date(item.date_found) - new Date(lostItem.date_lost)
|
||||
) <
|
||||
7 * 24 * 60 * 60 * 1000 // Within 7 days
|
||||
)
|
||||
.map((item) => ({
|
||||
item_id: item.id,
|
||||
item_name: item.name,
|
||||
location: item.location,
|
||||
date_found: item.date_found,
|
||||
match_score: calculateMatchScore(lostItem, item),
|
||||
}))
|
||||
.sort((a, b) => b.match_score - a.match_score)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
...lostItem,
|
||||
ai_suggestions: potentialMatches,
|
||||
};
|
||||
}
|
||||
return lostItem;
|
||||
})
|
||||
);
|
||||
|
||||
state.setLostItems(lostItemsWithSuggestions);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat laporan barang hilang", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Simple match score calculator
|
||||
const calculateMatchScore = (lostItem, foundItem) => {
|
||||
// Helper function untuk hitung skor teks sederhana
|
||||
const getKeywordScore = (sourceText, targetText, minWordLength = 3) => {
|
||||
if (!sourceText || !targetText) return 0;
|
||||
|
||||
const source = sourceText.toLowerCase();
|
||||
const target = targetText.toLowerCase();
|
||||
|
||||
// 1. Cocok Sempurna
|
||||
if (source === target) return 100;
|
||||
|
||||
// 2. Target mengandung source utuh
|
||||
if (target.includes(source)) return 95;
|
||||
|
||||
// 3. Keyword Matching
|
||||
const keywords = source
|
||||
.split(/[\s,.]+/)
|
||||
.filter((w) => w.length >= minWordLength);
|
||||
if (keywords.length === 0) return 0;
|
||||
|
||||
let matchCount = 0;
|
||||
keywords.forEach((word) => {
|
||||
if (target.includes(word)) {
|
||||
matchCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return (matchCount / keywords.length) * 100;
|
||||
};
|
||||
|
||||
// --- HITUNG SKOR ---
|
||||
|
||||
// 1. Skor Nama Barang (Bobot 50%)
|
||||
// Membandingkan Nama di laporan hilang vs Nama di barang temuan
|
||||
const nameScore = getKeywordScore(lostItem.name, foundItem.name, 2);
|
||||
|
||||
// 2. Skor Secret Details (Bobot 50%)
|
||||
// Membandingkan Deskripsi User vs Secret Details Admin
|
||||
const secretScore = getKeywordScore(
|
||||
lostItem.description,
|
||||
foundItem.secret_details,
|
||||
3
|
||||
);
|
||||
|
||||
// --- TOTAL SKOR ---
|
||||
// Menggabungkan kedua skor dengan rata-rata
|
||||
const totalScore = nameScore * 0.5 + secretScore * 0.5;
|
||||
|
||||
return Math.round(totalScore);
|
||||
};
|
||||
|
||||
const handleMatchLostItem = (lostItem) => {
|
||||
state.setSelectedLostItem(lostItem);
|
||||
state.setShowMatchLostItemModal(true);
|
||||
};
|
||||
|
||||
const submitMatchLostItem = async (lostItemId, foundItemId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Menghubungkan barang...", "info");
|
||||
|
||||
// Call backend API to match
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}/match`,
|
||||
{
|
||||
item_id: foundItemId,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Barang berhasil dihubungkan!", "success");
|
||||
state.setShowMatchLostItemModal(false);
|
||||
state.setSelectedLostItem(null);
|
||||
loadLostItems();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghubungkan barang: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLostItem = async (lostItemId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.LOST_ITEMS}/${lostItemId}`);
|
||||
showToast("Laporan barang hilang berhasil dihapus!", "success");
|
||||
loadLostItems();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghapus laporan: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ ADD: Handle Delete Item
|
||||
const handleDeleteItem = async (itemId) => {
|
||||
try {
|
||||
state.setLoading(true);
|
||||
await ApiUtils.delete(`${CONFIG.API_ENDPOINTS.ITEMS}/${itemId}`);
|
||||
showToast("Barang berhasil dihapus!", "success");
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menghapus barang: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReportFound = async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
showToast("Memproses laporan...", "info");
|
||||
|
||||
// Upload photo if exists
|
||||
let photoUrl = "";
|
||||
const photoFile = formData.get("photo");
|
||||
|
||||
if (photoFile && photoFile.size > 0) {
|
||||
showToast("Mengupload foto...", "info");
|
||||
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("image", photoFile);
|
||||
|
||||
const uploadData = await ApiUtils.uploadFile(
|
||||
CONFIG.API_ENDPOINTS.UPLOAD,
|
||||
uploadFormData
|
||||
);
|
||||
photoUrl = uploadData.data.url;
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const dateFound = formData.get("date_found");
|
||||
const dateFoundISO = new Date(dateFound + "T00:00:00Z").toISOString();
|
||||
|
||||
const reporterUserIdRaw = formData.get("reporter_user_id");
|
||||
const reporterUserId = reporterUserIdRaw
|
||||
? parseInt(reporterUserIdRaw)
|
||||
: 0;
|
||||
|
||||
const itemData = {
|
||||
name: formData.get("name"),
|
||||
category_id: parseInt(formData.get("category_id")),
|
||||
photo_url: photoUrl,
|
||||
location: formData.get("location"),
|
||||
description: formData.get("description"),
|
||||
secret_details: formData.get("secret_details"),
|
||||
date_found: dateFoundISO,
|
||||
reporter_name: formData.get("reporter_name"),
|
||||
reporter_contact: formData.get("reporter_contact"),
|
||||
reporter_user_id: reporterUserId,
|
||||
manager_notes: formData.get("manager_notes") || "",
|
||||
};
|
||||
|
||||
await ApiUtils.post(CONFIG.API_ENDPOINTS.ITEMS, itemData);
|
||||
|
||||
showToast("Laporan berhasil disimpan!", "success");
|
||||
setShowReportFoundModal(false); // ✅ Now this works
|
||||
setPhotoPreview(null); // ✅ Now this works
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal menyimpan laporan: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLostItemDetail = (item) => {
|
||||
state.setSelectedLostDetail(item);
|
||||
state.setShowLostDetailModal(true);
|
||||
};
|
||||
|
||||
const handleManualClaimClick = (item) => {
|
||||
state.setSelectedItemForClaim(item);
|
||||
state.setShowManualClaimModal(true);
|
||||
};
|
||||
|
||||
const submitManualClaim = async (formData) => {
|
||||
try {
|
||||
state.setLoading(true);
|
||||
showToast("Memproses klaim manual...", "info");
|
||||
|
||||
const payload = {
|
||||
item_id: parseInt(formData.item_id),
|
||||
user_id: formData.user_id ? parseInt(formData.user_id) : null,
|
||||
description: formData.description,
|
||||
contact: formData.contact,
|
||||
claimer_name: formData.claimer_name,
|
||||
manager_notes: formData.manager_notes || "",
|
||||
};
|
||||
|
||||
await ApiUtils.post(CONFIG.API_ENDPOINTS.CLAIMS, payload);
|
||||
|
||||
showToast("Klaim manual berhasil dicatat!", "success");
|
||||
state.setShowManualClaimModal(false);
|
||||
state.setSelectedItemForClaim(null);
|
||||
loadData();
|
||||
loadClaims();
|
||||
} catch (error) {
|
||||
showToast("Gagal mencatat klaim: " + error.message, "error");
|
||||
} finally {
|
||||
state.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadClaims = async () => {
|
||||
try {
|
||||
const response = await ApiUtils.get(CONFIG.API_ENDPOINTS.CLAIMS);
|
||||
setClaims(response.data || []);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat klaim", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (Helpers.showConfirm("Apakah Anda yakin ingin logout?")) {
|
||||
AuthUtils.clearAuth();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (item) => {
|
||||
try {
|
||||
const response = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.ITEMS}/${item.id}`
|
||||
);
|
||||
|
||||
let itemData = response.data || response;
|
||||
|
||||
const finalData = {
|
||||
...itemData,
|
||||
reporter_name: itemData.reporter_name || "Data tidak tersedia",
|
||||
reporter_contact: itemData.reporter_contact || "Data tidak tersedia",
|
||||
description: itemData.description || "Tidak ada deskripsi",
|
||||
secret_details:
|
||||
itemData.secret_details || "Tidak ada deskripsi rahasia",
|
||||
};
|
||||
|
||||
setSelectedItem(finalData);
|
||||
setShowDetailModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat detail: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyClaim = async (claim) => {
|
||||
try {
|
||||
const detailData = await ApiUtils.get(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}`
|
||||
);
|
||||
|
||||
if (!detailData || !detailData.data) {
|
||||
throw new Error("Invalid claim data");
|
||||
}
|
||||
|
||||
setSelectedClaim(detailData.data);
|
||||
setShowVerifyModal(true);
|
||||
} catch (error) {
|
||||
showToast("Gagal memuat klaim: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveClaim = () => {
|
||||
// Tutup modal verifikasi detail agar tidak tumpuk (opsional, tergantung preferensi UX)
|
||||
state.setShowVerifyModal(false);
|
||||
// Buka modal konfirmasi approve
|
||||
state.setShowApproveModal(true);
|
||||
};
|
||||
|
||||
// 2. Fungsi eksekusi: Melakukan request API (Dipanggil saat tombol "Ya, Approve" di modal diklik)
|
||||
const submitApproveClaim = async (notes) => {
|
||||
// Validasi sederhana
|
||||
if (!state.selectedClaim || !state.selectedClaim.id) {
|
||||
showToast("Data klaim tidak valid!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true); // Aktifkan loading state
|
||||
|
||||
// Request ke API
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${state.selectedClaim.id}/verify`,
|
||||
{
|
||||
status: "approved",
|
||||
notes: notes || "", // Menggunakan notes dari input Modal
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Klaim berhasil diapprove!", "success");
|
||||
|
||||
// Tutup modal dan refresh data
|
||||
state.setShowApproveModal(false);
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal approve klaim: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false); // Matikan loading state
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectClaim = async () => {
|
||||
if (!selectedClaim || !selectedClaim.id) {
|
||||
showToast("Data klaim tidak valid!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = prompt("Alasan penolakan (wajib):");
|
||||
if (!notes) {
|
||||
showToast("Alasan penolakan harus diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/verify`,
|
||||
{
|
||||
status: "rejected",
|
||||
notes,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Klaim berhasil ditolak!", "success");
|
||||
setShowVerifyModal(false);
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal reject klaim: " + error.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCase = async (claim) => {
|
||||
setSelectedClaim(claim);
|
||||
setShowCloseCaseModal(true);
|
||||
};
|
||||
|
||||
const submitCloseCase = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!closeCaseData.berita_acara_no) {
|
||||
showToast("No. Berita Acara wajib diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
let buktiUrl = closeCaseData.bukti_serah_terima;
|
||||
const buktiFile = document.querySelector('input[name="bukti_file"]')
|
||||
?.files[0];
|
||||
|
||||
if (buktiFile) {
|
||||
showToast("Mengupload bukti...", "info");
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("proof", buktiFile);
|
||||
|
||||
const uploadResponse = await ApiUtils.uploadFile(
|
||||
"/api/upload/claim-proof",
|
||||
uploadFormData
|
||||
);
|
||||
buktiUrl = uploadResponse.data.url;
|
||||
}
|
||||
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${selectedClaim.id}/close`,
|
||||
{
|
||||
berita_acara_no: closeCaseData.berita_acara_no,
|
||||
bukti_serah_terima: buktiUrl,
|
||||
notes: closeCaseData.notes,
|
||||
}
|
||||
);
|
||||
|
||||
showToast("Case berhasil ditutup!", "success");
|
||||
setShowCloseCaseModal(false);
|
||||
setCloseCaseData({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal close case: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelApproval = async (claimId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Kita panggil endpoint baru (perlu dibuat di backend)
|
||||
// Atau gunakan endpoint verify dengan status khusus jika backend mendukung
|
||||
await ApiUtils.post(
|
||||
`${CONFIG.API_ENDPOINTS.CLAIMS}/${claimId}/cancel-approval`,
|
||||
{}
|
||||
);
|
||||
|
||||
showToast(
|
||||
"Status Approved berhasil dibatalkan. Klaim kembali ke Pending.",
|
||||
"success"
|
||||
);
|
||||
loadClaims();
|
||||
loadData(); // Refresh stats
|
||||
} catch (error) {
|
||||
showToast("Gagal membatalkan approval: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReopenCase = async (claim) => {
|
||||
const reason = prompt("Alasan Reopen Case (WAJIB):");
|
||||
|
||||
if (!reason || reason.trim() === "") {
|
||||
showToast("Alasan reopen wajib diisi!", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`Yakin reopen case untuk "${claim.item_name}"?\n\nAlasan: ${reason}`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
await ApiUtils.post(`${CONFIG.API_ENDPOINTS.CLAIMS}/${claim.id}/reopen`, {
|
||||
reason,
|
||||
});
|
||||
|
||||
showToast("Case berhasil dibuka kembali!", "success");
|
||||
loadClaims();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast("Gagal reopen case: " + error.message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showToast,
|
||||
loadData,
|
||||
loadClaims,
|
||||
handleLogout,
|
||||
handleViewDetail,
|
||||
handleVerifyClaim,
|
||||
handleApproveClaim,
|
||||
handleRejectClaim,
|
||||
handleCloseCase,
|
||||
submitCloseCase,
|
||||
handleReopenCase,
|
||||
handlePhotoChange, // ✅ Pastikan ini ada
|
||||
submitReportFound,
|
||||
// ✅ REMOVED: loadAuditLogs (not needed in manager)
|
||||
handleManualClaimClick,
|
||||
submitManualClaim,
|
||||
handleCancelApproval,
|
||||
handleEditItemClick,
|
||||
handleUpdateItem,
|
||||
handleDeleteItem,
|
||||
loadLostItems,
|
||||
handleMatchLostItem,
|
||||
submitMatchLostItem,
|
||||
handleDeleteLostItem,
|
||||
handleViewLostItemDetail,
|
||||
handleApproveClaim, // Fungsi pemicu (dihubungkan ke tombol di VerifyModal)
|
||||
submitApproveClaim, // Fungsi submit (dihubungkan ke form di ApproveClaimModal)
|
||||
};
|
||||
};
|
||||
|
||||
window.useManagerHandlers = useManagerHandlers;
|
||||
@@ -0,0 +1,100 @@
|
||||
const { useState } = React;
|
||||
|
||||
const useManagerState = () => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState("items");
|
||||
const [stats, setStats] = useState({
|
||||
total_items: 0,
|
||||
pending_claims: 0,
|
||||
verified: 0,
|
||||
expired: 0,
|
||||
});
|
||||
const [items, setItems] = useState([]);
|
||||
const [claims, setClaims] = useState([]);
|
||||
const [expiredItems, setExpiredItems] = useState([]);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [selectedClaim, setSelectedClaim] = useState(null);
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||
const [toast, setToast] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCloseCaseModal, setShowCloseCaseModal] = useState(false);
|
||||
const [closeCaseData, setCloseCaseData] = useState({
|
||||
berita_acara_no: "",
|
||||
bukti_serah_terima: "",
|
||||
notes: "",
|
||||
});
|
||||
const [showReportFoundModal, setShowReportFoundModal] = useState(false);
|
||||
const [photoPreview, setPhotoPreview] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [showManualClaimModal, setShowManualClaimModal] = useState(false);
|
||||
const [selectedItemForClaim, setSelectedItemForClaim] = useState(null);
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showEditItemModal, setShowEditItemModal] = useState(false);
|
||||
const [selectedItemToEdit, setSelectedItemToEdit] = useState(null);
|
||||
const [lostItems, setLostItems] = useState([]);
|
||||
const [showMatchLostItemModal, setShowMatchLostItemModal] = useState(false);
|
||||
const [selectedLostItem, setSelectedLostItem] = useState(null);
|
||||
const [showLostDetailModal, setShowLostDetailModal] = useState(false);
|
||||
const [selectedLostDetail, setSelectedLostDetail] = useState(null);
|
||||
|
||||
return {
|
||||
user,
|
||||
setUser,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
stats,
|
||||
setStats,
|
||||
items,
|
||||
setItems,
|
||||
claims,
|
||||
setClaims,
|
||||
expiredItems,
|
||||
setExpiredItems,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
showDetailModal,
|
||||
setShowDetailModal,
|
||||
selectedClaim,
|
||||
setSelectedClaim,
|
||||
showVerifyModal,
|
||||
setShowVerifyModal,
|
||||
toast,
|
||||
setToast,
|
||||
loading,
|
||||
setLoading,
|
||||
showCloseCaseModal,
|
||||
setShowCloseCaseModal,
|
||||
closeCaseData,
|
||||
setCloseCaseData,
|
||||
showReportFoundModal,
|
||||
setShowReportFoundModal,
|
||||
photoPreview,
|
||||
setPhotoPreview,
|
||||
categories,
|
||||
setCategories,
|
||||
showManualClaimModal,
|
||||
setShowManualClaimModal,
|
||||
selectedItemForClaim,
|
||||
setSelectedItemForClaim,
|
||||
showApproveModal,
|
||||
setShowApproveModal,
|
||||
showEditItemModal,
|
||||
setShowEditItemModal,
|
||||
selectedItemToEdit,
|
||||
setSelectedItemToEdit,
|
||||
lostItems,
|
||||
setLostItems,
|
||||
showMatchLostItemModal,
|
||||
setShowMatchLostItemModal,
|
||||
selectedLostItem,
|
||||
setSelectedLostItem,
|
||||
showLostDetailModal,
|
||||
setShowLostDetailModal,
|
||||
selectedLostDetail,
|
||||
setSelectedLostDetail,
|
||||
};
|
||||
};
|
||||
|
||||
// Export ke global scope
|
||||
window.useManagerState = useManagerState;
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user