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:
2026-11-05 01:35:10 +03:00
parent 1cf9fd9d8c
commit 3b94bceaf0
9 changed files with 1282 additions and 0 deletions
@@ -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>
);
}