Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1
2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
const AIChatbot = () => {
const { useState, useEffect, useRef } = React;
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => {
if (isOpen && messages.length === 0) {
loadHistory();
}
}, [isOpen]);
const loadHistory = async () => {
try {
const data = await ApiUtils.get(
`${CONFIG.API_ENDPOINTS.AI.HISTORY}?limit=10`
);
if (data.data && data.data.history) {
const formatted = data.data.history.reverse().flatMap((chat) => [
{ text: chat.message, sender: "user", time: chat.created_at },
{ text: chat.response, sender: "ai", time: chat.created_at },
]);
setMessages(formatted);
}
} catch (error) {
console.error("Failed to load history:", error);
}
};
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMessage = { text: input, sender: "user", time: new Date() };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setLoading(true);
try {
const data = await ApiUtils.post(CONFIG.API_ENDPOINTS.AI.CHAT, {
message: input,
history: messages.slice(-10).map((msg) => ({
text: msg.text,
sender: msg.sender,
})),
});
const aiMessage = {
text:
data.data.message?.content ||
data.data.response ||
"Maaf, tidak ada respons.",
sender: "ai",
time: new Date(),
};
setMessages((prev) => [...prev, aiMessage]);
} catch (error) {
console.error("AI Chat error:", error);
const errorMessage = {
text: "Maaf, terjadi kesalahan. Silakan coba lagi.",
sender: "ai",
time: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setLoading(false);
}
};
const clearHistory = async () => {
if (!confirm("Hapus semua riwayat chat?")) return;
try {
await ApiUtils.delete(CONFIG.API_ENDPOINTS.AI.HISTORY);
setMessages([]);
} catch (error) {
console.error("Failed to clear history:", error);
alert("Gagal menghapus riwayat");
}
};
return (
<>
{/* Floating Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-6 right-6 w-16 h-16 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-full shadow-2xl hover:scale-110 transition-transform z-50 flex items-center justify-center"
>
{isOpen ? (
<span className="text-2xl">✕</span>
) : (
<span className="text-3xl">🤖</span>
)}
</button>
{/* Chat Window - REDUCED HEIGHT */}
{isOpen && (
<div className="fixed bottom-24 right-6 w-96 h-[500px] bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl shadow-2xl border border-slate-700 flex flex-col z-50">
{/* Header - REDUCED PADDING */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 p-3 rounded-t-2xl flex justify-between items-center">
<div>
<h3 className="text-white font-bold text-base">
🤖 AI Assistant
</h3>
<p className="text-blue-100 text-xs">
Tanya apa saja tentang Lost & Found
</p>
</div>
<button
onClick={clearHistory}
className="text-white hover:text-blue-200 text-sm"
title="Clear History"
>
🗑️
</button>
</div>
{/* Messages Area - ADJUSTED HEIGHT */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{messages.length === 0 && (
<div className="text-center text-slate-400 mt-6">
<p className="text-3xl mb-2">👋</p>
<p className="text-sm">Halo! Ada yang bisa saya bantu?</p>
<div className="mt-3 text-xs space-y-1">
<p>💡 Coba tanya:</p>
<p>"Ada dompet yang ditemukan?"</p>
<p>"Bagaimana cara lapor kehilangan?"</p>
</div>
</div>
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${
msg.sender === "user" ? "justify-end" : "justify-start"
}`}
>
<div
className={`max-w-[80%] p-2.5 rounded-2xl text-sm ${
msg.sender === "user"
? "bg-blue-600 text-white rounded-br-none"
: "bg-slate-700 text-slate-100 rounded-bl-none"
}`}
>
<p className="whitespace-pre-wrap">{msg.text}</p>
{msg.intent && (
<span className="text-xs opacity-70 mt-1 block">
🏷️ {msg.intent}
</span>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-slate-700 text-slate-100 p-2.5 rounded-2xl rounded-bl-none">
<span className="inline-block animate-pulse text-sm">
💭 Sedang berpikir...
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area - REDUCED PADDING */}
<div className="p-3 border-t border-slate-700">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && sendMessage()}
placeholder="Ketik pesan..."
className="flex-1 px-3 py-2 bg-slate-700 text-white text-sm rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim()}
className="px-5 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
📤
</button>
</div>
</div>
</div>
)}
</>
);
};
+258
View File
@@ -0,0 +1,258 @@
const ClaimCard = ({
claim,
onViewDetails,
onVerify,
onReopen,
onCancelApproval,
onUserRespond,
isOwnerView = false,
currentUserId,
}) => {
const getStatusClass = (status) => {
switch (status) {
case "approved":
case "verified":
return "bg-green-600 text-white border-green-500";
case "rejected":
return "bg-red-600 text-white border-red-500";
case "waiting_owner":
return "bg-yellow-600 text-white border-yellow-500";
default:
return "bg-slate-700 text-slate-300 border-slate-600";
}
};
const getStatusText = (status) => {
if (status === "waiting_owner") {
// Jika yang login adalah pemilik barang yang hilang
if (
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id
) {
return "🔔 MENUNGGU ANDA";
}
// Jika yang login adalah penemu yang mengajukan klaim
if (claim.user_id && currentUserId === claim.user_id) {
return "⏳ MENUNGGU OWNER"; // Updated: Sesuai permintaan
}
return onUserRespond ? "🔔 MENUNGGU PEMILIK" : "⏳ MENUNGGU OWNER";
}
return status.toUpperCase();
};
const ActorBadge = ({ label, name, icon, color }) => (
<div
className={`flex items-center gap-1 text-xs ${color} bg-slate-950/30 px-2 py-1 rounded border border-slate-700`}
>
<span>{icon}</span>
<span className="opacity-70">{label}:</span>
<span className="font-semibold">{name}</span>
</div>
);
return (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 border-2 border-slate-700 rounded-xl p-6 hover:shadow-2xl hover:shadow-blue-500/20 hover:border-blue-500 transition-all group flex flex-col h-full">
<div className="flex justify-between items-start mb-4 border-b border-slate-700 pb-3">
<div>
<h3 className="text-lg font-bold text-white group-hover:text-blue-400 transition">
{claim.item_name}
</h3>
<p className="text-xs text-slate-400 mt-1">
Pengklaim:{" "}
<span className="text-white font-medium">{claim.user_name}</span> •{" "}
{claim.contact}
</p>
</div>
<div className="text-right">
<span
className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider border ${getStatusClass(
claim.status
)}`}
>
{getStatusText(claim.status)}
</span>
{claim.match_percentage && (
<div
className={`text-xs font-bold mt-2 ${
claim.match_percentage >= 70
? "text-green-400"
: "text-yellow-400"
}`}
>
Match: {claim.match_percentage}%
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 flex-grow">
<div className="bg-slate-950/50 p-3 rounded-lg border border-slate-600 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-yellow-500"></div>
<p className="text-xs text-slate-500 mb-1 font-bold uppercase">
Ciri Rahasia (Dari Penemu)
</p>
<p className="text-sm text-slate-300 italic">
"{claim.item_secret_details || "Tidak ada data"}"
</p>
</div>
<div className="bg-slate-950/50 p-3 rounded-lg border border-slate-600 relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-blue-500"></div>
<p className="text-xs text-slate-500 mb-1 font-bold uppercase">
Deskripsi Pengklaim
</p>
<p className="text-sm text-slate-300">"{claim.description}"</p>
</div>
</div>
{claim.status === "waiting_owner" &&
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id && (
<div className="bg-blue-500/20 border border-blue-500/50 p-3 rounded-lg mb-4 text-center">
<p className="text-sm text-blue-200 font-semibold">
🔔 Seseorang menemukan barang ini dan mencocokkannya dengan Anda.
</p>
<p className="text-xs text-blue-300 mt-1">
Apakah benar ini barang Anda?
</p>
</div>
)}
{claim.status === "waiting_owner" &&
claim.user_id &&
currentUserId === claim.user_id && (
<div className="bg-yellow-500/20 border border-yellow-500/50 p-3 rounded-lg mb-4 text-center">
<p className="text-sm text-yellow-200 font-semibold">
⏳ Menunggu keputusan dari pemilik barang
</p>
<p className="text-xs text-yellow-300 mt-1">
Pemilik sedang memverifikasi apakah ini barang mereka
</p>
</div>
)}
<div className="space-y-2 mb-4 pt-3 border-t border-slate-700 border-dashed">
<div className="flex justify-between items-center">
<ActorBadge
label="Penemu"
name={claim.reporter_name || "Anonim"}
icon="📦"
color="text-slate-300"
/>
<span className="text-[10px] text-slate-500">
Ditemukan: {Helpers.formatDate(claim.created_at)}
</span>
</div>
{claim.verifier_name && (
<div className="flex justify-between items-center">
<ActorBadge
label={
claim.status === "rejected"
? "Ditolak Oleh"
: "Diverifikasi Oleh"
}
name={claim.verifier_name}
icon={claim.status === "rejected" ? "❌" : "✅"}
color={
claim.status === "rejected" ? "text-red-300" : "text-green-300"
}
/>
<span className="text-[10px] text-slate-500">
{Helpers.formatDateTime(claim.verified_at)}
</span>
</div>
)}
{claim.case_closed_by_name && (
<div className="flex justify-between items-center bg-purple-500/10 p-1 rounded mt-1">
<ActorBadge
label="Closed By"
name={claim.case_closed_by_name}
icon="🔒"
color="text-purple-300"
/>
<div className="text-right">
<span className="block text-[10px] text-purple-300/70 font-bold">
BA: {claim.berita_acara_no}
</span>
<span className="block text-[10px] text-purple-300/50">
{Helpers.formatDateTime(claim.case_closed_at)}
</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-auto pt-2 border-t border-slate-700">
{onViewDetails && (
<button
onClick={onViewDetails}
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded transition"
>
📋 Detail
</button>
)}
{claim.status === "waiting_owner" &&
claim.lost_item_user_id &&
currentUserId === claim.lost_item_user_id &&
onUserRespond && (
<div className="flex gap-2 w-full">
<button
onClick={() => onUserRespond(claim.id, "reject")}
className="flex-1 px-4 py-2 border border-red-500 text-red-400 hover:bg-red-500/10 rounded transition"
>
❌ Bukan Punya Saya
</button>
<button
onClick={() => onUserRespond(claim.id, "approve")}
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded transition shadow-lg"
>
✅ Ya, Benar
</button>
</div>
)}
{claim.status === "pending" && onVerify && (
<button
onClick={() => onVerify(claim)}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔍</span> Verifikasi
</button>
)}
{claim.status === "approved" && !claim.berita_acara_no && (
<>
{onCancelApproval && (
<button
onClick={() => {
if (confirm("Yakin ingin membatalkan status Approved?"))
onCancelApproval(claim.id);
}}
className="px-3 py-2 border border-red-500 text-red-400 hover:bg-red-500/10 text-sm rounded transition"
title="Batalkan Approval"
>
🚫 Batal
</button>
)}
{onVerify && (
<button
onClick={() => onVerify(claim)}
className="flex-1 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔒</span> Close Case
</button>
)}
</>
)}
{claim.status === "approved" && claim.berita_acara_no && onReopen && (
<button
onClick={() => onReopen(claim)}
className="flex-1 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-sm rounded transition shadow-lg flex justify-center items-center gap-2"
>
<span>🔄</span> Reopen Case
</button>
)}
</div>
</div>
);
};
+144
View File
@@ -0,0 +1,144 @@
const DirectClaimModal = ({ isOpen, onClose, onSubmit, loading, lostItem }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80 backdrop-blur-sm p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-r from-blue-900 to-slate-900 p-6 border-b border-slate-700 z-10">
<h3 className="text-2xl font-bold text-white flex items-center gap-3">
<span className="text-3xl">⚡</span>
Klaim Langsung ke Pemilik
</h3>
<p className="text-slate-300 mt-2 text-sm">
Formulir ini akan langsung dikirim ke pemilik barang untuk
disetujui/ditolak
</p>
</div>
{lostItem && (
<div className="p-6 bg-blue-900/20 border-b border-slate-700">
<h4 className="text-lg font-bold text-white mb-3">
📦 Barang yang Akan Diklaim:
</h4>
<div className="bg-slate-800/50 rounded-lg p-4 space-y-2 text-sm">
<p className="text-white">
<span className="font-semibold">Nama:</span> {lostItem.name}
</p>
<p className="text-slate-300">
<span className="font-semibold">Kategori:</span>{" "}
{lostItem.category}
</p>
{lostItem.color && (
<p className="text-slate-300">
<span className="font-semibold">Warna:</span> {lostItem.color}
</p>
)}
<p className="text-slate-300">
<span className="font-semibold">Lokasi Hilang:</span>{" "}
{lostItem.location}
</p>
<p className="text-slate-300">
<span className="font-semibold">Deskripsi:</span>{" "}
{lostItem.description}
</p>
</div>
</div>
)}
<form onSubmit={onSubmit} className="p-6 space-y-6">
<div className="bg-orange-900/20 border border-orange-500/30 rounded-lg p-4">
<p className="text-orange-300 text-sm flex items-center gap-2">
<span className="text-xl">⚠️</span>
<span>
<strong>Penting:</strong> Pastikan ini adalah barang yang
benar-benar Anda temukan. Pemilik akan langsung menerima
notifikasi.
</span>
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📝 Deskripsi Barang yang Anda Temukan *
</label>
<textarea
name="description"
required
rows="5"
placeholder="Jelaskan detail barang yang Anda temukan (warna, kondisi, ciri khusus, dll)"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
<p className="text-xs text-slate-500 mt-1">
Berikan detail yang cukup agar pemilik yakin ini barangnya
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📞 Nomor Kontak Anda *
</label>
<input
type="text"
name="contact"
required
placeholder="Contoh: 081234567890"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Pemilik akan menghubungi Anda untuk koordinasi pengambilan
</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-2">
📎 URL Bukti (Opsional)
</label>
<input
type="text"
name="proof_url"
placeholder="https://example.com/foto-barang.jpg"
className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-slate-500 mt-1">
Link foto atau dokumen pendukung (jika ada)
</p>
</div>
<div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4">
<h5 className="font-bold text-green-300 mb-2 flex items-center gap-2">
<span className="text-xl">✅</span>
Alur Setelah Submit:
</h5>
<ol className="text-sm text-slate-300 space-y-1 list-decimal list-inside">
<li>Notifikasi langsung terkirim ke pemilik</li>
<li>Pemilik akan melihat detail klaim Anda</li>
<li>Pemilik bisa Approve/Reject klaim</li>
<li>Jika Approved, Anda koordinasi pengambilan</li>
<li>Setelah serah terima, pemilik konfirmasi case closed</li>
</ol>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-700">
<button
type="button"
onClick={onClose}
disabled={loading}
className="flex-1 px-6 py-3 bg-slate-800 text-slate-300 rounded-lg font-semibold hover:bg-slate-700 transition disabled:opacity-50"
>
Batal
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg font-semibold hover:from-blue-700 hover:to-blue-800 transition disabled:opacity-50 shadow-lg shadow-blue-500/30"
>
{loading ? "⏳ Mengirim..." : "⚡ Kirim ke Pemilik"}
</button>
</div>
</form>
</div>
</div>
);
};
window.DirectClaimModal = DirectClaimModal;
+76
View File
@@ -0,0 +1,76 @@
// web/js/components/FoundOptionModal.js
const FoundOptionModal = ({ isOpen, onClose, onSelectOption }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-80 backdrop-blur-sm p-4">
<div className="bg-slate-900 rounded-2xl border border-slate-700 shadow-2xl max-w-lg w-full overflow-hidden animate-fade-in-up">
{/* Header Modal */}
<div className="p-6 border-b border-slate-800">
<h3 className="text-xl font-bold text-white text-center">
🧐 Bagaimana proses pengembaliannya?
</h3>
<p className="text-slate-400 text-center text-sm mt-2">
Pilih metode yang Anda inginkan untuk memproses barang temuan ini.
</p>
</div>
{/* Pilihan Opsi */}
<div className="p-6 space-y-4">
{/* OPSI 1: Langsung ke Pemilik (Direct) */}
<button
onClick={() => onSelectOption("direct")}
className="w-full group relative flex items-center p-4 bg-gradient-to-r from-blue-900/40 to-slate-900 border-2 border-blue-500/30 hover:border-blue-500 rounded-xl transition-all hover:shadow-lg hover:shadow-blue-500/20 text-left"
>
<div className="bg-blue-500/20 p-3 rounded-full mr-4 group-hover:bg-blue-500 group-hover:text-white transition-colors text-blue-400">
⚡
</div>
<div>
<h4 className="font-bold text-white group-hover:text-blue-400 transition">
Langsung ke Pemilik (Cepat)
</h4>
<p className="text-xs text-slate-400 mt-1">
Anda mengisi form, notifikasi langsung ke pemilik untuk
<span className="text-green-400 font-bold ml-1">
Approve/Reject.
</span>
</p>
<span className="text-[10px] text-blue-300 font-mono mt-1 block">
*Tanpa perantara Manager
</span>
</div>
</button>
{/* OPSI 2: Via Manager (Standard) */}
<button
onClick={() => onSelectOption("manager")}
className="w-full group relative flex items-center p-4 bg-gradient-to-r from-purple-900/40 to-slate-900 border-2 border-purple-500/30 hover:border-purple-500 rounded-xl transition-all hover:shadow-lg hover:shadow-purple-500/20 text-left"
>
<div className="bg-purple-500/20 p-3 rounded-full mr-4 group-hover:bg-purple-500 group-hover:text-white transition-colors text-purple-400">
🛡️
</div>
<div>
<h4 className="font-bold text-white group-hover:text-purple-400 transition">
Verifikasi Manager (Aman)
</h4>
<p className="text-xs text-slate-400 mt-1">
Laporan diperiksa Manager/Posko dulu sebelum diteruskan ke
pemilik.
</p>
</div>
</button>
</div>
{/* Footer / Tombol Batal */}
<div className="p-4 bg-slate-950/50 border-t border-slate-800 text-center">
<button
onClick={onClose}
className="text-slate-500 hover:text-white text-sm font-medium transition px-4 py-2 hover:bg-slate-800 rounded-lg"
>
Batal
</button>
</div>
</div>
</div>
);
};
+188
View File
@@ -0,0 +1,188 @@
// web/js/components/ItemCard.js
const ItemCard = ({
item,
onViewDetail,
onClaim,
onEdit,
onDelete,
onManualClaim,
currentUserId,
showActions = true,
isManager = false,
}) => {
const isOwnItem = currentUserId && item.reporter_id === currentUserId;
// ✅ FIX: Status logic yang lebih akurat
const isCaseClosed = item.status === "case_closed";
const isExpired = item.status === "expired";
const isVerified = item.status === "verified" || item.status === "completed";
const isPendingClaim = item.status === "pending_claim";
const isUnclaimed = item.status === "unclaimed";
// ✅ User hanya bisa claim jika unclaimed DAN bukan barang sendiri
const canClaim = isUnclaimed && !isOwnItem;
// ✅ Edit/Delete logic
let canEditOrDelete = false;
if (!isCaseClosed) {
if (isManager) {
canEditOrDelete = true;
} else {
canEditOrDelete = !isExpired;
}
}
const showEditButton = (isOwnItem || isManager) && canEditOrDelete;
// ✅ Improved status display text
const getStatusText = (status) => {
const statusMap = {
unclaimed: "Belum Diklaim",
pending_claim: "Sedang Diklaim",
verified: "Sudah Diklaim",
completed: "Selesai",
case_closed: "Kasus Ditutup",
expired: "Kadaluarsa",
};
return statusMap[status] || status;
};
return (
<div
className={`bg-gradient-to-br from-slate-800 to-slate-900 border-2 rounded-xl overflow-hidden hover:shadow-2xl transition-all cursor-pointer ${
isExpired
? "border-red-500/50 hover:border-red-500"
: "border-slate-700 hover:border-blue-500 hover:shadow-blue-500/20 hover:-translate-y-1"
}`}
>
{/* Expired Banner */}
{isExpired && (
<div className="absolute top-0 left-0 right-0 bg-red-600/90 text-white text-center py-1 text-xs font-bold z-10">
⚠️ EXPIRED - Manager Only View
</div>
)}
{/* Completed Banner - TAMBAHKAN INI */}
{isVerified && (
<div className="absolute top-0 left-0 right-0 bg-green-600/90 text-white text-center py-1 text-xs font-bold z-10">
✅ SUDAH DIKLAIM
</div>
)}
<img
src={
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}
alt={item.name}
className={`w-full h-48 object-cover ${isExpired ? "opacity-50" : ""} ${
isVerified ? "opacity-80" : ""
}`}
onClick={() => onViewDetail && onViewDetail(item)}
onError={(e) =>
(e.target.src = "https://via.placeholder.com/280x200?text=No+Image")
}
/>
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">{item.name}</h3>
<div className="flex flex-col gap-1 text-sm text-slate-400 mb-3">
<span>📍 {item.location}</span>
<span>📅 {Helpers.formatDate(item.date_found)}</span>
</div>
<div className="flex items-center justify-between mb-3">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
item.status
)}`}
>
{getStatusText(item.status)} {/* ✅ Better display */}
</span>
{isOwnItem && (
<span className="px-3 py-1 bg-green-500/20 text-green-400 text-xs font-semibold rounded-full border border-green-500/50">
Barang Anda
</span>
)}
</div>
{showActions && (
<div className="flex gap-2 flex-wrap">
{/* Detail Button */}
{onViewDetail && (
<button
onClick={(e) => {
e.stopPropagation();
onViewDetail(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white text-sm rounded-lg hover:from-blue-700 hover:to-blue-800 transition shadow-lg"
>
Detail
</button>
)}
{/* Manual Claim (Manager) */}
{onManualClaim && isUnclaimed && (
<button
onClick={(e) => {
e.stopPropagation();
onManualClaim(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
🤝 Klaim
</button>
)}
{/* User Claim - ✅ HANYA SHOW JIKA UNCLAIMED */}
{onClaim && canClaim && (
<button
onClick={(e) => {
e.stopPropagation();
onClaim(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded-lg hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
Klaim
</button>
)}
{/* Info untuk status pending */}
{isPendingClaim && !isOwnItem && (
<div className="flex-1 px-3 py-2 bg-yellow-600/20 text-yellow-400 text-xs rounded-lg border border-yellow-600/50 text-center">
⏳ Sedang diproses
</div>
)}
{/* Edit Button */}
{onEdit && showEditButton && (
<button
onClick={(e) => {
e.stopPropagation();
onEdit(item);
}}
className="flex-1 px-3 py-2 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded-lg hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
>
Edit
</button>
)}
{/* Delete Button */}
{onDelete && canEditOrDelete && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm(`Yakin hapus "${item.name}"?`)) onDelete(item.id);
}}
className="px-3 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded-lg hover:from-red-700 hover:to-red-800 transition shadow-lg"
title="Hapus Barang"
>
🗑️
</button>
)}
</div>
)}
</div>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
// assets/js/components/Modal.js
const Modal = ({ isOpen, onClose, title, children, size = "default" }) => {
if (!isOpen) return null;
const sizeClasses = {
default: "max-w-2xl",
large: "max-w-4xl",
small: "max-w-md",
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 p-4"
onClick={onClose}
>
<div
className={`bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl w-full max-h-[90vh] overflow-y-auto border border-slate-700 shadow-2xl ${sizeClasses[size]}`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-6 border-b border-slate-700 sticky top-0 bg-slate-800/95 backdrop-blur">
<h3 className="text-xl font-semibold text-white">{title}</h3>
<button
onClick={onClose}
className="text-2xl text-slate-400 hover:text-white transition"
>
×
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
};
+60
View File
@@ -0,0 +1,60 @@
// assets/js/components/Navbar.js
const Navbar = ({ user, onLogout, userType = "user" }) => {
const colorMap = {
admin: "from-red-500 to-red-600",
manager: "from-yellow-500 to-yellow-600",
user: "from-blue-500 to-blue-600",
};
const labelMap = {
admin: "Admin",
manager: "Manager",
user: "User",
};
const textColorMap = {
admin: "text-red-400",
manager: "text-yellow-400",
user: "text-blue-400",
};
return (
<nav className="bg-gradient-to-r from-slate-800 to-slate-900 shadow-xl sticky top-0 z-50 border-b border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="text-2xl font-bold text-blue-400">
🔍 Lost & Found
</div>
<div className="flex items-center gap-4">
<NotificationDropdown />
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-full bg-gradient-to-br ${
colorMap[userType] || colorMap.user
} text-white flex items-center justify-center font-semibold shadow-lg`}
>
{Helpers.getInitials(user?.name)}
</div>
<div className="flex flex-col">
<span className="font-medium text-white">{user?.name}</span>
{userType !== "user" && (
<span
className={`text-xs ${textColorMap[userType]} font-semibold`}
>
{labelMap[userType]}
</span>
)}
</div>
</div>
<button
onClick={onLogout}
className="px-4 py-2 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition shadow-lg"
>
Logout
</button>
</div>
</div>
</div>
</nav>
);
};
+130
View File
@@ -0,0 +1,130 @@
// web/js/components/NotificationDropdown.js
const NotificationDropdown = () => {
const [notifications, setNotifications] = React.useState([]);
const [unreadCount, setUnreadCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
// Load notifications periodically
React.useEffect(() => {
loadNotifications();
const interval = setInterval(loadNotifications, 30000); // Refresh tiap 30 detik
return () => clearInterval(interval);
}, []);
const loadNotifications = async () => {
try {
const response = await fetch(
`${CONFIG.API_URL}${CONFIG.API_ENDPOINTS.NOTIFICATIONS}?limit=5`,
{
headers: { Authorization: `Bearer ${AuthUtils.getToken()}` },
}
);
if (response.ok) {
const result = await response.json();
setNotifications(result.data.notifications || []);
setUnreadCount(result.data.unread_count || 0);
}
} catch (error) {
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (id) => {
try {
await fetch(
`${CONFIG.API_URL}${CONFIG.API_ENDPOINTS.NOTIFICATIONS}/${id}/read`,
{
method: "PATCH",
headers: { Authorization: `Bearer ${AuthUtils.getToken()}` },
}
);
loadNotifications();
} catch (error) {
console.error("Error marking as read", error);
}
};
const handleItemClick = (notif) => {
handleMarkAsRead(notif.id);
// Redirect logic based on type
if (notif.entity_type === "claim") {
// Trigger event custom agar UserApp bisa membuka tab Claims
window.dispatchEvent(new CustomEvent("open-my-claims"));
}
};
return (
<div className="relative">
{/* Bell Icon */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-slate-300 hover:text-white transition"
>
<span className="text-2xl">🔔</span>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full border-2 border-slate-900 animate-pulse">
{unreadCount}
</span>
)}
</button>
{/* Dropdown Content */}
{isOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
></div>
<div className="absolute right-0 mt-2 w-80 bg-slate-800 border border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden animate-fade-in">
<div className="p-3 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
<h3 className="font-semibold text-white text-sm">Notifikasi</h3>
<button
onClick={loadNotifications}
className="text-xs text-blue-400 hover:text-blue-300"
>
Refresh
</button>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-slate-500 text-sm">
Belum ada notifikasi
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
onClick={() => handleItemClick(notif)}
className={`p-3 border-b border-slate-700 cursor-pointer hover:bg-slate-700 transition ${
!notif.is_read
? "bg-slate-700/30 border-l-4 border-l-blue-500"
: ""
}`}
>
<div className="flex justify-between items-start mb-1">
<strong
className={`text-sm ${
!notif.is_read ? "text-blue-400" : "text-slate-300"
}`}
>
{notif.title}
</strong>
<span className="text-[10px] text-slate-500">
{new Date(notif.created_at).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-slate-400 line-clamp-2">
{notif.message}
</p>
</div>
))
)}
</div>
</div>
</>
)}
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
// assets/js/components/Pagination.js
const Pagination = ({
currentPage,
totalPages,
totalRecords,
onPageChange,
itemsPerPage = 10,
}) => {
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
pages.push(1, 2, 3, 4, "...", totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(
1,
"...",
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages
);
} else {
pages.push(
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages
);
}
}
return pages;
};
const startRecord = Math.min(
(currentPage - 1) * itemsPerPage + 1,
totalRecords
);
const endRecord = Math.min(currentPage * itemsPerPage, totalRecords);
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 px-6 py-4 bg-slate-900/50 rounded-xl border border-slate-700">
<div className="text-slate-400 text-sm">
Menampilkan {startRecord} - {endRecord} dari {totalRecords} data
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition disabled:bg-slate-900 disabled:text-slate-600 disabled:cursor-not-allowed border border-slate-700"
>
← Prev
</button>
{getPageNumbers().map((page, index) =>
page === "..." ? (
<span key={`dots-${index}`} className="px-3 py-2 text-slate-400">
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page)}
className={`px-4 py-2 rounded-lg transition border ${
currentPage === page
? "bg-blue-600 text-white border-blue-500 font-semibold shadow-lg shadow-blue-500/50"
: "bg-slate-800 text-white hover:bg-slate-700 border-slate-700"
}`}
>
{page}
</button>
)
)}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition disabled:bg-slate-900 disabled:text-slate-600 disabled:cursor-not-allowed border border-slate-700"
>
Next →
</button>
</div>
</div>
);
};
+12
View File
@@ -0,0 +1,12 @@
// assets/js/components/StatCard.js
const StatCard = ({ title, value, icon, colorClass = "text-blue-400" }) => (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 p-6 rounded-xl shadow-lg border border-slate-700 hover:border-blue-500 transition-all">
<div className="flex items-center justify-between">
<div>
<h3 className="text-slate-400 text-sm mb-2">{title}</h3>
<div className={`text-3xl font-bold ${colorClass}`}>{value}</div>
</div>
<div className="text-4xl">{icon}</div>
</div>
</div>
);
+26
View File
@@ -0,0 +1,26 @@
// assets/js/components/Toast.js
const Toast = ({ message, type, onClose }) => {
const { useEffect } = React;
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
const typeClasses = {
success: "bg-green-100 text-green-800 border-2 border-green-500",
error: "bg-red-100 text-red-800 border-2 border-red-500",
info: "bg-blue-100 text-blue-800 border-2 border-blue-500",
warning: "bg-yellow-100 text-yellow-800 border-2 border-yellow-500",
};
return (
<div
className={`fixed top-20 right-4 px-6 py-4 rounded-xl shadow-lg z-50 animate-slide-in ${
typeClasses[type] || typeClasses.info
}`}
>
{message}
</div>
);
};
+53
View File
@@ -0,0 +1,53 @@
// assets/js/components/UserRow.js
const UserRow = ({ user, onEdit, onBlock, onUnblock }) => (
<tr className="hover:bg-slate-700/50 transition">
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-white">{user.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-slate-300">{user.email}</td>
<td className="px-6 py-4 whitespace-nowrap text-slate-300">{user.nrp}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getRoleBadgeClass(
user.role
)}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${Helpers.getStatusBadgeClass(
user.status || "active"
)}`}
>
{user.status || "active"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<button
onClick={() => onEdit(user)}
className="px-3 py-1 bg-gradient-to-r from-yellow-600 to-yellow-700 text-white text-sm rounded hover:from-yellow-700 hover:to-yellow-800 transition shadow-lg"
>
Edit
</button>
{user.status === "active" || !user.status ? (
<button
onClick={() => onBlock(user.id)}
className="px-3 py-1 bg-gradient-to-r from-red-600 to-red-700 text-white text-sm rounded hover:from-red-700 hover:to-red-800 transition shadow-lg"
>
Block
</button>
) : (
<button
onClick={() => onUnblock(user.id)}
className="px-3 py-1 bg-gradient-to-r from-green-600 to-green-700 text-white text-sm rounded hover:from-green-700 hover:to-green-800 transition shadow-lg"
>
Unblock
</button>
)}
</div>
</td>
</tr>
);