Files
nextgo/app/admin/users/UsersClient.tsx
Beyhan Oğur 9eb7aea821 first commit
2026-04-26 22:15:25 +03:00

390 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useActionState, useCallback, useEffect, useState, useTransition } from 'react'
import Swal, { type SweetAlertIcon } from 'sweetalert2'
import {
createUser,
updateUser,
deleteUser,
changeUserStatus,
type User,
type UserFormState,
type UsersResponse,
} from './actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Loader2, Plus, Pencil, Trash2, AlertTriangle } from 'lucide-react'
type Props = { initialData: UsersResponse }
type ModalMode = 'create' | 'edit' | null
function getSwalThemeOptions() {
const isDark = document.documentElement.classList.contains('dark')
return isDark
? { background: '#111827', color: '#f3f4f6' }
: { background: '#ffffff', color: '#111827' }
}
function showToast(icon: SweetAlertIcon, title: string) {
void Swal.fire({
...getSwalThemeOptions(),
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 2200,
timerProgressBar: true,
icon,
title,
})
}
/* ─── Field ─────────────────────────────────────────── */
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
)
}
/* ─── UserModal ─────────────────────────────────────── */
function UserModal({
open,
mode,
user,
onSaved,
onClose,
}: {
open: boolean
mode: 'create' | 'edit'
user?: User
onSaved: (savedUser: User, mode: 'create' | 'edit') => void
onClose: () => void
}) {
const isEdit = mode === 'edit' && user != null
const boundAction = isEdit ? updateUser.bind(null, user.id) : createUser
const [state, formAction, pending] = useActionState<UserFormState, FormData>(boundAction, {})
useEffect(() => {
if (!state.success) return
if (state.user) onSaved(state.user, mode)
showToast('success', mode === 'edit' ? 'Kullanıcı güncellendi' : 'Kullanıcı oluşturuldu')
onClose()
}, [state.success, state.user, mode, onSaved, onClose])
useEffect(() => {
if (!state.error) return
showToast('error', state.error)
}, [state.error])
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Kullanıcıyı Düzenle' : 'Yeni Kullanıcı Ekle'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Kullanıcı bilgilerini güncelleyin.' : 'Yeni bir kullanıcı hesabı oluşturun.'}
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-4">
{state.error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{state.error}
</div>
)}
<Field id="username" label="Kullanıcı Adı">
<Input id="username" name="username" defaultValue={user?.username} required />
</Field>
<Field id="email" label="E-posta">
<Input id="email" name="email" type="email" defaultValue={user?.email} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field id="password" label="Şifre">
<Input
id="password"
name="password"
type="password"
required={!isEdit}
placeholder={isEdit ? '••••••' : ''}
/>
</Field>
<Field id="confirm_password" label="Şifre Tekrar">
<Input
id="confirm_password"
name="confirm_password"
type="password"
required={!isEdit}
placeholder={isEdit ? '••••••' : ''}
/>
</Field>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="is_active"
name="is_active"
value="true"
defaultChecked={user?.is_active ?? true}
/>
<Label htmlFor="is_active" className="cursor-pointer font-normal">Aktif</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="is_admin"
name="is_admin"
value="true"
defaultChecked={user?.is_admin ?? false}
/>
<Label htmlFor="is_admin" className="cursor-pointer font-normal">Admin</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>İptal</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="size-4 animate-spin" />}
{pending ? 'Kaydediliyor…' : 'Kaydet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/* ─── UsersClient ────────────────────────────────────── */
export default function UsersClient({ initialData }: Props) {
const [data, setData] = useState<UsersResponse>(initialData)
const [modal, setModal] = useState<ModalMode>(null)
const [selectedUser, setSelectedUser] = useState<User | undefined>()
const [, startTransition] = useTransition()
const openCreate = useCallback(() => {
setSelectedUser(undefined)
setModal('create')
}, [])
const openEdit = useCallback((user: User) => {
setSelectedUser(user)
setModal('edit')
}, [])
const closeModal = useCallback(() => {
setModal(null)
setSelectedUser(undefined)
}, [])
const handleUserSaved = useCallback((savedUser: User, mode: 'create' | 'edit') => {
setData((prev) => {
const existingIndex = prev.items.findIndex((u) => u.id === savedUser.id)
if (mode === 'edit' || existingIndex >= 0) {
return {
...prev,
items: prev.items.map((u) => (u.id === savedUser.id ? savedUser : u)),
}
}
return {
...prev,
items: [savedUser, ...prev.items],
meta: { ...prev.meta, total: prev.meta.total + 1 },
}
})
}, [])
const handleUserDeleted = useCallback((id: number) => {
setData((prev) => {
const nextItems = prev.items.filter((u) => u.id !== id)
const removed = nextItems.length !== prev.items.length
return {
...prev,
items: nextItems,
meta: {
...prev.meta,
total: removed ? Math.max(0, prev.meta.total - 1) : prev.meta.total,
},
}
})
}, [])
const handleDelete = useCallback(async (user: User) => {
const result = await Swal.fire({
...getSwalThemeOptions(),
icon: 'warning',
title: 'Kullanıcı silinsin mi?',
text: `${user.username} kalıcı olarak silinecek.`,
showCancelButton: true,
confirmButtonText: 'Evet, sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#dc2626',
reverseButtons: true,
focusCancel: true,
})
if (!result.isConfirmed) return
const formData = new FormData()
formData.set('id', String(user.id))
const deleted = await deleteUser({}, formData)
if (!deleted.success) {
showToast('error', deleted.error ?? 'Kullanıcı silinemedi')
return
}
handleUserDeleted(deleted.deletedId ?? user.id)
showToast('success', 'Kullanıcı silindi')
}, [handleUserDeleted])
async function handleStatusToggle(user: User) {
const nextStatus = !user.is_active
const statusLabel = nextStatus ? 'aktif' : 'pasif'
const decision = await Swal.fire({
...getSwalThemeOptions(),
icon: 'question',
title: 'Durum değiştirilsin mi?',
text: `${user.username} kullanıcısı ${statusLabel} yapılacak.`,
showCancelButton: true,
confirmButtonText: 'Evet',
cancelButtonText: 'İptal',
reverseButtons: true,
focusCancel: true,
})
if (!decision.isConfirmed) return
startTransition(async () => {
const result = await changeUserStatus(user.id, nextStatus)
if (!result.success || !result.user) {
showToast('error', result.error ?? 'Durum güncellenemedi')
return
}
setData((prev) => ({
...prev,
items: prev.items.map((u) => (u.id === result.user!.id ? result.user! : u)),
}))
showToast('success', `Kullanıcı ${statusLabel} yapıldı`)
})
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Kullanıcılar</h1>
<p className="text-sm text-muted-foreground">
Toplam <strong className="text-foreground">{data.meta.total}</strong> kullanıcı
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Yeni Kullanıcı
</Button>
</div>
{/* Table */}
<div className="rounded-xl border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>Kullanıcı Adı</TableHead>
<TableHead>E-posta</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Kayıt Tarihi</TableHead>
<TableHead className="text-right">İşlemler</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-xs text-muted-foreground">{user.id}</TableCell>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell>
<button onClick={() => void handleStatusToggle(user)} className="transition hover:opacity-75">
{user.is_active
? <Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400"> Aktif</Badge>
: <Badge variant="outline" className="border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400"> Pasif</Badge>
}
</button>
</TableCell>
<TableCell>
{user.is_admin
? <Badge variant="outline" className="border-violet-500/40 bg-violet-500/10 text-violet-600 dark:text-violet-400">Admin</Badge>
: <Badge variant="secondary">Kullanıcı</Badge>
}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(user.created_at).toLocaleDateString('tr-TR')}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(user)} aria-label="Düzenle">
<Pencil className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(user)} aria-label="Sil" className="text-destructive hover:text-destructive">
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{data.items.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
Henüz kullanıcı yok
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data.meta.total > data.meta.limit && (
<p className="mt-4 text-sm text-muted-foreground">
Sayfa {data.meta.page} / {Math.ceil(data.meta.total / data.meta.limit)}
</p>
)}
{/* Modals */}
<UserModal
open={modal === 'create' || modal === 'edit'}
mode={modal === 'edit' ? 'edit' : 'create'}
user={selectedUser}
onSaved={handleUserSaved}
onClose={closeModal}
/>
</div>
)
}