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>
|
||||
);
|
||||
Reference in New Issue
Block a user