-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add admin pages for announcements and user management #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,11 @@ | ||
| import React, { useState, useEffect } from 'react'; | ||
| import React, { useState } from 'react'; | ||
| import { Plus, Edit2, Trash2, X, Save, Loader2, Megaphone } from 'lucide-react'; | ||
| import { motion, AnimatePresence } from 'framer-motion'; | ||
| import { supabase } from '../../lib/supabase'; | ||
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | ||
|
|
||
| export const Announcements = () => { | ||
| const [announcements, setAnnouncements] = useState<any[]>([]); | ||
| const [loading, setLoading] = useState(true); | ||
| const queryClient = useQueryClient(); | ||
| const [isEditing, setIsEditing] = useState(false); | ||
| const [formData, setFormData] = useState<any>({ | ||
| title: '', | ||
|
|
@@ -16,71 +16,81 @@ export const Announcements = () => { | |
| category: 'General', | ||
| status: 'Baru', | ||
| color: 'from-purple-500 to-blue-500', | ||
| highlights: '', // comma separated for input | ||
| highlights: '', | ||
| is_active: true | ||
| }); | ||
|
|
||
| const fetchAnnouncements = async () => { | ||
| setLoading(true); | ||
| try { | ||
| // Query for Announcements | ||
| const { data: announcements = [], isLoading } = useQuery({ | ||
| queryKey: ['announcements'], | ||
| queryFn: async () => { | ||
| const { data, error } = await supabase | ||
| .from('announcements') | ||
| .select('id, title, subtitle, description, date, type, status, color, is_active') | ||
| .select('id, title, subtitle, description, date, type, status, color, is_active, content, category, highlights') | ||
| .order('date', { ascending: false }); | ||
|
|
||
| if (error) throw error; | ||
| setAnnouncements(data || []); | ||
| } catch (error) { | ||
| console.error('Error fetching announcements:', error); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| fetchAnnouncements(); | ||
| }, []); | ||
| return data; | ||
| }, | ||
| }); | ||
|
|
||
| const handleSubmit = async (e: React.FormEvent) => { | ||
| e.preventDefault(); | ||
| try { | ||
| const payload = { | ||
| ...formData, | ||
| highlights: formData.highlights.split(',').map((h: string) => h.trim()).filter(Boolean), | ||
| date: new Date().toISOString() | ||
| }; | ||
|
|
||
| if (formData.id) { | ||
| // Mutation for Save/Update | ||
| const saveMutation = useMutation({ | ||
| mutationFn: async (payload: any) => { | ||
| if (payload.id) { | ||
| const { error } = await supabase | ||
| .from('announcements') | ||
| .update(payload) | ||
| .eq('id', formData.id); | ||
| .eq('id', payload.id); | ||
| if (error) throw error; | ||
| } else { | ||
| const { error } = await supabase | ||
| .from('announcements') | ||
| .insert([payload]); | ||
| if (error) throw error; | ||
| } | ||
|
|
||
| }, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['announcements'] }); | ||
| setIsEditing(false); | ||
| fetchAnnouncements(); | ||
| resetForm(); | ||
| } catch (error) { | ||
| }, | ||
| onError: (error) => { | ||
| console.error('Error saving announcement:', error); | ||
| alert('Failed to save announcement.'); | ||
| alert('Gagal menyimpan pengumuman.'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Penggunaan |
||
| } | ||
| }; | ||
| }); | ||
|
|
||
| const handleDelete = async (id: string) => { | ||
| if (!window.confirm('Delete this announcement?')) return; | ||
| try { | ||
| // Mutation for Delete | ||
| const deleteMutation = useMutation({ | ||
| mutationFn: async (id: string) => { | ||
| const { error } = await supabase.from('announcements').delete().eq('id', id); | ||
| if (error) throw error; | ||
| setAnnouncements(announcements.filter(a => a.id !== id)); | ||
| } catch (error) { | ||
| }, | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['announcements'] }); | ||
| }, | ||
| onError: (error) => { | ||
| console.error('Error deleting:', error); | ||
| alert('Gagal menghapus pengumuman.'); | ||
| } | ||
| }); | ||
|
|
||
| const handleSubmit = (e: React.FormEvent) => { | ||
| e.preventDefault(); | ||
| const payload = { | ||
| ...formData, | ||
| highlights: typeof formData.highlights === 'string' | ||
| ? formData.highlights.split(',').map((h: string) => h.trim()).filter(Boolean) | ||
| : formData.highlights, | ||
| date: formData.id ? formData.date : new Date().toISOString() | ||
| }; | ||
| saveMutation.mutate(payload); | ||
| }; | ||
|
|
||
| const handleDelete = (id: string) => { | ||
| if (!window.confirm('Hapus pengumuman ini?')) return; | ||
| deleteMutation.mutate(id); | ||
| }; | ||
|
|
||
| const handleEdit = (item: any) => { | ||
|
|
@@ -116,7 +126,7 @@ export const Announcements = () => { | |
|
|
||
| {/* List - Zen Refinement */} | ||
| <div className="grid gap-4"> | ||
| {loading ? ( | ||
| {isLoading ? ( | ||
| <div className="flex flex-col items-center justify-center py-20"> | ||
| <Loader2 className="animate-spin text-rose-500 mb-4" size={32} /> | ||
| <p className="text-gray-500 font-black uppercase tracking-widest text-[10px]">Menarik data...</p> | ||
|
|
@@ -127,7 +137,7 @@ export const Announcements = () => { | |
| </div> | ||
| ) : ( | ||
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | ||
| {announcements.map((item, index) => ( | ||
| {announcements.map((item: any, index: number) => ( | ||
| <motion.div | ||
| key={item.id} | ||
| initial={{ opacity: 0, y: 10 }} | ||
|
|
@@ -159,8 +169,12 @@ export const Announcements = () => { | |
| <button onClick={() => handleEdit(item)} className="w-10 h-10 flex items-center justify-center bg-white/5 hover:bg-white/10 rounded-xl text-white transition-all"> | ||
| <Edit2 size={16} /> | ||
| </button> | ||
| <button onClick={() => handleDelete(item.id)} className="w-10 h-10 flex items-center justify-center bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white rounded-xl transition-all"> | ||
| <Trash2 size={16} /> | ||
| <button | ||
| onClick={() => handleDelete(item.id)} | ||
| disabled={deleteMutation.isPending} | ||
| className="w-10 h-10 flex items-center justify-center bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white rounded-xl transition-all" | ||
| > | ||
| {deleteMutation.isPending && deleteMutation.variables === item.id ? <Loader2 className="animate-spin" size={16} /> : <Trash2 size={16} />} | ||
| </button> | ||
| </div> | ||
| <div className={`w-2 h-2 rounded-full ${item.is_active ? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]' : 'bg-gray-800'}`} /> | ||
|
|
@@ -178,13 +192,13 @@ export const Announcements = () => { | |
| initial={{ opacity: 0 }} | ||
| animate={{ opacity: 1 }} | ||
| exit={{ opacity: 0 }} | ||
| className="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100] flex items-center justify-center p-6" | ||
| className="fixed inset-0 bg-black/90 backdrop-blur-xl z-[100] flex items-center justify-center p-6 overflow-y-auto" | ||
| > | ||
| <motion.div | ||
| initial={{ scale: 0.95, y: 20 }} | ||
| animate={{ scale: 1, y: 0 }} | ||
| exit={{ scale: 0.95, y: 20 }} | ||
| className="bg-[#080808] border border-white/[0.08] w-full max-w-3xl rounded-[3rem] shadow-2xl overflow-hidden pointer-events-auto" | ||
| className="bg-[#080808] border border-white/[0.08] w-full max-w-3xl rounded-[3rem] shadow-2xl overflow-hidden pointer-events-auto my-8" | ||
| > | ||
| <div className="p-10 border-b border-white/[0.05] flex justify-between items-center"> | ||
| <div> | ||
|
|
@@ -238,8 +252,13 @@ export const Announcements = () => { | |
| </form> | ||
| <div className="p-10 border-t border-white/[0.05] flex justify-end gap-4 bg-white/[0.01]"> | ||
| <button type="button" onClick={() => setIsEditing(false)} className="px-8 py-3 rounded-2xl font-black uppercase tracking-widest text-[10px] text-gray-500 hover:text-white transition-all">Batal</button> | ||
| <button onClick={handleSubmit} className="px-10 py-3 bg-rose-500 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] flex items-center gap-2 hover:bg-rose-600 transition-all shadow-xl shadow-rose-900/20 active:scale-95"> | ||
| <Save size={14} /> Simpan Perubahan | ||
| <button | ||
| onClick={handleSubmit} | ||
| disabled={saveMutation.isPending} | ||
| className="px-10 py-3 bg-rose-500 text-white rounded-2xl font-black uppercase tracking-widest text-[10px] flex items-center gap-2 hover:bg-rose-600 transition-all shadow-xl shadow-rose-900/20 active:scale-95" | ||
| > | ||
| {saveMutation.isPending ? <Loader2 className="animate-spin" size={14} /> : <Save size={14} />} | ||
| {saveMutation.isPending ? 'Menyimpan...' : 'Simpan Perubahan'} | ||
| </button> | ||
| </div> | ||
| </motion.div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||
| import React, { useState, useEffect } from 'react'; | ||||||
| import { motion, AnimatePresence } from 'framer-motion'; | ||||||
| import { Search, Filter, MoreVertical, Check, X, Shield, User, Loader2 } from 'lucide-react'; | ||||||
| import { Search, Filter, MoreVertical, Check, X, Shield, User, Loader2, Trash2 } from 'lucide-react'; | ||||||
| import { supabase } from '../../lib/supabase'; | ||||||
| import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; | ||||||
| import { Pagination } from '../../components/Pagination'; | ||||||
|
|
@@ -74,7 +74,31 @@ export const Users = () => { | |||||
| } | ||||||
| }); | ||||||
|
|
||||||
| const handleAction = (userId: string, action: 'approve' | 'reject' | 'make_admin' | 'remove_admin') => { | ||||||
| const deleteMutation = useMutation({ | ||||||
| mutationFn: async (userId: string) => { | ||||||
| const { error } = await supabase | ||||||
| .from('profiles') | ||||||
| .delete() | ||||||
| .eq('id', userId); | ||||||
| if (error) throw error; | ||||||
| }, | ||||||
| onSuccess: () => { | ||||||
| queryClient.invalidateQueries({ queryKey: ['users'] }); | ||||||
| alert('Pengguna berhasil dihapus.'); | ||||||
| }, | ||||||
| onError: (error) => { | ||||||
| console.error('Delete failed:', error); | ||||||
| alert('Gagal menghapus pengguna.'); | ||||||
| } | ||||||
| }); | ||||||
|
Comment on lines
+77
to
+93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Operasi penghapusan ini hanya menargetkan tabel Untuk memastikan integritas data, proses penghapusan harus mencakup data di |
||||||
|
|
||||||
| const handleAction = (userId: string, action: 'approve' | 'reject' | 'make_admin' | 'remove_admin' | 'delete') => { | ||||||
| if (action === 'delete') { | ||||||
| if (window.confirm('Apakah Anda yakin ingin menghapus pengguna ini secara permanen? Semua data profil akan hilang.')) { | ||||||
| deleteMutation.mutate(userId); | ||||||
| } | ||||||
| return; | ||||||
| } | ||||||
| let updates = {}; | ||||||
| if (action === 'approve') updates = { is_approved: true }; | ||||||
| if (action === 'reject') updates = { is_approved: false }; | ||||||
|
|
@@ -217,6 +241,14 @@ export const Users = () => { | |||||
| > | ||||||
| {user.role === 'admin' ? <User size={18} /> : <Shield size={18} />} | ||||||
| </button> | ||||||
| <button | ||||||
| onClick={() => handleAction(user.id, 'delete')} | ||||||
| className="w-10 h-10 bg-rose-500/10 text-rose-500 rounded-xl hover:bg-rose-600 hover:text-white transition-all flex items-center justify-center shadow-lg shadow-rose-900/10" | ||||||
| title="Hapus Pengguna" | ||||||
| disabled={deleteMutation.isPending} | ||||||
| > | ||||||
| <Trash2 size={18} /> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tombol hapus ini sudah dinonaktifkan saat proses penghapusan berjalan, namun tidak menampilkan indikator loading. Untuk konsistensi UI dengan bagian lain di aplikasi (seperti di halaman Pengumuman) dan untuk memberikan umpan balik visual yang lebih baik kepada pengguna, sebaiknya tambahkan ikon Pengecekan terhadap
Suggested change
|
||||||
| </button> | ||||||
| </div> | ||||||
| </td> | ||||||
| </tr> | ||||||
|
|
@@ -232,18 +264,20 @@ export const Users = () => { | |||||
| </table> | ||||||
| </div> | ||||||
|
|
||||||
| {users.length > 0 && ( | ||||||
| <div className="px-8 pb-8"> | ||||||
| <Pagination | ||||||
| page={page} | ||||||
| totalPages={totalPages} | ||||||
| hasMore={!isPlaceholderData && page < totalPages} | ||||||
| onPageChange={(newPage) => setPage(newPage)} | ||||||
| loading={isPlaceholderData} | ||||||
| /> | ||||||
| </div> | ||||||
| )} | ||||||
| </motion.div> | ||||||
| </div> | ||||||
| { | ||||||
| users.length > 0 && ( | ||||||
| <div className="px-8 pb-8"> | ||||||
| <Pagination | ||||||
| page={page} | ||||||
| totalPages={totalPages} | ||||||
| hasMore={!isPlaceholderData && page < totalPages} | ||||||
| onPageChange={(newPage) => setPage(newPage)} | ||||||
| loading={isPlaceholderData} | ||||||
| /> | ||||||
| </div> | ||||||
| ) | ||||||
| } | ||||||
| </motion.div > | ||||||
| </div > | ||||||
| ); | ||||||
| }; | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Untuk meningkatkan keamanan tipe (type safety), sebaiknya berikan tipe data yang eksplisit untuk hasil dari
useQuery. Anda bisa mendefinisikan sebuahinterfaceuntukAnnouncementdan menggunakannya sepertiuseQuery<Announcement[]>(...). Ini akan memberikan tipe yang benar untuk variabelannouncementsdan menghindari penggunaananydi bagian lain kode, seperti pada saat melakukanmap.Contoh
interface: