2025-10-27 16:40:59 +09:00
|
|
|
import { Key, History, Edit } from "lucide-react";
|
2025-08-21 09:41:46 +09:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { User } from "@/types/user";
|
|
|
|
|
import { USER_TABLE_COLUMNS } from "@/constants/user";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
|
import { PaginationInfo } from "@/components/common/Pagination";
|
|
|
|
|
import { UserStatusConfirmDialog } from "./UserStatusConfirmDialog";
|
|
|
|
|
import { UserHistoryModal } from "./UserHistoryModal";
|
|
|
|
|
|
|
|
|
|
interface UserTableProps {
|
|
|
|
|
users: User[];
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
paginationInfo: PaginationInfo;
|
|
|
|
|
onStatusToggle: (user: User, newStatus: string) => void;
|
|
|
|
|
onPasswordReset: (userId: string, userName: string) => void;
|
2025-10-27 16:40:59 +09:00
|
|
|
onEdit: (user: User) => void;
|
2025-08-21 09:41:46 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 사용자 목록 테이블 컴포넌트
|
|
|
|
|
*/
|
2025-10-27 16:40:59 +09:00
|
|
|
export function UserTable({
|
|
|
|
|
users,
|
|
|
|
|
isLoading,
|
|
|
|
|
paginationInfo,
|
|
|
|
|
onStatusToggle,
|
|
|
|
|
onPasswordReset,
|
|
|
|
|
onEdit,
|
|
|
|
|
}: UserTableProps) {
|
2025-08-21 09:41:46 +09:00
|
|
|
// 확인 모달 상태 관리
|
|
|
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
user: User | null;
|
|
|
|
|
newStatus: string;
|
|
|
|
|
}>({
|
|
|
|
|
isOpen: false,
|
|
|
|
|
user: null,
|
|
|
|
|
newStatus: "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 히스토리 모달 상태 관리
|
|
|
|
|
const [historyModal, setHistoryModal] = useState<{
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
userId: string;
|
|
|
|
|
userName: string;
|
|
|
|
|
}>({
|
|
|
|
|
isOpen: false,
|
|
|
|
|
userId: "",
|
|
|
|
|
userName: "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// NO 컬럼 계산 함수 (페이지네이션 고려)
|
|
|
|
|
const getRowNumber = (index: number) => {
|
|
|
|
|
return paginationInfo.startItem + index;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 날짜 포맷팅 함수
|
|
|
|
|
const formatDate = (dateString: string) => {
|
|
|
|
|
if (!dateString) return "-";
|
|
|
|
|
return dateString.split(" ")[0]; // "2024-01-15 14:30:00" -> "2024-01-15"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 상태 토글 핸들러 (확인 모달 표시)
|
|
|
|
|
const handleStatusToggle = (user: User, checked: boolean) => {
|
|
|
|
|
const newStatus = checked ? "active" : "inactive";
|
|
|
|
|
setConfirmDialog({
|
|
|
|
|
isOpen: true,
|
|
|
|
|
user,
|
|
|
|
|
newStatus,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 상태 변경 확인
|
|
|
|
|
const handleConfirmStatusChange = () => {
|
|
|
|
|
if (confirmDialog.user) {
|
|
|
|
|
onStatusToggle(confirmDialog.user, confirmDialog.newStatus);
|
|
|
|
|
}
|
|
|
|
|
setConfirmDialog({ isOpen: false, user: null, newStatus: "" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 상태 변경 취소
|
|
|
|
|
const handleCancelStatusChange = () => {
|
|
|
|
|
setConfirmDialog({ isOpen: false, user: null, newStatus: "" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 변경이력 모달 열기
|
|
|
|
|
const handleOpenHistoryModal = (user: User) => {
|
|
|
|
|
setHistoryModal({
|
|
|
|
|
isOpen: true,
|
2025-08-25 18:30:07 +09:00
|
|
|
userId: user.userId,
|
|
|
|
|
userName: user.userName || user.userId,
|
2025-08-21 09:41:46 +09:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 변경이력 모달 닫기
|
|
|
|
|
const handleCloseHistoryModal = () => {
|
|
|
|
|
setHistoryModal({
|
|
|
|
|
isOpen: false,
|
|
|
|
|
userId: "",
|
|
|
|
|
userName: "",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 로딩 상태 렌더링
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
2025-10-22 14:52:13 +09:00
|
|
|
<>
|
|
|
|
|
{/* 데스크톱 테이블 스켈레톤 */}
|
2025-10-30 15:39:39 +09:00
|
|
|
<div className="bg-card hidden shadow-sm lg:block">
|
2025-10-22 14:52:13 +09:00
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
2025-10-30 15:39:39 +09:00
|
|
|
<TableRow className="bg-muted/50">
|
2025-08-21 09:41:46 +09:00
|
|
|
{USER_TABLE_COLUMNS.map((column) => (
|
2025-10-22 14:52:13 +09:00
|
|
|
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
|
|
|
|
|
{column.label}
|
|
|
|
|
</TableHead>
|
2025-08-21 09:41:46 +09:00
|
|
|
))}
|
2025-10-22 14:52:13 +09:00
|
|
|
<TableHead className="h-12 w-[200px] text-sm font-semibold">작업</TableHead>
|
2025-08-21 09:41:46 +09:00
|
|
|
</TableRow>
|
2025-10-22 14:52:13 +09:00
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{Array.from({ length: 10 }).map((_, index) => (
|
2025-10-30 15:39:39 +09:00
|
|
|
<TableRow key={index}>
|
2025-10-22 14:52:13 +09:00
|
|
|
{USER_TABLE_COLUMNS.map((column) => (
|
|
|
|
|
<TableCell key={column.key} className="h-16">
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</TableCell>
|
|
|
|
|
))}
|
|
|
|
|
<TableCell className="h-16">
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
2025-10-27 16:40:59 +09:00
|
|
|
<div key={i} className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
|
|
|
{Array.from({ length: 6 }).map((_, index) => (
|
2025-10-27 16:40:59 +09:00
|
|
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
2025-10-22 14:52:13 +09:00
|
|
|
<div className="mb-4 flex items-start justify-between">
|
|
|
|
|
<div className="flex-1 space-y-2">
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
|
|
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="bg-muted h-6 w-11 animate-pulse rounded-full"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-2 border-t pt-4">
|
|
|
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
|
|
|
<div key={i} className="flex justify-between">
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
|
|
|
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
2025-10-27 16:40:59 +09:00
|
|
|
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
|
|
|
|
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
2025-08-21 09:41:46 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터가 없을 때
|
|
|
|
|
if (users.length === 0) {
|
|
|
|
|
return (
|
2025-10-30 15:39:39 +09:00
|
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
2025-10-22 14:52:13 +09:00
|
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
2025-10-27 16:40:59 +09:00
|
|
|
<p className="text-muted-foreground text-sm">등록된 사용자가 없습니다.</p>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 실제 데이터 렌더링
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
2025-10-30 15:39:39 +09:00
|
|
|
<div className="bg-card hidden shadow-sm lg:block">
|
2025-08-21 09:41:46 +09:00
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
2025-10-30 15:39:39 +09:00
|
|
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
2025-08-21 09:41:46 +09:00
|
|
|
{USER_TABLE_COLUMNS.map((column) => (
|
2025-10-22 14:52:13 +09:00
|
|
|
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
|
2025-08-21 09:41:46 +09:00
|
|
|
{column.label}
|
|
|
|
|
</TableHead>
|
|
|
|
|
))}
|
2025-10-22 14:52:13 +09:00
|
|
|
<TableHead className="h-12 w-[200px] text-sm font-semibold">작업</TableHead>
|
2025-08-21 09:41:46 +09:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
2025-10-22 14:52:13 +09:00
|
|
|
{users.map((user, index) => (
|
2025-10-30 15:39:39 +09:00
|
|
|
<TableRow key={`${user.userId}-${index}`} className="hover:bg-muted/50 transition-colors">
|
2025-10-22 14:52:13 +09:00
|
|
|
<TableCell className="h-16 font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
|
|
|
|
|
<TableCell className="h-16 font-mono text-sm">{user.sabun || "-"}</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm font-medium">{user.companyCode || "-"}</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm font-medium">{user.deptName || "-"}</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm font-medium">{user.positionName || "-"}</TableCell>
|
|
|
|
|
<TableCell className="h-16 font-mono text-sm">{user.userId}</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm font-medium">{user.userName}</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm">{user.tel || user.cellPhone || "-"}</TableCell>
|
|
|
|
|
<TableCell className="h-16 max-w-[200px] truncate text-sm" title={user.email}>
|
|
|
|
|
{user.email || "-"}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="h-16 text-sm">{formatDate(user.regDate || "")}</TableCell>
|
|
|
|
|
<TableCell className="h-16">
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={user.status === "active"}
|
|
|
|
|
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
|
|
|
|
|
aria-label={`${user.userName} 상태 토글`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="h-16">
|
|
|
|
|
<div className="flex gap-2">
|
2025-10-27 16:40:59 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onEdit(user)}
|
|
|
|
|
className="h-8 w-8"
|
|
|
|
|
title="사용자 정보 수정"
|
|
|
|
|
>
|
|
|
|
|
<Edit className="h-4 w-4" />
|
|
|
|
|
</Button>
|
2025-10-22 14:52:13 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
|
|
|
|
|
className="h-8 w-8"
|
|
|
|
|
title="비밀번호 초기화"
|
|
|
|
|
>
|
|
|
|
|
<Key className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => handleOpenHistoryModal(user)}
|
|
|
|
|
className="h-8 w-8"
|
|
|
|
|
title="변경이력 조회"
|
|
|
|
|
>
|
|
|
|
|
<History className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
2025-08-21 09:41:46 +09:00
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-22 14:52:13 +09:00
|
|
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
|
|
|
{users.map((user, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${user.userId}-${index}`}
|
2025-10-27 16:40:59 +09:00
|
|
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
2025-10-22 14:52:13 +09:00
|
|
|
>
|
|
|
|
|
{/* 헤더: 이름과 상태 */}
|
|
|
|
|
<div className="mb-4 flex items-start justify-between">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<h3 className="text-base font-semibold">{user.userName}</h3>
|
2025-10-27 16:40:59 +09:00
|
|
|
<p className="text-muted-foreground mt-1 font-mono text-sm">{user.userId}</p>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={user.status === "active"}
|
|
|
|
|
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
|
|
|
|
|
aria-label={`${user.userName} 상태 토글`}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 정보 그리드 */}
|
|
|
|
|
<div className="space-y-2 border-t pt-4">
|
|
|
|
|
{user.sabun && (
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">사번</span>
|
|
|
|
|
<span className="font-mono font-medium">{user.sabun}</span>
|
2025-08-21 09:41:46 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
)}
|
|
|
|
|
{user.companyCode && (
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">회사</span>
|
|
|
|
|
<span className="font-medium">{user.companyCode}</span>
|
2025-08-21 09:41:46 +09:00
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
)}
|
|
|
|
|
{user.deptName && (
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">부서</span>
|
|
|
|
|
<span className="font-medium">{user.deptName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{user.positionName && (
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">직책</span>
|
|
|
|
|
<span className="font-medium">{user.positionName}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(user.tel || user.cellPhone) && (
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">연락처</span>
|
|
|
|
|
<span>{user.tel || user.cellPhone}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{user.email && (
|
|
|
|
|
<div className="flex flex-col gap-1 text-sm">
|
|
|
|
|
<span className="text-muted-foreground">이메일</span>
|
|
|
|
|
<span className="break-all">{user.email}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
<span className="text-muted-foreground">등록일</span>
|
|
|
|
|
<span>{formatDate(user.regDate || "")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
2025-10-27 16:40:59 +09:00
|
|
|
<Button variant="outline" size="sm" onClick={() => onEdit(user)} className="h-9 flex-1 gap-2 text-sm">
|
|
|
|
|
<Edit className="h-4 w-4" />
|
|
|
|
|
수정
|
|
|
|
|
</Button>
|
2025-10-22 14:52:13 +09:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
|
2025-10-27 16:40:59 +09:00
|
|
|
className="h-9 w-9 p-0"
|
|
|
|
|
title="비밀번호 초기화"
|
2025-10-22 14:52:13 +09:00
|
|
|
>
|
|
|
|
|
<Key className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleOpenHistoryModal(user)}
|
2025-10-27 16:40:59 +09:00
|
|
|
className="h-9 w-9 p-0"
|
|
|
|
|
title="변경이력"
|
2025-10-22 14:52:13 +09:00
|
|
|
>
|
|
|
|
|
<History className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-08-21 09:41:46 +09:00
|
|
|
|
|
|
|
|
{/* 상태 변경 확인 모달 */}
|
|
|
|
|
<UserStatusConfirmDialog
|
|
|
|
|
user={confirmDialog.user}
|
|
|
|
|
newStatus={confirmDialog.newStatus}
|
|
|
|
|
isOpen={confirmDialog.isOpen}
|
|
|
|
|
onConfirm={handleConfirmStatusChange}
|
|
|
|
|
onCancel={handleCancelStatusChange}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 사용자 변경이력 모달 */}
|
|
|
|
|
<UserHistoryModal
|
|
|
|
|
isOpen={historyModal.isOpen}
|
|
|
|
|
onClose={handleCloseHistoryModal}
|
|
|
|
|
userId={historyModal.userId}
|
|
|
|
|
userName={historyModal.userName}
|
|
|
|
|
/>
|
2025-10-22 14:52:13 +09:00
|
|
|
</>
|
2025-08-21 09:41:46 +09:00
|
|
|
);
|
|
|
|
|
}
|