feat(admin): kullanıcı yönetimi tablosu ve diyalogları (mock)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { createContext, use, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
columnFilteringFeature,
|
||||
createColumnHelper,
|
||||
createFilteredRowModel,
|
||||
createPaginatedRowModel,
|
||||
createSortedRowModel,
|
||||
globalFilteringFeature,
|
||||
rowPaginationFeature,
|
||||
rowSortingFeature,
|
||||
tableFeatures,
|
||||
useTable,
|
||||
type SortFn,
|
||||
} from "@tanstack/react-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "@/shared/format";
|
||||
import type { SystemRole } from "@/shared/schemas/common";
|
||||
import type { UserCreate, UserUpdate } from "@/shared/schemas/user";
|
||||
import { compareTr, includesTr } from "@/shared/search";
|
||||
import {
|
||||
checkAdminAction,
|
||||
findUniqueConflicts,
|
||||
type AdminAction,
|
||||
type GuardViolation,
|
||||
} from "../guards";
|
||||
import type { AdminUser } from "../types";
|
||||
import { BanUserDialog, DeleteUserDialog, SetPasswordDialog } from "./user-action-dialogs";
|
||||
import { UserFormDialog } from "./user-form-dialog";
|
||||
|
||||
const t = tr.adminUsers;
|
||||
|
||||
// --- Tablo yapılandırması (modül seviyesinde sabit: v9 kararlı girdi ister) ---
|
||||
|
||||
const features = tableFeatures({
|
||||
rowSortingFeature,
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
columnFilteringFeature,
|
||||
globalFilteringFeature,
|
||||
filteredRowModel: createFilteredRowModel(),
|
||||
rowPaginationFeature,
|
||||
paginatedRowModel: createPaginatedRowModel(),
|
||||
});
|
||||
|
||||
const byTr: SortFn<typeof features, AdminUser> = (a, b, id) =>
|
||||
compareTr(String(a.getValue(id) ?? ""), String(b.getValue(id) ?? ""));
|
||||
|
||||
const helper = createColumnHelper<typeof features, AdminUser>();
|
||||
|
||||
/** Mobilde gizlenen sütunlar. */
|
||||
const COLUMN_CLASS: Partial<Record<string, string>> = {
|
||||
username: "hidden sm:table-cell",
|
||||
email: "hidden lg:table-cell",
|
||||
createdAt: "hidden md:table-cell",
|
||||
actions: "w-12 text-right",
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50] as const;
|
||||
|
||||
type DialogState =
|
||||
{ kind: "create" } | { kind: "edit" | "password" | "ban" | "delete"; user: AdminUser } | null;
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
sorted,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
sorted: false | "asc" | "desc";
|
||||
onToggle: ((event: unknown) => void) | undefined;
|
||||
}) {
|
||||
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-2.5"
|
||||
onClick={onToggle}
|
||||
aria-label={`${label} — sırala`}
|
||||
>
|
||||
{label}
|
||||
<Icon className={cn("size-3.5", !sorted && "opacity-40")} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type RowActionsApi = {
|
||||
currentUserId: string;
|
||||
guard: (action: AdminAction, target: AdminUser) => GuardViolation | null;
|
||||
guardMessage: (v: GuardViolation) => string;
|
||||
setDialog: (d: DialogState) => void;
|
||||
setRole: (user: AdminUser, role: SystemRole) => void;
|
||||
unbanUser: (user: AdminUser) => void;
|
||||
};
|
||||
|
||||
/** Satır aksiyonları güncel durumu context'ten okur; sütunlar modül seviyesinde sabit kalır. */
|
||||
const RowActionsContext = createContext<RowActionsApi | null>(null);
|
||||
|
||||
function useRowActions() {
|
||||
const ctx = use(RowActionsContext);
|
||||
if (!ctx) throw new Error("RowActionsContext eksik");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function NameCell({ user }: { user: AdminUser }) {
|
||||
const { currentUserId } = useRowActions();
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">
|
||||
{user.name}
|
||||
{user.id === currentUserId ? (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">({t.you})</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground sm:hidden">@{user.username}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions({ user }: { user: AdminUser }) {
|
||||
const { guard, guardMessage, setDialog, setRole, unbanUser } = useRowActions();
|
||||
const del = guard({ type: "delete" }, user);
|
||||
const ban = guard({ type: "ban" }, user);
|
||||
const toggleRole: SystemRole = user.role === "admin" ? "user" : "admin";
|
||||
const role = guard({ type: "setRole", role: toggleRole }, user);
|
||||
const blocked = del ?? role;
|
||||
return (
|
||||
// modal={false}: menüden açılan Dialog kapandıktan sonra sayfanın kilitli kalmasını önler.
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`${user.name} — ${tr.common.actions}`}>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{user.name}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onSelect={() => setDialog({ kind: "edit", user })}>
|
||||
<Pencil />
|
||||
{t.actions.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={Boolean(role)} onSelect={() => setRole(user, toggleRole)}>
|
||||
{toggleRole === "admin" ? <ShieldCheck /> : <ShieldOff />}
|
||||
{toggleRole === "admin" ? t.actions.makeAdmin : t.actions.makeUser}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setDialog({ kind: "password", user })}>
|
||||
<KeyRound />
|
||||
{t.actions.resetPassword}
|
||||
</DropdownMenuItem>
|
||||
{user.banned ? (
|
||||
<DropdownMenuItem onSelect={() => unbanUser(user)}>
|
||||
<UserCheck />
|
||||
{t.actions.unban}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled={Boolean(ban)}
|
||||
onSelect={() => setDialog({ kind: "ban", user })}
|
||||
>
|
||||
<Ban />
|
||||
{t.actions.ban}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={Boolean(del)}
|
||||
onSelect={() => setDialog({ kind: "delete", user })}
|
||||
>
|
||||
<Trash2 />
|
||||
{t.actions.delete}
|
||||
</DropdownMenuItem>
|
||||
{blocked ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">{guardMessage(blocked)}</p>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = helper.columns([
|
||||
helper.accessor("name", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.name}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
sortFn: byTr,
|
||||
cell: ({ row }) => <NameCell user={row.original} />,
|
||||
}),
|
||||
helper.accessor("username", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.username}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
sortFn: byTr,
|
||||
cell: ({ getValue }) => <span className="text-muted-foreground">@{getValue()}</span>,
|
||||
}),
|
||||
helper.accessor((u) => u.email ?? "", {
|
||||
id: "email",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.email}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
sortFn: byTr,
|
||||
cell: ({ getValue }) =>
|
||||
getValue() || <span className="text-muted-foreground">{t.noEmail}</span>,
|
||||
}),
|
||||
helper.accessor("role", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.role}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<Badge variant={getValue() === "admin" ? "default" : "secondary"}>
|
||||
{tr.roles[getValue()]}
|
||||
</Badge>
|
||||
),
|
||||
}),
|
||||
helper.accessor("banned", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.status}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.banned ? (
|
||||
<Badge variant="destructive" title={row.original.banReason}>
|
||||
{t.status.banned}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-income/30 text-income">
|
||||
{t.status.active}
|
||||
</Badge>
|
||||
),
|
||||
}),
|
||||
helper.accessor("createdAt", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
label={t.columns.createdAt}
|
||||
sorted={column.getIsSorted()}
|
||||
onToggle={column.getToggleSortingHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => <span className="tabular-nums">{formatDate(getValue())}</span>,
|
||||
}),
|
||||
helper.display({
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">{tr.common.actions}</span>,
|
||||
cell: ({ row }) => <RowActions user={row.original} />,
|
||||
}),
|
||||
]);
|
||||
|
||||
export function UsersTable({
|
||||
initialUsers,
|
||||
currentUserId,
|
||||
}: {
|
||||
initialUsers: AdminUser[];
|
||||
currentUserId: string;
|
||||
}) {
|
||||
// FAZ 3: yerel durum yerine Server Action + revalidatePath.
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [dialog, setDialog] = useState<DialogState>(null);
|
||||
const adminCount = users.filter((u) => u.role === "admin").length;
|
||||
|
||||
const guard = (action: AdminAction, target: AdminUser): GuardViolation | null =>
|
||||
checkAdminAction(action, { actorId: currentUserId, target, adminCount });
|
||||
|
||||
const guardMessage = (v: GuardViolation) => (v === "self" ? t.guards.self : t.guards.lastAdmin);
|
||||
|
||||
// --- Mock işlemler (FAZ 3'te Server Action'lara taşınacak; aynı ActionResult sözleşmesi) ---
|
||||
|
||||
async function createUser(values: UserCreate): Promise<ActionResult> {
|
||||
const conflicts = findUniqueConflicts(users, values);
|
||||
if (conflicts.username || conflicts.email) {
|
||||
return fail(t.errors.checkFields, {
|
||||
username: conflicts.username && t.errors.usernameTaken,
|
||||
email: conflicts.email && t.errors.emailTaken,
|
||||
});
|
||||
}
|
||||
const { password: _password, ...rest } = values;
|
||||
setUsers((prev) => [
|
||||
{
|
||||
...rest,
|
||||
id: `u_${crypto.randomUUID().slice(0, 8)}`,
|
||||
banned: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
toast.success(t.toast.created(values.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async function updateUser(id: string, values: UserUpdate): Promise<ActionResult> {
|
||||
const target = users.find((u) => u.id === id);
|
||||
if (!target) return fail(tr.common.genericError);
|
||||
const conflicts = findUniqueConflicts(users, values, id);
|
||||
if (conflicts.username || conflicts.email) {
|
||||
return fail(t.errors.checkFields, {
|
||||
username: conflicts.username && t.errors.usernameTaken,
|
||||
email: conflicts.email && t.errors.emailTaken,
|
||||
});
|
||||
}
|
||||
const v =
|
||||
values.role !== target.role ? guard({ type: "setRole", role: values.role }, target) : null;
|
||||
if (v) return fail(guardMessage(v), { role: guardMessage(v) });
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...values } : u)));
|
||||
toast.success(t.toast.updated(values.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function setRole(user: AdminUser, role: SystemRole) {
|
||||
const v = guard({ type: "setRole", role }, user);
|
||||
if (v) return void toast.error(guardMessage(v));
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role } : u)));
|
||||
toast.success(t.toast.roleChanged(user.name));
|
||||
}
|
||||
|
||||
async function setPassword(user: AdminUser): Promise<ActionResult> {
|
||||
toast.success(t.toast.passwordSet(user.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async function banUser(user: AdminUser, { reason }: { reason?: string }): Promise<ActionResult> {
|
||||
const v = guard({ type: "ban" }, user);
|
||||
if (v) return fail(guardMessage(v));
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === user.id ? { ...u, banned: true, banReason: reason } : u)),
|
||||
);
|
||||
toast.success(t.toast.banned(user.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function unbanUser(user: AdminUser) {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === user.id ? { ...u, banned: false, banReason: undefined } : u)),
|
||||
);
|
||||
toast.success(t.toast.unbanned(user.name));
|
||||
}
|
||||
|
||||
async function removeUser(user: AdminUser): Promise<ActionResult> {
|
||||
const v = guard({ type: "delete" }, user);
|
||||
if (v) return fail(guardMessage(v));
|
||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||
toast.success(t.toast.removed(user.name));
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
const table = useTable({
|
||||
features,
|
||||
columns,
|
||||
data: users,
|
||||
// Türkçe, büyük/küçük harf ve aksan duyarsız arama (ad, kullanıcı adı, e-posta).
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const q = String(filterValue ?? "");
|
||||
const u = row.original;
|
||||
return includesTr(u.name, q) || includesTr(u.username, q) || includesTr(u.email, q);
|
||||
},
|
||||
getColumnCanGlobalFilter: (column) => column.id !== "actions",
|
||||
initialState: {
|
||||
sorting: [{ id: "createdAt", desc: true }],
|
||||
pagination: { pageIndex: 0, pageSize: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
const globalFilter = String(table.state.globalFilter ?? "");
|
||||
const pageCount = Math.max(table.getPageCount(), 1);
|
||||
const filteredCount = table.getFilteredRowModel().rows.length;
|
||||
const selected = dialog && dialog.kind !== "create" ? dialog.user : undefined;
|
||||
const close = (open: boolean) => !open && setDialog(null);
|
||||
|
||||
const rowActions: RowActionsApi = {
|
||||
currentUserId,
|
||||
guard,
|
||||
guardMessage,
|
||||
setDialog,
|
||||
setRole,
|
||||
unbanUser,
|
||||
};
|
||||
|
||||
return (
|
||||
<RowActionsContext value={rowActions}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<InputGroup className="max-w-sm flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={globalFilter}
|
||||
onChange={(e) => table.setGlobalFilter(e.target.value)}
|
||||
placeholder={t.searchPlaceholder}
|
||||
aria-label={tr.common.search}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button className="ml-auto" onClick={() => setDialog({ kind: "create" })}>
|
||||
<Plus />
|
||||
{t.newUser}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
{group.headers.map((header) => (
|
||||
<TableHead key={header.id} className={COLUMN_CLASS[header.column.id]}>
|
||||
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className={cn(row.original.banned && "opacity-70")}>
|
||||
{row.getAllCells().map((cell) => (
|
||||
<TableCell key={cell.id} className={COLUMN_CLASS[cell.column.id]}>
|
||||
<table.FlexRender cell={cell} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{tr.common.noResults}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>{tr.common.totalRows(filteredCount)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden sm:inline">{tr.common.rowsPerPage}</span>
|
||||
<Select
|
||||
value={String(table.state.pagination.pageSize)}
|
||||
onValueChange={(v) => table.setPageSize(Number(v))}
|
||||
>
|
||||
<SelectTrigger size="sm" aria-label={tr.common.rowsPerPage} className="w-18">
|
||||
<SelectValue>{table.state.pagination.pageSize}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">
|
||||
{tr.common.page(table.state.pagination.pageIndex + 1, pageCount)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label={tr.common.previous}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label={tr.common.next}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
open={dialog?.kind === "create" || dialog?.kind === "edit"}
|
||||
onOpenChange={close}
|
||||
user={dialog?.kind === "edit" ? dialog.user : undefined}
|
||||
onCreate={createUser}
|
||||
onUpdate={updateUser}
|
||||
/>
|
||||
<SetPasswordDialog
|
||||
user={selected}
|
||||
open={dialog?.kind === "password"}
|
||||
onOpenChange={close}
|
||||
onConfirm={setPassword}
|
||||
/>
|
||||
<BanUserDialog
|
||||
user={selected}
|
||||
open={dialog?.kind === "ban"}
|
||||
onOpenChange={close}
|
||||
onConfirm={banUser}
|
||||
/>
|
||||
<DeleteUserDialog
|
||||
user={selected}
|
||||
open={dialog?.kind === "delete"}
|
||||
onOpenChange={close}
|
||||
onConfirm={removeUser}
|
||||
/>
|
||||
</div>
|
||||
</RowActionsContext>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user