e16fb16987
SUPER_ADMIN 토큰(company_code=*)이면 등록 회사들 DB 를 순회해 결과를 집계해 돌려주는 CrossTenantAggregator/Controller 추가. 사용자/권한그룹/ 배치/다국어 키 4개 도메인의 list API 가 cross-tenant 모드 지원. UserTable + ResponsiveDataView 에 compact/scrollContainer prop 추가. 페이지 헤더/툴바/페이지네이션은 고정, 테이블만 자체 스크롤. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
8.3 KiB
TypeScript
311 lines
8.3 KiB
TypeScript
import { Key, History, Edit } from "lucide-react";
|
|
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";
|
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
|
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;
|
|
onEdit: (user: User) => void;
|
|
}
|
|
|
|
/**
|
|
* 사용자 목록 테이블 컴포넌트
|
|
*/
|
|
export function UserTable({
|
|
users,
|
|
isLoading,
|
|
paginationInfo,
|
|
onStatusToggle,
|
|
onPasswordReset,
|
|
onEdit,
|
|
}: UserTableProps) {
|
|
// 확인 모달 상태 관리
|
|
const [confirmDialog, setConfirmDialog] = useState<{
|
|
isOpen: boolean;
|
|
user: User | null;
|
|
newStatus: string;
|
|
}>({
|
|
isOpen: false,
|
|
user: null,
|
|
newStatus: "",
|
|
});
|
|
|
|
// 히스토리 모달 상태 관리
|
|
const [historyModal, setHistoryModal] = useState<{
|
|
isOpen: boolean;
|
|
user_id: string;
|
|
user_name: string;
|
|
}>({
|
|
isOpen: false,
|
|
user_id: "",
|
|
user_name: "",
|
|
});
|
|
|
|
// NO 컬럼 계산 함수 (페이지네이션 고려)
|
|
const getRowNumber = (index: number) => {
|
|
return paginationInfo.startItem + index;
|
|
};
|
|
|
|
// 날짜 포맷팅 함수
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return "-";
|
|
return dateString.split(" ")[0];
|
|
};
|
|
|
|
// 상태 토글 핸들러 (확인 모달 표시)
|
|
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,
|
|
user_id: user.user_id,
|
|
user_name: user.user_name || user.user_id,
|
|
});
|
|
};
|
|
|
|
// 변경이력 모달 닫기
|
|
const handleCloseHistoryModal = () => {
|
|
setHistoryModal({
|
|
isOpen: false,
|
|
user_id: "",
|
|
user_name: "",
|
|
});
|
|
};
|
|
|
|
// 데스크톱 테이블 컬럼 정의
|
|
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",
|
|
label: "사번",
|
|
width: "80px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-mono">{value || "-"}</span>,
|
|
},
|
|
{
|
|
key: "company_code",
|
|
label: "회사",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
|
},
|
|
{
|
|
key: "dept_name",
|
|
label: "부서명",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
|
},
|
|
{
|
|
key: "position_name",
|
|
label: "직책",
|
|
width: "100px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-medium">{value || "-"}</span>,
|
|
},
|
|
{
|
|
key: "user_id",
|
|
label: "사용자 ID",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-mono">{value}</span>,
|
|
},
|
|
{
|
|
key: "user_name",
|
|
label: "사용자명",
|
|
width: "100px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span className="font-medium">{value}</span>,
|
|
},
|
|
{
|
|
key: "tel",
|
|
label: "전화번호",
|
|
width: "120px",
|
|
hideOnMobile: true,
|
|
render: (_value, row) => <span>{row.tel || row.cell_phone || "-"}</span>,
|
|
},
|
|
{
|
|
key: "email",
|
|
label: "이메일",
|
|
width: "200px",
|
|
hideOnMobile: true,
|
|
className: "max-w-[200px] truncate",
|
|
render: (value, row) => (
|
|
<span title={row.email}>{value || "-"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "reg_date",
|
|
label: "등록일",
|
|
width: "100px",
|
|
hideOnMobile: true,
|
|
render: (value) => <span>{formatDate(value || "")}</span>,
|
|
},
|
|
{
|
|
key: "status",
|
|
label: "상태",
|
|
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.user_name} 상태 토글`}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// 모바일 카드 필드 정의
|
|
const cardFields: RDVCardField<User>[] = [
|
|
{
|
|
label: "사번",
|
|
render: (user) => <span className="font-mono font-medium">{user.sabun || "-"}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "회사",
|
|
render: (user) => <span className="font-medium">{user.company_code || ""}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "부서",
|
|
render: (user) => <span className="font-medium">{user.dept_name || ""}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "직책",
|
|
render: (user) => <span className="font-medium">{user.position_name || ""}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "연락처",
|
|
render: (user) => <span>{user.tel || user.cell_phone || ""}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "이메일",
|
|
render: (user) => <span className="break-all">{user.email || ""}</span>,
|
|
hideEmpty: true,
|
|
},
|
|
{
|
|
label: "등록일",
|
|
render: (user) => <span>{formatDate(user.reg_date || "")}</span>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<ResponsiveDataView<User>
|
|
data={users}
|
|
columns={columns}
|
|
keyExtractor={(u) => `${u.company_code || "C"}::${u.user_id}`}
|
|
isLoading={isLoading}
|
|
emptyMessage="등록된 사용자가 없습니다."
|
|
skeletonCount={10}
|
|
compact
|
|
scrollContainer
|
|
cardTitle={(u) => u.user_name || ""}
|
|
cardSubtitle={(u) => <span className="font-mono">{u.user_id}</span>}
|
|
cardHeaderRight={(u) => (
|
|
<Switch
|
|
checked={u.status === "active"}
|
|
onCheckedChange={(checked) => handleStatusToggle(u, checked)}
|
|
aria-label={`${u.user_name} 상태 토글`}
|
|
/>
|
|
)}
|
|
cardFields={cardFields}
|
|
actionsLabel="작업"
|
|
actionsWidth="200px"
|
|
renderActions={(user) => (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onEdit(user)}
|
|
className="h-8 w-8"
|
|
title="사용자 정보 수정"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onPasswordReset(user.user_id, user.user_name || user.user_id)}
|
|
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>
|
|
</>
|
|
)}
|
|
/>
|
|
|
|
{/* 상태 변경 확인 모달 */}
|
|
<UserStatusConfirmDialog
|
|
user={confirmDialog.user}
|
|
newStatus={confirmDialog.newStatus}
|
|
isOpen={confirmDialog.isOpen}
|
|
onConfirm={handleConfirmStatusChange}
|
|
onCancel={handleCancelStatusChange}
|
|
/>
|
|
|
|
{/* 사용자 변경이력 모달 */}
|
|
<UserHistoryModal
|
|
isOpen={historyModal.isOpen}
|
|
onClose={handleCloseHistoryModal}
|
|
userId={historyModal.user_id}
|
|
userName={historyModal.user_name}
|
|
/>
|
|
</>
|
|
);
|
|
}
|