Compare commits

...

25 Commits

Author SHA1 Message Date
kjs aa066a1ea9 저장후 시각적 효과 표시 2025-09-05 10:27:10 +09:00
kjs f57a7babe6 Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-09-05 10:25:40 +09:00
hyeonsu c5b287a2fe Merge pull request '외부 아이피에서 동작하도록 수정' (#15) from ipadress into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/15
2025-09-04 17:51:38 +09:00
hyeonsu b4368148e2 auth 수정 2025-09-04 17:37:50 +09:00
hyeonsu 5e13f16e73 로그인 쪽 url 수정 2025-09-04 17:31:45 +09:00
hyeonsu 40d8fa605b 모든 곳의 API URL 통일 2025-09-04 17:19:30 +09:00
hyeonsu 2bffec1dbf 빌드 시 환경변수 적용 2025-09-04 16:55:11 +09:00
hyeonsu 71b509b11e CORS origin 처리 단순화 2025-09-04 16:29:57 +09:00
hyeonsu 272385a120 추가수정 (claude) 2025-09-04 16:10:26 +09:00
hyeonsu 205dc05251 요청 서버 주소 수정(claude) 2025-09-04 16:01:33 +09:00
hyeonsu 592b4d7222 디버그 로그 추가 2025-09-04 15:46:17 +09:00
hyeonsu 0d629a27a6 서버/로컬 나눠 설정 2025-09-04 15:39:29 +09:00
hyeonsu 62e31fa682 더 강력한 동적 설정 2025-09-04 15:27:30 +09:00
hyeonsu ee86347e0d Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into ipadress 2025-09-04 15:19:12 +09:00
hyeonsu fc5bd97ac1 동적 API URL 설정 2025-09-04 15:18:25 +09:00
hyeonsu e24ec7e8b4 Merge pull request '외부 ip에서 접근 가능하게 수정' (#14) from ipadress into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/14
2025-09-04 14:59:39 +09:00
hyeonsu 30b56b1acf Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into ipadress 2025-09-04 14:59:08 +09:00
hyeonsu 103dd9907d 외부 ip 로 api 호출 전환 2025-09-04 14:45:58 +09:00
hyeonsu c78b239db2 파일 되돌리기 2025-09-04 14:15:25 +09:00
hyeonsu 22b0f839c6 명시적 HOST 설정 추가 2025-09-04 14:11:20 +09:00
hyeonsu b4e01641a0 조건부 API URL 2025-09-04 13:56:26 +09:00
hyeonsu 22b8bdd400 내부 IP로 바인딩 2025-09-04 12:27:45 +09:00
hyeonsu 8d76df1cfe 도커 yml에 cors 추가 2025-09-04 12:14:06 +09:00
hyeonsu a03db24ab9 동적 API URL 설정 2025-09-04 12:02:35 +09:00
hyeonsu 2d6b0fc7ce ip adress 추가 2025-09-04 11:46:42 +09:00
15 changed files with 956 additions and 66 deletions

View File

@ -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",

View File

@ -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, // 쿠키 및 인증 정보 포함 허용
},
// 로깅 설정

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -42,6 +42,7 @@
- **웹타입 지원**: text, number, decimal, date, datetime, select, dropdown, textarea, boolean, checkbox, radio, code, entity, file
- **데이터 테이블 컴포넌트**: 완전한 실시간 설정 시스템, 컬럼 관리, 필터링, 페이징
- **🆕 실시간 데이터 테이블**: 실제 PostgreSQL 데이터 조회, 웹타입별 검색 필터, 페이지네이션, 데이터 포맷팅
- **🆕 화면 저장 후 메뉴 할당**: 저장 완료 시 자동 메뉴 할당 모달, 기존 화면 교체 확인, 시각적 피드백 및 자동 목록 복귀
#### 🔧 해결된 기술적 문제들
@ -410,6 +411,9 @@ const removeItem = useCallback(
- **회사별 메뉴 할당**: 각 회사의 메뉴에만 화면 할당
- **메뉴-화면 연결**: 메뉴와 화면의 1:1 또는 1:N 연결
- **권한 기반 메뉴 표시**: 사용자 권한에 따른 메뉴 표시 제어
- **🆕 저장 후 자동 할당**: 화면 저장 완료 시 메뉴 할당 모달 자동 팝업
- **🆕 기존 화면 교체**: 이미 할당된 화면이 있을 때 교체 확인 및 안전한 처리
- **🆕 완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
## 🗄️ 데이터베이스 설계
@ -1172,9 +1176,264 @@ function generateValidationRules(column: ColumnInfo): ValidationRule[] {
}
```
## 🎯 메뉴 할당 시스템 (신규 완성)
### 1. 화면 저장 후 메뉴 할당 워크플로우
#### 전체 프로세스
```
화면 설계 완료 → 저장 버튼 클릭 → 메뉴 할당 모달 자동 팝업
메뉴 선택 및 할당 OR "나중에 할당" 클릭
성공 화면 표시 (3초간 시각적 피드백)
자동으로 화면 목록 페이지로 이동
```
#### 메뉴 할당 모달 (MenuAssignmentModal)
**주요 기능:**
1. **관리자 메뉴만 표시**: 화면관리는 관리자 전용 기능이므로 관리자 메뉴(`menuType: "0"`)만 로드
2. **셀렉트박스 내부 검색**: 메뉴명, URL, 설명으로 실시간 검색 가능
3. **기존 화면 감지**: 선택한 메뉴에 이미 할당된 화면이 있는지 자동 확인
4. **화면 교체 확인**: 기존 화면이 있을 때 교체 확인 대화상자 표시
```typescript
interface MenuAssignmentModalProps {
isOpen: boolean;
onClose: () => void;
screenInfo: ScreenDefinition | null;
onAssignmentComplete?: () => void;
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백
}
```
### 2. 메뉴 검색 시스템
```typescript
// 셀렉트박스 내부 검색 구현
<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 onClick={() => setSearchTerm("")}>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* 메뉴 옵션들 */}
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
</SelectContent>
```
### 3. 기존 화면 감지 및 교체 시스템
```typescript
// 메뉴 선택 시 기존 할당된 화면 확인
const handleMenuSelect = async (menuId: string) => {
const menu = menus.find((m) => m.objid?.toString() === menuId);
setSelectedMenu(menu || null);
if (menu) {
try {
const menuObjid = parseInt(menu.objid?.toString() || "0");
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
setExistingScreens(screens);
} catch (error) {
console.error("할당된 화면 조회 실패:", error);
}
}
};
// 할당 시 기존 화면 확인
const handleAssignScreen = async () => {
if (existingScreens.length > 0) {
// 이미 같은 화면이 할당되어 있는지 확인
const alreadyAssigned = existingScreens.some(
(screen) => screen.screenId === screenInfo.screenId
);
if (alreadyAssigned) {
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
return;
}
// 다른 화면이 할당되어 있으면 교체 확인
setShowReplaceDialog(true);
return;
}
// 기존 화면이 없으면 바로 할당
await performAssignment();
};
```
### 4. 화면 교체 확인 대화상자
**시각적 구분:**
- 🔴 **제거될 화면**: 빨간색 배경으로 표시
- 🟢 **새로 할당될 화면**: 초록색 배경으로 표시
- 🟠 **주의 메시지**: 작업이 되돌릴 수 없음을 명확히 안내
**안전한 교체 프로세스:**
1. 기존 화면들을 하나씩 제거
2. 새 화면 할당
3. 성공/실패 로그 출력
```typescript
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
if (replaceExisting && existingScreens.length > 0) {
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);
```
### 5. 성공 피드백 및 자동 이동
**성공 화면 구성:**
- ✅ **체크마크 아이콘**: 성공을 나타내는 녹색 체크마크
- 🎯 **성공 메시지**: 구체적인 할당 완료 메시지
- ⏱️ **자동 이동 안내**: "3초 후 자동으로 화면 목록으로 이동합니다..."
- 🔵 **로딩 애니메이션**: 3개의 점이 순차적으로 바운스하는 애니메이션
```typescript
// 성공 상태 설정
setAssignmentSuccess(true);
setAssignmentMessage(successMessage);
// 3초 후 자동으로 화면 목록으로 이동
setTimeout(() => {
if (onBackToList) {
onBackToList();
} else {
onClose();
}
}, 3000);
```
**성공 화면 UI:**
```jsx
{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>
</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>
</>
) : (
// 기본 할당 화면
// ...
)}
```
### 6. 사용자 경험 개선사항
1. **선택적 할당**: 필수가 아닌 선택적 기능으로 "나중에 할당" 가능
2. **직관적 UI**: 저장된 화면 정보를 모달에서 바로 확인 가능
3. **검색 기능**: 많은 메뉴 중에서 쉽게 찾을 수 있음
4. **상태 표시**: 메뉴 활성/비활성 상태, 기존 할당된 화면 정보 표시
5. **완전한 워크플로우**: 저장 → 할당 → 목록 복귀의 자연스러운 흐름
## 🌐 API 설계
### 1. 화면 정의 API
### 1. 메뉴-화면 할당 API
#### 화면을 메뉴에 할당
```typescript
POST /screen-management/screens/:screenId/assign-menu
Request: {
menuObjid: number;
displayOrder?: number;
}
```
#### 메뉴별 할당된 화면 목록 조회
```typescript
GET /screen-management/menus/:menuObjid/screens
Response: {
success: boolean;
data: ScreenDefinition[];
}
```
#### 화면-메뉴 할당 해제
```typescript
DELETE /screen-management/screens/:screenId/menus/:menuObjid
Response: {
success: boolean;
message: string;
}
```
### 2. 화면 정의 API
#### 화면 목록 조회 (회사별)
@ -2675,7 +2934,19 @@ export class TableTypeIntegrationService {
3. **커스터마이징**: 템플릿을 기반으로 필요한 부분 수정
4. **저장**: 커스터마이징된 화면 저장
### 6. 메뉴 할당 및 관리
### 6. 메뉴 할당 및 관리 (신규 완성)
#### 🆕 저장 후 자동 메뉴 할당
1. **화면 저장 완료**: 화면 설계 완료 후 저장 버튼 클릭
2. **메뉴 할당 모달 자동 팝업**: 저장 성공 시 즉시 메뉴 할당 모달 표시
3. **관리자 메뉴 검색**: 메뉴명, URL, 설명으로 실시간 검색
4. **기존 화면 확인**: 선택한 메뉴에 이미 할당된 화면 자동 감지
5. **교체 확인**: 기존 화면이 있을 때 교체 여부 확인 대화상자
6. **안전한 교체**: 기존 화면 제거 후 새 화면 할당
7. **성공 피드백**: 3초간 성공 화면 표시 후 자동으로 화면 목록으로 이동
#### 기존 메뉴 할당 방식
1. **메뉴 선택**: 화면을 할당할 메뉴 선택 (회사별 메뉴만 표시)
2. **화면 할당**: 선택한 화면을 메뉴에 할당
@ -2778,6 +3049,7 @@ export class TableTypeIntegrationService {
- [x] 메뉴-화면 할당 기능 구현
- [x] 인터랙티브 화면 뷰어 구현
- [x] 사용자 피드백 반영 완료
- [x] 🆕 화면 저장 후 메뉴 할당 워크플로우 구현
**구현 완료 사항:**
@ -2788,6 +3060,7 @@ export class TableTypeIntegrationService {
- 메뉴 관리에서 화면 할당 기능 구현
- 할당된 화면을 실제 사용 가능한 인터랙티브 화면으로 렌더링
- 실제 사용자 입력 및 상호작용 가능한 완전 기능 화면 구현
- 🆕 **완전한 메뉴 할당 워크플로우**: 저장 → 메뉴 할당 모달 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀
## 🎯 현재 구현된 핵심 기능
@ -3559,3 +3832,4 @@ ComponentData = ContainerComponent | WidgetComponent | GroupComponent 등으로
- ✅ **메뉴 연동**: 설계한 화면을 실제 메뉴에 할당하여 즉시 사용
- ✅ **인터랙티브 화면**: 할당된 화면에서 실제 사용자 입력 및 상호작용 가능
- ✅ **13가지 웹 타입 지원**: 모든 업무 요구사항에 대응 가능한 다양한 위젯
- ✅ **🆕 완전한 메뉴 할당 워크플로우**: 저장 → 자동 메뉴 할당 → 기존 화면 교체 확인 → 성공 피드백 → 목록 복귀의 완벽한 사용자 경험

View File

@ -0,0 +1,563 @@
"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 handleAssignLater = () => {
if (!screenInfo) return;
// 성공 상태 설정 (나중에 할당 메시지)
setAssignmentSuccess(true);
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
// 할당 완료 콜백 호출
if (onAssignmentComplete) {
onAssignmentComplete();
}
// 3초 후 자동으로 화면 목록으로 이동
setTimeout(() => {
if (onBackToList) {
onBackToList();
} else {
onClose();
}
}, 3000);
};
// 필터된 메뉴 목록
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>
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
</DialogTitle>
<DialogDescription>
{assignmentMessage.includes("나중에")
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
: "화면이 성공적으로 메뉴에 할당되었습니다."}
</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={handleAssignLater} 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>
</>
);
};

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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 설정 (동적으로 결정)
/**
*

View File

@ -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",

View File

@ -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 = {

View File

@ -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> {

View File

@ -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",
},
};