Elementor #32077

import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { toast } from "sonner"; import { QRCodeSVG } from "qrcode.react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Sprout, ArrowLeft, ArrowRight, Search, CheckCircle2, Star, Loader2, User, Pencil, Save, X, Download, IdCard } from "lucide-react"; import { fetchOptions, fetchProvinces, fetchCities, fetchDistricts, fetchVillages, lookupNRW, submitPresensi, searchParticipantByName, updateSelf, otpRequest, otpVerify, publicSessionsStatus, selfEditStatus, } from "@/lib/api"; const STEPS = ["Mulai", "Identitas", "Alamat", "Program & Peran", "Selesai"]; export default function PresensiForm() { const navigate = useNavigate(); const [step, setStep] = useState(0); const [mode, setMode] = useState(null); // 'new' | 'returning' const [nrwInput, setNrwInput] = useState(""); const [loading, setLoading] = useState(false); const [options, setOptions] = useState({ disability_types: [], programs: [], roles: [], genders: [] }); const [existingParticipant, setExistingParticipant] = useState(null); const [editMode, setEditMode] = useState(false); // returning user editing their own data const [otpToken, setOtpToken] = useState(null); // WhatsApp verification token const [otpDialogOpen, setOtpDialogOpen] = useState(false); const [form, setForm] = useState({ name: "", nik: "", whatsapp: "", email: "", gender: "", disability_types: [], program: "", role: "", province_id: "", city_id: "", district_id: "", village_id: "", rating: 0, testimonial: "", }); const [provinces, setProvinces] = useState([]); const [cities, setCities] = useState([]); const [districts, setDistricts] = useState([]); const [villages, setVillages] = useState([]); const [result, setResult] = useState(null); const [sessionStatus, setSessionStatus] = useState({ active: [], has_any: false }); useEffect(() => { fetchOptions().then(setOptions).catch(() => {}); fetchProvinces().then(setProvinces).catch(() => {}); publicSessionsStatus().then(setSessionStatus).catch(() => {}); }, []); useEffect(() => { if (form.province_id) { fetchCities(form.province_id).then(setCities); setForm((f) => ({ ...f, city_id: "", district_id: "", village_id: "" })); setDistricts([]); setVillages([]); } }, [form.province_id]); useEffect(() => { if (form.city_id) { fetchDistricts(form.city_id).then(setDistricts); setForm((f) => ({ ...f, district_id: "", village_id: "" })); setVillages([]); } }, [form.city_id]); useEffect(() => { if (form.district_id) { fetchVillages(form.district_id).then(setVillages); setForm((f) => ({ ...f, village_id: "" })); } }, [form.district_id]); const handleLookup = async () => { if (!nrwInput.trim()) { toast.error("Masukkan NRW terlebih dahulu"); return; } setLoading(true); try { const p = await lookupNRW(nrwInput.trim()); setExistingParticipant(p); setMode("returning"); setForm((f) => ({ ...f, name: p.name, nik: p.nik || "", whatsapp: p.whatsapp, gender: p.gender, disability_types: p.disability_types && p.disability_types.length ? p.disability_types : (p.disability_type ? [p.disability_type] : []), province_id: p.province_id, city_id: p.city_id, district_id: p.district_id, village_id: p.village_id, program: p.program, role: p.role, })); toast.success(`Selamat datang kembali, ${p.name}!`); setStep(3); // jump to program/role } catch (e) { toast.error(e?.response?.data?.detail || "NRW tidak ditemukan"); } finally { setLoading(false); } }; const validateStep = () => { if (step === 1) { if (!form.name || !form.whatsapp || !form.gender || !form.disability_types.length) { toast.error("Lengkapi identitas terlebih dahulu (termasuk minimal 1 ragam disabilitas)"); return false; } } if (step === 2) { if (!form.province_id || !form.city_id || !form.district_id || !form.village_id) { toast.error("Lengkapi data wilayah"); return false; } } if (step === 3) { if (!form.program || !form.role) { toast.error("Pilih program dan peran Anda"); return false; } } return true; }; const handleNext = () => { if (!validateStep()) return; setStep((s) => s + 1); }; const handleSubmit = async () => { if (!validateStep()) return; setLoading(true); try { const payload = mode === "returning" ? { nrw: existingParticipant.nrw, program: form.program, role: form.role, rating: form.rating || null, testimonial: form.testimonial || null } : { ...form, rating: null, testimonial: null }; const res = await submitPresensi(payload); setResult(res); setStep(4); toast.success("Presensi berhasil dicatat"); } catch (e) { toast.error(e?.response?.data?.detail || "Gagal mencatat presensi"); } finally { setLoading(false); } }; const handleSaveSelf = async () => { setLoading(true); try { const patch = { name: form.name, whatsapp: form.whatsapp, gender: form.gender, disability_types: form.disability_types, province_id: form.province_id, city_id: form.city_id, district_id: form.district_id, village_id: form.village_id, }; const updated = await updateSelf(existingParticipant.nrw, patch); if (updated?.status === "no_changes") { toast.info("Tidak ada perubahan untuk dikirim"); } else if (updated?.status === "pending") { toast.success("Usulan perubahan Anda telah dikirim dan menunggu persetujuan Admin."); } else { toast.success("Data berhasil dikirim"); } setEditMode(false); } catch (e) { const detail = e?.response?.data?.detail || "Gagal memperbarui data"; toast.error(detail); } finally { setLoading(false); } }; const goViewIdentity = () => { setEditMode(false); setStep(1); }; const requestEditMode = () => { setEditMode(true); setStep(1); }; return (
LINKSOS

LINKSOS Presensi

Isi presensi kegiatan

Beranda
{sessionStatus.has_any && sessionStatus.active.length === 0 && step < 4 && (

🔒 Presensi sedang ditutup

Belum ada sesi presensi yang dibuka admin saat ini. Silakan tunggu pengumuman atau hubungi pengurus LINKSOS.

)} {sessionStatus.active.length > 0 && step < 4 && (

🟢 Sesi presensi berlangsung: {sessionStatus.active[0].name}

Ditutup pada {new Date(sessionStatus.active[0].closes_at).toLocaleString("id-ID")} {sessionStatus.active[0].program ? ` · Program: ${sessionStatus.active[0].program}` : ""}

)} {editMode && (
Mode Edit aktif. Ubah data yang perlu, lalu klik Simpan. Perubahan akan menunggu persetujuan admin.
)} {step === 0 && ( { setMode(m); setStep(1); }} /> )} {step === 1 && ( )} {step === 2 && ( )} {step === 3 && ( )} {step === 4 && result && ( )} {step > 0 && step < 4 && (
{step < 3 ? ( ) : ( )}
)}
setOtpDialogOpen(false)} nrw={existingParticipant?.nrw} onVerified={(token) => { setOtpToken(token); setOtpDialogOpen(false); setEditMode(true); setStep(1); toast.success("Verifikasi berhasil. Anda dapat mengubah data."); }} />
); } function OtpDialog({ open, onClose, nrw, onVerified }) { const [phase, setPhase] = useState("request"); // 'request' | 'verify' const [loading, setLoading] = useState(false); const [maskedWa, setMaskedWa] = useState(""); const [code, setCode] = useState(""); const [demoCode, setDemoCode] = useState(null); useEffect(() => { if (!open) { setPhase("request"); setCode(""); setDemoCode(null); } }, [open]); const handleRequest = async () => { if (!nrw) return; setLoading(true); try { const res = await otpRequest(nrw); setMaskedWa(res.whatsapp_masked || ""); setDemoCode(res.demo_code || null); setPhase("verify"); if (res.sent) { toast.success(`Kode dikirim ke WhatsApp ${res.whatsapp_masked}`); } else if (res.send_error === "device_disconnected") { toast.error("Device WhatsApp Fonnte terputus. Hubungi admin LINKSOS untuk mendapatkan kode.", { duration: 8000 }); } else if (res.demo_code) { toast.warning("WhatsApp belum dikonfigurasi. Gunakan kode demo di layar."); } else if (res.send_message) { toast.error(res.send_message, { duration: 8000 }); } else { toast.error("Gagal mengirim kode. Hubungi admin."); } } catch (e) { toast.error(e?.response?.data?.detail || "Gagal mengirim kode"); } finally { setLoading(false); } }; const handleVerify = async () => { if (code.length !== 6) { toast.error("Kode harus 6 digit"); return; } setLoading(true); try { const res = await otpVerify(nrw, code); onVerified(res.otp_token); } catch (e) { toast.error(e?.response?.data?.detail || "Kode salah"); } finally { setLoading(false); } }; return ( !v && onClose()}> Verifikasi Kepemilikan {phase === "request" ? (

Untuk melindungi data Anda, kami akan mengirimkan kode 6 digit ke nomor WhatsApp yang terdaftar pada NRW ini.

NRW

{nrw}

) : (

Kami mengirim kode ke WhatsApp {maskedWa}. Berlaku 5 menit.

{demoCode && (

Mode Demo (Fonnte belum dikonfigurasi)

Kode Anda: {demoCode}

)} {!demoCode && (

Tidak menerima kode?

Device WhatsApp mungkin sedang terputus. Hubungi admin LINKSOS via nomor kontak resmi untuk mendapatkan kode 6-digit Anda secara manual.

)}
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} maxLength={6} inputMode="numeric" autoFocus className="mt-2 text-center font-mono-nrw text-2xl tracking-widest" placeholder="••••••" />
)}
); } function Stepper({ current }) { return (
    {STEPS.map((label, i) => { const active = i === current; const done = i < current; return (
  1. {i + 1} {label} {i < STEPS.length - 1 && }
  2. ); })}
); } function StepStart({ nrwInput, setNrwInput, handleLookup, loading, setMode }) { const [searchOpen, setSearchOpen] = useState(false); return (

Selamat datang!

Sudah punya NRW? Cari dulu, dan data Anda otomatis terisi.

setNrwInput(e.target.value)} className="font-mono-nrw" />

Format: tahun-LINKSOS-nomor. Boleh pakai spasi atau tanda "–".

atau
setSearchOpen(false)} onPick={(nrw) => { setNrwInput(nrw); setSearchOpen(false); }} />
); } function NameSearchDialog({ open, onClose, onPick }) { const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!open) { setQ(""); setResults([]); return; } }, [open]); useEffect(() => { if (q.trim().length < 2) { setResults([]); return; } const timer = setTimeout(async () => { setLoading(true); try { setResults(await searchParticipantByName(q.trim())); } catch { setResults([]); } finally { setLoading(false); } }, 300); return () => clearTimeout(timer); }, [q]); return ( !v && onClose()}> Cari NRW berdasarkan Nama

Ketik nama Anda, lalu pilih dari daftar. NRW akan otomatis terisi.

setQ(e.target.value)} placeholder="Ketik minimal 2 huruf..." className="mt-2" />
{loading &&

Mencari...

} {!loading && q.trim().length < 2 &&

Mulai ketik untuk mencari

} {!loading && q.trim().length >= 2 && results.length === 0 && (

Tidak ada hasil untuk "{q}"

)} {results.map((r) => ( ))}
); } function StepIdentity({ form, setForm, options, mode, editMode, setEditMode, onSaveSelf, loading, requestEditMode }) { const readonly = mode === "returning" && !editMode; const infoMap = Object.fromEntries((options.disability_types_info || []).map((d) => [d.key, d.examples])); const toggleDisability = (key) => { if (readonly) return; setForm({ ...form, disability_types: form.disability_types.includes(key) ? form.disability_types.filter((k) => k !== key) : [...form.disability_types, key], }); }; return (

Identitas Anda

Data ini digunakan untuk menerbitkan NRW dan menjaga kontinuitas keanggotaan.

{mode === "returning" && ( editMode ? (
) : ( ) )}
{mode === "returning" && !editMode && (
Anda sedang melihat data yang tersimpan. Jika ada perubahan, klik Perbarui Data Saya untuk mengedit — perubahan akan menunggu persetujuan admin.
)}
setForm({ ...form, name: e.target.value })} /> setForm({ ...form, nik: e.target.value })} /> setForm({ ...form, whatsapp: e.target.value })} /> {mode === "new" && ( setForm({ ...form, email: e.target.value })} /> )}
{options.disability_types.map((d) => { const checked = form.disability_types.includes(d); const examples = infoMap[d] || ""; return ( ); })}
); } function StepAddress({ form, setForm, provinces, cities, districts, villages, mode, editMode, setEditMode, onSaveSelf, loading, requestEditMode }) { const readonly = mode === "returning" && !editMode; return (

Alamat Lengkap

Pilih wilayah tempat tinggal Anda saat ini.

{mode === "returning" && ( editMode ? (
) : ( ) )}
{districts.length === 0 && form.city_id && (

Data kecamatan untuk wilayah ini belum tersedia di dataset. Pilih kabupaten/kota lain (rekomendasi: Kab. Malang atau Kota Malang).

)}
); } function StepProgram({ form, setForm, options, mode, existingParticipant, onGoEdit }) { const showRating = mode === "returning" && existingParticipant?.attendance_count >= 1; return (

Program & Peran

Pilih program yang Anda ikuti hari ini dan peran Anda.

{mode === "returning" && ( )}
{showRating && (

Bagaimana pengalaman Anda?

Bagikan penilaian & masukan (opsional).

{[1, 2, 3, 4, 5].map((n) => ( ))} {form.rating > 0 && ( {form.rating}/5 )}
)}
); } function StepDone({ result, navigate }) { const p = result.participant; const [editStatus, setEditStatus] = useState(null); useEffect(() => { if (!p?.nrw) return; selfEditStatus(p.nrw).then((d) => setEditStatus(d?.last_request || null)).catch(() => {}); }, [p?.nrw]); const hasCard = (p.attendance_count || 0) >= 5; const remaining = Math.max(0, 5 - (p.attendance_count || 0)); const pct = Math.min(100,1].join(", ") : ""; const statusMeta = editStatus && { pending: { color: "bg-amber-50 border-amber-200 text-amber-900", dot: "bg-amber-500", label: "Menunggu Persetujuan Admin" }, approved: { color: "bg-emerald-50 border-emerald-200 text-emerald-900", dot: "bg-emerald-500", label: "Disetujui Admin" }, rejected: { color: "bg-red-50 border-red-200 text-red-900", dot: "bg-red-500", label: "Belum Disetujui Admin" }, }[editStatus.status]; return (

Presensi Berhasil Dicatat

Terima kasih, {p.name}. Kehadiran Anda ke-{p.attendance_count} berhasil disimpan.

Nomor Registrasi Warga

{p.nrw}

Simpan NRW Anda — atau scan QR di samping untuk presensi selanjutnya.

Scan untuk presensi

{editStatus && statusMeta && (

Usulan Perubahan Data: {statusMeta.label}

{changedFields && (

Bidang: {changedFields}

)}

{editStatus.status === "pending" ? `Dikirim ${new Date(editStatus.created_at).toLocaleString("id-ID")}` : `Ditinjau ${new Date(editStatus.reviewed_at || editStatus.created_at).toLocaleString("id-ID")}${editStatus.reviewer_email ? ` oleh ${editStatus.reviewer_email}` : ""}`}

{editStatus.status === "rejected" && editStatus.reviewer_note && (

Catatan admin: {editStatus.reviewer_note}

)}
)} {hasCard ? (

Kartu Anggota Digital Anda siap!

Sebagai anggota aktif LINKSOS dengan {p.attendance_count} kali kehadiran, unduh kartu digital Anda untuk disimpan atau dibagikan.

{`Kartu Unduh Kartu Anggota (PNG)
) : (

{p.attendance_count} dari 5 kegiatan menuju Kartu Anggota Digital

Ikuti {remaining} kegiatan LINKSOS lagi untuk mendapatkan Kartu Anggota Digital resmi lengkap dengan QR code dan branding LINKSOS.

Kemajuan: {p.attendance_count}/5 ({Math.round(pct)}%)

)}
); } function Field({ label, required, children }) { return (
{children}
); } function Info({ label, value }) { return (

{label}

{value}

); }
  1. p.attendance_count || 0) / 5) * 100); const cardUrl = `${process.env.REACT_APP_BACKEND_URL}/api/public/card/${encodeURIComponent(p.nrw)}.png`; const fieldLabels = { name: "Nama", whatsapp: "WhatsApp", gender: "Jenis Kelamin", disability_types: "Ragam Disabilitas", province_name: "Alamat", city_name: "Alamat", district_name: "Alamat", village_name: "Alamat", }; const changedFields = editStatus ? [...new Set(Object.keys(editStatus.changes || {}).map((k) => fieldLabels[k]).filter(Boolean []
Skip to content