1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/UserListPage.tsx
2026-01-29 14:47:20 +09:00

271 lines
9.2 KiB
TypeScript

import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronLeft,
ChevronRight,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const limit = 50;
const offset = (page - 1) * limit;
const query = useQuery({
queryKey: ["users", { limit, offset, search }],
queryFn: () => fetchUsers(limit, offset, search),
placeholderData: (previousData) => previousData,
});
const deleteMutation = useMutation({
mutationFn: (userId: string) => deleteUser(userId),
onSuccess: () => {
query.refetch();
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
return;
}
deleteMutation.mutate(userId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardDescription>
{total} .
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
</Button>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>COMPANY / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span>{user.companyCode || "-"}</span>
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default UserListPage;