add features

This commit is contained in:
panggilsajarey 2026-03-01 21:27:44 +07:00
parent 878f2994be
commit c7960f598c
16 changed files with 1059 additions and 5 deletions

View File

@ -0,0 +1,114 @@
'use client'
import { useState } from 'react'
import { Star, Trash2, Calendar, User, MessageCircle } from 'lucide-react'
import { deleteReview } from './action-admin-review'
import { showSwal } from '@/lib/swal'
interface Review {
id: string
rating: number
ulasan: string
nama_pengulas: string
created_at: string
}
interface Props {
posyanduId: string
initialReviews: Review[]
}
export function AdminReviewList({ posyanduId, initialReviews }: Props) {
const [isDeleting, setIsDeleting] = useState<string | null>(null)
const handleDelete = async (reviewId: string) => {
const result = await showSwal.confirm(
'Hapus Ulasan?',
'Apakah Anda yakin ingin menghapus ulasan ini? Tindakan ini tidak dapat dibatalkan.'
)
if (!result.isConfirmed) return
setIsDeleting(reviewId)
try {
const res = await deleteReview(reviewId, posyanduId)
if (res.success) {
showSwal.success('Berhasil!', 'Ulasan telah dihapus.')
} else {
showSwal.error('Gagal!', res.error || 'Gagal menghapus ulasan.')
}
} catch (err) {
showSwal.error('Gagal!', 'Terjadi kesalahan sistem.')
} finally {
setIsDeleting(null)
}
}
return (
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
<div className="p-6 border-b-2 border-black bg-purple-600 flex items-center justify-between">
<h3 className="text-lg font-black text-white flex items-center gap-3 uppercase">
<MessageCircle className="w-6 h-6" />
Ulasan Masyarakat
</h3>
<span className="bg-white text-purple-600 px-3 py-1 rounded-full text-xs font-black border-2 border-black">
{initialReviews.length} TOTAL
</span>
</div>
<div className="divide-y divide-gray-100">
{initialReviews.length > 0 ? (
initialReviews.map((rev) => (
<div key={rev.id} className="p-8 hover:bg-gray-50/50 transition-all flex flex-col gap-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-100 text-purple-600 rounded-2xl flex items-center justify-center font-black text-xl border-2 border-purple-200">
{rev.nama_pengulas?.[0] || 'A'}
</div>
<div>
<p className="font-bold text-gray-900 text-lg">{rev.nama_pengulas || 'Orang Tua'}</p>
<div className="flex items-center gap-3 mt-1">
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-3.5 h-3.5 ${i < rev.rating ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
/>
))}
</div>
<div className="w-1 h-1 bg-gray-300 rounded-full"></div>
<div className="flex items-center gap-1.5 text-[10px] text-gray-400 font-bold uppercase tracking-widest">
<Calendar className="w-3 h-3" />
{new Date(rev.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
</div>
</div>
</div>
<button
onClick={() => handleDelete(rev.id)}
disabled={isDeleting === rev.id}
className="p-3 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all border border-transparent hover:border-red-100 disabled:opacity-50"
title="Hapus Ulasan"
>
<Trash2 className={`w-5 h-5 ${isDeleting === rev.id ? 'animate-pulse' : ''}`} />
</button>
</div>
<div className="bg-gray-50 rounded-2xl p-5 border-2 border-gray-100 relative">
<p className="text-gray-700 font-semibold italic leading-relaxed text-sm">
"{rev.ulasan}"
</p>
</div>
</div>
))
) : (
<div className="p-20 text-center flex flex-col items-center gap-4 text-gray-300">
<MessageCircle className="w-16 h-16 opacity-10" />
<div className="flex flex-col gap-1">
<p className="font-black text-xl">Belum Ada Ulasan</p>
<p className="text-sm font-semibold opacity-60">Posyandu ini belum memiliki riwayat ulasan dari masyarakat.</p>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,19 @@
'use server'
import { supabase } from '@/lib/supabase'
import { revalidatePath } from 'next/cache'
export async function deleteReview(reviewId: string, posyanduId: string) {
const { error } = await supabase
.from('ulasan_posyandu')
.delete()
.eq('id', reviewId)
if (error) {
console.error('Error deleting review:', error)
return { success: false, error: error.message }
}
revalidatePath(`/dashboard/manajemen-posyandu/review/${posyanduId}`)
return { success: true }
}

View File

@ -2,8 +2,9 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button' import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Building2, MapPin, Phone, User, ExternalLink, Calendar, Map as MapIcon } from 'lucide-react' import { ArrowLeft, Building2, MapPin, User, ExternalLink, Map as MapIcon } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { AdminReviewList } from './AdminReviewList'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -22,7 +23,8 @@ export default async function ReviewPosyanduPage({ params }: Props) {
.from('detail_posyandu') .from('detail_posyandu')
.select(` .select(`
*, *,
petugas:petugas_posyandu_lokal(*) petugas:petugas_posyandu_lokal(*),
reviews:ulasan_posyandu(*)
`) `)
.eq('id', id) .eq('id', id)
.single() .single()
@ -89,7 +91,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
<div className="p-6 border-b border-gray-100 flex items-center justify-between"> <div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-base font-black flex items-center gap-3"> <h3 className="text-base font-black flex items-center gap-3">
<User className="w-5 h-5 text-purple-600" /> <User className="w-5 h-5 text-purple-600" />
Daftarkan Petugas Bertugas Daftar Petugas Terdaftar
</h3> </h3>
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest"> <span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
{posyandu.petugas?.length || 0} Orang {posyandu.petugas?.length || 0} Orang
@ -123,6 +125,12 @@ export default async function ReviewPosyanduPage({ params }: Props) {
)} )}
</div> </div>
</div> </div>
{/* Admin Review List Card */}
<AdminReviewList
posyanduId={posyandu.id}
initialReviews={posyandu.reviews || []}
/>
</div> </div>
{/* Right Column - Map & Quick Sync */} {/* Right Column - Map & Quick Sync */}

View File

@ -0,0 +1,92 @@
[
{
"id": "1",
"title": "Stunting: Apa, Penyebab, dan Upaya Penanganannya",
"category": "Edukasi Dasar",
"readTime": "5 min",
"source": "Kemenkes RI",
"description": "Pelajari definisi stunting, penyebab utama seperti malnutrisi kronis, serta langkah-langkah penanganan yang efektif.",
"url": "https://kesmas.kemkes.go.id"
},
{
"id": "2",
"title": "Pemahaman Orang Tua: Kunci Mencegah Stunting",
"category": "Pola Asuh",
"readTime": "4 min",
"source": "Antara News",
"description": "Menkes menekankan bahwa pemahaman orang tua mengenai nutrisi adalah faktor terpenting dalam mencegah gagal tumbuh pada anak.",
"url": "https://www.antaranews.com"
},
{
"id": "3",
"title": "Panduan Gizi Kemenkes untuk Balita",
"category": "Gizi & Nutrisi",
"readTime": "6 min",
"source": "Kemenkes RI",
"description": "Panduan terbaru mengenai asupan gizi seimbang, pentingnya protein hewani, dan suplementasi untuk balita.",
"url": "https://kemkes.go.id"
},
{
"id": "4",
"title": "11 Program Intervensi Stunting Pemerintah",
"category": "Program Pemerintah",
"readTime": "7 min",
"source": "Kemenkes RI",
"description": "Mengenal program pemerintah mulai dari pemberian TTD bagi remaja putri hingga imunisasi lengkap untuk bayi.",
"url": "https://kemkes.go.id"
},
{
"id": "5",
"title": "Peran Ibu dalam Mencegah Stunting dan Obesitas",
"category": "Keluarga",
"readTime": "5 min",
"source": "Promkes Kemenkes",
"description": "Diskusi mendalam mengenai peran krusial ibu dalam memantau berat badan selama kehamilan dan pemberian ASI eksklusif.",
"url": "https://promkes.kemkes.go.id"
},
{
"id": "6",
"title": "Masalah Status Gizi Balita di Indonesia",
"category": "Edukasi Dasar",
"readTime": "6 min",
"source": "Kemenkes RI",
"description": "Ulasan mengenai faktor langsung dan tidak langsung yang mempengaruhi status gizi balita di masa emas pertumbuhan.",
"url": "https://kemkes.go.id"
},
{
"id": "7",
"title": "Pentingnya Gizi Seimbang untuk Pertumbuhan Balita",
"category": "Gizi & Nutrisi",
"readTime": "4 min",
"source": "Poltekkes Makassar",
"description": "Mengapa gizi seimbang di awal kehidupan menjadi fondasi kesehatan, sistem imun, dan kecerdasan anak di masa depan.",
"url": "https://poltekkes-mks.ac.id"
},
{
"id": "8",
"title": "Ayo ke Posyandu: Deteksi Dini Stunting",
"category": "Layanan Kesehatan",
"readTime": "3 min",
"source": "Stunting.go.id",
"description": "Manfaat rutin menimbang berat badan dan memantau perkembangan anak di Posyandu sebagai langkah deteksi dini.",
"url": "https://stunting.go.id"
},
{
"id": "9",
"title": "Cegah Stunting itu Penting: Gerakan #AksiBergizi",
"category": "Kampanye",
"readTime": "4 min",
"source": "Ayo Sehat Kemenkes",
"description": "Informasi mengenai kampanye nasional pencegahan stunting melalui gerakan Aksi Bergizi dan Posyandu Aktif.",
"url": "https://ayosehat.kemkes.go.id"
},
{
"id": "10",
"title": "Langkah Praktis Mencegah Stunting di Rumah",
"category": "Panduan Praktis",
"readTime": "5 min",
"source": "Ayo Sehat Kemenkes",
"description": "Tips harian bagi orang tua mulai dari stimulasi anak, menjaga kebersihan lingkungan, hingga pemenuhan gizi harian.",
"url": "https://ayosehat.kemkes.go.id"
}
]

View File

@ -0,0 +1,108 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, BookOpen, Clock, Tag, ExternalLink, Search } from 'lucide-react'
import Link from 'next/link'
import articles from './articles.json'
export default async function ArtikelStuntingPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'user') redirect('/dashboard')
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none text-purple-600">Edukasi Stunting</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">INFORMASI TERPERCAYA</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-10">
{/* Hero Section */}
<div className="relative bg-black rounded-[40px] p-10 md:p-16 overflow-hidden text-white shadow-[20px_20px_0px_0px_rgba(147,51,234,0.1)]">
<div className="relative z-10 max-w-2xl">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 rounded-full text-[10px] font-black uppercase tracking-widest mb-6 border border-purple-400">
Pencegahan Stunting
</div>
<h2 className="text-4xl md:text-5xl font-black mb-6 leading-tight uppercase">
Cegah Stunting <br /> <span className="text-purple-400">Mulai dari Sekarang</span>
</h2>
<p className="text-gray-400 text-lg font-medium leading-relaxed mb-8">
Kumpulan informasi dan panduan teruji dari para ahli untuk membantu Bapak/Ibu memastikan tumbuh kembang optimal sang buah hati.
</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-2xl">
<BookOpen className="w-5 h-5 text-purple-400" />
<span className="text-sm font-bold">{articles.length} Artikel Terpilih</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-2xl">
<Clock className="w-5 h-5 text-purple-400" />
<span className="text-sm font-bold">Update Berkala</span>
</div>
</div>
</div>
{/* Background Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full hidden lg:block opacity-20">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-600 rounded-full blur-[120px]"></div>
</div>
</div>
{/* Article Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-20">
{articles.map((article) => (
<div key={article.id} className="group bg-white rounded-3xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all overflow-hidden flex flex-col h-full">
<div className="p-8 flex-1 flex flex-col gap-6">
<div className="flex justify-between items-start">
<div className="px-3 py-1 bg-purple-50 text-purple-600 border border-purple-100 rounded-full text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5">
<Tag className="w-3 h-3" />
{article.category}
</div>
<div className="text-[10px] font-bold text-gray-400 flex items-center gap-1.5 uppercase tracking-tighter">
<Clock className="w-3 h-3" />
{article.readTime} Baca
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-2xl font-black text-gray-900 group-hover:text-purple-600 transition-colors uppercase leading-tight">
{article.title}
</h3>
<p className="text-gray-500 font-semibold text-sm leading-relaxed line-clamp-3">
{article.description}
</p>
</div>
</div>
<div className="px-8 py-6 bg-gray-50 border-t-2 border-black flex justify-between items-center group-hover:bg-purple-600 transition-colors">
<span className="text-[10px] font-black text-gray-400 group-hover:text-white/80 uppercase tracking-widest">SUMBER: {article.source}</span>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-black text-white rounded-xl text-xs font-black shadow-[3px_3px_0px_0px_rgba(147,51,234,0.5)] hover:shadow-none transition-all group-hover:bg-white group-hover:text-purple-600 group-hover:shadow-none"
>
BACA LENGKAP
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
))}
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,83 @@
'use client'
import { MapPin, Phone, Eye, Building2, Star } from 'lucide-react'
import Link from 'next/link'
interface Posyandu {
id: string
nama_posyandu: string
alamat: string
kontak: string | null
latitude: number | null
longitude: number | null
link_google_maps: string | null
petugas?: {
nama_petugas: string
nomor_hp: string | null
jabatan: string | null
}[]
reviews?: {
rating: number
ulasan: string
nama_pengulas: string
}[]
}
interface Props {
data: Posyandu
}
export default function PosyanduCard({ data }: Props) {
return (
<div className="group bg-white rounded-3xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all overflow-hidden flex flex-col h-full">
<div className="p-6 bg-purple-600 border-b-2 border-black flex justify-between items-center">
<div className="p-2 bg-white rounded-xl border-2 border-black">
<Building2 className="w-6 h-6 text-purple-600" />
</div>
{data.reviews && data.reviews.length > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1 bg-white border-2 border-black rounded-full shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
<Star className="w-3.5 h-3.5 text-yellow-400 fill-current" />
<span className="text-xs font-black">
{(data.reviews.reduce((acc, r) => acc + r.rating, 0) / data.reviews.length).toFixed(1)}
</span>
<span className="text-[10px] text-gray-400 font-bold">({data.reviews.length})</span>
</div>
)}
</div>
<div className="p-6 flex-1 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 className="text-xl font-black text-gray-900 group-hover:text-purple-600 transition-colors uppercase">
{data.nama_posyandu}
</h3>
<div className="flex items-center gap-1.5 text-gray-500 text-xs font-semibold">
<MapPin className="w-3 h-3 flex-shrink-0" />
<span className="line-clamp-1">{data.alamat}</span>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-auto">
<div className="px-3 py-1 bg-gray-50 border border-gray-100 rounded-lg text-[10px] font-bold text-gray-500 flex items-center gap-1.5">
<span className="text-purple-600">{data.petugas?.length || 0}</span> Petugas
</div>
{data.kontak && (
<div className="px-3 py-1 bg-gray-50 border border-gray-100 rounded-lg text-[10px] font-bold text-gray-500 flex items-center gap-1.5">
<Phone className="w-3 h-3 text-emerald-500" />
{data.kontak}
</div>
)}
</div>
</div>
<div className="p-6 pt-0">
<Link
href={`/user-dashboard/lokasi-posyandu/${data.id}`}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-black text-white font-black rounded-2xl hover:bg-gray-900 transition-all shadow-[4px_4px_0px_0px_rgba(147,51,234,0.5)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px]"
>
<Eye className="w-4 h-4" />
LIHAT DETAIL & REVIEW
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,70 @@
'use client'
import { useState, useMemo } from 'react'
import { Search, Building2 } from 'lucide-react'
import PosyanduCard from './PosyanduCard'
interface Posyandu {
id: string
nama_posyandu: string
alamat: string
kontak: string | null
latitude: number | null
longitude: number | null
link_google_maps: string | null
petugas?: {
nama_petugas: string
nomor_hp: string | null
jabatan: string | null
}[]
reviews?: {
rating: number
ulasan: string
nama_pengulas: string
}[]
}
interface Props {
initialData: Posyandu[]
}
export default function PosyanduList({ initialData }: Props) {
const [searchTerm, setSearchTerm] = useState('')
const filteredData = useMemo(() => {
return initialData.filter(p =>
p.nama_posyandu.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.alamat.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [initialData, searchTerm])
return (
<div className="flex flex-col gap-6">
{/* Search Bar */}
<div className="relative w-full md:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Cari nama posyandu atau alamat..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-semibold outline-none transition-all"
/>
</div>
{/* Grid of Cards */}
{filteredData.length === 0 ? (
<div className="py-20 text-center flex flex-col items-center gap-2 text-gray-400">
<Building2 className="w-12 h-12 opacity-20" />
<p className="font-bold">Tidak ada posyandu ditemukan</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredData.map((p) => (
<PosyanduCard key={p.id} data={p} />
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,270 @@
'use client'
import { useState, useEffect } from 'react'
import { MapPin, Phone, User, ExternalLink, Map as MapIcon, Star, Send, Loader2, Building2 } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { submitReview } from '../action-review'
import { showSwal } from '@/lib/swal'
interface Posyandu {
id: string
nama_posyandu: string
alamat: string
kontak: string | null
latitude: number | null
longitude: number | null
link_google_maps: string | null
petugas?: {
nama_petugas: string
nomor_hp: string | null
jabatan: string | null
}[]
}
interface Review {
id: string
rating: number
ulasan: string
nama_pengulas: string
created_at: string
}
interface Props {
data: Posyandu
userId: string
}
export default function PosyanduDetailClient({ data, userId }: Props) {
const [reviews, setReviews] = useState<Review[]>([])
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
const [rating, setRating] = useState(5)
const [comment, setComment] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
fetchReviews()
}, [data.id])
async function fetchReviews() {
setIsLoadingReviews(true)
const { data: reviewData, error } = await supabase
.from('ulasan_posyandu')
.select('*')
.eq('posyandu_id', data.id)
.order('created_at', { ascending: false })
if (!error && reviewData) {
setReviews(reviewData as any)
}
setIsLoadingReviews(false)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!comment.trim()) {
showSwal.error('Ops!', 'Komentar tidak boleh kosong.')
return
}
setIsSubmitting(true)
const result = await submitReview({
posyandu_id: data.id,
nama_pengulas: 'Orang Tua', // Or fetch the actual name if available in session
rating,
comment: comment
})
if (result.success) {
showSwal.success('Berhasil!', 'Review Anda telah terkirim.')
setComment('')
setRating(5)
fetchReviews()
} else {
showSwal.error('Gagal!', result.error || 'Terjadi kesalahan saat mengirim ulasan.')
}
setIsSubmitting(false)
}
const mapSrc = `https://maps.google.com/maps?q=${encodeURIComponent(data.alamat)}&z=15&output=embed`
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Information */}
<div className="lg:col-span-2 flex flex-col gap-8">
{/* Basic Info Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
<div className="p-8 bg-purple-600 border-b-2 border-black">
<h3 className="text-2xl font-black text-white flex items-center gap-3 uppercase">
<Building2 className="w-7 h-7" />
Informasi Utama
</h3>
</div>
<div className="p-8 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
<MapPin className="w-3 h-3" />
Alamat Posyandu
</span>
<p className="text-lg font-bold leading-relaxed">{data.alamat}</p>
</div>
{data.kontak && (
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
<Phone className="w-3 h-3" />
Kontak Layanan
</span>
<p className="text-lg font-bold">{data.kontak}</p>
</div>
)}
</div>
</div>
{/* Petugas Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(147,51,234,0.3)] overflow-hidden">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-base font-black flex items-center gap-3">
<User className="w-5 h-5 text-purple-600" />
Petugas Bertugas
</h3>
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
{data.petugas?.length || 0} Orang
</span>
</div>
<div className="divide-y divide-gray-100">
{data.petugas && data.petugas.length > 0 ? (
data.petugas.map((pt: any, i) => (
<div key={i} className="p-6 flex items-center justify-between hover:bg-gray-50/50 transition-all">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center font-black text-xl">
{pt.nama_petugas?.[0]}
</div>
<div>
<p className="font-bold text-gray-900">{pt.nama_petugas}</p>
<p className="text-xs text-gray-500 font-semibold uppercase tracking-wider">{pt.jabatan || 'Anggota'}</p>
</div>
</div>
{pt.nomor_hp && (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold">
<Phone className="w-3 h-3" />
{pt.nomor_hp}
</div>
)}
</div>
))
) : (
<div className="p-12 text-center text-gray-400 italic text-sm font-semibold">
Belum ada data petugas terdaftar di posyandu ini.
</div>
)}
</div>
</div>
{/* Reviews List */}
<div className="flex flex-col gap-6 mt-4">
<h3 className="font-black text-xl flex items-center justify-between">
<span className="flex items-center gap-2">
<Star className="w-6 h-6 text-yellow-500" />
Ulasan Pengguna
</span>
<span className="text-xs font-bold bg-gray-100 px-3 py-1 rounded-full text-gray-500">{reviews.length} REVIEW</span>
</h3>
<div className="grid grid-cols-1 gap-4">
{isLoadingReviews ? (
<div className="flex flex-col items-center py-12 gap-2 text-gray-300">
<Loader2 className="w-10 h-10 animate-spin" />
<p className="text-sm font-bold">Memuat ulasan masyarakat...</p>
</div>
) : reviews.length > 0 ? (
reviews.map((rev) => (
<div key={rev.id} className="p-6 bg-white border-2 border-black rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,0.05)] flex flex-col gap-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-[10px] font-black">
{rev.nama_pengulas?.[0] || 'A'}
</div>
<p className="text-sm font-black text-gray-900">{rev.nama_pengulas || 'Orang Tua'}</p>
</div>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-3 h-3 ${i < rev.rating ? 'text-yellow-400 fill-current' : 'text-gray-200'}`} />
))}
</div>
</div>
<p className="text-gray-600 font-semibold italic">"{rev.ulasan}"</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest">{new Date(rev.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}</p>
</div>
))
) : (
<div className="py-20 text-center text-gray-300 flex flex-col items-center gap-4 border-4 border-dashed border-gray-100 rounded-[32px]">
<Star className="w-12 h-12 opacity-10" />
<p className="font-bold italic">Belum ada ulasan untuk lokasi ini.</p>
</div>
)}
</div>
</div>
</div>
{/* Right Column - Map & Review Form */}
<div className="flex flex-col gap-8">
{/* Map Card */}
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(59,130,246,0.2)] overflow-hidden">
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-blue-50/30">
<h3 className="text-base font-black flex items-center gap-3">
<MapIcon className="w-5 h-5 text-blue-600" />
Lokasi Maps
</h3>
{data.link_google_maps && (
<a href={data.link_google_maps} target="_blank" rel="noopener noreferrer" className="p-2 bg-white text-blue-600 border border-blue-200 rounded-lg hover:shadow-md transition-all">
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
<div className="aspect-square w-full bg-gray-100 relative">
<iframe width="100%" height="100%" style={{ border: 0 }} loading="lazy" allowFullScreen src={mapSrc}></iframe>
</div>
</div>
{/* Review Form Card */}
<div className="bg-black text-white rounded-[32px] p-8 flex flex-col gap-6 shadow-[12px_12px_0px_0px_rgba(147,51,234,0.4)] relative overflow-hidden">
<div className="z-10">
<h3 className="text-xl font-black text-purple-400 uppercase tracking-tight mb-2">Berikan Ulasan</h3>
<p className="text-xs text-gray-400 font-semibold mb-6">Ceritakan pengalaman Bapak/Ibu mengenai pelayanan di posyandu ini.</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex gap-3">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className={`transition-all duration-200 ${rating >= star ? 'text-yellow-400 scale-125 drop-shadow-[0_0_8px_rgba(250,204,21,0.5)]' : 'text-gray-700 hover:text-gray-500'}`}
>
<Star className="w-8 h-8 fill-current" />
</button>
))}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Tuliskan ulasan anda disini..."
className="w-full bg-[#111] border-2 border-gray-800 rounded-2xl p-4 text-sm font-semibold outline-none focus:border-purple-500 transition-all min-h-[120px] resize-none"
></textarea>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-black py-4 rounded-2xl flex items-center justify-center gap-3 transition-all active:scale-95 disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
KIRIM ULASAN SEKARANG
</button>
</form>
</div>
{/* Background decoration */}
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-purple-600/10 rounded-full blur-3xl"></div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Building2, MapPin, Phone, User, ExternalLink, Map as MapIcon } from 'lucide-react'
import Link from 'next/link'
import PosyanduDetailClient from "./PosyanduDetailClient"
interface Props {
params: Promise<{ id: string }>
}
export default async function PosyanduDetailPage({ params }: Props) {
const { id } = await params
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'user') redirect('/dashboard')
const { data: posyandu, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*)
`)
.eq('id', id)
.single()
if (error || !posyandu) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
<p className="font-black text-xl mb-2">Data Tidak Ditemukan</p>
<Link href="/user-dashboard/lokasi-posyandu" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Daftar Posyandu</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/user-dashboard/lokasi-posyandu" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none text-purple-600">{posyandu.nama_posyandu}</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DETAIL LOKASI & ULASAN</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto w-full">
<PosyanduDetailClient data={posyandu} userId={session.id} />
</main>
</div>
)
}

View File

@ -0,0 +1,52 @@
'use server'
import { supabase } from '@/lib/supabase'
import { revalidatePath } from 'next/cache'
export async function getPosyanduWithReviews() {
const { data: posyandu, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*),
reviews:ulasan_posyandu(
*,
nama_pengulas
)
`)
.order('nama_posyandu', { ascending: true })
if (error) {
console.error('Error fetching posyandu:', error)
return []
}
return posyandu
}
export async function submitReview(formData: {
posyandu_id: string
nama_pengulas: string
rating: number
comment: string
}) {
const { error } = await supabase
.from('ulasan_posyandu')
.insert([
{
posyandu_id: formData.posyandu_id,
rating: formData.rating,
ulasan: formData.comment,
nama_pengulas: formData.nama_pengulas, // Add this field to the formData or handle it separately
created_at: new Date().toISOString()
}
])
if (error) {
console.error('Error submitting review:', error)
return { success: false, error: error.message }
}
revalidatePath('/user-dashboard/lokasi-posyandu')
return { success: true }
}

View File

@ -0,0 +1,96 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { supabase } from '@/lib/supabase'
import { LogoutButton } from '@/components/logout-button'
import { ArrowLeft, Building2, Search } from 'lucide-react'
import Link from 'next/link'
import PosyanduList from "./PosyanduList"
export async function getPosyanduWithReviews() {
const { data: posyandu, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*),
reviews:ulasan_posyandu(
rating,
ulasan,
nama_pengulas,
created_at
)
`)
.order('nama_posyandu', { ascending: true })
if (error) {
console.error('Error fetching posyandu:', error)
return []
}
return posyandu
}
export default async function LokasiPosyanduPage() {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('user_session')
if (!sessionCookie) redirect('/')
const session = JSON.parse(sessionCookie.value)
if (session.role !== 'user') redirect('/dashboard')
// Fetch all posyandu data using server action
const posyanduData = await getPosyanduWithReviews()
if (!posyanduData) {
return (
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
<p className="font-black text-xl mb-2">Gagal Memuat Data</p>
<p className="text-sm opacity-80">Terjadi kesalahan pada server.</p>
<Link href="/user-dashboard" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Dashboard</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
{/* Header */}
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
<div className="flex items-center gap-4">
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
<ArrowLeft className="h-5 w-5" />
</div>
<span className="hidden md:block">Kembali</span>
</Link>
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
<div className="flex flex-col justify-center">
<h1 className="text-xl font-bold leading-none text-purple-600">Lokasi Posyandu</h1>
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">LAYANAN KESEHATAN</p>
</div>
</div>
<LogoutButton />
</header>
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-8">
{/* Hero Section */}
<div className="flex flex-col md:flex-row items-center gap-8 bg-purple-50 p-8 rounded-3xl border-2 border-purple-100 relative overflow-hidden">
<div className="flex-1 z-10">
<h2 className="text-3xl font-black mb-2 text-purple-900">Temukan Layanan Terdekat</h2>
<p className="text-purple-600/80 max-w-lg leading-relaxed font-semibold">
Cari lokasi posyandu di wilayah Anda, cek petugas yang bertugas, dan berikan ulasan untuk meningkatkan kualitas layanan.
</p>
</div>
<div className="w-20 h-20 bg-purple-600 rounded-2xl flex items-center justify-center text-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rotate-3 z-10">
<Building2 className="w-10 h-10" />
</div>
<div className="absolute -right-10 -bottom-10 w-48 h-48 bg-purple-200/50 rounded-full blur-3xl"></div>
</div>
{/* Client Component for Search and Cards */}
<PosyanduList initialData={(posyanduData as any) ?? []} />
</main>
</div>
)
}

View File

@ -0,0 +1,40 @@
-- Query Supabase untuk Tabel Ulasan (Reviews) Posyandu
-- 0. Aktifkan Ekstensi UUID (Jika belum aktif)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 1. Buat Tabel posyandu_reviews
CREATE TABLE IF NOT EXISTS posyandu_reviews (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
posyandu_id UUID REFERENCES detail_posyandu(id) ON DELETE CASCADE,
user_id UUID REFERENCES akun_balita(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 2. Aktifkan Row Level Security (RLS)
ALTER TABLE posyandu_reviews ENABLE ROW LEVEL SECURITY;
-- 3. Kebijakan RLS (Policies)
-- Kebijakan: Semua orang dapat melihat ulasan
CREATE POLICY "Anyone can view reviews"
ON posyandu_reviews FOR SELECT
USING (true);
-- Kebijakan: User terautentikasi dapat mengirim ulasan (untuk akun_balita)
CREATE POLICY "Authenticated users can insert reviews"
ON posyandu_reviews FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Kebijakan: User dapat menghapus ulasan mereka sendiri
CREATE POLICY "Users can delete their own reviews"
ON posyandu_reviews FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- 4. Indeks untuk Performa
CREATE INDEX IF NOT EXISTS idx_posyandu_reviews_posyandu_id ON posyandu_reviews(posyandu_id);
CREATE INDEX IF NOT EXISTS idx_posyandu_reviews_user_id ON posyandu_reviews(user_id);

View File

@ -201,7 +201,7 @@ export default async function UserDashboardPage() {
title="Lokasi Posyandu" title="Lokasi Posyandu"
description="Temukan lokasi posyandu terdekat dan jadwal kegiatan." description="Temukan lokasi posyandu terdekat dan jadwal kegiatan."
icon={MapPin} icon={MapPin}
href="#" href="/user-dashboard/lokasi-posyandu"
color="orange" color="orange"
/> />
@ -210,7 +210,7 @@ export default async function UserDashboardPage() {
title="Artikel Stunting" title="Artikel Stunting"
description="Baca artikel dan informasi edukasi pencegahan stunting." description="Baca artikel dan informasi edukasi pencegahan stunting."
icon={ClipboardList} icon={ClipboardList}
href="#" href="/user-dashboard/artikel"
color="purple" color="purple"
/> />
</div> </div>

BIN
lint_full.txt Normal file

Binary file not shown.

BIN
lint_output.txt Normal file

Binary file not shown.

35
tmp/debug_query.ts Normal file
View File

@ -0,0 +1,35 @@
import { createClient } from '@supabase/supabase-js'
import dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
const supabase = createClient(supabaseUrl, supabaseAnonKey)
async function debugQuery() {
console.log('Testing query...')
const { data, error } = await supabase
.from('detail_posyandu')
.select(`
*,
petugas:petugas_posyandu_lokal(*),
reviews:posyandu_reviews(
rating,
comment,
created_at,
user:akun_balita(nama_orang_tua)
)
`)
.limit(1)
if (error) {
console.error('Full Error Object:', error)
console.error('Error JSON:', JSON.stringify(error, null, 2))
} else {
console.log('Data fetched successfully:', JSON.stringify(data, null, 2))
}
}
debugQuery()