forked from baron/baron-sso
271 lines
9.2 KiB
TypeScript
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;
|