각각 별도 TSX 병합 및 회사선택기능 추가
This commit is contained in:
parent
e1d6c1740f
commit
58233e51de
|
|
@ -141,6 +141,110 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
static async switchCompany(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.body;
|
||||||
|
const authHeader = req.get("Authorization");
|
||||||
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 토큰이 필요합니다.",
|
||||||
|
error: { code: "TOKEN_MISSING" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 사용자 정보 확인
|
||||||
|
const currentUser = JwtUtils.verifyToken(token);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType = "SUPER_ADMIN"만 확인)
|
||||||
|
// 이미 다른 회사로 전환한 상태(companyCode != "*")에서도 다시 전환 가능해야 함
|
||||||
|
if (currentUser.userType !== "SUPER_ADMIN") {
|
||||||
|
logger.warn(`회사 전환 권한 없음: userId=${currentUser.userId}, userType=${currentUser.userType}, companyCode=${currentUser.companyCode}`);
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환은 최고 관리자(SUPER_ADMIN)만 가능합니다.",
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전환할 회사 코드 검증
|
||||||
|
if (!companyCode || companyCode.trim() === "") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "전환할 회사 코드가 필요합니다.",
|
||||||
|
error: { code: "INVALID_INPUT" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`=== WACE 관리자 회사 전환 ===`, {
|
||||||
|
userId: currentUser.userId,
|
||||||
|
originalCompanyCode: currentUser.companyCode,
|
||||||
|
targetCompanyCode: companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 회사 코드 존재 여부 확인 (company_code가 "*"가 아닌 경우만)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const { query } = await import("../database/db");
|
||||||
|
const companies = await query<any>(
|
||||||
|
"SELECT company_code, company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (companies.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "존재하지 않는 회사 코드입니다.",
|
||||||
|
error: { code: "COMPANY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 JWT 토큰 발급 (company_code만 변경)
|
||||||
|
const newPersonBean: PersonBean = {
|
||||||
|
...currentUser,
|
||||||
|
companyCode: companyCode.trim(), // 전환할 회사 코드로 변경
|
||||||
|
};
|
||||||
|
|
||||||
|
const newToken = JwtUtils.generateToken(newPersonBean);
|
||||||
|
|
||||||
|
logger.info(`✅ 회사 전환 성공: ${currentUser.userId} → ${companyCode}`);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "회사 전환 완료",
|
||||||
|
data: {
|
||||||
|
token: newToken,
|
||||||
|
companyCode: companyCode.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`회사 전환 API 오류: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "회사 전환 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "SERVER_ERROR",
|
||||||
|
details:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "알 수 없는 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
* 기존 Java ApiLoginController.logout() 메서드 포팅
|
||||||
|
|
@ -226,13 +330,14 @@ export class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||||
|
// ⚠️ JWT 토큰의 companyCode를 우선 사용 (회사 전환 기능 지원)
|
||||||
const userInfoResponse: any = {
|
const userInfoResponse: any = {
|
||||||
userId: dbUserInfo.userId,
|
userId: dbUserInfo.userId,
|
||||||
userName: dbUserInfo.userName || "",
|
userName: dbUserInfo.userName || "",
|
||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
companyCode: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
company_code: userInfo.companyCode || dbUserInfo.companyCode || "ILSHIN", // JWT 토큰 우선
|
||||||
userType: dbUserInfo.userType || "USER",
|
userType: userInfo.userType || dbUserInfo.userType || "USER", // JWT 토큰 우선
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
photo: dbUserInfo.photo,
|
photo: dbUserInfo.photo,
|
||||||
|
|
|
||||||
|
|
@ -47,4 +47,10 @@ router.post("/refresh", AuthController.refreshToken);
|
||||||
*/
|
*/
|
||||||
router.post("/signup", AuthController.signup);
|
router.post("/signup", AuthController.signup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/switch-company
|
||||||
|
* WACE 관리자 전용: 다른 회사로 전환
|
||||||
|
*/
|
||||||
|
router.post("/switch-company", AuthController.switchCompany);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -412,9 +412,9 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
if (userType === "SUPER_ADMIN") {
|
||||||
// SUPER_ADMIN: 권한 그룹 체크 없이 공통 메뉴만 표시
|
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||||
authFilter = "";
|
authFilter = "";
|
||||||
unionFilter = "";
|
unionFilter = "";
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,9 +1,124 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { BatchAPI, BatchMonitoring } from "@/lib/api/batch";
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
|
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMonitoringData();
|
||||||
|
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoRefresh]);
|
||||||
|
|
||||||
|
const loadMonitoringData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await BatchAPI.getBatchMonitoring();
|
||||||
|
setMonitoring(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("모니터링 데이터 조회 오류:", error);
|
||||||
|
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadMonitoringData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoRefresh = () => {
|
||||||
|
setAutoRefresh(!autoRefresh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4 text-blue-500" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants = {
|
||||||
|
completed: "bg-green-100 text-green-800",
|
||||||
|
failed: "bg-destructive/20 text-red-800",
|
||||||
|
running: "bg-primary/20 text-blue-800",
|
||||||
|
pending: "bg-yellow-100 text-yellow-800",
|
||||||
|
cancelled: "bg-gray-100 text-gray-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
completed: "완료",
|
||||||
|
failed: "실패",
|
||||||
|
running: "실행 중",
|
||||||
|
pending: "대기 중",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
||||||
|
{labels[status as keyof typeof labels] || status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${(ms / 60000).toFixed(1)}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSuccessRate = () => {
|
||||||
|
if (!monitoring) return 0;
|
||||||
|
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
||||||
|
if (total === 0) return 100;
|
||||||
|
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!monitoring) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
||||||
|
<p>모니터링 데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
|
@ -16,7 +131,170 @@ export default function MonitoringPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모니터링 대시보드 */}
|
{/* 모니터링 대시보드 */}
|
||||||
<MonitoringDashboard />
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleAutoRefresh}
|
||||||
|
className={autoRefresh ? "bg-accent text-primary" : ""}
|
||||||
|
>
|
||||||
|
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
||||||
|
자동 새로고침
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
||||||
|
<div className="text-2xl">📋</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
활성: {monitoring.active_jobs}개
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
||||||
|
<div className="text-2xl">🔄</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
현재 실행 중인 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
||||||
|
<div className="text-2xl">✅</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
성공률: {getSuccessRate()}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
||||||
|
<div className="text-2xl">❌</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
주의가 필요한 작업
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 성공률 진행바 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>성공: {monitoring.successful_jobs_today}건</span>
|
||||||
|
<span>실패: {monitoring.failed_jobs_today}건</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={getSuccessRate()} className="h-2" />
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
{getSuccessRate()}% 성공률
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 최근 실행 이력 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{monitoring.recent_executions.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
최근 실행 이력이 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead>작업 ID</TableHead>
|
||||||
|
<TableHead>시작 시간</TableHead>
|
||||||
|
<TableHead>완료 시간</TableHead>
|
||||||
|
<TableHead>실행 시간</TableHead>
|
||||||
|
<TableHead>오류 메시지</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{monitoring.recent_executions.map((execution) => (
|
||||||
|
<TableRow key={execution.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(execution.execution_status)}
|
||||||
|
{getStatusBadge(execution.execution_status)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.started_at
|
||||||
|
? new Date(execution.started_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.completed_at
|
||||||
|
? new Date(execution.completed_at).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{execution.execution_time_ms
|
||||||
|
? formatDuration(execution.execution_time_ms)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs">
|
||||||
|
{execution.error_message ? (
|
||||||
|
<span className="text-destructive text-sm truncate block">
|
||||||
|
{execution.error_message}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package, Building2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
|
||||||
{/* 주요 관리 기능 */}
|
{/* 주요 관리 기능 */}
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
|
|
|
||||||
|
|
@ -1,449 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
|
||||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 목록 클라이언트 컴포넌트
|
|
||||||
* - CSR 방식으로 초기 데이터 로드
|
|
||||||
* - 대시보드 목록 조회
|
|
||||||
* - 대시보드 생성/수정/삭제/복사
|
|
||||||
*/
|
|
||||||
export default function DashboardListClient() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
|
|
||||||
// 페이지네이션 상태
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(10);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
|
||||||
|
|
||||||
// 대시보드 목록 로드
|
|
||||||
const loadDashboards = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const result = await dashboardApi.getMyDashboards({
|
|
||||||
search: searchTerm,
|
|
||||||
page: currentPage,
|
|
||||||
limit: pageSize,
|
|
||||||
});
|
|
||||||
setDashboards(result.dashboards);
|
|
||||||
setTotalCount(result.pagination.total);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load dashboards:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboards();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchTerm, currentPage, pageSize]);
|
|
||||||
|
|
||||||
// 페이지네이션 정보 계산
|
|
||||||
const paginationInfo: PaginationInfo = {
|
|
||||||
currentPage,
|
|
||||||
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
|
||||||
totalItems: totalCount,
|
|
||||||
itemsPerPage: pageSize,
|
|
||||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
|
||||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 변경 핸들러
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 크기 변경 핸들러
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
|
||||||
setPageSize(size);
|
|
||||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
|
||||||
setDeleteTarget({ id, title });
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 삭제 실행
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete dashboard:", err);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 삭제에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 대시보드 복사
|
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
|
||||||
try {
|
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
|
||||||
|
|
||||||
await dashboardApi.createDashboard({
|
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
|
||||||
description: fullDashboard.description,
|
|
||||||
elements: fullDashboard.elements || [],
|
|
||||||
isPublic: false,
|
|
||||||
tags: fullDashboard.tags,
|
|
||||||
category: fullDashboard.category,
|
|
||||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 복사되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy dashboard:", err);
|
|
||||||
toast({
|
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 복사에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 포맷팅 헬퍼
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 검색 및 액션 */}
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative w-full sm:w-[300px]">
|
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
||||||
<Input
|
|
||||||
placeholder="대시보드 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="h-10 pl-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
|
|
||||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
|
||||||
<TableRow key={index} className="border-b">
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16">
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex justify-between">
|
|
||||||
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : error ? (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
|
||||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
|
||||||
<AlertCircle className="text-destructive h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
|
||||||
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : dashboards.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
|
||||||
<TableCell className="h-16 text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
{dashboard.title}
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
|
||||||
{dashboard.description || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.createdAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
|
||||||
{formatDate(dashboard.updatedAt)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="h-16 text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{dashboards.map((dashboard) => (
|
|
||||||
<div
|
|
||||||
key={dashboard.id}
|
|
||||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
|
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
|
||||||
</button>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">설명</span>
|
|
||||||
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성자</span>
|
|
||||||
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">생성일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">수정일</span>
|
|
||||||
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 */}
|
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/edit/${dashboard.id}`)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={() => handleCopy(dashboard)}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{!loading && dashboards.length > 0 && (
|
|
||||||
<Pagination
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onPageSizeChange={handlePageSizeChange}
|
|
||||||
showPageSizeSelector={true}
|
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
|
||||||
<DeleteConfirmModal
|
|
||||||
open={deleteDialogOpen}
|
|
||||||
onOpenChange={setDeleteDialogOpen}
|
|
||||||
title="대시보드 삭제"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardCanvas } from "./DashboardCanvas";
|
import { DashboardCanvas } from "@/components/admin/dashboard/DashboardCanvas";
|
||||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu";
|
||||||
import { WidgetConfigSidebar } from "./WidgetConfigSidebar";
|
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils";
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -32,18 +33,24 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
interface DashboardDesignerProps {
|
|
||||||
dashboardId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 설계 도구 메인 컴포넌트
|
* 대시보드 생성/편집 페이지
|
||||||
|
* URL: /admin/screenMng/dashboardList/[id]
|
||||||
|
* - id가 "new"면 새 대시보드 생성
|
||||||
|
* - id가 숫자면 기존 대시보드 편집
|
||||||
|
*
|
||||||
|
* 기능:
|
||||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||||
* - 그리드 기반 레이아웃 (12 컬럼)
|
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||||
* - 요소 이동, 크기 조절, 삭제 기능
|
* - 요소 이동, 크기 조절, 삭제 기능
|
||||||
* - 레이아웃 저장/불러오기 기능
|
* - 레이아웃 저장/불러오기 기능
|
||||||
*/
|
*/
|
||||||
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
|
export default function DashboardDesignerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: paramId } = use(params);
|
||||||
|
|
||||||
|
// "new"면 생성 모드, 아니면 편집 모드
|
||||||
|
const initialDashboardId = paramId === "new" ? undefined : paramId;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { use } from "react";
|
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대시보드 편집 페이지
|
|
||||||
* - 기존 대시보드 편집
|
|
||||||
*/
|
|
||||||
export default function DashboardEditPage({ params }: PageProps) {
|
|
||||||
const { id } = use(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner dashboardId={id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 대시보드 생성 페이지
|
|
||||||
*/
|
|
||||||
export default function DashboardNewPage() {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<DashboardDesigner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,167 @@
|
||||||
import DashboardListClient from "@/app/(main)/admin/screenMng/dashboardList/DashboardListClient";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 관리 페이지
|
* 대시보드 관리 페이지
|
||||||
* - 클라이언트 컴포넌트를 렌더링하는 래퍼
|
* - CSR 방식으로 초기 데이터 로드
|
||||||
* - 초기 로딩부터 CSR로 처리
|
* - 대시보드 목록 조회
|
||||||
|
* - 대시보드 생성/수정/삭제/복사
|
||||||
*/
|
*/
|
||||||
export default function DashboardListPage() {
|
export default function DashboardListPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 페이지네이션 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
// 대시보드 목록 로드
|
||||||
|
const loadDashboards = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await dashboardApi.getMyDashboards({
|
||||||
|
search: searchTerm,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
|
setDashboards(result.dashboards);
|
||||||
|
setTotalCount(result.pagination.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load dashboards:", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboards();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchTerm, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 페이지네이션 정보 계산
|
||||||
|
const paginationInfo: PaginationInfo = {
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
||||||
|
totalItems: totalCount,
|
||||||
|
itemsPerPage: pageSize,
|
||||||
|
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||||
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경 핸들러
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 크기 변경 핸들러
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 확인 모달 열기
|
||||||
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
|
setDeleteTarget({ id, title });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 삭제 실행
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete dashboard:", err);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 삭제에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 복사
|
||||||
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
|
try {
|
||||||
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
|
await dashboardApi.createDashboard({
|
||||||
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
|
description: fullDashboard.description,
|
||||||
|
elements: fullDashboard.elements || [],
|
||||||
|
isPublic: false,
|
||||||
|
tags: fullDashboard.tags,
|
||||||
|
category: fullDashboard.category,
|
||||||
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 복사되었습니다.",
|
||||||
|
});
|
||||||
|
loadDashboards();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy dashboard:", err);
|
||||||
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 복사에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 포맷팅 헬퍼
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|
@ -15,8 +171,287 @@ export default function DashboardListPage() {
|
||||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 클라이언트 컴포넌트 */}
|
{/* 검색 및 액션 */}
|
||||||
<DashboardListClient />
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="대시보드 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
새 대시보드 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대시보드 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 스켈레톤 */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
|
<TableRow key={index} className="border-b">
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16">
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<div className="bg-muted ml-auto h-8 w-8 animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 스켈레톤 */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
|
||||||
|
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
<AlertCircle className="text-destructive h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
||||||
|
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : dashboards.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||||||
|
<div className="bg-card hidden shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||||
|
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
|
<TableCell className="h-16 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{dashboard.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
|
{dashboard.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{dashboard.createdByName || dashboard.createdBy || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-16 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
||||||
|
{dashboards.map((dashboard) => (
|
||||||
|
<div
|
||||||
|
key={dashboard.id}
|
||||||
|
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||||
|
</button>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">설명</span>
|
||||||
|
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성자</span>
|
||||||
|
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">생성일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">수정일</span>
|
||||||
|
<span className="font-medium">{formatDate(dashboard.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 */}
|
||||||
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
|
onClick={() => handleCopy(dashboard)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 gap-2 text-sm"
|
||||||
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && dashboards.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
title="대시보드 삭제"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,823 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import MultiLang from "@/components/admin/MultiLang";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
import { DataTable } from "@/components/common/DataTable";
|
||||||
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import LangKeyModal from "@/components/admin/LangKeyModal";
|
||||||
|
import LanguageModal from "@/components/admin/LanguageModal";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
langCode: string;
|
||||||
|
langName: string;
|
||||||
|
langNative: string;
|
||||||
|
isActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LangKey {
|
||||||
|
keyId: number;
|
||||||
|
companyCode: string;
|
||||||
|
menuName: string;
|
||||||
|
langKey: string;
|
||||||
|
description: string;
|
||||||
|
isActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LangText {
|
||||||
|
textId: number;
|
||||||
|
keyId: number;
|
||||||
|
langCode: string;
|
||||||
|
langText: string;
|
||||||
|
isActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function I18nPage() {
|
export default function I18nPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [languages, setLanguages] = useState<Language[]>([]);
|
||||||
|
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<LangKey | null>(null);
|
||||||
|
const [langTexts, setLangTexts] = useState<LangText[]>([]);
|
||||||
|
const [editingTexts, setEditingTexts] = useState<LangText[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState("all");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState<LangKey | null>(null);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// 언어 관리 관련 상태
|
||||||
|
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
|
||||||
|
const [editingLanguage, setEditingLanguage] = useState<Language | null>(null);
|
||||||
|
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
|
||||||
|
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
|
||||||
|
|
||||||
|
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
|
||||||
|
// 회사 목록 조회
|
||||||
|
const fetchCompanies = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/admin/companies");
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
const companyList = data.data.map((company: any) => ({
|
||||||
|
code: company.company_code,
|
||||||
|
name: company.company_name,
|
||||||
|
}));
|
||||||
|
setCompanies(companyList);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("회사 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 목록 조회
|
||||||
|
const fetchLanguages = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/multilang/languages");
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
setLanguages(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("언어 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다국어 키 목록 조회
|
||||||
|
const fetchLangKeys = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/multilang/keys");
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
setLangKeys(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("다국어 키 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 데이터 계산
|
||||||
|
const getFilteredLangKeys = () => {
|
||||||
|
let filteredKeys = langKeys;
|
||||||
|
|
||||||
|
// 회사 필터링
|
||||||
|
if (selectedCompany && selectedCompany !== "all") {
|
||||||
|
filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 검색 필터링
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const searchLower = searchText.toLowerCase();
|
||||||
|
filteredKeys = filteredKeys.filter((key) => {
|
||||||
|
const langKey = (key.langKey || "").toLowerCase();
|
||||||
|
const description = (key.description || "").toLowerCase();
|
||||||
|
const menuName = (key.menuName || "").toLowerCase();
|
||||||
|
const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
langKey.includes(searchLower) ||
|
||||||
|
description.includes(searchLower) ||
|
||||||
|
menuName.includes(searchLower) ||
|
||||||
|
companyName.includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredKeys;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 키의 다국어 텍스트 조회
|
||||||
|
const fetchLangTexts = async (keyId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
setLangTexts(data.data);
|
||||||
|
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
||||||
|
setEditingTexts(editingData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("다국어 텍스트 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 키 선택 처리
|
||||||
|
const handleKeySelect = (key: LangKey) => {
|
||||||
|
setSelectedKey(key);
|
||||||
|
fetchLangTexts(key.keyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트 변경 처리
|
||||||
|
const handleTextChange = (langCode: string, value: string) => {
|
||||||
|
const newEditingTexts = [...editingTexts];
|
||||||
|
const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
newEditingTexts[existingIndex].langText = value;
|
||||||
|
} else {
|
||||||
|
newEditingTexts.push({
|
||||||
|
textId: 0,
|
||||||
|
keyId: selectedKey!.keyId,
|
||||||
|
langCode: langCode,
|
||||||
|
langText: value,
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingTexts(newEditingTexts);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 텍스트 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
texts: editingTexts.map((text) => ({
|
||||||
|
langCode: text.langCode,
|
||||||
|
langText: text.langText,
|
||||||
|
isActive: text.isActive || "Y",
|
||||||
|
createdBy: user?.userId || "system",
|
||||||
|
updatedBy: user?.userId || "system",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
alert("저장되었습니다.");
|
||||||
|
fetchLangTexts(selectedKey.keyId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 키 추가/수정 모달 열기
|
||||||
|
const handleAddKey = () => {
|
||||||
|
setEditingKey(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 추가/수정 모달 열기
|
||||||
|
const handleAddLanguage = () => {
|
||||||
|
setEditingLanguage(null);
|
||||||
|
setIsLanguageModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 수정
|
||||||
|
const handleEditLanguage = (language: Language) => {
|
||||||
|
setEditingLanguage(language);
|
||||||
|
setIsLanguageModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 저장 (추가/수정)
|
||||||
|
const handleSaveLanguage = async (languageData: any) => {
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
...languageData,
|
||||||
|
createdBy: user?.userId || "admin",
|
||||||
|
updatedBy: user?.userId || "admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingLanguage) {
|
||||||
|
response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
|
||||||
|
} else {
|
||||||
|
response = await apiClient.post("/multilang/languages", requestData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
|
||||||
|
setIsLanguageModalOpen(false);
|
||||||
|
fetchLanguages();
|
||||||
|
} else {
|
||||||
|
alert(`오류: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("언어 저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 삭제
|
||||||
|
const handleDeleteLanguages = async () => {
|
||||||
|
if (selectedLanguages.size === 0) {
|
||||||
|
alert("삭제할 언어를 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletePromises = Array.from(selectedLanguages).map((langCode) =>
|
||||||
|
apiClient.delete(`/multilang/languages/${langCode}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(deletePromises);
|
||||||
|
const failedDeletes = responses.filter((response) => !response.data.success);
|
||||||
|
|
||||||
|
if (failedDeletes.length === 0) {
|
||||||
|
alert("선택된 언어가 삭제되었습니다.");
|
||||||
|
setSelectedLanguages(new Set());
|
||||||
|
fetchLanguages();
|
||||||
|
} else {
|
||||||
|
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("언어 삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 선택 체크박스 처리
|
||||||
|
const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
|
||||||
|
const newSelected = new Set(selectedLanguages);
|
||||||
|
if (checked) {
|
||||||
|
newSelected.add(langCode);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(langCode);
|
||||||
|
}
|
||||||
|
setSelectedLanguages(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 전체 선택/해제
|
||||||
|
const handleSelectAllLanguages = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
|
||||||
|
} else {
|
||||||
|
setSelectedLanguages(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 키 수정 모달 열기
|
||||||
|
const handleEditKey = (key: LangKey) => {
|
||||||
|
setEditingKey(key);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 키 저장 (추가/수정)
|
||||||
|
const handleSaveKey = async (keyData: any) => {
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
...keyData,
|
||||||
|
createdBy: user?.userId || "admin",
|
||||||
|
updatedBy: user?.userId || "admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingKey) {
|
||||||
|
response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
|
||||||
|
} else {
|
||||||
|
response = await apiClient.post("/multilang/keys", requestData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
|
||||||
|
fetchLangKeys();
|
||||||
|
setIsModalOpen(false);
|
||||||
|
} else {
|
||||||
|
if (data.message && data.message.includes("이미 존재하는 언어키")) {
|
||||||
|
alert(data.message);
|
||||||
|
} else {
|
||||||
|
alert(data.message || "언어 키 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("언어 키 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 체크박스 선택/해제
|
||||||
|
const handleCheckboxChange = (keyId: number, checked: boolean) => {
|
||||||
|
const newSelectedKeys = new Set(selectedKeys);
|
||||||
|
if (checked) {
|
||||||
|
newSelectedKeys.add(keyId);
|
||||||
|
} else {
|
||||||
|
newSelectedKeys.delete(keyId);
|
||||||
|
}
|
||||||
|
setSelectedKeys(newSelectedKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키 상태 토글
|
||||||
|
const handleToggleStatus = async (keyId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
alert(`키가 ${data.data}되었습니다.`);
|
||||||
|
fetchLangKeys();
|
||||||
|
} else {
|
||||||
|
alert("상태 변경 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("키 상태 변경 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 언어 상태 토글
|
||||||
|
const handleToggleLanguageStatus = async (langCode: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
alert(`언어가 ${data.data}되었습니다.`);
|
||||||
|
fetchLanguages();
|
||||||
|
} else {
|
||||||
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
|
||||||
|
setSelectedKeys(new Set(allKeyIds));
|
||||||
|
} else {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 키들 일괄 삭제
|
||||||
|
const handleDeleteSelectedKeys = async () => {
|
||||||
|
if (selectedKeys.size === 0) {
|
||||||
|
alert("삭제할 키를 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
|
||||||
|
|
||||||
|
const responses = await Promise.all(deletePromises);
|
||||||
|
const allSuccess = responses.every((response) => response.data.success);
|
||||||
|
|
||||||
|
if (allSuccess) {
|
||||||
|
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
fetchLangKeys();
|
||||||
|
|
||||||
|
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("일부 키 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("선택된 키 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 키 삭제
|
||||||
|
const handleDeleteKey = async (keyId: number) => {
|
||||||
|
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success) {
|
||||||
|
alert("언어 키가 영구적으로 삭제되었습니다.");
|
||||||
|
fetchLangKeys();
|
||||||
|
if (selectedKey && selectedKey.keyId === keyId) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("언어 키 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소 처리
|
||||||
|
const handleCancel = () => {
|
||||||
|
setSelectedKey(null);
|
||||||
|
setLangTexts([]);
|
||||||
|
setEditingTexts([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
initializeData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => {
|
||||||
|
const filteredKeys = getFilteredLangKeys();
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.size === filteredKeys.length && filteredKeys.length > 0}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedKeys.has(row.original.keyId)}
|
||||||
|
onChange={(e) => handleCheckboxChange(row.original.keyId, e.target.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4"
|
||||||
|
disabled={row.original.isActive === "N"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "companyCode",
|
||||||
|
header: "회사",
|
||||||
|
cell: ({ row }: any) => {
|
||||||
|
const companyName =
|
||||||
|
row.original.companyCode === "*"
|
||||||
|
? "공통"
|
||||||
|
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
|
||||||
|
|
||||||
|
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "menuName",
|
||||||
|
header: "메뉴명",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "langKey",
|
||||||
|
header: "언어 키",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
||||||
|
row.original.isActive === "N" ? "text-gray-400" : ""
|
||||||
|
}`}
|
||||||
|
onDoubleClick={() => handleEditKey(row.original)}
|
||||||
|
>
|
||||||
|
{row.original.langKey}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "설명",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "isActive",
|
||||||
|
header: "상태",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(row.original.keyId)}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
row.original.isActive === "Y"
|
||||||
|
? "bg-green-100 text-green-800 hover:bg-green-200"
|
||||||
|
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 언어 테이블 컬럼 정의
|
||||||
|
const languageColumns = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedLanguages.size === languages.length && languages.length > 0}
|
||||||
|
onChange={(e) => handleSelectAllLanguages(e.target.checked)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedLanguages.has(row.original.langCode)}
|
||||||
|
onChange={(e) => handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4"
|
||||||
|
disabled={row.original.isActive === "N"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "langCode",
|
||||||
|
header: "언어 코드",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
||||||
|
row.original.isActive === "N" ? "text-gray-400" : ""
|
||||||
|
}`}
|
||||||
|
onDoubleClick={() => handleEditLanguage(row.original)}
|
||||||
|
>
|
||||||
|
{row.original.langCode}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "langName",
|
||||||
|
header: "언어명 (영문)",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "langNative",
|
||||||
|
header: "언어명 (원어)",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "isActive",
|
||||||
|
header: "상태",
|
||||||
|
cell: ({ row }: any) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
row.original.isActive === "Y"
|
||||||
|
? "bg-green-100 text-green-800 hover:bg-green-200"
|
||||||
|
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="w-full max-w-none px-4 py-8">
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<MultiLang />
|
<div className="container mx-auto p-2">
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div className="flex space-x-1 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("keys")}
|
||||||
|
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
다국어 키 관리
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("languages")}
|
||||||
|
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
언어 관리
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 영역 */}
|
||||||
|
<div className="mt-2">
|
||||||
|
{/* 언어 관리 탭 */}
|
||||||
|
{activeTab === "languages" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>언어 관리</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{selectedLanguages.size > 0 && (
|
||||||
|
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
||||||
|
선택 삭제 ({selectedLanguages.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleAddLanguage}>새 언어 추가</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataTable data={languages} columns={languageColumns} searchable />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 다국어 키 관리 탭 */}
|
||||||
|
{activeTab === "keys" && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
||||||
|
{/* 좌측: 언어 키 목록 (7/10) */}
|
||||||
|
<Card className="lg:col-span-7">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>언어 키 목록</CardTitle>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
|
||||||
|
선택 삭제 ({selectedKeys.size})
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddKey}>새 키 추가</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 검색 필터 영역 */}
|
||||||
|
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="company">회사</Label>
|
||||||
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="전체 회사" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 회사</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.code} value={company.code}>
|
||||||
|
{company.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="search">검색</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="키명, 설명, 메뉴, 회사로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 영역 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={getFilteredLangKeys()}
|
||||||
|
searchable={false}
|
||||||
|
onRowClick={handleKeySelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
|
||||||
|
<Card className="lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{selectedKey ? (
|
||||||
|
<>
|
||||||
|
선택된 키:{" "}
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"다국어 편집"
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedKey ? (
|
||||||
|
<div>
|
||||||
|
{/* 스크롤 가능한 텍스트 영역 */}
|
||||||
|
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
|
||||||
|
{languages
|
||||||
|
.filter((lang) => lang.isActive === "Y")
|
||||||
|
.map((lang) => {
|
||||||
|
const text = editingTexts.find((t) => t.langCode === lang.langCode);
|
||||||
|
return (
|
||||||
|
<div key={lang.langCode} className="flex items-center space-x-4">
|
||||||
|
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
|
||||||
|
{lang.langName}
|
||||||
|
</Badge>
|
||||||
|
<Input
|
||||||
|
placeholder={`${lang.langName} 텍스트 입력`}
|
||||||
|
value={text?.langText || ""}
|
||||||
|
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 저장 버튼 - 고정 위치 */}
|
||||||
|
<div className="mt-4 flex space-x-2 border-t pt-4">
|
||||||
|
<Button onClick={handleSave}>저장</Button>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
||||||
|
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 언어 키 추가/수정 모달 */}
|
||||||
|
<LangKeyModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSaveKey}
|
||||||
|
keyData={editingKey}
|
||||||
|
companies={companies}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 언어 추가/수정 모달 */}
|
||||||
|
<LanguageModal
|
||||||
|
isOpen={isLanguageModalOpen}
|
||||||
|
onClose={() => setIsLanguageModalOpen(false)}
|
||||||
|
onSave={handleSaveLanguage}
|
||||||
|
languageData={editingLanguage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,115 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useState, useEffect } from "react";
|
||||||
import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
|
||||||
|
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
|
||||||
|
import type { Department } from "@/types/department";
|
||||||
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 관리 메인 페이지
|
||||||
|
* 좌측: 부서 구조, 우측: 부서 인원
|
||||||
|
*/
|
||||||
export default function DepartmentManagementPage() {
|
export default function DepartmentManagementPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const companyCode = params.companyCode as string;
|
const companyCode = params.companyCode as string;
|
||||||
|
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("structure");
|
||||||
|
const [companyName, setCompanyName] = useState<string>("");
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
return <DepartmentManagement companyCode={companyCode} />;
|
// 부서원 변경 시 부서 구조 새로고침
|
||||||
|
const handleMemberChange = () => {
|
||||||
|
setRefreshTrigger((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 회사 정보 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCompanyInfo = async () => {
|
||||||
|
const response = await getCompanyList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const company = response.data.find((c) => c.company_code === companyCode);
|
||||||
|
if (company) {
|
||||||
|
setCompanyName(company.company_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCompanyInfo();
|
||||||
|
}, [companyCode]);
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
router.push("/admin/userMng/companyList");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
회사 목록
|
||||||
|
</Button>
|
||||||
|
<div className="bg-border h-6 w-px" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">부서 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 탭 네비게이션 (모바일용) */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="structure">부서 구조</TabsTrigger>
|
||||||
|
<TabsTrigger value="members">부서 인원</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="structure" className="mt-4">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="members" className="mt-4">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onMemberChange={handleMemberChange}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 좌우 레이아웃 (데스크톱) */}
|
||||||
|
<div className="hidden h-full gap-6 lg:flex">
|
||||||
|
{/* 좌측: 부서 구조 (20%) */}
|
||||||
|
<div className="w-[20%] border-r pr-6">
|
||||||
|
<DepartmentStructure
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onSelectDepartment={setSelectedDepartment}
|
||||||
|
refreshTrigger={refreshTrigger}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 부서 인원 (80%) */}
|
||||||
|
<div className="w-[80%] pl-0">
|
||||||
|
<DepartmentMembers
|
||||||
|
companyCode={companyCode}
|
||||||
|
selectedDepartment={selectedDepartment}
|
||||||
|
onMemberChange={handleMemberChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,56 @@
|
||||||
import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
"use client";
|
||||||
|
|
||||||
|
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
||||||
|
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
||||||
|
import { CompanyTable } from "@/components/admin/CompanyTable";
|
||||||
|
import { CompanyFormModal } from "@/components/admin/CompanyFormModal";
|
||||||
|
import { CompanyDeleteDialog } from "@/components/admin/CompanyDeleteDialog";
|
||||||
|
import { DiskUsageSummary } from "@/components/admin/DiskUsageSummary";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회사 관리 페이지
|
* 회사 관리 페이지
|
||||||
|
* 모든 회사 관리 기능을 통합하여 제공
|
||||||
*/
|
*/
|
||||||
export default function CompanyPage() {
|
export default function CompanyPage() {
|
||||||
|
const {
|
||||||
|
// 데이터
|
||||||
|
companies,
|
||||||
|
searchFilter,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// 디스크 사용량 관련
|
||||||
|
diskUsageInfo,
|
||||||
|
isDiskUsageLoading,
|
||||||
|
loadDiskUsage,
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
modalState,
|
||||||
|
deleteState,
|
||||||
|
|
||||||
|
// 검색 기능
|
||||||
|
updateSearchFilter,
|
||||||
|
clearSearchFilter,
|
||||||
|
|
||||||
|
// 모달 제어
|
||||||
|
openCreateModal,
|
||||||
|
openEditModal,
|
||||||
|
closeModal,
|
||||||
|
updateFormData,
|
||||||
|
|
||||||
|
// 삭제 다이얼로그 제어
|
||||||
|
openDeleteDialog,
|
||||||
|
closeDeleteDialog,
|
||||||
|
|
||||||
|
// CRUD 작업
|
||||||
|
saveCompany,
|
||||||
|
deleteCompany,
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
clearError,
|
||||||
|
} = useCompanyManagement();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|
@ -14,8 +60,42 @@ export default function CompanyPage() {
|
||||||
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
<p className="text-sm text-muted-foreground">시스템에서 사용하는 회사 정보를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 디스크 사용량 요약 */}
|
||||||
<CompanyManagement />
|
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
||||||
|
|
||||||
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
|
<CompanyToolbar
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
totalCount={companies.length}
|
||||||
|
filteredCount={companies.length}
|
||||||
|
onSearchChange={updateSearchFilter}
|
||||||
|
onSearchClear={clearSearchFilter}
|
||||||
|
onCreateClick={openCreateModal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 회사 목록 테이블 */}
|
||||||
|
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
|
||||||
|
|
||||||
|
{/* 회사 등록/수정 모달 */}
|
||||||
|
<CompanyFormModal
|
||||||
|
modalState={modalState}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
onClose={closeModal}
|
||||||
|
onSave={saveCompany}
|
||||||
|
onFormChange={updateFormData}
|
||||||
|
onClearError={clearError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 회사 삭제 확인 다이얼로그 */}
|
||||||
|
<CompanyDeleteDialog
|
||||||
|
deleteState={deleteState}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
onClose={closeDeleteDialog}
|
||||||
|
onConfirm={deleteCompany}
|
||||||
|
onClearError={clearError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowLeft, Users, Menu as MenuIcon, Save, AlertCircle } from "lucide-react";
|
||||||
|
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { DualListBox } from "@/components/common/DualListBox";
|
||||||
|
import { MenuPermissionsTable } from "@/components/admin/MenuPermissionsTable";
|
||||||
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 상세 페이지
|
* 권한 그룹 상세 페이지
|
||||||
* URL: /admin/roles/[id]
|
* URL: /admin/userMng/rolesList/[id]
|
||||||
*
|
*
|
||||||
* 기능:
|
* 기능:
|
||||||
* - 권한 그룹 멤버 관리 (Dual List Box)
|
* - 권한 그룹 멤버 관리 (Dual List Box)
|
||||||
|
|
@ -14,13 +22,324 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
*/
|
*/
|
||||||
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
|
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
|
||||||
const { id } = use(params);
|
const { id: roleId } = use(params);
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const { refreshMenus } = useMenu();
|
||||||
|
|
||||||
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [roleGroup, setRoleGroup] = useState<RoleGroup | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 탭 상태
|
||||||
|
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
|
||||||
|
|
||||||
|
// 멤버 관리 상태
|
||||||
|
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
|
||||||
|
const [isSavingMembers, setIsSavingMembers] = useState(false);
|
||||||
|
|
||||||
|
// 메뉴 권한 상태
|
||||||
|
const [menuPermissions, setMenuPermissions] = useState<any[]>([]);
|
||||||
|
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadRoleGroup = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await roleAPI.getById(parseInt(roleId, 10));
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRoleGroup(response.data);
|
||||||
|
} else {
|
||||||
|
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("권한 그룹 정보 로드 오류:", err);
|
||||||
|
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [roleId]);
|
||||||
|
|
||||||
|
// 멤버 목록 로드
|
||||||
|
const loadMembers = useCallback(async () => {
|
||||||
|
if (!roleGroup) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 권한 그룹 멤버 조회
|
||||||
|
const membersResponse = await roleAPI.getMembers(roleGroup.objid);
|
||||||
|
if (membersResponse.success && membersResponse.data) {
|
||||||
|
setSelectedUsers(
|
||||||
|
membersResponse.data.map((member: any) => ({
|
||||||
|
id: member.userId,
|
||||||
|
label: member.userName || member.userId,
|
||||||
|
description: member.deptName,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 전체 사용자 목록 조회 (같은 회사)
|
||||||
|
const userAPI = await import("@/lib/api/user");
|
||||||
|
|
||||||
|
console.log("🔍 사용자 목록 조회 요청:", {
|
||||||
|
companyCode: roleGroup.companyCode,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersResponse = await userAPI.userAPI.getList({
|
||||||
|
companyCode: roleGroup.companyCode,
|
||||||
|
size: 1000, // 대량 조회
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 사용자 목록 응답:", {
|
||||||
|
success: usersResponse.success,
|
||||||
|
count: usersResponse.data?.length,
|
||||||
|
total: usersResponse.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usersResponse.success && usersResponse.data) {
|
||||||
|
setAvailableUsers(
|
||||||
|
usersResponse.data.map((user: any) => ({
|
||||||
|
id: user.userId,
|
||||||
|
label: user.userName || user.userId,
|
||||||
|
description: user.deptName,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("멤버 목록 로드 오류:", err);
|
||||||
|
}
|
||||||
|
}, [roleGroup]);
|
||||||
|
|
||||||
|
// 메뉴 권한 로드
|
||||||
|
const loadMenuPermissions = useCallback(async () => {
|
||||||
|
if (!roleGroup) return;
|
||||||
|
|
||||||
|
console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
|
||||||
|
roleGroupId: roleGroup.objid,
|
||||||
|
roleGroupName: roleGroup.authName,
|
||||||
|
companyCode: roleGroup.companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await roleAPI.getMenuPermissions(roleGroup.objid);
|
||||||
|
|
||||||
|
console.log("✅ [loadMenuPermissions] API 응답", {
|
||||||
|
success: response.success,
|
||||||
|
dataCount: response.data?.length,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setMenuPermissions(response.data);
|
||||||
|
console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
|
||||||
|
count: response.data.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
|
||||||
|
message: response.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
|
||||||
|
}
|
||||||
|
}, [roleGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRoleGroup();
|
||||||
|
}, [loadRoleGroup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roleGroup && activeTab === "members") {
|
||||||
|
loadMembers();
|
||||||
|
} else if (roleGroup && activeTab === "permissions") {
|
||||||
|
loadMenuPermissions();
|
||||||
|
}
|
||||||
|
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
|
||||||
|
|
||||||
|
// 멤버 저장 핸들러
|
||||||
|
const handleSaveMembers = useCallback(async () => {
|
||||||
|
if (!roleGroup) return;
|
||||||
|
|
||||||
|
setIsSavingMembers(true);
|
||||||
|
try {
|
||||||
|
// 현재 선택된 사용자 ID 목록
|
||||||
|
const selectedUserIds = selectedUsers.map((user) => user.id);
|
||||||
|
|
||||||
|
// 멤버 업데이트 API 호출
|
||||||
|
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
alert("멤버가 성공적으로 저장되었습니다.");
|
||||||
|
loadMembers(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
|
||||||
|
await refreshMenus();
|
||||||
|
} else {
|
||||||
|
alert(response.message || "멤버 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("멤버 저장 오류:", err);
|
||||||
|
alert("멤버 저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsSavingMembers(false);
|
||||||
|
}
|
||||||
|
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
|
||||||
|
|
||||||
|
// 메뉴 권한 저장 핸들러
|
||||||
|
const handleSavePermissions = useCallback(async () => {
|
||||||
|
if (!roleGroup) return;
|
||||||
|
|
||||||
|
setIsSavingPermissions(true);
|
||||||
|
try {
|
||||||
|
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
||||||
|
loadMenuPermissions(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
|
||||||
|
await refreshMenus();
|
||||||
|
} else {
|
||||||
|
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("메뉴 권한 저장 오류:", err);
|
||||||
|
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsSavingPermissions(false);
|
||||||
|
}
|
||||||
|
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">권한 그룹 정보를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !roleGroup) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">오류 발생</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
|
||||||
|
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
|
||||||
|
목록으로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 페이지 헤더 */}
|
||||||
<RoleDetailManagement roleId={id} />
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{roleGroup.authCode} • {roleGroup.companyCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||||
|
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{roleGroup.status === "active" ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<div className="flex gap-4 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("members")}
|
||||||
|
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "members"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
멤버 관리 ({selectedUsers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("permissions")}
|
||||||
|
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "permissions"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MenuIcon className="h-4 w-4" />
|
||||||
|
메뉴 권한 ({menuPermissions.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activeTab === "members" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">멤버 관리</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">이 권한 그룹에 속한 사용자를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSavingMembers ? "저장 중..." : "멤버 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DualListBox
|
||||||
|
availableItems={availableUsers}
|
||||||
|
selectedItems={selectedUsers}
|
||||||
|
onSelectionChange={setSelectedUsers}
|
||||||
|
availableLabel="전체 사용자"
|
||||||
|
selectedLabel="그룹 멤버"
|
||||||
|
enableSearch
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "permissions" && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">메뉴 권한 설정</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{isSavingPermissions ? "저장 중..." : "권한 저장"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MenuPermissionsTable
|
||||||
|
permissions={menuPermissions}
|
||||||
|
onPermissionsChange={setMenuPermissions}
|
||||||
|
roleGroup={roleGroup}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RoleManagement } from "@/components/admin/RoleManagement";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
|
||||||
|
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { RoleFormModal } from "@/components/admin/RoleFormModal";
|
||||||
|
import { RoleDeleteModal } from "@/components/admin/RoleDeleteModal";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { companyAPI } from "@/lib/api/company";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -14,21 +24,336 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
* - 권한 그룹 생성/수정/삭제
|
* - 권한 그룹 생성/수정/삭제
|
||||||
* - 멤버 관리 (Dual List Box)
|
* - 멤버 관리 (Dual List Box)
|
||||||
* - 메뉴 권한 설정 (CRUD 권한)
|
* - 메뉴 권한 설정 (CRUD 권한)
|
||||||
|
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
|
||||||
*/
|
*/
|
||||||
export default function RolesPage() {
|
export default function RolesPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 회사 관리자 또는 최고 관리자 여부
|
||||||
|
const isAdmin =
|
||||||
|
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
|
||||||
|
currentUser?.userType === "COMPANY_ADMIN";
|
||||||
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 회사 필터 (최고 관리자 전용)
|
||||||
|
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [formModal, setFormModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
editingRole: null as RoleGroup | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteModal, setDeleteModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
role: null as RoleGroup | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 회사 목록 로드 (최고 관리자만)
|
||||||
|
const loadCompanies = useCallback(async () => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companies = await companyAPI.getList();
|
||||||
|
setCompanies(companies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 목록 로드 오류:", error);
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadRoleGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
|
||||||
|
// 회사 관리자: 자기 회사만 조회
|
||||||
|
const companyFilter =
|
||||||
|
isSuperAdmin && selectedCompany !== "all"
|
||||||
|
? selectedCompany
|
||||||
|
: isSuperAdmin
|
||||||
|
? undefined
|
||||||
|
: currentUser?.companyCode;
|
||||||
|
|
||||||
|
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
|
||||||
|
|
||||||
|
const response = await roleAPI.getList({
|
||||||
|
companyCode: companyFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRoleGroups(response.data);
|
||||||
|
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
||||||
|
} else {
|
||||||
|
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("권한 그룹 목록 로드 오류:", err);
|
||||||
|
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
|
||||||
|
}
|
||||||
|
loadRoleGroups();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
|
||||||
|
|
||||||
|
// 권한 그룹 생성 핸들러
|
||||||
|
const handleCreateRole = useCallback(() => {
|
||||||
|
setFormModal({ isOpen: true, editingRole: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 그룹 수정 핸들러
|
||||||
|
const handleEditRole = useCallback((role: RoleGroup) => {
|
||||||
|
setFormModal({ isOpen: true, editingRole: role });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 그룹 삭제 핸들러
|
||||||
|
const handleDeleteRole = useCallback((role: RoleGroup) => {
|
||||||
|
setDeleteModal({ isOpen: true, role });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 폼 모달 닫기
|
||||||
|
const handleFormModalClose = useCallback(() => {
|
||||||
|
setFormModal({ isOpen: false, editingRole: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 삭제 모달 닫기
|
||||||
|
const handleDeleteModalClose = useCallback(() => {
|
||||||
|
setDeleteModal({ isOpen: false, role: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 모달 성공 후 새로고침
|
||||||
|
const handleModalSuccess = useCallback(() => {
|
||||||
|
loadRoleGroups();
|
||||||
|
}, [loadRoleGroups]);
|
||||||
|
|
||||||
|
// 상세 페이지로 이동
|
||||||
|
const handleViewDetail = useCallback(
|
||||||
|
(role: RoleGroup) => {
|
||||||
|
router.push(`/admin/userMng/rolesList/${role.objid}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 관리자가 아니면 접근 제한
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||||
|
권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
뒤로 가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">권한 그룹 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)</p>
|
||||||
회사 내 권한 그룹을 생성하고 멤버를 관리합니다 (회사 관리자 이상)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 에러 메시지 */}
|
||||||
<RoleManagement />
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 영역 */}
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">권한 그룹 목록 ({roleGroups.length})</h2>
|
||||||
|
|
||||||
|
{/* 최고 관리자 전용: 회사 필터 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="text-muted-foreground h-4 w-4" />
|
||||||
|
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
||||||
|
<SelectTrigger className="h-10 w-[200px]">
|
||||||
|
<SelectValue placeholder="회사 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 회사</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.company_code} value={company.company_code}>
|
||||||
|
{company.company_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedCompany !== "all" && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleCreateRole} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
권한 그룹 생성
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 권한 그룹 목록 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-card rounded-lg border p-12 shadow-sm">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||||
|
<p className="text-muted-foreground text-sm">권한 그룹 목록을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : roleGroups.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">등록된 권한 그룹이 없습니다.</p>
|
||||||
|
<p className="text-muted-foreground text-xs">권한 그룹을 생성하여 멤버를 관리해보세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{roleGroups.map((role) => (
|
||||||
|
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
|
||||||
|
{/* 헤더 (클릭 시 상세 페이지) */}
|
||||||
|
<div
|
||||||
|
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
|
||||||
|
onClick={() => handleViewDetail(role)}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-base font-semibold">{role.authName}</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role.status === "active" ? "활성" : "비활성"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
{/* 최고 관리자는 회사명 표시 */}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">회사</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
멤버 수
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{role.memberCount || 0}명</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<Menu className="h-3 w-3" />
|
||||||
|
메뉴 권한
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{role.menuCount || 0}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex gap-2 border-t p-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditRole(role);
|
||||||
|
}}
|
||||||
|
className="flex-1 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteRole(role);
|
||||||
|
}}
|
||||||
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 모달들 */}
|
||||||
|
<RoleFormModal
|
||||||
|
isOpen={formModal.isOpen}
|
||||||
|
onClose={handleFormModalClose}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
editingRole={formModal.editingRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleDeleteModal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
onClose={handleDeleteModalClose}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
role={deleteModal.role}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { UserAuthTable } from "@/components/admin/UserAuthTable";
|
||||||
|
import { UserAuthEditModal } from "@/components/admin/UserAuthEditModal";
|
||||||
|
import { userAPI } from "@/lib/api/user";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,6 +17,119 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
|
* 사용자별 권한 레벨(SUPER_ADMIN, COMPANY_ADMIN, USER 등) 관리
|
||||||
*/
|
*/
|
||||||
export default function UserAuthPage() {
|
export default function UserAuthPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
// 최고 관리자 여부
|
||||||
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [paginationInfo, setPaginationInfo] = useState({
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
totalItems: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 권한 변경 모달
|
||||||
|
const [authEditModal, setAuthEditModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
user: null as any | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const loadUsers = useCallback(
|
||||||
|
async (page: number = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await userAPI.getList({
|
||||||
|
page,
|
||||||
|
size: paginationInfo.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setUsers(response.data);
|
||||||
|
setPaginationInfo({
|
||||||
|
currentPage: response.currentPage || page,
|
||||||
|
pageSize: response.pageSize || paginationInfo.pageSize,
|
||||||
|
totalItems: response.total || 0,
|
||||||
|
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("사용자 목록 로드 오류:", err);
|
||||||
|
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[paginationInfo.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 권한 변경 핸들러
|
||||||
|
const handleEditAuth = (user: any) => {
|
||||||
|
setAuthEditModal({
|
||||||
|
isOpen: true,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 변경 모달 닫기
|
||||||
|
const handleAuthEditClose = () => {
|
||||||
|
setAuthEditModal({
|
||||||
|
isOpen: false,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 변경 성공
|
||||||
|
const handleAuthEditSuccess = () => {
|
||||||
|
loadUsers(paginationInfo.currentPage);
|
||||||
|
handleAuthEditClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
loadUsers(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 최고 관리자가 아닌 경우
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">사용자 권한 관리</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||||
|
권한 관리는 최고 관리자만 접근할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
뒤로 가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|
@ -20,8 +139,39 @@ export default function UserAuthPage() {
|
||||||
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
<p className="text-muted-foreground text-sm">사용자별 권한 레벨을 관리합니다. (최고 관리자 전용)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 에러 메시지 */}
|
||||||
<UserAuthManagement />
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 권한 테이블 */}
|
||||||
|
<UserAuthTable
|
||||||
|
users={users}
|
||||||
|
isLoading={isLoading}
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onEditAuth={handleEditAuth}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 권한 변경 모달 */}
|
||||||
|
<UserAuthEditModal
|
||||||
|
isOpen={authEditModal.isOpen}
|
||||||
|
onClose={handleAuthEditClose}
|
||||||
|
onSuccess={handleAuthEditSuccess}
|
||||||
|
user={authEditModal.user}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserManagement } from "@/components/admin/UserManagement";
|
import { useState } from "react";
|
||||||
|
import { useUserManagement } from "@/hooks/useUserManagement";
|
||||||
|
import { UserToolbar } from "@/components/admin/UserToolbar";
|
||||||
|
import { UserTable } from "@/components/admin/UserTable";
|
||||||
|
import { Pagination } from "@/components/common/Pagination";
|
||||||
|
import { UserPasswordResetModal } from "@/components/admin/UserPasswordResetModal";
|
||||||
|
import { UserFormModal } from "@/components/admin/UserFormModal";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -8,8 +14,100 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
* URL: /admin/userMng
|
* URL: /admin/userMng
|
||||||
*
|
*
|
||||||
* shadcn/ui 스타일 가이드 적용
|
* shadcn/ui 스타일 가이드 적용
|
||||||
|
* - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
|
||||||
|
* - 실제 데이터베이스와 연동되어 작동
|
||||||
*/
|
*/
|
||||||
export default function UserMngPage() {
|
export default function UserMngPage() {
|
||||||
|
const {
|
||||||
|
// 데이터
|
||||||
|
users,
|
||||||
|
searchFilter,
|
||||||
|
isLoading,
|
||||||
|
isSearching,
|
||||||
|
error,
|
||||||
|
paginationInfo,
|
||||||
|
|
||||||
|
// 검색 기능
|
||||||
|
updateSearchFilter,
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
handlePageChange,
|
||||||
|
handlePageSizeChange,
|
||||||
|
|
||||||
|
// 액션 핸들러
|
||||||
|
handleStatusToggle,
|
||||||
|
|
||||||
|
// 유틸리티
|
||||||
|
clearError,
|
||||||
|
refreshData,
|
||||||
|
} = useUserManagement();
|
||||||
|
|
||||||
|
// 비밀번호 초기화 모달 상태
|
||||||
|
const [passwordResetModal, setPasswordResetModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null as string | null,
|
||||||
|
userName: null as string | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 등록/수정 모달 상태
|
||||||
|
const [userFormModal, setUserFormModal] = useState({
|
||||||
|
isOpen: false,
|
||||||
|
editingUser: null as any | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사용자 등록 핸들러
|
||||||
|
const handleCreateUser = () => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: true,
|
||||||
|
editingUser: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 수정 핸들러
|
||||||
|
const handleEditUser = (user: any) => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: true,
|
||||||
|
editingUser: user,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 등록/수정 모달 닫기
|
||||||
|
const handleUserFormClose = () => {
|
||||||
|
setUserFormModal({
|
||||||
|
isOpen: false,
|
||||||
|
editingUser: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사용자 등록/수정 성공 핸들러
|
||||||
|
const handleUserFormSuccess = () => {
|
||||||
|
refreshData();
|
||||||
|
handleUserFormClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 핸들러
|
||||||
|
const handlePasswordReset = (userId: string, userName: string) => {
|
||||||
|
setPasswordResetModal({
|
||||||
|
isOpen: true,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 모달 닫기
|
||||||
|
const handlePasswordResetClose = () => {
|
||||||
|
setPasswordResetModal({
|
||||||
|
isOpen: false,
|
||||||
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호 초기화 성공 핸들러
|
||||||
|
const handlePasswordResetSuccess = () => {
|
||||||
|
handlePasswordResetClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|
@ -19,8 +117,70 @@ export default function UserMngPage() {
|
||||||
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
<p className="text-sm text-muted-foreground">시스템 사용자 계정 및 권한을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
||||||
<UserManagement />
|
<UserToolbar
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
totalCount={paginationInfo.totalItems}
|
||||||
|
isSearching={isSearching}
|
||||||
|
onSearchChange={updateSearchFilter}
|
||||||
|
onCreateClick={handleCreateUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={clearError}
|
||||||
|
className="text-destructive hover:text-destructive/80 transition-colors"
|
||||||
|
aria-label="에러 메시지 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 목록 테이블 */}
|
||||||
|
<UserTable
|
||||||
|
users={users}
|
||||||
|
isLoading={isLoading}
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onStatusToggle={handleStatusToggle}
|
||||||
|
onPasswordReset={handlePasswordReset}
|
||||||
|
onEdit={handleEditUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!isLoading && users.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
className="mt-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용자 등록/수정 모달 */}
|
||||||
|
<UserFormModal
|
||||||
|
isOpen={userFormModal.isOpen}
|
||||||
|
onClose={handleUserFormClose}
|
||||||
|
onSuccess={handleUserFormSuccess}
|
||||||
|
editingUser={userFormModal.editingUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 비밀번호 초기화 모달 */}
|
||||||
|
<UserPasswordResetModal
|
||||||
|
isOpen={passwordResetModal.isOpen}
|
||||||
|
onClose={handlePasswordResetClose}
|
||||||
|
userId={passwordResetModal.userId}
|
||||||
|
userName={passwordResetModal.userName}
|
||||||
|
onSuccess={handlePasswordResetSuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge";
|
||||||
export default function MainPage() {
|
export default function MainPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
{/* 메인 컨텐츠 */}
|
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
|
||||||
import { CompanyToolbar } from "./CompanyToolbar";
|
|
||||||
import { CompanyTable } from "./CompanyTable";
|
|
||||||
import { CompanyFormModal } from "./CompanyFormModal";
|
|
||||||
import { CompanyDeleteDialog } from "./CompanyDeleteDialog";
|
|
||||||
import { DiskUsageSummary } from "./DiskUsageSummary";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회사 관리 메인 컴포넌트
|
|
||||||
* 모든 회사 관리 기능을 통합하여 제공
|
|
||||||
*/
|
|
||||||
export function CompanyManagement() {
|
|
||||||
const {
|
|
||||||
// 데이터
|
|
||||||
companies,
|
|
||||||
searchFilter,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// 디스크 사용량 관련
|
|
||||||
diskUsageInfo,
|
|
||||||
isDiskUsageLoading,
|
|
||||||
loadDiskUsage,
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
modalState,
|
|
||||||
deleteState,
|
|
||||||
|
|
||||||
// 검색 기능
|
|
||||||
updateSearchFilter,
|
|
||||||
clearSearchFilter,
|
|
||||||
|
|
||||||
// 모달 제어
|
|
||||||
openCreateModal,
|
|
||||||
openEditModal,
|
|
||||||
closeModal,
|
|
||||||
updateFormData,
|
|
||||||
|
|
||||||
// 삭제 다이얼로그 제어
|
|
||||||
openDeleteDialog,
|
|
||||||
closeDeleteDialog,
|
|
||||||
|
|
||||||
// CRUD 작업
|
|
||||||
saveCompany,
|
|
||||||
deleteCompany,
|
|
||||||
|
|
||||||
// 에러 처리
|
|
||||||
clearError,
|
|
||||||
} = useCompanyManagement();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 디스크 사용량 요약 */}
|
|
||||||
<DiskUsageSummary diskUsageInfo={diskUsageInfo} isLoading={isDiskUsageLoading} onRefresh={loadDiskUsage} />
|
|
||||||
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
|
||||||
<CompanyToolbar
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
totalCount={companies.length} // 실제 API에서 가져온 데이터 개수 사용
|
|
||||||
filteredCount={companies.length}
|
|
||||||
onSearchChange={updateSearchFilter}
|
|
||||||
onSearchClear={clearSearchFilter}
|
|
||||||
onCreateClick={openCreateModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 회사 목록 테이블 */}
|
|
||||||
<CompanyTable companies={companies} isLoading={isLoading} onEdit={openEditModal} onDelete={openDeleteDialog} />
|
|
||||||
|
|
||||||
{/* 회사 등록/수정 모달 */}
|
|
||||||
<CompanyFormModal
|
|
||||||
modalState={modalState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
onClose={closeModal}
|
|
||||||
onSave={saveCompany}
|
|
||||||
onFormChange={updateFormData}
|
|
||||||
onClearError={clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 회사 삭제 확인 다이얼로그 */}
|
|
||||||
<CompanyDeleteDialog
|
|
||||||
deleteState={deleteState}
|
|
||||||
isLoading={isLoading}
|
|
||||||
error={error}
|
|
||||||
onClose={closeDeleteDialog}
|
|
||||||
onConfirm={deleteCompany}
|
|
||||||
onClearError={clearError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Building2, Search } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { logger } from "@/lib/utils/logger";
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
company_code: string;
|
||||||
|
company_name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanySwitcherProps {
|
||||||
|
onClose?: () => void;
|
||||||
|
isOpen?: boolean; // Dialog 열림 상태 (AppLayout에서 전달)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WACE 관리자 전용: 회사 선택 및 전환 컴포넌트
|
||||||
|
*
|
||||||
|
* - WACE 관리자(company_code = "*", userType = "SUPER_ADMIN")만 표시
|
||||||
|
* - 회사 선택 시 해당 회사로 전환하여 시스템 사용
|
||||||
|
* - JWT 토큰 재발급으로 company_code 변경
|
||||||
|
*/
|
||||||
|
export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProps = {}) {
|
||||||
|
const { user, switchCompany } = useAuth();
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [filteredCompanies, setFilteredCompanies] = useState<Company[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// WACE 관리자 권한 체크 (userType만 확인)
|
||||||
|
const isWaceAdmin = user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
// 현재 선택된 회사명 표시
|
||||||
|
const currentCompanyName = React.useMemo(() => {
|
||||||
|
if (!user?.companyCode) return "로딩 중...";
|
||||||
|
|
||||||
|
if (user.companyCode === "*") {
|
||||||
|
return "WACE (최고 관리자)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// companies 배열에서 현재 회사 찾기
|
||||||
|
const currentCompany = companies.find(c => c.company_code === user.companyCode);
|
||||||
|
return currentCompany?.company_name || user.companyCode;
|
||||||
|
}, [user?.companyCode, companies]);
|
||||||
|
|
||||||
|
// 회사 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWaceAdmin && isOpen) {
|
||||||
|
fetchCompanies();
|
||||||
|
}
|
||||||
|
}, [isWaceAdmin, isOpen]);
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchText.trim() === "") {
|
||||||
|
setFilteredCompanies(companies);
|
||||||
|
} else {
|
||||||
|
const filtered = companies.filter(company =>
|
||||||
|
company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
company.company_code.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredCompanies(filtered);
|
||||||
|
}
|
||||||
|
}, [searchText, companies]);
|
||||||
|
|
||||||
|
const fetchCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiClient.get("/admin/companies/db");
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
// 활성 상태의 회사만 필터링 + company_code="*" 제외 (WACE는 별도 추가)
|
||||||
|
const activeCompanies = response.data.data
|
||||||
|
.filter((c: Company) => c.company_code !== "*") // DB의 "*" 제외
|
||||||
|
.filter((c: Company) => c.status === "active" || !c.status)
|
||||||
|
.sort((a: Company, b: Company) => a.company_name.localeCompare(b.company_name));
|
||||||
|
|
||||||
|
// WACE 복귀 옵션 추가
|
||||||
|
const companiesWithWace: Company[] = [
|
||||||
|
{
|
||||||
|
company_code: "*",
|
||||||
|
company_name: "WACE (최고 관리자)",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
...activeCompanies,
|
||||||
|
];
|
||||||
|
|
||||||
|
setCompanies(companiesWithWace);
|
||||||
|
setFilteredCompanies(companiesWithWace);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("회사 목록 조회 실패", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompanySwitch = async (companyCode: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await switchCompany(companyCode);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.message || "회사 전환에 실패했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("회사 전환 성공", { companyCode });
|
||||||
|
|
||||||
|
// 즉시 페이지 새로고침 (토큰이 이미 저장됨)
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("회사 전환 실패", error);
|
||||||
|
alert(error.message || "회사 전환 중 오류가 발생했습니다.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WACE 관리자가 아니면 렌더링하지 않음
|
||||||
|
if (!isWaceAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 현재 회사 정보 */}
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
||||||
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">현재 관리 회사</p>
|
||||||
|
<p className="text-sm font-semibold">{currentCompanyName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="회사명 또는 코드 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 목록 */}
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : filteredCompanies.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCompanies.map((company) => (
|
||||||
|
<div
|
||||||
|
key={company.company_code}
|
||||||
|
className={`flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||||
|
company.company_code === user?.companyCode
|
||||||
|
? "bg-accent/50 font-semibold"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCompanySwitch(company.company_code)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{company.company_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{company.company_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{company.company_code === user?.companyCode && (
|
||||||
|
<span className="text-xs text-primary">현재</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,288 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { BatchAPI, BatchMonitoring, BatchExecution } from "@/lib/api/batch";
|
|
||||||
|
|
||||||
export default function MonitoringDashboard() {
|
|
||||||
const [monitoring, setMonitoring] = useState<BatchMonitoring | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadMonitoringData();
|
|
||||||
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (autoRefresh) {
|
|
||||||
interval = setInterval(loadMonitoringData, 30000); // 30초마다 자동 새로고침
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (interval) clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [autoRefresh]);
|
|
||||||
|
|
||||||
const loadMonitoringData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await BatchAPI.getBatchMonitoring();
|
|
||||||
setMonitoring(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("모니터링 데이터 조회 오류:", error);
|
|
||||||
toast.error("모니터링 데이터를 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
loadMonitoringData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAutoRefresh = () => {
|
|
||||||
setAutoRefresh(!autoRefresh);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed':
|
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
||||||
case 'failed':
|
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
|
||||||
case 'running':
|
|
||||||
return <Play className="h-4 w-4 text-blue-500" />;
|
|
||||||
case 'pending':
|
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
|
||||||
default:
|
|
||||||
return <Clock className="h-4 w-4 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const variants = {
|
|
||||||
completed: "bg-green-100 text-green-800",
|
|
||||||
failed: "bg-destructive/20 text-red-800",
|
|
||||||
running: "bg-primary/20 text-blue-800",
|
|
||||||
pending: "bg-yellow-100 text-yellow-800",
|
|
||||||
cancelled: "bg-gray-100 text-gray-800",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
completed: "완료",
|
|
||||||
failed: "실패",
|
|
||||||
running: "실행 중",
|
|
||||||
pending: "대기 중",
|
|
||||||
cancelled: "취소됨",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge className={variants[status as keyof typeof variants] || variants.pending}>
|
|
||||||
{labels[status as keyof typeof labels] || status}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (ms: number) => {
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
return `${(ms / 60000).toFixed(1)}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSuccessRate = () => {
|
|
||||||
if (!monitoring) return 0;
|
|
||||||
const total = monitoring.successful_jobs_today + monitoring.failed_jobs_today;
|
|
||||||
if (total === 0) return 100;
|
|
||||||
return Math.round((monitoring.successful_jobs_today / total) * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!monitoring) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
|
||||||
<p>모니터링 데이터를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-2xl font-bold">배치 모니터링</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={toggleAutoRefresh}
|
|
||||||
className={autoRefresh ? "bg-accent text-primary" : ""}
|
|
||||||
>
|
|
||||||
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />}
|
|
||||||
자동 새로고침
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">총 작업 수</CardTitle>
|
|
||||||
<div className="text-2xl">📋</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{monitoring.total_jobs}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
활성: {monitoring.active_jobs}개
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">실행 중</CardTitle>
|
|
||||||
<div className="text-2xl">🔄</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
현재 실행 중인 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">오늘 성공</CardTitle>
|
|
||||||
<div className="text-2xl">✅</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
성공률: {getSuccessRate()}%
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">오늘 실패</CardTitle>
|
|
||||||
<div className="text-2xl">❌</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
주의가 필요한 작업
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 성공률 진행바 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">오늘 실행 성공률</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>성공: {monitoring.successful_jobs_today}건</span>
|
|
||||||
<span>실패: {monitoring.failed_jobs_today}건</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={getSuccessRate()} className="h-2" />
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
|
||||||
{getSuccessRate()}% 성공률
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 최근 실행 이력 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">최근 실행 이력</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{monitoring.recent_executions.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
최근 실행 이력이 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>상태</TableHead>
|
|
||||||
<TableHead>작업 ID</TableHead>
|
|
||||||
<TableHead>시작 시간</TableHead>
|
|
||||||
<TableHead>완료 시간</TableHead>
|
|
||||||
<TableHead>실행 시간</TableHead>
|
|
||||||
<TableHead>오류 메시지</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{monitoring.recent_executions.map((execution) => (
|
|
||||||
<TableRow key={execution.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getStatusIcon(execution.execution_status)}
|
|
||||||
{getStatusBadge(execution.execution_status)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono">#{execution.job_id}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.started_at
|
|
||||||
? new Date(execution.started_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.completed_at
|
|
||||||
? new Date(execution.completed_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{execution.execution_time_ms
|
|
||||||
? formatDuration(execution.execution_time_ms)
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-xs">
|
|
||||||
{execution.error_message ? (
|
|
||||||
<span className="text-destructive text-sm truncate block">
|
|
||||||
{execution.error_message}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,859 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
import { DataTable } from "@/components/common/DataTable";
|
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import LangKeyModal from "./LangKeyModal";
|
|
||||||
import LanguageModal from "./LanguageModal";
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
|
|
||||||
interface Language {
|
|
||||||
langCode: string;
|
|
||||||
langName: string;
|
|
||||||
langNative: string;
|
|
||||||
isActive: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LangKey {
|
|
||||||
keyId: number;
|
|
||||||
companyCode: string;
|
|
||||||
menuName: string;
|
|
||||||
langKey: string;
|
|
||||||
description: string;
|
|
||||||
isActive: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LangText {
|
|
||||||
textId: number;
|
|
||||||
keyId: number;
|
|
||||||
langCode: string;
|
|
||||||
langText: string;
|
|
||||||
isActive: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MultiLangPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [languages, setLanguages] = useState<Language[]>([]);
|
|
||||||
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
|
|
||||||
const [selectedKey, setSelectedKey] = useState<LangKey | null>(null);
|
|
||||||
const [langTexts, setLangTexts] = useState<LangText[]>([]);
|
|
||||||
const [editingTexts, setEditingTexts] = useState<LangText[]>([]);
|
|
||||||
const [selectedCompany, setSelectedCompany] = useState("all");
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [editingKey, setEditingKey] = useState<LangKey | null>(null);
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// 언어 관리 관련 상태
|
|
||||||
const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false);
|
|
||||||
const [editingLanguage, setEditingLanguage] = useState<Language | null>(null);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
|
|
||||||
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
|
|
||||||
|
|
||||||
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
|
|
||||||
|
|
||||||
// 회사 목록 조회
|
|
||||||
const fetchCompanies = async () => {
|
|
||||||
try {
|
|
||||||
// console.log("회사 목록 조회 시작");
|
|
||||||
const response = await apiClient.get("/admin/companies");
|
|
||||||
// console.log("회사 목록 응답 데이터:", response.data);
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
const companyList = data.data.map((company: any) => ({
|
|
||||||
code: company.company_code,
|
|
||||||
name: company.company_name,
|
|
||||||
}));
|
|
||||||
// console.log("변환된 회사 목록:", companyList);
|
|
||||||
setCompanies(companyList);
|
|
||||||
} else {
|
|
||||||
// console.error("회사 목록 조회 실패:", data.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("회사 목록 조회 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 목록 조회
|
|
||||||
const fetchLanguages = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get("/multilang/languages");
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
setLanguages(data.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 목록 조회 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다국어 키 목록 조회
|
|
||||||
const fetchLangKeys = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get("/multilang/keys");
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
// console.log("✅ 전체 키 목록 로드:", data.data.length, "개");
|
|
||||||
setLangKeys(data.data);
|
|
||||||
} else {
|
|
||||||
// console.error("❌ 키 목록 로드 실패:", data.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("다국어 키 목록 조회 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필터링된 데이터 계산 - 메뉴관리와 동일한 방식
|
|
||||||
const getFilteredLangKeys = () => {
|
|
||||||
let filteredKeys = langKeys;
|
|
||||||
|
|
||||||
// 회사 필터링
|
|
||||||
if (selectedCompany && selectedCompany !== "all") {
|
|
||||||
filteredKeys = filteredKeys.filter((key) => key.companyCode === selectedCompany);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 텍스트 검색 필터링
|
|
||||||
if (searchText.trim()) {
|
|
||||||
const searchLower = searchText.toLowerCase();
|
|
||||||
filteredKeys = filteredKeys.filter((key) => {
|
|
||||||
const langKey = (key.langKey || "").toLowerCase();
|
|
||||||
const description = (key.description || "").toLowerCase();
|
|
||||||
const menuName = (key.menuName || "").toLowerCase();
|
|
||||||
const companyName = companies.find((c) => c.code === key.companyCode)?.name?.toLowerCase() || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
langKey.includes(searchLower) ||
|
|
||||||
description.includes(searchLower) ||
|
|
||||||
menuName.includes(searchLower) ||
|
|
||||||
companyName.includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredKeys;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 선택된 키의 다국어 텍스트 조회
|
|
||||||
const fetchLangTexts = async (keyId: number) => {
|
|
||||||
try {
|
|
||||||
// console.log("다국어 텍스트 조회 시작: keyId =", keyId);
|
|
||||||
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
|
|
||||||
const data = response.data;
|
|
||||||
// console.log("다국어 텍스트 조회 응답:", data);
|
|
||||||
if (data.success) {
|
|
||||||
setLangTexts(data.data);
|
|
||||||
// 편집용 텍스트 초기화
|
|
||||||
const editingData = data.data.map((text: LangText) => ({ ...text }));
|
|
||||||
setEditingTexts(editingData);
|
|
||||||
// console.log("편집용 텍스트 설정:", editingData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("다국어 텍스트 조회 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 키 선택 처리
|
|
||||||
const handleKeySelect = (key: LangKey) => {
|
|
||||||
// console.log("언어 키 선택:", key);
|
|
||||||
setSelectedKey(key);
|
|
||||||
fetchLangTexts(key.keyId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 디버깅용 useEffect
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedKey) {
|
|
||||||
// console.log("선택된 키 변경:", selectedKey);
|
|
||||||
// console.log("언어 목록:", languages);
|
|
||||||
// console.log("편집 텍스트:", editingTexts);
|
|
||||||
}
|
|
||||||
}, [selectedKey, languages, editingTexts]);
|
|
||||||
|
|
||||||
// 텍스트 변경 처리
|
|
||||||
const handleTextChange = (langCode: string, value: string) => {
|
|
||||||
const newEditingTexts = [...editingTexts];
|
|
||||||
const existingIndex = newEditingTexts.findIndex((t) => t.langCode === langCode);
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
newEditingTexts[existingIndex].langText = value;
|
|
||||||
} else {
|
|
||||||
newEditingTexts.push({
|
|
||||||
textId: 0,
|
|
||||||
keyId: selectedKey!.keyId,
|
|
||||||
langCode: langCode,
|
|
||||||
langText: value,
|
|
||||||
isActive: "Y",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingTexts(newEditingTexts);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 텍스트 저장
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!selectedKey) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 백엔드가 기대하는 형식으로 데이터 변환
|
|
||||||
const requestData = {
|
|
||||||
texts: editingTexts.map((text) => ({
|
|
||||||
langCode: text.langCode,
|
|
||||||
langText: text.langText,
|
|
||||||
isActive: text.isActive || "Y",
|
|
||||||
createdBy: user?.userId || "system",
|
|
||||||
updatedBy: user?.userId || "system",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/multilang/keys/${selectedKey.keyId}/texts`, requestData);
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
alert("저장되었습니다.");
|
|
||||||
// 저장 후 다시 조회
|
|
||||||
fetchLangTexts(selectedKey.keyId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("텍스트 저장 실패:", error);
|
|
||||||
alert("저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 키 추가/수정 모달 열기
|
|
||||||
const handleAddKey = () => {
|
|
||||||
setEditingKey(null); // 새 키 추가는 null로 설정
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 추가/수정 모달 열기
|
|
||||||
const handleAddLanguage = () => {
|
|
||||||
setEditingLanguage(null);
|
|
||||||
setIsLanguageModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 수정
|
|
||||||
const handleEditLanguage = (language: Language) => {
|
|
||||||
setEditingLanguage(language);
|
|
||||||
setIsLanguageModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 저장 (추가/수정)
|
|
||||||
const handleSaveLanguage = async (languageData: any) => {
|
|
||||||
try {
|
|
||||||
const requestData = {
|
|
||||||
...languageData,
|
|
||||||
createdBy: user?.userId || "admin",
|
|
||||||
updatedBy: user?.userId || "admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
let response;
|
|
||||||
if (editingLanguage) {
|
|
||||||
response = await apiClient.put(`/multilang/languages/${editingLanguage.langCode}`, requestData);
|
|
||||||
} else {
|
|
||||||
response = await apiClient.post("/multilang/languages", requestData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = response.data;
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
alert(editingLanguage ? "언어가 수정되었습니다." : "언어가 추가되었습니다.");
|
|
||||||
setIsLanguageModalOpen(false);
|
|
||||||
fetchLanguages(); // 언어 목록 새로고침
|
|
||||||
} else {
|
|
||||||
alert(`오류: ${result.message}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 저장 중 오류:", error);
|
|
||||||
alert("언어 저장 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 삭제
|
|
||||||
const handleDeleteLanguages = async () => {
|
|
||||||
if (selectedLanguages.size === 0) {
|
|
||||||
alert("삭제할 언어를 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`선택된 ${selectedLanguages.size}개의 언어를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletePromises = Array.from(selectedLanguages).map((langCode) =>
|
|
||||||
apiClient.delete(`/multilang/languages/${langCode}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const responses = await Promise.all(deletePromises);
|
|
||||||
const failedDeletes = responses.filter((response) => !response.data.success);
|
|
||||||
|
|
||||||
if (failedDeletes.length === 0) {
|
|
||||||
alert("선택된 언어가 삭제되었습니다.");
|
|
||||||
setSelectedLanguages(new Set());
|
|
||||||
fetchLanguages(); // 언어 목록 새로고침
|
|
||||||
} else {
|
|
||||||
alert(`${failedDeletes.length}개의 언어 삭제에 실패했습니다.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 삭제 중 오류:", error);
|
|
||||||
alert("언어 삭제 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 선택 체크박스 처리
|
|
||||||
const handleLanguageCheckboxChange = (langCode: string, checked: boolean) => {
|
|
||||||
const newSelected = new Set(selectedLanguages);
|
|
||||||
if (checked) {
|
|
||||||
newSelected.add(langCode);
|
|
||||||
} else {
|
|
||||||
newSelected.delete(langCode);
|
|
||||||
}
|
|
||||||
setSelectedLanguages(newSelected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 전체 선택/해제
|
|
||||||
const handleSelectAllLanguages = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
setSelectedLanguages(new Set(languages.map((lang) => lang.langCode)));
|
|
||||||
} else {
|
|
||||||
setSelectedLanguages(new Set());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 키 수정 모달 열기
|
|
||||||
const handleEditKey = (key: LangKey) => {
|
|
||||||
setEditingKey(key);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 키 저장 (추가/수정)
|
|
||||||
const handleSaveKey = async (keyData: any) => {
|
|
||||||
try {
|
|
||||||
const requestData = {
|
|
||||||
...keyData,
|
|
||||||
createdBy: user?.userId || "admin",
|
|
||||||
updatedBy: user?.userId || "admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
let response;
|
|
||||||
if (editingKey) {
|
|
||||||
response = await apiClient.put(`/multilang/keys/${editingKey.keyId}`, requestData);
|
|
||||||
} else {
|
|
||||||
response = await apiClient.post("/multilang/keys", requestData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
alert(editingKey ? "언어 키가 수정되었습니다." : "언어 키가 추가되었습니다.");
|
|
||||||
fetchLangKeys(); // 목록 새로고침
|
|
||||||
setIsModalOpen(false);
|
|
||||||
} else {
|
|
||||||
// 중복 체크 오류 메시지 처리
|
|
||||||
if (data.message && data.message.includes("이미 존재하는 언어키")) {
|
|
||||||
alert(data.message);
|
|
||||||
} else {
|
|
||||||
alert(data.message || "언어 키 저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 키 저장 실패:", error);
|
|
||||||
alert("언어 키 저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 체크박스 선택/해제
|
|
||||||
const handleCheckboxChange = (keyId: number, checked: boolean) => {
|
|
||||||
const newSelectedKeys = new Set(selectedKeys);
|
|
||||||
if (checked) {
|
|
||||||
newSelectedKeys.add(keyId);
|
|
||||||
} else {
|
|
||||||
newSelectedKeys.delete(keyId);
|
|
||||||
}
|
|
||||||
setSelectedKeys(newSelectedKeys);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 키 상태 토글
|
|
||||||
const handleToggleStatus = async (keyId: number) => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
alert(`키가 ${data.data}되었습니다.`);
|
|
||||||
fetchLangKeys();
|
|
||||||
} else {
|
|
||||||
alert("상태 변경 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("키 상태 토글 실패:", error);
|
|
||||||
alert("키 상태 변경 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 상태 토글
|
|
||||||
const handleToggleLanguageStatus = async (langCode: string) => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.put(`/multilang/languages/${langCode}/toggle`);
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
alert(`언어가 ${data.data}되었습니다.`);
|
|
||||||
fetchLanguages();
|
|
||||||
} else {
|
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 상태 토글 실패:", error);
|
|
||||||
alert("언어 상태 변경 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 전체 선택/해제
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
const allKeyIds = getFilteredLangKeys().map((key) => key.keyId);
|
|
||||||
setSelectedKeys(new Set(allKeyIds));
|
|
||||||
} else {
|
|
||||||
setSelectedKeys(new Set());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 선택된 키들 일괄 삭제
|
|
||||||
const handleDeleteSelectedKeys = async () => {
|
|
||||||
if (selectedKeys.size === 0) {
|
|
||||||
alert("삭제할 키를 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
`선택된 ${selectedKeys.size}개의 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletePromises = Array.from(selectedKeys).map((keyId) => apiClient.delete(`/multilang/keys/${keyId}`));
|
|
||||||
|
|
||||||
const responses = await Promise.all(deletePromises);
|
|
||||||
const allSuccess = responses.every((response) => response.data.success);
|
|
||||||
|
|
||||||
if (allSuccess) {
|
|
||||||
alert(`${selectedKeys.size}개의 언어 키가 영구적으로 삭제되었습니다.`);
|
|
||||||
setSelectedKeys(new Set());
|
|
||||||
fetchLangKeys(); // 목록 새로고침
|
|
||||||
|
|
||||||
// 선택된 키가 삭제된 경우 편집 영역 닫기
|
|
||||||
if (selectedKey && selectedKeys.has(selectedKey.keyId)) {
|
|
||||||
handleCancel();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert("일부 키 삭제에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("선택된 키 삭제 실패:", error);
|
|
||||||
alert("선택된 키 삭제에 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 개별 키 삭제 (기존 함수 유지)
|
|
||||||
const handleDeleteKey = async (keyId: number) => {
|
|
||||||
if (!confirm("정말로 이 언어 키를 영구적으로 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
|
|
||||||
const data = response.data;
|
|
||||||
if (data.success) {
|
|
||||||
alert("언어 키가 영구적으로 삭제되었습니다.");
|
|
||||||
fetchLangKeys(); // 목록 새로고침
|
|
||||||
if (selectedKey && selectedKey.keyId === keyId) {
|
|
||||||
handleCancel(); // 선택된 키가 삭제된 경우 편집 영역 닫기
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("언어 키 삭제 실패:", error);
|
|
||||||
alert("언어 키 삭제에 실패했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 취소 처리
|
|
||||||
const handleCancel = () => {
|
|
||||||
setSelectedKey(null);
|
|
||||||
setLangTexts([]);
|
|
||||||
setEditingTexts([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await Promise.all([fetchCompanies(), fetchLanguages(), fetchLangKeys()]);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
initializeData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 검색 관련 useEffect 제거 - 실시간 필터링만 사용
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: () => {
|
|
||||||
const filteredKeys = getFilteredLangKeys();
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedKeys.size === filteredKeys.length && filteredKeys.length > 0}
|
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedKeys.has(row.original.keyId)}
|
|
||||||
onChange={(e) => handleCheckboxChange(row.original.keyId, e.target.checked)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="h-4 w-4"
|
|
||||||
disabled={row.original.isActive === "N"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "companyCode",
|
|
||||||
header: "회사",
|
|
||||||
cell: ({ row }: any) => {
|
|
||||||
const companyName =
|
|
||||||
row.original.companyCode === "*"
|
|
||||||
? "공통"
|
|
||||||
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
|
|
||||||
|
|
||||||
return <span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{companyName}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "menuName",
|
|
||||||
header: "메뉴명",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.menuName}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
accessorKey: "langKey",
|
|
||||||
header: "언어 키",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<div
|
|
||||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
|
||||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
|
||||||
}`}
|
|
||||||
onDoubleClick={() => handleEditKey(row.original)}
|
|
||||||
>
|
|
||||||
{row.original.langKey}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
accessorKey: "description",
|
|
||||||
header: "설명",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.description}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "isActive",
|
|
||||||
header: "상태",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleStatus(row.original.keyId)}
|
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
|
||||||
row.original.isActive === "Y"
|
|
||||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
|
||||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 언어 테이블 컬럼 정의
|
|
||||||
const languageColumns = [
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: () => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedLanguages.size === languages.length && languages.length > 0}
|
|
||||||
onChange={(e) => handleSelectAllLanguages(e.target.checked)}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedLanguages.has(row.original.langCode)}
|
|
||||||
onChange={(e) => handleLanguageCheckboxChange(row.original.langCode, e.target.checked)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="h-4 w-4"
|
|
||||||
disabled={row.original.isActive === "N"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "langCode",
|
|
||||||
header: "언어 코드",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<div
|
|
||||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
|
||||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
|
||||||
}`}
|
|
||||||
onDoubleClick={() => handleEditLanguage(row.original)}
|
|
||||||
>
|
|
||||||
{row.original.langCode}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "langName",
|
|
||||||
header: "언어명 (영문)",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langName}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "langNative",
|
|
||||||
header: "언어명 (원어)",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<span className={row.original.isActive === "N" ? "text-gray-400" : ""}>{row.original.langNative}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "isActive",
|
|
||||||
header: "상태",
|
|
||||||
cell: ({ row }: any) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
|
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
|
||||||
row.original.isActive === "Y"
|
|
||||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
|
||||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-2">
|
|
||||||
{/* 탭 네비게이션 */}
|
|
||||||
<div className="flex space-x-1 border-b">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("keys")}
|
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "keys" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
다국어 키 관리
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("languages")}
|
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "languages" ? "bg-accent0 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
언어 관리
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 콘텐츠 영역 */}
|
|
||||||
<div className="mt-2">
|
|
||||||
{/* 언어 관리 탭 */}
|
|
||||||
{activeTab === "languages" && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>언어 관리</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-muted-foreground">총 {languages.length}개의 언어가 등록되어 있습니다.</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{selectedLanguages.size > 0 && (
|
|
||||||
<Button variant="destructive" onClick={handleDeleteLanguages}>
|
|
||||||
선택 삭제 ({selectedLanguages.size})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleAddLanguage}>새 언어 추가</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DataTable data={languages} columns={languageColumns} searchable />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 다국어 키 관리 탭의 메인 영역 */}
|
|
||||||
{activeTab === "keys" && (
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
|
||||||
{/* 좌측: 언어 키 목록 (7/10) */}
|
|
||||||
<Card className="lg:col-span-7">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle>언어 키 목록</CardTitle>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
|
|
||||||
선택 삭제 ({selectedKeys.size})
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleAddKey}>새 키 추가</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* 검색 필터 영역 */}
|
|
||||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="company">회사</Label>
|
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="전체 회사" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 회사</SelectItem>
|
|
||||||
{companies.map((company) => (
|
|
||||||
<SelectItem key={company.code} value={company.code}>
|
|
||||||
{company.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="search">검색</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="키명, 설명, 메뉴, 회사로 검색..."
|
|
||||||
value={searchText}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 영역 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={getFilteredLangKeys()}
|
|
||||||
searchable={false}
|
|
||||||
onRowClick={handleKeySelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
|
|
||||||
<Card className="lg:col-span-3">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{selectedKey ? (
|
|
||||||
<>
|
|
||||||
선택된 키:{" "}
|
|
||||||
<Badge variant="secondary" className="ml-2">
|
|
||||||
{selectedKey.companyCode}.{selectedKey.menuName}.{selectedKey.langKey}
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"다국어 편집"
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{selectedKey ? (
|
|
||||||
<div>
|
|
||||||
{/* 스크롤 가능한 텍스트 영역 */}
|
|
||||||
<div className="max-h-80 space-y-4 overflow-y-auto pr-2">
|
|
||||||
{languages
|
|
||||||
.filter((lang) => lang.isActive === "Y")
|
|
||||||
.map((lang) => {
|
|
||||||
const text = editingTexts.find((t) => t.langCode === lang.langCode);
|
|
||||||
return (
|
|
||||||
<div key={lang.langCode} className="flex items-center space-x-4">
|
|
||||||
<Badge variant="outline" className="w-20 flex-shrink-0 text-center">
|
|
||||||
{lang.langName}
|
|
||||||
</Badge>
|
|
||||||
<Input
|
|
||||||
placeholder={`${lang.langName} 텍스트 입력`}
|
|
||||||
value={text?.langText || ""}
|
|
||||||
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 저장 버튼 - 고정 위치 */}
|
|
||||||
<div className="mt-4 flex space-x-2 border-t pt-4">
|
|
||||||
<Button onClick={handleSave}>저장</Button>
|
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
|
||||||
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 언어 키 추가/수정 모달 */}
|
|
||||||
<LangKeyModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={() => setIsModalOpen(false)}
|
|
||||||
onSave={handleSaveKey}
|
|
||||||
keyData={editingKey}
|
|
||||||
companies={companies}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 언어 추가/수정 모달 */}
|
|
||||||
<LanguageModal
|
|
||||||
isOpen={isLanguageModalOpen}
|
|
||||||
onClose={() => setIsLanguageModalOpen(false)}
|
|
||||||
onSave={handleSaveLanguage}
|
|
||||||
languageData={editingLanguage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
|
|
||||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { DualListBox } from "@/components/common/DualListBox";
|
|
||||||
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
|
||||||
|
|
||||||
interface RoleDetailManagementProps {
|
|
||||||
roleId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 상세 관리 컴포넌트
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 정보 표시
|
|
||||||
* - 멤버 관리 (Dual List Box)
|
|
||||||
* - 메뉴 권한 설정 (CRUD 체크박스)
|
|
||||||
*/
|
|
||||||
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const { refreshMenus } = useMenu();
|
|
||||||
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [roleGroup, setRoleGroup] = useState<RoleGroup | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 탭 상태
|
|
||||||
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
|
|
||||||
|
|
||||||
// 멤버 관리 상태
|
|
||||||
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
|
|
||||||
const [selectedUsers, setSelectedUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
|
|
||||||
const [isSavingMembers, setIsSavingMembers] = useState(false);
|
|
||||||
|
|
||||||
// 메뉴 권한 상태
|
|
||||||
const [menuPermissions, setMenuPermissions] = useState<any[]>([]);
|
|
||||||
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
const loadRoleGroup = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await roleAPI.getById(parseInt(roleId, 10));
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setRoleGroup(response.data);
|
|
||||||
} else {
|
|
||||||
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("권한 그룹 정보 로드 오류:", err);
|
|
||||||
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [roleId]);
|
|
||||||
|
|
||||||
// 멤버 목록 로드
|
|
||||||
const loadMembers = useCallback(async () => {
|
|
||||||
if (!roleGroup) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 권한 그룹 멤버 조회
|
|
||||||
const membersResponse = await roleAPI.getMembers(roleGroup.objid);
|
|
||||||
if (membersResponse.success && membersResponse.data) {
|
|
||||||
setSelectedUsers(
|
|
||||||
membersResponse.data.map((member: any) => ({
|
|
||||||
id: member.userId,
|
|
||||||
label: member.userName || member.userId,
|
|
||||||
description: member.deptName,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 전체 사용자 목록 조회 (같은 회사)
|
|
||||||
const userAPI = await import("@/lib/api/user");
|
|
||||||
|
|
||||||
console.log("🔍 사용자 목록 조회 요청:", {
|
|
||||||
companyCode: roleGroup.companyCode,
|
|
||||||
size: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const usersResponse = await userAPI.userAPI.getList({
|
|
||||||
companyCode: roleGroup.companyCode,
|
|
||||||
size: 1000, // 대량 조회
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 사용자 목록 응답:", {
|
|
||||||
success: usersResponse.success,
|
|
||||||
count: usersResponse.data?.length,
|
|
||||||
total: usersResponse.total,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (usersResponse.success && usersResponse.data) {
|
|
||||||
setAvailableUsers(
|
|
||||||
usersResponse.data.map((user: any) => ({
|
|
||||||
id: user.userId,
|
|
||||||
label: user.userName || user.userId,
|
|
||||||
description: user.deptName,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("멤버 목록 로드 오류:", err);
|
|
||||||
}
|
|
||||||
}, [roleGroup]);
|
|
||||||
|
|
||||||
// 메뉴 권한 로드
|
|
||||||
const loadMenuPermissions = useCallback(async () => {
|
|
||||||
if (!roleGroup) return;
|
|
||||||
|
|
||||||
console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
|
|
||||||
roleGroupId: roleGroup.objid,
|
|
||||||
roleGroupName: roleGroup.authName,
|
|
||||||
companyCode: roleGroup.companyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await roleAPI.getMenuPermissions(roleGroup.objid);
|
|
||||||
|
|
||||||
console.log("✅ [loadMenuPermissions] API 응답", {
|
|
||||||
success: response.success,
|
|
||||||
dataCount: response.data?.length,
|
|
||||||
data: response.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setMenuPermissions(response.data);
|
|
||||||
console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
|
|
||||||
count: response.data.length,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
|
|
||||||
message: response.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
|
|
||||||
}
|
|
||||||
}, [roleGroup]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadRoleGroup();
|
|
||||||
}, [loadRoleGroup]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (roleGroup && activeTab === "members") {
|
|
||||||
loadMembers();
|
|
||||||
} else if (roleGroup && activeTab === "permissions") {
|
|
||||||
loadMenuPermissions();
|
|
||||||
}
|
|
||||||
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
|
|
||||||
|
|
||||||
// 멤버 저장 핸들러
|
|
||||||
const handleSaveMembers = useCallback(async () => {
|
|
||||||
if (!roleGroup) return;
|
|
||||||
|
|
||||||
setIsSavingMembers(true);
|
|
||||||
try {
|
|
||||||
// 현재 선택된 사용자 ID 목록
|
|
||||||
const selectedUserIds = selectedUsers.map((user) => user.id);
|
|
||||||
|
|
||||||
// 멤버 업데이트 API 호출
|
|
||||||
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
alert("멤버가 성공적으로 저장되었습니다.");
|
|
||||||
loadMembers(); // 새로고침
|
|
||||||
|
|
||||||
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
|
|
||||||
await refreshMenus();
|
|
||||||
} else {
|
|
||||||
alert(response.message || "멤버 저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("멤버 저장 오류:", err);
|
|
||||||
alert("멤버 저장 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsSavingMembers(false);
|
|
||||||
}
|
|
||||||
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
|
|
||||||
|
|
||||||
// 메뉴 권한 저장 핸들러
|
|
||||||
const handleSavePermissions = useCallback(async () => {
|
|
||||||
if (!roleGroup) return;
|
|
||||||
|
|
||||||
setIsSavingPermissions(true);
|
|
||||||
try {
|
|
||||||
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
|
||||||
loadMenuPermissions(); // 새로고침
|
|
||||||
|
|
||||||
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
|
|
||||||
await refreshMenus();
|
|
||||||
} else {
|
|
||||||
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("메뉴 권한 저장 오류:", err);
|
|
||||||
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsSavingPermissions(false);
|
|
||||||
}
|
|
||||||
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
|
||||||
<p className="text-muted-foreground text-sm">권한 그룹 정보를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !roleGroup) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
|
||||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">오류 발생</h3>
|
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
|
|
||||||
<Button variant="outline" onClick={() => router.push("/admin/userMng/rolesList")}>
|
|
||||||
목록으로 돌아가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 페이지 헤더 */}
|
|
||||||
<div className="space-y-2 border-b pb-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10">
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{roleGroup.authCode} • {roleGroup.companyCode}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
|
||||||
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{roleGroup.status === "active" ? "활성" : "비활성"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
|
||||||
<div className="flex gap-4 border-b">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("members")}
|
|
||||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "members"
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
멤버 관리 ({selectedUsers.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("permissions")}
|
|
||||||
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === "permissions"
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground border-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MenuIcon className="h-4 w-4" />
|
|
||||||
메뉴 권한 ({menuPermissions.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{activeTab === "members" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">멤버 관리</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">이 권한 그룹에 속한 사용자를 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{isSavingMembers ? "저장 중..." : "멤버 저장"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DualListBox
|
|
||||||
availableItems={availableUsers}
|
|
||||||
selectedItems={selectedUsers}
|
|
||||||
onSelectionChange={setSelectedUsers}
|
|
||||||
availableLabel="전체 사용자"
|
|
||||||
selectedLabel="그룹 멤버"
|
|
||||||
enableSearch
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "permissions" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">메뉴 권한 설정</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">이 권한 그룹에서 접근 가능한 메뉴와 권한을 설정합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{isSavingPermissions ? "저장 중..." : "권한 저장"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MenuPermissionsTable
|
|
||||||
permissions={menuPermissions}
|
|
||||||
onPermissionsChange={setMenuPermissions}
|
|
||||||
roleGroup={roleGroup}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
|
|
||||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { RoleFormModal } from "./RoleFormModal";
|
|
||||||
import { RoleDeleteModal } from "./RoleDeleteModal";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { companyAPI } from "@/lib/api/company";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 권한 그룹 관리 메인 컴포넌트
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 권한 그룹 목록 조회 (회사별)
|
|
||||||
* - 권한 그룹 생성/수정/삭제
|
|
||||||
* - 상세 페이지로 이동 (멤버 관리 + 메뉴 권한 설정)
|
|
||||||
*/
|
|
||||||
export function RoleManagement() {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// 회사 관리자 또는 최고 관리자 여부
|
|
||||||
const isAdmin =
|
|
||||||
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
|
|
||||||
currentUser?.userType === "COMPANY_ADMIN";
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 회사 필터 (최고 관리자 전용)
|
|
||||||
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
|
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [formModal, setFormModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
editingRole: null as RoleGroup | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
role: null as RoleGroup | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 회사 목록 로드 (최고 관리자만)
|
|
||||||
const loadCompanies = useCallback(async () => {
|
|
||||||
if (!isSuperAdmin) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const companies = await companyAPI.getList();
|
|
||||||
setCompanies(companies);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("회사 목록 로드 오류:", error);
|
|
||||||
}
|
|
||||||
}, [isSuperAdmin]);
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
const loadRoleGroups = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
|
|
||||||
// 회사 관리자: 자기 회사만 조회
|
|
||||||
const companyFilter =
|
|
||||||
isSuperAdmin && selectedCompany !== "all"
|
|
||||||
? selectedCompany
|
|
||||||
: isSuperAdmin
|
|
||||||
? undefined
|
|
||||||
: currentUser?.companyCode;
|
|
||||||
|
|
||||||
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
|
|
||||||
|
|
||||||
const response = await roleAPI.getList({
|
|
||||||
companyCode: companyFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setRoleGroups(response.data);
|
|
||||||
console.log("권한 그룹 조회 성공:", response.data.length, "개");
|
|
||||||
} else {
|
|
||||||
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("권한 그룹 목록 로드 오류:", err);
|
|
||||||
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAdmin) {
|
|
||||||
if (isSuperAdmin) {
|
|
||||||
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
|
|
||||||
}
|
|
||||||
loadRoleGroups();
|
|
||||||
} else {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
|
|
||||||
|
|
||||||
// 권한 그룹 생성 핸들러
|
|
||||||
const handleCreateRole = useCallback(() => {
|
|
||||||
setFormModal({ isOpen: true, editingRole: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 권한 그룹 수정 핸들러
|
|
||||||
const handleEditRole = useCallback((role: RoleGroup) => {
|
|
||||||
setFormModal({ isOpen: true, editingRole: role });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 권한 그룹 삭제 핸들러
|
|
||||||
const handleDeleteRole = useCallback((role: RoleGroup) => {
|
|
||||||
setDeleteModal({ isOpen: true, role });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 폼 모달 닫기
|
|
||||||
const handleFormModalClose = useCallback(() => {
|
|
||||||
setFormModal({ isOpen: false, editingRole: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 삭제 모달 닫기
|
|
||||||
const handleDeleteModalClose = useCallback(() => {
|
|
||||||
setDeleteModal({ isOpen: false, role: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 모달 성공 후 새로고침
|
|
||||||
const handleModalSuccess = useCallback(() => {
|
|
||||||
loadRoleGroups();
|
|
||||||
}, [loadRoleGroups]);
|
|
||||||
|
|
||||||
// 상세 페이지로 이동
|
|
||||||
const handleViewDetail = useCallback(
|
|
||||||
(role: RoleGroup) => {
|
|
||||||
router.push(`/admin/userMng/rolesList/${role.objid}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 관리자가 아니면 접근 제한
|
|
||||||
if (!isAdmin) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
|
||||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
|
||||||
권한 그룹 관리는 회사 관리자 이상만 접근할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
뒤로 가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
|
||||||
aria-label="에러 메시지 닫기"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 액션 버튼 영역 */}
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h2 className="text-xl font-semibold">권한 그룹 목록 ({roleGroups.length})</h2>
|
|
||||||
|
|
||||||
{/* 최고 관리자 전용: 회사 필터 */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter className="text-muted-foreground h-4 w-4" />
|
|
||||||
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
|
|
||||||
<SelectTrigger className="h-10 w-[200px]">
|
|
||||||
<SelectValue placeholder="회사 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 회사</SelectItem>
|
|
||||||
{companies.map((company) => (
|
|
||||||
<SelectItem key={company.company_code} value={company.company_code}>
|
|
||||||
{company.company_name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedCompany !== "all" && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleCreateRole} className="gap-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
권한 그룹 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 권한 그룹 목록 */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="bg-card rounded-lg border p-12 shadow-sm">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
|
||||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
|
||||||
<p className="text-muted-foreground text-sm">권한 그룹 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : roleGroups.length === 0 ? (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">등록된 권한 그룹이 없습니다.</p>
|
|
||||||
<p className="text-muted-foreground text-xs">권한 그룹을 생성하여 멤버를 관리해보세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{roleGroups.map((role) => (
|
|
||||||
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
|
|
||||||
{/* 헤더 (클릭 시 상세 페이지) */}
|
|
||||||
<div
|
|
||||||
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
|
|
||||||
onClick={() => handleViewDetail(role)}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-base font-semibold">{role.authName}</h3>
|
|
||||||
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{role.status === "active" ? "활성" : "비활성"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 */}
|
|
||||||
<div className="space-y-2 border-t pt-4">
|
|
||||||
{/* 최고 관리자는 회사명 표시 */}
|
|
||||||
{isSuperAdmin && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">회사</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
멤버 수
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{role.memberCount || 0}명</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-1">
|
|
||||||
<Menu className="h-3 w-3" />
|
|
||||||
메뉴 권한
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{role.menuCount || 0}개</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
|
||||||
<div className="flex gap-2 border-t p-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditRole(role);
|
|
||||||
}}
|
|
||||||
className="flex-1 gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteRole(role);
|
|
||||||
}}
|
|
||||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모달들 */}
|
|
||||||
<RoleFormModal
|
|
||||||
isOpen={formModal.isOpen}
|
|
||||||
onClose={handleFormModalClose}
|
|
||||||
onSuccess={handleModalSuccess}
|
|
||||||
editingRole={formModal.editingRole}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RoleDeleteModal
|
|
||||||
isOpen={deleteModal.isOpen}
|
|
||||||
onClose={handleDeleteModalClose}
|
|
||||||
onSuccess={handleModalSuccess}
|
|
||||||
role={deleteModal.role}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { UserAuthTable } from "./UserAuthTable";
|
|
||||||
import { UserAuthEditModal } from "./UserAuthEditModal";
|
|
||||||
import { userAPI } from "@/lib/api/user";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 권한 관리 메인 컴포넌트
|
|
||||||
*
|
|
||||||
* 기능:
|
|
||||||
* - 사용자 목록 조회 (권한 정보 포함)
|
|
||||||
* - 권한 변경 모달
|
|
||||||
* - 최고 관리자 권한 체크
|
|
||||||
*/
|
|
||||||
export function UserAuthManagement() {
|
|
||||||
const { user: currentUser } = useAuth();
|
|
||||||
|
|
||||||
// 최고 관리자 여부
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
|
||||||
|
|
||||||
// 상태 관리
|
|
||||||
const [users, setUsers] = useState<any[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [paginationInfo, setPaginationInfo] = useState({
|
|
||||||
currentPage: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
totalItems: 0,
|
|
||||||
totalPages: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 권한 변경 모달
|
|
||||||
const [authEditModal, setAuthEditModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
user: null as any | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
const loadUsers = useCallback(
|
|
||||||
async (page: number = 1) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await userAPI.getList({
|
|
||||||
page,
|
|
||||||
size: paginationInfo.pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setUsers(response.data);
|
|
||||||
setPaginationInfo({
|
|
||||||
currentPage: response.currentPage || page,
|
|
||||||
pageSize: response.pageSize || paginationInfo.pageSize,
|
|
||||||
totalItems: response.total || 0,
|
|
||||||
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("사용자 목록 로드 오류:", err);
|
|
||||||
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[paginationInfo.pageSize],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUsers(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 권한 변경 핸들러
|
|
||||||
const handleEditAuth = (user: any) => {
|
|
||||||
setAuthEditModal({
|
|
||||||
isOpen: true,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 권한 변경 모달 닫기
|
|
||||||
const handleAuthEditClose = () => {
|
|
||||||
setAuthEditModal({
|
|
||||||
isOpen: false,
|
|
||||||
user: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 권한 변경 성공
|
|
||||||
const handleAuthEditSuccess = () => {
|
|
||||||
loadUsers(paginationInfo.currentPage);
|
|
||||||
handleAuthEditClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 변경
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
loadUsers(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 최고 관리자가 아닌 경우
|
|
||||||
if (!isSuperAdmin) {
|
|
||||||
return (
|
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
|
|
||||||
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
|
||||||
<h3 className="mb-2 text-lg font-semibold">접근 권한 없음</h3>
|
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">권한 관리는 최고 관리자만 접근할 수 있습니다.</p>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
뒤로 가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
|
||||||
aria-label="에러 메시지 닫기"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 사용자 권한 테이블 */}
|
|
||||||
<UserAuthTable
|
|
||||||
users={users}
|
|
||||||
isLoading={isLoading}
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onEditAuth={handleEditAuth}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 권한 변경 모달 */}
|
|
||||||
<UserAuthEditModal
|
|
||||||
isOpen={authEditModal.isOpen}
|
|
||||||
onClose={handleAuthEditClose}
|
|
||||||
onSuccess={handleAuthEditSuccess}
|
|
||||||
user={authEditModal.user}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useUserManagement } from "@/hooks/useUserManagement";
|
|
||||||
import { UserToolbar } from "./UserToolbar";
|
|
||||||
import { UserTable } from "./UserTable";
|
|
||||||
import { Pagination } from "@/components/common/Pagination";
|
|
||||||
import { UserPasswordResetModal } from "./UserPasswordResetModal";
|
|
||||||
import { UserFormModal } from "./UserFormModal";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 관리 메인 컴포넌트
|
|
||||||
* - 원본 Spring + JSP 코드 패턴 기반 REST API 연동
|
|
||||||
* - 실제 데이터베이스와 연동되어 작동
|
|
||||||
*/
|
|
||||||
export function UserManagement() {
|
|
||||||
const {
|
|
||||||
// 데이터
|
|
||||||
users,
|
|
||||||
searchFilter,
|
|
||||||
isLoading,
|
|
||||||
isSearching,
|
|
||||||
error,
|
|
||||||
paginationInfo,
|
|
||||||
|
|
||||||
// 검색 기능
|
|
||||||
updateSearchFilter,
|
|
||||||
|
|
||||||
// 페이지네이션
|
|
||||||
handlePageChange,
|
|
||||||
handlePageSizeChange,
|
|
||||||
|
|
||||||
// 액션 핸들러
|
|
||||||
handleStatusToggle,
|
|
||||||
|
|
||||||
// 유틸리티
|
|
||||||
clearError,
|
|
||||||
refreshData,
|
|
||||||
} = useUserManagement();
|
|
||||||
|
|
||||||
// 비밀번호 초기화 모달 상태
|
|
||||||
const [passwordResetModal, setPasswordResetModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
userId: null as string | null,
|
|
||||||
userName: null as string | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 등록/수정 모달 상태
|
|
||||||
const [userFormModal, setUserFormModal] = useState({
|
|
||||||
isOpen: false,
|
|
||||||
editingUser: null as any | null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 등록 핸들러
|
|
||||||
const handleCreateUser = () => {
|
|
||||||
setUserFormModal({
|
|
||||||
isOpen: true,
|
|
||||||
editingUser: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 사용자 수정 핸들러
|
|
||||||
const handleEditUser = (user: any) => {
|
|
||||||
setUserFormModal({
|
|
||||||
isOpen: true,
|
|
||||||
editingUser: user,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 사용자 등록/수정 모달 닫기
|
|
||||||
const handleUserFormClose = () => {
|
|
||||||
setUserFormModal({
|
|
||||||
isOpen: false,
|
|
||||||
editingUser: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 사용자 등록/수정 성공 핸들러
|
|
||||||
const handleUserFormSuccess = () => {
|
|
||||||
refreshData(); // 목록 새로고침
|
|
||||||
handleUserFormClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비밀번호 초기화 핸들러
|
|
||||||
const handlePasswordReset = (userId: string, userName: string) => {
|
|
||||||
setPasswordResetModal({
|
|
||||||
isOpen: true,
|
|
||||||
userId,
|
|
||||||
userName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비밀번호 초기화 모달 닫기
|
|
||||||
const handlePasswordResetClose = () => {
|
|
||||||
setPasswordResetModal({
|
|
||||||
isOpen: false,
|
|
||||||
userId: null,
|
|
||||||
userName: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비밀번호 초기화 성공 핸들러
|
|
||||||
const handlePasswordResetSuccess = () => {
|
|
||||||
// refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
|
|
||||||
handlePasswordResetClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 툴바 - 검색, 필터, 등록 버튼 */}
|
|
||||||
<UserToolbar
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
totalCount={paginationInfo.totalItems} // 전체 총 개수
|
|
||||||
isSearching={isSearching}
|
|
||||||
onSearchChange={updateSearchFilter}
|
|
||||||
onCreateClick={handleCreateUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-destructive text-sm font-semibold">오류가 발생했습니다</p>
|
|
||||||
<button
|
|
||||||
onClick={clearError}
|
|
||||||
className="text-destructive hover:text-destructive/80 transition-colors"
|
|
||||||
aria-label="에러 메시지 닫기"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 사용자 목록 테이블 */}
|
|
||||||
<UserTable
|
|
||||||
users={users}
|
|
||||||
isLoading={isLoading}
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onStatusToggle={handleStatusToggle}
|
|
||||||
onPasswordReset={handlePasswordReset}
|
|
||||||
onEdit={handleEditUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{!isLoading && users.length > 0 && (
|
|
||||||
<Pagination
|
|
||||||
paginationInfo={paginationInfo}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
onPageSizeChange={handlePageSizeChange}
|
|
||||||
showPageSizeSelector={true}
|
|
||||||
pageSizeOptions={[10, 20, 50, 100]}
|
|
||||||
className="mt-6"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 사용자 등록/수정 모달 */}
|
|
||||||
<UserFormModal
|
|
||||||
isOpen={userFormModal.isOpen}
|
|
||||||
onClose={handleUserFormClose}
|
|
||||||
onSuccess={handleUserFormSuccess}
|
|
||||||
editingUser={userFormModal.editingUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 비밀번호 초기화 모달 */}
|
|
||||||
<UserPasswordResetModal
|
|
||||||
isOpen={passwordResetModal.isOpen}
|
|
||||||
onClose={handlePasswordResetClose}
|
|
||||||
userId={passwordResetModal.userId}
|
|
||||||
userName={passwordResetModal.userName}
|
|
||||||
onSuccess={handlePasswordResetSuccess}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { DepartmentStructure } from "./DepartmentStructure";
|
|
||||||
import { DepartmentMembers } from "./DepartmentMembers";
|
|
||||||
import type { Department } from "@/types/department";
|
|
||||||
import { getCompanyList } from "@/lib/api/company";
|
|
||||||
|
|
||||||
interface DepartmentManagementProps {
|
|
||||||
companyCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 부서 관리 메인 컴포넌트
|
|
||||||
* 좌측: 부서 구조, 우측: 부서 인원
|
|
||||||
*/
|
|
||||||
export function DepartmentManagement({ companyCode }: DepartmentManagementProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<string>("structure");
|
|
||||||
const [companyName, setCompanyName] = useState<string>("");
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
||||||
|
|
||||||
// 부서원 변경 시 부서 구조 새로고침
|
|
||||||
const handleMemberChange = () => {
|
|
||||||
setRefreshTrigger((prev) => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 회사 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadCompanyInfo = async () => {
|
|
||||||
const response = await getCompanyList();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const company = response.data.find((c) => c.company_code === companyCode);
|
|
||||||
if (company) {
|
|
||||||
setCompanyName(company.company_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadCompanyInfo();
|
|
||||||
}, [companyCode]);
|
|
||||||
|
|
||||||
const handleBackToList = () => {
|
|
||||||
router.push("/admin/userMng/companyList");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
|
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
회사 목록
|
|
||||||
</Button>
|
|
||||||
<div className="bg-border h-6 w-px" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">부서 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 탭 네비게이션 (모바일용) */}
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="structure">부서 구조</TabsTrigger>
|
|
||||||
<TabsTrigger value="members">부서 인원</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="structure" className="mt-4">
|
|
||||||
<DepartmentStructure
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedDepartment={selectedDepartment}
|
|
||||||
onSelectDepartment={setSelectedDepartment}
|
|
||||||
refreshTrigger={refreshTrigger}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="members" className="mt-4">
|
|
||||||
<DepartmentMembers
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedDepartment={selectedDepartment}
|
|
||||||
onMemberChange={handleMemberChange}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 좌우 레이아웃 (데스크톱) */}
|
|
||||||
<div className="hidden h-full gap-6 lg:flex">
|
|
||||||
{/* 좌측: 부서 구조 (20%) */}
|
|
||||||
<div className="w-[20%] border-r pr-6">
|
|
||||||
<DepartmentStructure
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedDepartment={selectedDepartment}
|
|
||||||
onSelectDepartment={setSelectedDepartment}
|
|
||||||
refreshTrigger={refreshTrigger}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 우측: 부서 인원 (80%) */}
|
|
||||||
<div className="w-[80%] pl-0">
|
|
||||||
<DepartmentMembers
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedDepartment={selectedDepartment}
|
|
||||||
onMemberChange={handleMemberChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -17,12 +17,14 @@ import {
|
||||||
UserCheck,
|
UserCheck,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
|
Building2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useProfile } from "@/hooks/useProfile";
|
import { useProfile } from "@/hooks/useProfile";
|
||||||
import { MenuItem } from "@/lib/api/menu";
|
import { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ProfileModal } from "./ProfileModal";
|
import { ProfileModal } from "./ProfileModal";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
|
@ -35,6 +37,14 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
||||||
|
|
||||||
// useAuth의 UserInfo 타입을 확장
|
// useAuth의 UserInfo 타입을 확장
|
||||||
interface ExtendedUserInfo {
|
interface ExtendedUserInfo {
|
||||||
|
|
@ -206,11 +216,38 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { user, logout, refreshUserData } = useAuth();
|
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
||||||
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||||
|
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||||
|
|
||||||
|
// 현재 회사명 조회 (SUPER_ADMIN 전용)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCurrentCompanyName = async () => {
|
||||||
|
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
|
||||||
|
const companyCode = (user as ExtendedUserInfo)?.companyCode;
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
setCurrentCompanyName("WACE (최고 관리자)");
|
||||||
|
} else if (companyCode) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/admin/companies/db");
|
||||||
|
if (response.data.success) {
|
||||||
|
const company = response.data.data.find((c: any) => c.company_code === companyCode);
|
||||||
|
setCurrentCompanyName(company?.company_name || companyCode);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCurrentCompanyName(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCurrentCompanyName();
|
||||||
|
}, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
|
||||||
|
|
||||||
// 화면 크기 감지 및 사이드바 초기 상태 설정
|
// 화면 크기 감지 및 사이드바 초기 상태 설정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -333,12 +370,33 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모드 전환 핸들러
|
// 모드 전환 핸들러
|
||||||
const handleModeSwitch = () => {
|
const handleModeSwitch = async () => {
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
|
// 관리자 → 사용자 모드: 선택한 회사 유지
|
||||||
router.push("/main");
|
router.push("/main");
|
||||||
} else {
|
} else {
|
||||||
|
// 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
|
||||||
|
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
|
||||||
|
const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
|
||||||
|
|
||||||
|
// 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
|
||||||
|
if (currentCompanyCode !== "*") {
|
||||||
|
const result = await switchCompany("*");
|
||||||
|
if (result.success) {
|
||||||
|
// 페이지 새로고침 (관리자 페이지로 이동)
|
||||||
|
window.location.href = "/admin";
|
||||||
|
} else {
|
||||||
|
toast.error("WACE로 전환 실패");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 이미 WACE면 바로 관리자 페이지로 이동
|
||||||
router.push("/admin");
|
router.push("/admin");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 일반 관리자는 바로 관리자 페이지로 이동
|
||||||
|
router.push("/admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로그아웃 핸들러
|
// 로그아웃 핸들러
|
||||||
|
|
@ -498,11 +556,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* WACE 관리자: 현재 관리 회사 표시 */}
|
||||||
|
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
||||||
|
<div className="mx-3 mt-3 rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground">현재 관리 회사</p>
|
||||||
|
<p className="truncate text-sm font-semibold" title={currentCompanyName || "로딩 중..."}>
|
||||||
|
{currentCompanyName || "로딩 중..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
|
||||||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
|
||||||
(user as ExtendedUserInfo)?.userType === "admin") && (
|
(user as ExtendedUserInfo)?.userType === "admin") && (
|
||||||
<div className="border-b border-slate-200 p-3">
|
<div className="space-y-2 border-b border-slate-200 p-3">
|
||||||
|
{/* 관리자/사용자 메뉴 전환 */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleModeSwitch}
|
onClick={handleModeSwitch}
|
||||||
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
||||||
|
|
@ -523,6 +597,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* WACE 관리자 전용: 회사 선택 버튼 */}
|
||||||
|
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
|
||||||
|
<Button
|
||||||
|
onClick={() => { console.log("🔴 회사 선택 버튼 클릭!"); setShowCompanySwitcher(true); }}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-medium text-purple-700 transition-colors duration-200 hover:cursor-pointer hover:bg-purple-100"
|
||||||
|
>
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
회사 선택
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -653,6 +738,21 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
onSave={saveProfile}
|
onSave={saveProfile}
|
||||||
onAlertClose={closeAlert}
|
onAlertClose={closeAlert}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 회사 전환 모달 (WACE 관리자 전용) */}
|
||||||
|
<Dialog open={showCompanySwitcher} onOpenChange={setShowCompanySwitcher}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">회사 선택</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
관리할 회사를 선택하면 해당 회사의 관점에서 시스템을 사용할 수 있습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4">
|
||||||
|
<CompanySwitcher onClose={() => setShowCompanySwitcher(false)} isOpen={showCompanySwitcher} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||||
import type { MenuItem } from "@/lib/api/menu";
|
import type { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
|
import { menuApi } from "@/lib/api/menu"; // API 호출 활성화
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth"; // user 정보 가져오기
|
||||||
|
|
||||||
interface MenuContextType {
|
interface MenuContextType {
|
||||||
adminMenus: MenuItem[];
|
adminMenus: MenuItem[];
|
||||||
|
|
@ -18,6 +19,7 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
|
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
|
||||||
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
|
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { user } = useAuth(); // user 정보 가져오기
|
||||||
|
|
||||||
const convertMenuData = (data: any[]): MenuItem[] => {
|
const convertMenuData = (data: any[]): MenuItem[] => {
|
||||||
return data.map((item) => ({
|
return data.map((item) => ({
|
||||||
|
|
@ -96,8 +98,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// user.companyCode가 변경되면 메뉴 다시 로드
|
||||||
|
// console.log("🔄 MenuContext: user.companyCode 변경 감지, 메뉴 재로드", user?.companyCode);
|
||||||
loadMenus();
|
loadMenus();
|
||||||
}, []); // 초기 로드만
|
}, [user?.companyCode]); // companyCode 변경 시 재로드
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuContext.Provider value={{ adminMenus, userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>
|
<MenuContext.Provider value={{ adminMenus, userMenus, loading, refreshMenus }}>{children}</MenuContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,61 @@ export const useAuth = () => {
|
||||||
[apiCall, refreshUserData],
|
[apiCall, refreshUserData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 전환 처리 (WACE 관리자 전용)
|
||||||
|
*/
|
||||||
|
const switchCompany = useCallback(
|
||||||
|
async (companyCode: string): Promise<{ success: boolean; message: string }> => {
|
||||||
|
try {
|
||||||
|
// console.log("🔵 useAuth.switchCompany 시작:", companyCode);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// console.log("🔵 API 호출: POST /auth/switch-company");
|
||||||
|
const response = await apiCall<any>("POST", "/auth/switch-company", {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
// console.log("🔵 API 응답:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data?.token) {
|
||||||
|
// console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "...");
|
||||||
|
|
||||||
|
// 새로운 JWT 토큰 저장
|
||||||
|
TokenManager.setToken(response.data.token);
|
||||||
|
// console.log("🔵 토큰 저장 완료");
|
||||||
|
|
||||||
|
// refreshUserData 호출하지 않고 바로 성공 반환
|
||||||
|
// (페이지 새로고침 시 자동으로 갱신됨)
|
||||||
|
// console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: response.message || "회사 전환에 성공했습니다.",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// console.error("🔵 API 응답 실패:", response);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: response.message || "회사 전환에 실패했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// console.error("🔵 switchCompany 에러:", error);
|
||||||
|
const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다.";
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: errorMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
// console.log("🔵 switchCompany 완료");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[apiCall]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그아웃 처리
|
* 로그아웃 처리
|
||||||
*/
|
*/
|
||||||
|
|
@ -493,6 +548,7 @@ export const useAuth = () => {
|
||||||
// 함수
|
// 함수
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
switchCompany, // 🆕 회사 전환 함수
|
||||||
checkMenuAuth,
|
checkMenuAuth,
|
||||||
refreshUserData,
|
refreshUserData,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,9 @@ export const menuApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
|
// 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시, 회사별 필터링)
|
||||||
getUserMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
getUserMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||||
const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
|
const response = await apiClient.get("/admin/user-menus");
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue