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 { Button } from "@/components/ui/button";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import { PaginationInfo } from "@/components/common/Pagination";
|
2026-03-09 22:07:11 +09:00
|
|
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
2025-08-21 09:41:46 +09:00
|
|
|
import { UserStatusConfirmDialog } from "./UserStatusConfirmDialog";
|
|
|
|
|
import { UserHistoryModal } from "./UserHistoryModal";
|
2026-04-01 15:49:49 +09:00
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
2025-08-21 09:41:46 +09:00
|
|
|
|
|
|
|
|
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;
|
2026-04-01 15:57:12 +09:00
|
|
|
t?: (key: string, params?: Record<string, string | number>) => string;
|
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,
|
2026-04-01 15:57:12 +09:00
|
|
|
t: tProp,
|
2025-10-27 16:40:59 +09:00
|
|
|
}: UserTableProps) {
|
2026-04-01 15:57:12 +09:00
|
|
|
// 다국어 함수 (prop이 없으면 한국어 기본값 사용)
|
|
|
|
|
const _t = tProp || ((key: string) => {
|
|
|
|
|
const defaults: Record<string, string> = {
|
|
|
|
|
"table.sabun": "사번",
|
|
|
|
|
"table.company": "회사",
|
|
|
|
|
"table.dept": "부서명",
|
|
|
|
|
"table.position": "직책",
|
|
|
|
|
"table.userId": "사용자 ID",
|
|
|
|
|
"table.userName": "사용자명",
|
|
|
|
|
"table.phone": "전화번호",
|
|
|
|
|
"table.email": "이메일",
|
|
|
|
|
"table.regDate": "등록일",
|
|
|
|
|
"table.status": "상태",
|
|
|
|
|
"table.dept.short": "부서",
|
|
|
|
|
"table.contact": "연락처",
|
|
|
|
|
"table.empty": "등록된 사용자가 없습니다.",
|
|
|
|
|
"table.actions": "작업",
|
|
|
|
|
"action.edit.user": "사용자 정보 수정",
|
|
|
|
|
"action.reset.password": "비밀번호 초기화",
|
|
|
|
|
"action.view.history": "변경이력 조회",
|
|
|
|
|
};
|
|
|
|
|
return defaults[key] || key;
|
|
|
|
|
});
|
2026-04-01 15:49:49 +09:00
|
|
|
const { user: currentUser } = useAuth();
|
|
|
|
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
|
|
|
|
|
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 "-";
|
2026-03-09 22:07:11 +09:00
|
|
|
return dateString.split(" ")[0];
|
2025-08-21 09:41:46 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 상태 토글 핸들러 (확인 모달 표시)
|
|
|
|
|
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: "",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-09 22:07:11 +09:00
|
|
|
// 데스크톱 테이블 컬럼 정의
|
|
|
|
|
const columns: RDVColumn<User>[] = [
|
|
|
|
|
{
|
|
|
|
|
key: "no",
|
|
|
|
|
label: "No",
|
|
|
|
|
width: "60px",
|
|
|
|
|
render: (_value, _row, index) => (
|
|
|
|
|
<span className="font-mono font-medium">{getRowNumber(index)}</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "sabun",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.sabun"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "80px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span className="font-mono">{value || "-"}</span>,
|
|
|
|
|
},
|
2026-04-01 15:49:49 +09:00
|
|
|
...(isSuperAdmin
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
key: "companyCode" as keyof User,
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.company"),
|
2026-04-01 15:49:49 +09:00
|
|
|
width: "120px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value: any, user: User) => (
|
|
|
|
|
<span className="font-medium">{(user as any).companyName || value || "-"}</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
2026-03-09 22:07:11 +09:00
|
|
|
{
|
|
|
|
|
key: "deptName",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.dept"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "120px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "positionName",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.position"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "100px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "userId",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.userId"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "120px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span className="font-mono">{value}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "userName",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.userName"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "100px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span className="font-medium">{value}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "tel",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.phone"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "120px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (_value, row) => <span>{row.tel || row.cellPhone || "-"}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "email",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.email"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "200px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
className: "max-w-[200px] truncate",
|
|
|
|
|
render: (value, row) => (
|
|
|
|
|
<span title={row.email}>{value || "-"}</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "regDate",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.regDate"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "100px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (value) => <span>{formatDate(value || "")}</span>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "status",
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.status"),
|
2026-03-09 22:07:11 +09:00
|
|
|
width: "120px",
|
|
|
|
|
hideOnMobile: true,
|
|
|
|
|
render: (_value, row) => (
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<Switch
|
|
|
|
|
checked={row.status === "active"}
|
|
|
|
|
onCheckedChange={(checked) => handleStatusToggle(row, checked)}
|
|
|
|
|
aria-label={`${row.userName} 상태 토글`}
|
|
|
|
|
/>
|
2025-10-22 14:52:13 +09:00
|
|
|
</div>
|
2026-03-09 22:07:11 +09:00
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
2025-10-22 14:52:13 +09:00
|
|
|
|
2026-03-09 22:07:11 +09:00
|
|
|
// 모바일 카드 필드 정의
|
|
|
|
|
const cardFields: RDVCardField<User>[] = [
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.sabun"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span className="font-mono font-medium">{user.sabun || "-"}</span>,
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
2026-04-01 15:49:49 +09:00
|
|
|
...(isSuperAdmin
|
|
|
|
|
? [
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.company"),
|
2026-04-01 15:49:49 +09:00
|
|
|
render: (user: User) => (
|
|
|
|
|
<span className="font-medium">{(user as any).companyName || user.companyCode || ""}</span>
|
|
|
|
|
),
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
2026-03-09 22:07:11 +09:00
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.dept.short"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span className="font-medium">{user.deptName || ""}</span>,
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.position"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span className="font-medium">{user.positionName || ""}</span>,
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.contact"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span>{user.tel || user.cellPhone || ""}</span>,
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.email"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span className="break-all">{user.email || ""}</span>,
|
|
|
|
|
hideEmpty: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-04-01 15:57:12 +09:00
|
|
|
label: _t("table.regDate"),
|
2026-03-09 22:07:11 +09:00
|
|
|
render: (user) => <span>{formatDate(user.regDate || "")}</span>,
|
|
|
|
|
},
|
|
|
|
|
];
|
2025-10-22 14:52:13 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-03-09 22:07:11 +09:00
|
|
|
<ResponsiveDataView<User>
|
|
|
|
|
data={users}
|
|
|
|
|
columns={columns}
|
|
|
|
|
keyExtractor={(u) => u.userId}
|
|
|
|
|
isLoading={isLoading}
|
2026-04-01 15:57:12 +09:00
|
|
|
emptyMessage={_t("table.empty")}
|
2026-03-09 22:07:11 +09:00
|
|
|
skeletonCount={10}
|
|
|
|
|
cardTitle={(u) => u.userName || ""}
|
|
|
|
|
cardSubtitle={(u) => <span className="font-mono">{u.userId}</span>}
|
|
|
|
|
cardHeaderRight={(u) => (
|
|
|
|
|
<Switch
|
|
|
|
|
checked={u.status === "active"}
|
|
|
|
|
onCheckedChange={(checked) => handleStatusToggle(u, checked)}
|
|
|
|
|
aria-label={`${u.userName} 상태 토글`}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
cardFields={cardFields}
|
2026-04-01 15:57:12 +09:00
|
|
|
actionsLabel={_t("table.actions")}
|
2026-03-09 22:07:11 +09:00
|
|
|
actionsWidth="200px"
|
|
|
|
|
renderActions={(user) => (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onEdit(user)}
|
|
|
|
|
className="h-8 w-8"
|
2026-04-01 15:57:12 +09:00
|
|
|
title={_t("action.edit.user")}
|
2026-03-09 22:07:11 +09:00
|
|
|
>
|
|
|
|
|
<Edit className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
|
|
|
|
|
className="h-8 w-8"
|
2026-04-01 15:57:12 +09:00
|
|
|
title={_t("action.reset.password")}
|
2026-03-09 22:07:11 +09:00
|
|
|
>
|
|
|
|
|
<Key className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => handleOpenHistoryModal(user)}
|
|
|
|
|
className="h-8 w-8"
|
2026-04-01 15:57:12 +09:00
|
|
|
title={_t("action.view.history")}
|
2026-03-09 22:07:11 +09:00
|
|
|
>
|
|
|
|
|
<History className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
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
|
|
|
);
|
|
|
|
|
}
|