Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 67 additions & 48 deletions pages/Admin/Announcements.tsx
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: '',
Expand All @@ -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;
},
});
Comment on lines +24 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Untuk meningkatkan keamanan tipe (type safety), sebaiknya berikan tipe data yang eksplisit untuk hasil dari useQuery. Anda bisa mendefinisikan sebuah interface untuk Announcement dan menggunakannya seperti useQuery<Announcement[]>(...). Ini akan memberikan tipe yang benar untuk variabel announcements dan menghindari penggunaan any di bagian lain kode, seperti pada saat melakukan map.

Contoh interface:

interface Announcement {
  id: string;
  title: string;
  subtitle: string;
  description: string;
  date: string;
  type: string;
  status: string;
  color: string;
  is_active: boolean;
  content: string;
  category: string;
  highlights: string[];
}


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.');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Penggunaan alert() untuk menampilkan pesan error dapat mengganggu pengalaman pengguna karena sifatnya yang blocking. Sebaiknya, pertimbangkan untuk menggunakan komponen notifikasi yang tidak blocking, seperti toast atau snackbar, untuk memberikan umpan balik yang lebih baik kepada pengguna tanpa menghentikan interaksi mereka dengan aplikasi.

}
};
});

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) => {
Expand Down Expand Up @@ -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>
Expand All @@ -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 }}
Expand Down Expand Up @@ -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'}`} />
Expand All @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
64 changes: 49 additions & 15 deletions pages/Admin/Users.tsx
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';
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Operasi penghapusan ini hanya menargetkan tabel profiles. Hal ini sangat berisiko meninggalkan data pengguna di auth.users (menjadi orphaned user). Pengguna yang profilnya telah dihapus kemungkinan masih bisa login, yang dapat menyebabkan error pada aplikasi jika data profilnya tidak ditemukan setelah login.

Untuk memastikan integritas data, proses penghapusan harus mencakup data di auth.users juga. Pendekatan yang disarankan adalah membuat fungsi di database (melalui RPC di Supabase) yang menangani penghapusan dari kedua tabel (profiles dan auth.users) secara atomik.


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 };
Expand Down Expand Up @@ -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} />
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 Loader2 saat deleteMutation.isPending.

Pengecekan terhadap deleteMutation.variables juga akan memastikan ikon loading hanya muncul pada baris yang sedang diproses.

Suggested change
<Trash2 size={18} />
{deleteMutation.isPending && deleteMutation.variables === user.id ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}

</button>
</div>
</td>
</tr>
Expand All @@ -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 >
);
};