Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
f57a7babe6
|
|
@ -27,28 +27,11 @@ app.use(compression());
|
|||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
||||
// CORS 설정
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
app.use(
|
||||
cors({
|
||||
origin: function (origin, callback) {
|
||||
const allowedOrigins = config.cors.origin
|
||||
.split(",")
|
||||
.map((url) => url.trim());
|
||||
// Allow requests with no origin (like mobile apps or curl requests)
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.indexOf(origin) !== -1) {
|
||||
return callback(null, true);
|
||||
} else {
|
||||
console.log(`CORS rejected origin: ${origin}`);
|
||||
return callback(
|
||||
new Error(
|
||||
"CORS policy does not allow access from the specified Origin."
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
origin: config.cors.origin, // 이미 배열 또는 boolean으로 처리됨
|
||||
credentials: config.cors.credentials,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface Config {
|
|||
|
||||
// CORS 설정
|
||||
cors: {
|
||||
origin: string;
|
||||
origin: string | string[] | boolean; // 타입을 확장하여 배열과 boolean도 허용
|
||||
credentials: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -58,6 +58,26 @@ interface Config {
|
|||
showErrorDetails: boolean;
|
||||
}
|
||||
|
||||
// CORS origin 처리 함수
|
||||
const getCorsOrigin = (): string[] | boolean => {
|
||||
// 개발 환경에서는 모든 origin 허용
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 환경변수가 있으면 쉼표로 구분하여 배열로 변환
|
||||
if (process.env.CORS_ORIGIN) {
|
||||
return process.env.CORS_ORIGIN.split(",").map((origin) => origin.trim());
|
||||
}
|
||||
|
||||
// 기본값: 허용할 도메인들
|
||||
return [
|
||||
"http://localhost:9771", // 로컬 개발 환경
|
||||
"http://192.168.0.70:5555", // 내부 네트워크 접근
|
||||
"http://39.117.244.52:5555", // 외부 네트워크 접근
|
||||
];
|
||||
};
|
||||
|
||||
const config: Config = {
|
||||
// 서버 설정
|
||||
port: parseInt(process.env.PORT || "3000", 10),
|
||||
|
|
@ -82,8 +102,8 @@ const config: Config = {
|
|||
|
||||
// CORS 설정
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:9771",
|
||||
credentials: process.env.CORS_CREDENTIALS === "true",
|
||||
origin: getCorsOrigin(),
|
||||
credentials: true, // 쿠키 및 인증 정보 포함 허용
|
||||
},
|
||||
|
||||
// 로깅 설정
|
||||
|
|
|
|||
|
|
@ -5,20 +5,17 @@ services:
|
|||
context: ../../backend-node
|
||||
dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile
|
||||
container_name: pms-backend-prod
|
||||
ports:
|
||||
- "8080:8080"
|
||||
network_mode: "host" # 호스트 네트워크 모드
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=http://192.168.0.70:5555
|
||||
- CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771
|
||||
- CORS_CREDENTIALS=true
|
||||
- LOG_LEVEL=info
|
||||
# 운영용에서는 볼륨 마운트 없음 (보안상 이유)
|
||||
networks:
|
||||
- pms-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ services:
|
|||
context: ../../frontend
|
||||
dockerfile: ../docker/prod/frontend.Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api
|
||||
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
|
||||
container_name: pms-frontend-linux
|
||||
ports:
|
||||
- "5555:5555"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api
|
||||
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
|
||||
networks:
|
||||
- pms-network
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ COPY . .
|
|||
# Disable telemetry during the build
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
|
||||
ARG NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Build the application
|
||||
ENV DISABLE_ESLINT_PLUGIN=true
|
||||
RUN npm run build
|
||||
|
|
|
|||
|
|
@ -0,0 +1,546 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Search, Monitor, Settings, X, Plus } from "lucide-react";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { MenuItem } from "@/lib/api/menu";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
|
||||
interface MenuAssignmentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
screenInfo: ScreenDefinition | null;
|
||||
onAssignmentComplete?: () => void;
|
||||
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백 추가
|
||||
}
|
||||
|
||||
export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
screenInfo,
|
||||
onAssignmentComplete,
|
||||
onBackToList,
|
||||
}) => {
|
||||
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||
const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [existingScreens, setExistingScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [showReplaceDialog, setShowReplaceDialog] = useState(false);
|
||||
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
||||
const [assignmentMessage, setAssignmentMessage] = useState("");
|
||||
|
||||
// 메뉴 목록 로드 (관리자 메뉴만)
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 화면관리는 관리자 전용 기능이므로 관리자 메뉴만 가져오기
|
||||
const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } });
|
||||
const adminMenus = adminResponse.data?.data || [];
|
||||
|
||||
// 관리자 메뉴 정규화
|
||||
const normalizedAdminMenus = adminMenus.map((menu: any) => ({
|
||||
objid: menu.objid || menu.OBJID,
|
||||
parent_obj_id: menu.parent_obj_id || menu.PARENT_OBJ_ID,
|
||||
menu_name_kor: menu.menu_name_kor || menu.MENU_NAME_KOR,
|
||||
menu_url: menu.menu_url || menu.MENU_URL,
|
||||
menu_desc: menu.menu_desc || menu.MENU_DESC,
|
||||
seq: menu.seq || menu.SEQ,
|
||||
menu_type: "0", // 관리자 메뉴
|
||||
status: menu.status || menu.STATUS,
|
||||
lev: menu.lev || menu.LEV,
|
||||
company_code: menu.company_code || menu.COMPANY_CODE,
|
||||
company_name: menu.company_name || menu.COMPANY_NAME,
|
||||
}));
|
||||
|
||||
console.log("로드된 관리자 메뉴 목록:", {
|
||||
total: normalizedAdminMenus.length,
|
||||
sample: normalizedAdminMenus.slice(0, 3),
|
||||
});
|
||||
setMenus(normalizedAdminMenus);
|
||||
} catch (error) {
|
||||
console.error("메뉴 목록 로드 실패:", error);
|
||||
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 모달이 열릴 때 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadMenus();
|
||||
setSelectedMenuId("");
|
||||
setSelectedMenu(null);
|
||||
setSearchTerm("");
|
||||
setAssignmentSuccess(false);
|
||||
setAssignmentMessage("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 메뉴 선택 처리
|
||||
const handleMenuSelect = async (menuId: string) => {
|
||||
// 유효하지 않은 메뉴 ID인 경우 처리하지 않음
|
||||
if (!menuId || menuId === "no-menu") {
|
||||
setSelectedMenuId("");
|
||||
setSelectedMenu(null);
|
||||
setExistingScreens([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedMenuId(menuId);
|
||||
const menu = menus.find((m) => m.objid?.toString() === menuId);
|
||||
setSelectedMenu(menu || null);
|
||||
|
||||
// 선택된 메뉴에 할당된 화면들 확인
|
||||
if (menu) {
|
||||
try {
|
||||
const menuObjid = parseInt(menu.objid?.toString() || "0");
|
||||
if (menuObjid > 0) {
|
||||
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
setExistingScreens(screens);
|
||||
console.log(`메뉴 "${menu.menu_name_kor}"에 할당된 화면:`, screens);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("할당된 화면 조회 실패:", error);
|
||||
setExistingScreens([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 할당 처리
|
||||
const handleAssignScreen = async () => {
|
||||
if (!selectedMenu || !screenInfo) {
|
||||
toast.error("메뉴와 화면 정보가 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존에 할당된 화면이 있는지 확인
|
||||
if (existingScreens.length > 0) {
|
||||
// 이미 같은 화면이 할당되어 있는지 확인
|
||||
const alreadyAssigned = existingScreens.some((screen) => screen.screenId === screenInfo.screenId);
|
||||
if (alreadyAssigned) {
|
||||
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 화면이 할당되어 있으면 교체 확인
|
||||
setShowReplaceDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 화면이 없으면 바로 할당
|
||||
await performAssignment();
|
||||
};
|
||||
|
||||
// 실제 할당 수행
|
||||
const performAssignment = async (replaceExisting = false) => {
|
||||
if (!selectedMenu || !screenInfo) return;
|
||||
|
||||
try {
|
||||
setAssigning(true);
|
||||
|
||||
const menuObjid = parseInt(selectedMenu.objid?.toString() || "0");
|
||||
if (menuObjid === 0) {
|
||||
toast.error("유효하지 않은 메뉴 ID입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
|
||||
if (replaceExisting && existingScreens.length > 0) {
|
||||
console.log("기존 화면들 제거 중...", existingScreens);
|
||||
for (const existingScreen of existingScreens) {
|
||||
try {
|
||||
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
|
||||
console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
|
||||
} catch (error) {
|
||||
console.error(`기존 화면 "${existingScreen.screenName}" 제거 실패:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 새 화면 할당
|
||||
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);
|
||||
|
||||
const successMessage = replaceExisting
|
||||
? `기존 화면을 제거하고 "${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 할당되었습니다.`
|
||||
: `"${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 성공적으로 할당되었습니다.`;
|
||||
|
||||
// 성공 상태 설정
|
||||
setAssignmentSuccess(true);
|
||||
setAssignmentMessage(successMessage);
|
||||
|
||||
// 할당 완료 콜백 호출
|
||||
if (onAssignmentComplete) {
|
||||
onAssignmentComplete();
|
||||
}
|
||||
|
||||
// 3초 후 자동으로 화면 목록으로 이동
|
||||
setTimeout(() => {
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
console.error("화면 할당 실패:", error);
|
||||
const errorMessage = error.response?.data?.message || "화면 할당에 실패했습니다.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 메뉴 목록
|
||||
const filteredMenus = menus.filter((menu) => {
|
||||
if (!searchTerm) return true;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
menu.menu_name_kor?.toLowerCase().includes(searchLower) ||
|
||||
menu.menu_url?.toLowerCase().includes(searchLower) ||
|
||||
menu.menu_desc?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// 메뉴 옵션 생성 (계층 구조 표시)
|
||||
const getMenuOptions = (): JSX.Element[] => {
|
||||
if (loading) {
|
||||
return [
|
||||
<SelectItem key="loading" value="loading" disabled>
|
||||
메뉴 로딩 중...
|
||||
</SelectItem>,
|
||||
];
|
||||
}
|
||||
|
||||
if (filteredMenus.length === 0) {
|
||||
return [
|
||||
<SelectItem key="no-menu" value="no-menu" disabled>
|
||||
{searchTerm ? `"${searchTerm}"에 대한 검색 결과가 없습니다` : "메뉴가 없습니다"}
|
||||
</SelectItem>,
|
||||
];
|
||||
}
|
||||
|
||||
return filteredMenus
|
||||
.filter((menu) => menu.objid && menu.objid.toString().trim() !== "") // objid가 유효한 메뉴만 필터링
|
||||
.map((menu) => {
|
||||
const indent = " ".repeat(Math.max(0, menu.lev || 0));
|
||||
const menuId = menu.objid!.toString(); // 이미 필터링했으므로 non-null assertion 사용
|
||||
|
||||
return (
|
||||
<SelectItem key={menuId} value={menuId}>
|
||||
{indent}
|
||||
{menu.menu_name_kor}
|
||||
</SelectItem>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{assignmentSuccess ? (
|
||||
// 성공 화면
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
화면 할당 완료
|
||||
</DialogTitle>
|
||||
<DialogDescription>화면이 성공적으로 메뉴에 할당되었습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-green-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||
<Monitor className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
|
||||
<p className="mt-1 text-xs text-green-700">3초 후 자동으로 화면 목록으로 이동합니다...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
화면 목록으로 이동
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
// 기본 할당 화면
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
메뉴에 화면 할당
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||
</DialogDescription>
|
||||
{screenInfo && (
|
||||
<div className="mt-2 rounded-lg border bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-blue-600" />
|
||||
<span className="font-medium text-blue-900">{screenInfo.screenName}</span>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{screenInfo.screenCode}
|
||||
</Badge>
|
||||
</div>
|
||||
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||
<div>
|
||||
<Label htmlFor="menu-select">할당할 메뉴 선택</Label>
|
||||
<Select value={selectedMenuId} onValueChange={handleMenuSelect} disabled={loading}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "메뉴 로딩 중..." : "메뉴를 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{/* 검색 입력 필드 */}
|
||||
<div className="sticky top-0 z-10 border-b bg-white p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="메뉴명, URL, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // 이벤트 전파 방지
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation(); // 키보드 이벤트 전파 방지
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 클릭 이벤트 전파 방지
|
||||
}}
|
||||
className="h-8 pr-8 pl-10 text-sm"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 메뉴 옵션들 */}
|
||||
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 선택된 메뉴 정보 */}
|
||||
{selectedMenu && (
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{selectedMenu.menu_name_kor}</h4>
|
||||
<Badge variant="default">관리자</Badge>
|
||||
<Badge variant={selectedMenu.status === "active" ? "default" : "outline"}>
|
||||
{selectedMenu.status === "active" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1 text-sm text-gray-600">
|
||||
{selectedMenu.menu_url && <p>URL: {selectedMenu.menu_url}</p>}
|
||||
{selectedMenu.menu_desc && <p>설명: {selectedMenu.menu_desc}</p>}
|
||||
{selectedMenu.company_name && <p>회사: {selectedMenu.company_name}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기존 할당된 화면 정보 */}
|
||||
{existingScreens.length > 0 && (
|
||||
<div className="mt-3 rounded border bg-yellow-50 p-2">
|
||||
<p className="text-sm font-medium text-yellow-800">
|
||||
⚠️ 이미 할당된 화면 ({existingScreens.length}개)
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{existingScreens.map((screen) => (
|
||||
<div key={screen.screenId} className="flex items-center gap-2 text-xs text-yellow-700">
|
||||
<Monitor className="h-3 w-3" />
|
||||
<span>{screen.screenName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{screen.screenCode}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-yellow-600">새 화면을 할당하면 기존 화면들이 제거됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (onBackToList) {
|
||||
onBackToList();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
disabled={assigning}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
나중에 할당
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssignScreen}
|
||||
disabled={!selectedMenu || assigning}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{assigning ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
할당 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
메뉴에 할당
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 화면 교체 확인 대화상자 */}
|
||||
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-orange-600" />
|
||||
화면 교체 확인
|
||||
</DialogTitle>
|
||||
<DialogDescription>선택한 메뉴에 이미 할당된 화면이 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기존 화면 목록 */}
|
||||
<div className="rounded-lg border bg-red-50 p-3">
|
||||
<p className="mb-2 text-sm font-medium text-red-800">제거될 화면 ({existingScreens.length}개):</p>
|
||||
<div className="space-y-1">
|
||||
{existingScreens.map((screen) => (
|
||||
<div key={screen.screenId} className="flex items-center gap-2 text-sm text-red-700">
|
||||
<X className="h-3 w-3" />
|
||||
<span>{screen.screenName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{screen.screenCode}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로 할당될 화면 */}
|
||||
{screenInfo && (
|
||||
<div className="rounded-lg border bg-green-50 p-3">
|
||||
<p className="mb-2 text-sm font-medium text-green-800">새로 할당될 화면:</p>
|
||||
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>{screenInfo.screenName}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{screenInfo.screenCode}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border-l-4 border-orange-400 bg-orange-50 p-3">
|
||||
<p className="text-sm text-orange-800">
|
||||
<strong>주의:</strong> 기존 화면들이 메뉴에서 제거되고 새 화면으로 교체됩니다. 이 작업은 되돌릴 수
|
||||
없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setShowReplaceDialog(false);
|
||||
await performAssignment(true);
|
||||
}}
|
||||
disabled={assigning}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
{assigning ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
교체 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
화면 교체
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreview";
|
||||
|
|
@ -133,6 +134,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 메뉴 할당 모달 상태
|
||||
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
||||
|
||||
// 해상도 설정 상태
|
||||
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
|
||||
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
||||
|
|
@ -802,13 +806,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
|
||||
// 저장 성공 후 메뉴 할당 모달 열기
|
||||
setShowMenuAssignmentModal(true);
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout]);
|
||||
}, [selectedScreen?.screenId, layout, screenResolution]);
|
||||
|
||||
// 템플릿 드래그 처리
|
||||
const handleTemplateDrop = useCallback(
|
||||
|
|
@ -2989,6 +2996,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메뉴 할당 모달 */}
|
||||
<MenuAssignmentModal
|
||||
isOpen={showMenuAssignmentModal}
|
||||
onClose={() => setShowMenuAssignmentModal(false)}
|
||||
screenInfo={selectedScreen}
|
||||
onAssignmentComplete={() => {
|
||||
console.log("메뉴 할당 완료");
|
||||
// 필요시 추가 작업 수행
|
||||
}}
|
||||
onBackToList={onBackToList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
export const AUTH_CONFIG = {
|
||||
API_BASE_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
||||
API_BASE_URL: process.env.NEXT_PUBLIC_API_URL || "http://39.117.244.52:8080/api",
|
||||
ENDPOINTS: {
|
||||
LOGIN: "/auth/login",
|
||||
STATUS: "/auth/status",
|
||||
|
|
@ -15,18 +15,21 @@ export const AUTH_CONFIG = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export const UI_CONFIG = {
|
||||
COMPANY_NAME: "WACE 솔루션",
|
||||
COPYRIGHT: "© 2024 WACE 솔루션. All rights reserved.",
|
||||
POWERED_BY: "Powered by WACE PLM System",
|
||||
} as const;
|
||||
|
||||
export const FORM_VALIDATION = {
|
||||
MESSAGES: {
|
||||
REQUIRED: "필수 입력 항목입니다.",
|
||||
INVALID_FORMAT: "형식이 올바르지 않습니다.",
|
||||
PASSWORD_MISMATCH: "비밀번호가 일치하지 않습니다.",
|
||||
INVALID_CREDENTIALS: "아이디 또는 비밀번호가 올바르지 않습니다.",
|
||||
USER_ID_REQUIRED: "사용자 ID를 입력해주세요.",
|
||||
PASSWORD_REQUIRED: "비밀번호를 입력해주세요.",
|
||||
LOGIN_FAILED: "로그인에 실패했습니다.",
|
||||
CONNECTION_FAILED: "서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요.",
|
||||
BACKEND_CONNECTION_FAILED: "백엔드 서버에 연결할 수 없습니다.",
|
||||
CONNECTION_FAILED: "서버 연결에 실패했습니다.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const UI_CONFIG = {
|
||||
COMPANY_NAME: "WACE 솔루션",
|
||||
COPYRIGHT: "© 2025 WACE PLM Solution. All rights reserved.",
|
||||
POWERED_BY: "Powered by Spring Boot + Next.js",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
export const LAYOUT_CONFIG = {
|
||||
COMPANY_NAME: "WACE 솔루션",
|
||||
API_BASE_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
||||
API_BASE_URL: process.env.NEXT_PUBLIC_API_URL || "http://39.117.244.52:8080/api",
|
||||
|
||||
ENDPOINTS: {
|
||||
USER_MENUS: "/admin/user-menus",
|
||||
|
|
@ -24,18 +24,9 @@ export const LAYOUT_CONFIG = {
|
|||
|
||||
export const MESSAGES = {
|
||||
LOADING: "로딩 중...",
|
||||
NO_MENUS: "메뉴가 없습니다.",
|
||||
PROFILE_SAVE_SUCCESS: "프로필이 성공적으로 저장되었습니다.",
|
||||
PROFILE_SAVE_ERROR: "프로필 저장 중 오류가 발생했습니다.",
|
||||
FILE_SIZE_ERROR: "파일 크기는 5MB를 초과할 수 없습니다.",
|
||||
FILE_TYPE_ERROR: "이미지 파일만 업로드 가능합니다.",
|
||||
} as const;
|
||||
|
||||
export const MENU_ICONS = {
|
||||
DEFAULT: "FileText",
|
||||
HOME: ["홈", "메인"],
|
||||
DOCUMENT: ["문서", "게시"],
|
||||
USERS: ["사용자", "회원"],
|
||||
STATISTICS: ["통계", "현황"],
|
||||
SETTINGS: ["설정", "관리"],
|
||||
ERROR: "오류가 발생했습니다.",
|
||||
SUCCESS: "성공적으로 처리되었습니다.",
|
||||
CONFIRM: "정말로 진행하시겠습니까?",
|
||||
NO_DATA: "데이터가 없습니다.",
|
||||
NO_MENUS: "사용 가능한 메뉴가 없습니다.",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
import { apiCall, API_BASE_URL } from "@/lib/api/client";
|
||||
|
||||
// 사용자 정보 타입 정의
|
||||
interface UserInfo {
|
||||
|
|
@ -98,8 +98,7 @@ export const useAuth = () => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// API 기본 URL 설정
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
|
||||
// API 기본 URL 설정 (동적으로 결정)
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { LoginFormData, LoginResponse } from "@/types/auth";
|
||||
import { AUTH_CONFIG, FORM_VALIDATION } from "@/constants/auth";
|
||||
import { API_BASE_URL } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* 로그인 관련 비즈니스 로직을 관리하는 커스텀 훅
|
||||
|
|
@ -60,7 +61,7 @@ export const useLogin = () => {
|
|||
* API 호출 공통 함수
|
||||
*/
|
||||
const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise<LoginResponse> => {
|
||||
const response = await fetch(`${AUTH_CONFIG.API_BASE_URL}${endpoint}`, {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,43 @@
|
|||
import axios, { AxiosResponse, AxiosError } from "axios";
|
||||
|
||||
// API 기본 URL 설정
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api";
|
||||
// API URL 동적 설정 - 환경별 명확한 분리
|
||||
const getApiBaseUrl = (): string => {
|
||||
console.log("🔍 API URL 결정 시작!");
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const fullUrl = window.location.href;
|
||||
|
||||
console.log("🌐 현재 접속 정보:", {
|
||||
hostname: currentHost,
|
||||
fullUrl: fullUrl,
|
||||
port: currentPort,
|
||||
});
|
||||
|
||||
// 로컬 개발환경: localhost:9771 → localhost:8080
|
||||
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "9771") {
|
||||
console.log("🏠 로컬 개발 환경 감지 → localhost:8080/api");
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
|
||||
// 서버 환경에서 localhost:5555 → 39.117.244.52:8080
|
||||
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "5555") {
|
||||
console.log("🌍 서버 환경 (localhost:5555) 감지 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
}
|
||||
|
||||
// 기타 서버 환경 (내부/외부 IP): → 39.117.244.52:8080
|
||||
console.log("🌍 서버 환경 감지 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
}
|
||||
|
||||
// 서버 사이드 렌더링 기본값
|
||||
console.log("🖥️ SSR 기본값 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// JWT 토큰 관리 유틸리티
|
||||
const TokenManager = {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { Company, CompanyFormData } from "@/types/company";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://39.117.244.52:8080/api";
|
||||
|
||||
// API 응답 타입 정의
|
||||
interface ApiResponse<T = any> {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const nextConfig = {
|
|||
|
||||
// 환경 변수 (런타임에 읽기)
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://192.168.0.70:8080/api",
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://39.117.244.52:8080/api",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue