chore: 프로젝트 초기 설정 및 팀 워크플로우 구성
- Spring Boot 3.2.1 + React 19 프로젝트 구조 - S&P Global Maritime API Bypass 및 Risk & Compliance Screening 기능 - 팀 워크플로우 v1.6.1 적용 (settings.json, hooks, workflow-version) - 프론트엔드 빌드 (Vite + TypeScript + Tailwind CSS) - 메인 카드 레이아웃 CSS Grid 전환 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
6e95715353
커밋
b2b268f1e5
87
.claude/settings.json
Normal file
87
.claude/settings.json
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(./mvnw *)",
|
||||||
|
"Bash(curl -s *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git branch *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git config *)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git log *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
|
"Bash(git rev-parse *)",
|
||||||
|
"Bash(git show *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Bash(git status)",
|
||||||
|
"Bash(git tag *)",
|
||||||
|
"Bash(java -jar *)",
|
||||||
|
"Bash(java -version)",
|
||||||
|
"Bash(mvn *)",
|
||||||
|
"Bash(sdk *)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Bash(git push --force*)",
|
||||||
|
"Bash(git push -f *)",
|
||||||
|
"Bash(git push origin --force*)",
|
||||||
|
"Bash(git reset --hard*)",
|
||||||
|
"Bash(git clean -fd*)",
|
||||||
|
"Bash(git checkout -- .)",
|
||||||
|
"Bash(git restore .)",
|
||||||
|
"Bash(rm -rf /)",
|
||||||
|
"Bash(rm -rf ~)",
|
||||||
|
"Bash(rm -rf .git*)",
|
||||||
|
"Bash(rm -rf /*)",
|
||||||
|
"Read(./**/.env)",
|
||||||
|
"Read(./**/.env.*)",
|
||||||
|
"Read(./**/secrets/**)",
|
||||||
|
"Read(./**/application-local.yml)",
|
||||||
|
"Read(./**/application-local.properties)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-post-compact.sh",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-pre-compact.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash .claude/scripts/on-commit.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"applied_global_version": "1.6.1",
|
||||||
|
"applied_date": "2026-04-07",
|
||||||
|
"project_type": "java-maven",
|
||||||
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
|
}
|
||||||
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{java,kt}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{sh,bash}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{gradle,groovy}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.xml]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Build and Deploy Batch
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: maven:3.9-eclipse-temurin-17
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 --branch=${GITHUB_REF_NAME} \
|
||||||
|
http://gitea:3000/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
|
- name: Configure Maven settings
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.m2
|
||||||
|
cat > ~/.m2/settings.xml << 'SETTINGS'
|
||||||
|
<settings>
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>${{ secrets.NEXUS_USERNAME }}</username>
|
||||||
|
<password>${{ secrets.NEXUS_PASSWORD }}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>
|
||||||
|
SETTINGS
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
cp target/snp-batch-validation-*.jar /deploy/snp-batch/app.jar
|
||||||
|
date '+%Y-%m-%d %H:%M:%S' > /deploy/snp-batch/.deploy-trigger
|
||||||
|
echo "Deployed at $(cat /deploy/snp-batch/.deploy-trigger)"
|
||||||
|
ls -la /deploy/snp-batch/
|
||||||
60
.githooks/commit-msg
Executable file
60
.githooks/commit-msg
Executable file
@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# commit-msg hook
|
||||||
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE="$1"
|
||||||
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
# Merge 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Revert 커밋은 검증 건너뜀
|
||||||
|
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conventional Commits 정규식
|
||||||
|
# type(scope): subject
|
||||||
|
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
|
||||||
|
# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택)
|
||||||
|
# - subject: 1~72자, 한/영 혼용 허용 (필수)
|
||||||
|
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$'
|
||||||
|
|
||||||
|
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
|
||||||
|
|
||||||
|
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " 올바른 형식: type(scope): subject"
|
||||||
|
echo ""
|
||||||
|
echo " type (필수):"
|
||||||
|
echo " feat — 새로운 기능"
|
||||||
|
echo " fix — 버그 수정"
|
||||||
|
echo " docs — 문서 변경"
|
||||||
|
echo " style — 코드 포맷팅"
|
||||||
|
echo " refactor — 리팩토링"
|
||||||
|
echo " test — 테스트"
|
||||||
|
echo " chore — 빌드/설정 변경"
|
||||||
|
echo " ci — CI/CD 변경"
|
||||||
|
echo " perf — 성능 개선"
|
||||||
|
echo ""
|
||||||
|
echo " scope (선택): 한/영 모두 가능"
|
||||||
|
echo " subject (필수): 1~72자, 한/영 모두 가능"
|
||||||
|
echo ""
|
||||||
|
echo " 예시:"
|
||||||
|
echo " feat(auth): JWT 기반 로그인 구현"
|
||||||
|
echo " fix(배치): 야간 배치 타임아웃 수정"
|
||||||
|
echo " docs: README 업데이트"
|
||||||
|
echo " chore: Gradle 의존성 업데이트"
|
||||||
|
echo ""
|
||||||
|
echo " 현재 메시지: $FIRST_LINE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# post-checkout hook
|
||||||
|
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
|
||||||
|
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
|
||||||
|
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
|
||||||
|
BRANCH_FLAG="$3"
|
||||||
|
|
||||||
|
# 파일 체크아웃은 건너뜀
|
||||||
|
if [ "$BRANCH_FLAG" = "0" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .githooks 디렉토리 존재 확인
|
||||||
|
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
if [ -d "${REPO_ROOT}/.githooks" ]; then
|
||||||
|
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
|
||||||
|
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
33
.githooks/pre-commit
Executable file
33
.githooks/pre-commit
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#==============================================================================
|
||||||
|
# pre-commit hook (Java Maven)
|
||||||
|
# Maven 컴파일 검증 — 컴파일 실패 시 커밋 차단
|
||||||
|
#==============================================================================
|
||||||
|
|
||||||
|
echo "pre-commit: Maven 컴파일 검증 중..."
|
||||||
|
|
||||||
|
# Maven Wrapper 사용 (없으면 mvn 사용)
|
||||||
|
if [ -f "./mvnw" ]; then
|
||||||
|
MVN="./mvnw"
|
||||||
|
elif command -v mvn &>/dev/null; then
|
||||||
|
MVN="mvn"
|
||||||
|
else
|
||||||
|
echo "경고: Maven이 설치되지 않았습니다. 컴파일 검증을 건너뜁니다."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 컴파일 검증 (테스트 제외, 오프라인 가능)
|
||||||
|
$MVN compile -q -DskipTests 2>&1
|
||||||
|
RESULT=$?
|
||||||
|
|
||||||
|
if [ $RESULT -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 컴파일 실패! 커밋이 차단되었습니다. ║"
|
||||||
|
echo "║ 컴파일 오류를 수정한 후 다시 커밋해주세요. ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-commit: 컴파일 성공"
|
||||||
120
.gitignore
vendored
Normal file
120
.gitignore
vendored
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Compiled class file
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Log file
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# BlueJ files
|
||||||
|
*.ctxt
|
||||||
|
|
||||||
|
# Mobile Tools for Java (J2ME)
|
||||||
|
.mtj.tmp/
|
||||||
|
|
||||||
|
# Package Files
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.nar
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# virtual machine crash logs
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
|
||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
.mvn/wrapper/maven-wrapper.properties
|
||||||
|
mvnw
|
||||||
|
mvnw.cmd
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
# IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
# Eclipse
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# NetBeans
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Application specific
|
||||||
|
application-local.yml
|
||||||
|
*.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# Frontend (Vite + React)
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/node/
|
||||||
|
src/main/resources/static/
|
||||||
|
|
||||||
|
# Claude Code (개인 설정)
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/CLAUDE.local.md
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Team workflow (managed by /sync-team-workflow)
|
||||||
|
.claude/rules/
|
||||||
|
.claude/agents/
|
||||||
|
.claude/skills/push/
|
||||||
|
.claude/skills/mr/
|
||||||
|
.claude/skills/create-mr/
|
||||||
|
.claude/skills/release/
|
||||||
|
.claude/skills/version/
|
||||||
|
.claude/skills/fix-issue/
|
||||||
|
.claude/scripts/
|
||||||
22
.mvn/settings.xml
Normal file
22
.mvn/settings.xml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
|
||||||
|
https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>nexus</id>
|
||||||
|
<username>admin</username>
|
||||||
|
<password>Gcsc!8932</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>nexus</id>
|
||||||
|
<name>GC Nexus Repository</name>
|
||||||
|
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
|
||||||
|
<mirrorOf>*</mirrorOf>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
</settings>
|
||||||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# SNP-Global (snp-global)
|
||||||
|
|
||||||
|
S&P Global Maritime API Gateway 및 Risk & Compliance Screening 시스템. 외부 Maritime API를 인증 기반으로 프록시(Bypass)하고, 선박/회사의 리스크·규정준수 스크리닝 데이터를 서비스.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
- Java 17, Spring Boot 3.2.1
|
||||||
|
- Spring Security (Basic Auth)
|
||||||
|
- PostgreSQL (스키마: std_snp_data)
|
||||||
|
- WebFlux WebClient (외부 Maritime API 호출)
|
||||||
|
- Spring Data JPA, JdbcTemplate
|
||||||
|
- Thymeleaf (이메일 템플릿)
|
||||||
|
- Springdoc OpenAPI 2.3.0 (Swagger)
|
||||||
|
- Lombok, Jackson
|
||||||
|
- React 19, TypeScript, Vite, Tailwind CSS (프론트엔드)
|
||||||
|
|
||||||
|
## 빌드 & 실행
|
||||||
|
```bash
|
||||||
|
# 빌드
|
||||||
|
sdk use java 17.0.18-amzn
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 실행
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 프론트엔드 개발
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 서버 설정
|
||||||
|
- 기본 포트: 8031 (dev/prod 프로파일: 8041)
|
||||||
|
- Context Path: /snp-global (dev/prod: /snp-api)
|
||||||
|
- Swagger UI: http://localhost:8031/snp-global/swagger-ui/index.html
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
```
|
||||||
|
src/main/java/com/snp/batch/
|
||||||
|
├── SnpGlobalApplication.java # 메인 애플리케이션
|
||||||
|
├── api/logging/ # API 접근 로깅 필터
|
||||||
|
├── common/web/ # 공통 프레임워크
|
||||||
|
│ ├── ApiResponse.java # 통합 API 응답 래퍼
|
||||||
|
│ ├── controller/BaseBypassController # Bypass 컨트롤러 베이스
|
||||||
|
│ └── service/BaseBypassService # Bypass 서비스 베이스 (WebClient)
|
||||||
|
├── global/
|
||||||
|
│ ├── config/ # Security, Swagger, WebClient, Auth 설정
|
||||||
|
│ ├── controller/ # Bypass 계정/설정 관리, Screening Guide, SPA 라우터
|
||||||
|
│ ├── dto/ # bypass/, screening/ DTO
|
||||||
|
│ ├── model/ # bypass 엔티티, screening/ 다국어 엔티티
|
||||||
|
│ └── repository/ # bypass, screening/ 리포지토리
|
||||||
|
├── jobs/web/ # S&P API Bypass 엔드포인트 (자동 생성 가능)
|
||||||
|
│ ├── compliance/ # CompliancesByImos
|
||||||
|
│ └── risk/ # RisksByImos, UpdatedComplianceList
|
||||||
|
└── service/ # 핵심 비즈니스 서비스
|
||||||
|
├── BypassApiAccountService # 계정 CRUD
|
||||||
|
├── BypassApiRequestService # 접근 요청 관리 (승인/거부)
|
||||||
|
├── BypassCodeGenerator # 서비스/컨트롤러 코드 자동 생성
|
||||||
|
├── BypassConfigService # Bypass API 설정 CRUD
|
||||||
|
├── EmailService # 이메일 발송 (승인/거부 알림)
|
||||||
|
└── ScreeningGuideService # 리스크·규정준수 스크리닝 데이터 조회
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주요 API 경로
|
||||||
|
|
||||||
|
### Bypass Account 관리 (/api/bypass-account)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /accounts | 계정 목록 (페이징) |
|
||||||
|
| GET/PUT/DELETE | /accounts/{id} | 계정 상세/수정/삭제 |
|
||||||
|
| POST | /accounts/{id}/reset-password | 비밀번호 초기화 |
|
||||||
|
| POST | /requests | API 접근 요청 (공개) |
|
||||||
|
| GET | /requests | 요청 목록 |
|
||||||
|
| POST | /requests/{id}/approve | 승인 (계정 자동 생성) |
|
||||||
|
| POST | /requests/{id}/reject | 거부 |
|
||||||
|
|
||||||
|
### Bypass Config 관리 (/api/bypass-config)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET/POST | / | 설정 목록/생성 |
|
||||||
|
| GET/PUT/DELETE | /{id} | 설정 상세/수정/삭제 |
|
||||||
|
| POST | /{id}/generate | 서비스·컨트롤러 코드 생성 |
|
||||||
|
|
||||||
|
### S&P API Bypass (/api/compliance, /api/risk) - Basic Auth 필요
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /compliance/CompliancesByImos | IMO별 규정준수 데이터 |
|
||||||
|
| GET | /risk/RisksByImos | IMO별 리스크 데이터 |
|
||||||
|
| GET | /risk/UpdatedComplianceList | 기간별 업데이트 목록 |
|
||||||
|
|
||||||
|
### Screening Guide (/api/screening-guide)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | /risk-indicators | 리스크 지표 (카테고리별) |
|
||||||
|
| GET | /compliance-indicators | 규정준수 지표 |
|
||||||
|
| GET | /methodology-history | 방법론 변경 이력 |
|
||||||
|
| GET | /history/ship-risk | 선박 리스크 변경 이력 |
|
||||||
|
| GET | /history/ship-compliance | 선박 규정준수 변경 이력 |
|
||||||
|
| GET | /history/company-compliance | 회사 규정준수 변경 이력 |
|
||||||
|
| GET | /ship-info, /company-info | 기본 정보 조회 |
|
||||||
|
| GET | /ship-risk-status | 선박 리스크 현황 |
|
||||||
|
| GET | /ship-compliance-status | 선박 규정준수 현황 |
|
||||||
|
| GET | /company-compliance-status | 회사 규정준수 현황 |
|
||||||
|
|
||||||
|
## Lint/Format
|
||||||
|
- 별도 lint 도구 미설정 (checkstyle, spotless 없음)
|
||||||
|
- IDE 기본 포매터 사용
|
||||||
106
README.md
106
README.md
@ -1,3 +1,105 @@
|
|||||||
# snp-global
|
# SNP-Batch (snp-batch-validation)
|
||||||
|
|
||||||
S&P Global API - Risk & Compliance 서비스
|
해양 데이터 통합 배치 시스템. Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스합니다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
|
||||||
|
- PostgreSQL, Quartz Scheduler, Caffeine Cache
|
||||||
|
- React 19 + Vite + Tailwind CSS 4 (관리 UI)
|
||||||
|
- frontend-maven-plugin (프론트엔드 빌드 통합)
|
||||||
|
|
||||||
|
## 사전 요구사항
|
||||||
|
|
||||||
|
| 항목 | 버전 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) |
|
||||||
|
| Maven | 3.9+ | |
|
||||||
|
| Node.js | 20+ | 프론트엔드 빌드용 |
|
||||||
|
| npm | 10+ | Node.js에 포함 |
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다.
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 프론트엔드 빌드
|
||||||
|
cd frontend && npm install && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. Maven 패키징 (프론트엔드 빌드 스킵)
|
||||||
|
mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 결과: `target/snp-batch-validation-1.0.0.jar`
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
`Cmd+Shift+B` (기본 빌드 태스크) → 프론트엔드 빌드 + Maven 패키징 순차 실행
|
||||||
|
|
||||||
|
개별 태스크: `Cmd+Shift+P` → "Tasks: Run Task" → 태스크 선택
|
||||||
|
|
||||||
|
> 태스크 설정: [.vscode/tasks.json](.vscode/tasks.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build`
|
||||||
|
2. **Maven 패키징**: Maven 패널 → Lifecycle → `package`
|
||||||
|
- VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
- 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm`
|
||||||
|
|
||||||
|
## 로컬 실행
|
||||||
|
|
||||||
|
### 터미널
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
||||||
|
```
|
||||||
|
|
||||||
|
### VSCode
|
||||||
|
|
||||||
|
Run/Debug 패널(F5) → "SNP-Batch (local)" 선택
|
||||||
|
|
||||||
|
> 실행 설정: [.vscode/launch.json](.vscode/launch.json)
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
|
||||||
|
Run Configuration → Spring Boot:
|
||||||
|
- Main class: `com.snp.batch.SnpBatchApplication`
|
||||||
|
- Active profiles: `local`
|
||||||
|
|
||||||
|
## 서버 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 빌드 (위 빌드 절차 수행)
|
||||||
|
|
||||||
|
# 2. JAR 전송
|
||||||
|
scp target/snp-batch-validation-1.0.0.jar {서버}:{경로}/
|
||||||
|
|
||||||
|
# 3. 실행
|
||||||
|
java -jar snp-batch-validation-1.0.0.jar --spring.profiles.active=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 접속 정보
|
||||||
|
|
||||||
|
| 항목 | URL |
|
||||||
|
|------|-----|
|
||||||
|
| 관리 UI | `http://localhost:8041/snp-api/` |
|
||||||
|
| Swagger | `http://localhost:8041/snp-api/swagger-ui/index.html` |
|
||||||
|
|
||||||
|
## 프로파일
|
||||||
|
|
||||||
|
| 프로파일 | 용도 | DB |
|
||||||
|
|----------|------|----|
|
||||||
|
| `local` | 로컬 개발 | 개발 DB |
|
||||||
|
| `dev` | 개발 서버 | 개발 DB |
|
||||||
|
| `prod` | 운영 서버 | 운영 DB |
|
||||||
|
|
||||||
|
## Maven 빌드 플래그 요약
|
||||||
|
|
||||||
|
| 플래그 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `-DskipTests` | 테스트 스킵 |
|
||||||
|
| `-Dskip.npm` | npm install/build 스킵 |
|
||||||
|
| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 |
|
||||||
|
|||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/snp-global/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/snp-global/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/snp-global/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/snp-global/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/snp-global/site.webmanifest" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>S&P Global API</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3935
frontend/package-lock.json
generated
Normal file
3935
frontend/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
BIN
frontend/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 12 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
BIN
frontend/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 26 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 11 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
BIN
frontend/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 707 B |
BIN
frontend/public/favicon-32x32.png
Normal file
BIN
frontend/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 1.6 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 15 KiB |
1
frontend/public/site.webmanifest
Normal file
1
frontend/public/site.webmanifest
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
69
frontend/src/App.tsx
Normal file
69
frontend/src/App.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
|
import { ToastProvider, useToastContext } from './contexts/ToastContext';
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import ToastContainer from './components/Toast';
|
||||||
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
const MainMenu = lazy(() => import('./pages/MainMenu'));
|
||||||
|
const BypassConfig = lazy(() => import('./pages/BypassConfig'));
|
||||||
|
const BypassCatalog = lazy(() => import('./pages/BypassCatalog'));
|
||||||
|
const ScreeningGuide = lazy(() => import('./pages/ScreeningGuide'));
|
||||||
|
const RiskComplianceHistory = lazy(() => import('./pages/RiskComplianceHistory'));
|
||||||
|
const BypassAccountRequests = lazy(() => import('./pages/BypassAccountRequests'));
|
||||||
|
const BypassAccountManagement = lazy(() => import('./pages/BypassAccountManagement'));
|
||||||
|
const BypassAccessRequest = lazy(() => import('./pages/BypassAccessRequest'));
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const { toasts, removeToast } = useToastContext();
|
||||||
|
const location = useLocation();
|
||||||
|
const isMainMenu = location.pathname === '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-wing-bg text-wing-text flex flex-col overflow-hidden">
|
||||||
|
{/* 메인 화면: 전체화면, 섹션 페이지: 탭 + 스크롤 콘텐츠 */}
|
||||||
|
{isMainMenu ? (
|
||||||
|
<div className="flex-1 overflow-auto px-4">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<MainMenu />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-shrink-0 px-4 pt-4 max-w-7xl mx-auto w-full">
|
||||||
|
<Navbar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-4 pt-6 max-w-7xl mx-auto w-full">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/bypass-catalog" element={<BypassCatalog />} />
|
||||||
|
<Route path="/bypass-config" element={<BypassConfig />} />
|
||||||
|
<Route path="/bypass-account-requests" element={<BypassAccountRequests />} />
|
||||||
|
<Route path="/bypass-account-management" element={<BypassAccountManagement />} />
|
||||||
|
<Route path="/bypass-access-request" element={<BypassAccessRequest />} />
|
||||||
|
<Route path="/screening-guide" element={<ScreeningGuide />} />
|
||||||
|
<Route path="/risk-compliance-history" element={<RiskComplianceHistory />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter basename="/snp-global">
|
||||||
|
<ToastProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/src/api/bypassAccountApi.ts
Normal file
161
frontend/src/api/bypassAccountApi.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
export interface BypassAccountResponse {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
organization: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
status: string;
|
||||||
|
accessStartDate: string | null;
|
||||||
|
accessEndDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
plainPassword: string | null;
|
||||||
|
serviceIps: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassAccountUpdateRequest {
|
||||||
|
displayName?: string;
|
||||||
|
organization?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
status?: string;
|
||||||
|
accessStartDate?: string;
|
||||||
|
accessEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassRequestResponse {
|
||||||
|
id: number;
|
||||||
|
applicantName: string;
|
||||||
|
organization: string | null;
|
||||||
|
purpose: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
requestedAccessPeriod: string | null;
|
||||||
|
status: string;
|
||||||
|
reviewedBy: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
rejectReason: string | null;
|
||||||
|
accountId: number | null;
|
||||||
|
accountUsername: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
projectName: string | null;
|
||||||
|
expectedCallVolume: string | null;
|
||||||
|
serviceIps: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassRequestSubmitRequest {
|
||||||
|
applicantName: string;
|
||||||
|
organization: string;
|
||||||
|
purpose: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
requestedAccessPeriod: string;
|
||||||
|
projectName: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
serviceIps: string; // JSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassRequestReviewRequest {
|
||||||
|
reviewedBy: string;
|
||||||
|
rejectReason?: string;
|
||||||
|
accessStartDate?: string;
|
||||||
|
accessEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[];
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceIpDto {
|
||||||
|
ip: string;
|
||||||
|
purpose: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASE URL
|
||||||
|
const BASE = '/snp-global/api/bypass-account';
|
||||||
|
|
||||||
|
// 헬퍼 함수 (bypassApi.ts 패턴과 동일)
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bypassAccountApi = {
|
||||||
|
// Accounts
|
||||||
|
getAccounts: (status?: string, page = 0, size = 20) => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
return fetchJson<ApiResponse<PageResponse<BypassAccountResponse>>>(`${BASE}/accounts?${params}`);
|
||||||
|
},
|
||||||
|
getAccount: (id: number) =>
|
||||||
|
fetchJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}`),
|
||||||
|
updateAccount: (id: number, data: BypassAccountUpdateRequest) =>
|
||||||
|
putJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}`, data),
|
||||||
|
deleteAccount: (id: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`),
|
||||||
|
resetPassword: (id: number) =>
|
||||||
|
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}/reset-password`, {}),
|
||||||
|
getAccountIps: (accountId: number) =>
|
||||||
|
fetchJson<ApiResponse<ServiceIpDto[]>>(`${BASE}/accounts/${accountId}/ips`),
|
||||||
|
addAccountIp: (accountId: number, data: ServiceIpDto) =>
|
||||||
|
postJson<ApiResponse<ServiceIpDto>>(`${BASE}/accounts/${accountId}/ips`, data),
|
||||||
|
deleteAccountIp: (accountId: number, ipId: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${accountId}/ips/${ipId}`),
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
submitRequest: (data: BypassRequestSubmitRequest) =>
|
||||||
|
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests`, data),
|
||||||
|
getRequests: (status?: string, page = 0, size = 20) => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
return fetchJson<ApiResponse<PageResponse<BypassRequestResponse>>>(`${BASE}/requests?${params}`);
|
||||||
|
},
|
||||||
|
approveRequest: (id: number, data: BypassRequestReviewRequest) =>
|
||||||
|
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
|
||||||
|
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
|
||||||
|
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reject`, data),
|
||||||
|
reopenRequest: (id: number) =>
|
||||||
|
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reopen`, {}),
|
||||||
|
};
|
||||||
109
frontend/src/api/bypassApi.ts
Normal file
109
frontend/src/api/bypassApi.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입 정의
|
||||||
|
export interface BypassParamDto {
|
||||||
|
id?: number;
|
||||||
|
paramName: string;
|
||||||
|
paramType: string; // STRING, INTEGER, LONG, BOOLEAN
|
||||||
|
paramIn: string; // PATH, QUERY, BODY
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
example: string; // Swagger @Parameter example 값
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigRequest {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BypassConfigResponse {
|
||||||
|
id: number;
|
||||||
|
domainName: string;
|
||||||
|
endpointName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
generated: boolean;
|
||||||
|
generatedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
params: BypassParamDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeGenerationResult {
|
||||||
|
controllerPath: string;
|
||||||
|
servicePaths: string[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebClientBeanInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASE URL
|
||||||
|
const BASE = '/snp-global/api/bypass-config';
|
||||||
|
|
||||||
|
// 헬퍼 함수 (batchApi.ts 패턴과 동일)
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJson<T>(url: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bypassApi = {
|
||||||
|
getConfigs: () =>
|
||||||
|
fetchJson<ApiResponse<BypassConfigResponse[]>>(BASE),
|
||||||
|
getConfig: (id: number) =>
|
||||||
|
fetchJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`),
|
||||||
|
createConfig: (data: BypassConfigRequest) =>
|
||||||
|
postJson<ApiResponse<BypassConfigResponse>>(BASE, data),
|
||||||
|
updateConfig: (id: number, data: BypassConfigRequest) =>
|
||||||
|
putJson<ApiResponse<BypassConfigResponse>>(`${BASE}/${id}`, data),
|
||||||
|
deleteConfig: (id: number) =>
|
||||||
|
deleteJson<ApiResponse<void>>(`${BASE}/${id}`),
|
||||||
|
generateCode: (id: number, force = false) =>
|
||||||
|
postJson<ApiResponse<CodeGenerationResult>>(`${BASE}/${id}/generate?force=${force}`),
|
||||||
|
getWebclientBeans: () =>
|
||||||
|
fetchJson<ApiResponse<WebClientBeanInfo[]>>(`${BASE}/webclient-beans`),
|
||||||
|
};
|
||||||
149
frontend/src/api/screeningGuideApi.ts
Normal file
149
frontend/src/api/screeningGuideApi.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk 지표 타입
|
||||||
|
export interface RiskIndicatorResponse {
|
||||||
|
indicatorId: number;
|
||||||
|
fieldKey: string;
|
||||||
|
fieldName: string;
|
||||||
|
description: string;
|
||||||
|
conditionRed: string;
|
||||||
|
conditionAmber: string;
|
||||||
|
conditionGreen: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskCategoryResponse {
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
indicators: RiskIndicatorResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compliance 지표 타입
|
||||||
|
export interface ComplianceIndicatorResponse {
|
||||||
|
indicatorId: number;
|
||||||
|
fieldKey: string;
|
||||||
|
fieldName: string;
|
||||||
|
description: string;
|
||||||
|
conditionRed: string;
|
||||||
|
conditionAmber: string;
|
||||||
|
conditionGreen: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceCategoryResponse {
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
indicatorType: string;
|
||||||
|
indicators: ComplianceIndicatorResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 방법론 변경 이력 타입
|
||||||
|
export interface MethodologyHistoryResponse {
|
||||||
|
historyId: number;
|
||||||
|
changeDate: string;
|
||||||
|
changeTypeCode: string;
|
||||||
|
changeType: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값 변경 이력 타입
|
||||||
|
export interface ChangeHistoryResponse {
|
||||||
|
rowIndex: number;
|
||||||
|
searchKey: string;
|
||||||
|
lastModifiedDate: string;
|
||||||
|
changedColumnName: string;
|
||||||
|
beforeValue: string;
|
||||||
|
afterValue: string;
|
||||||
|
fieldName: string;
|
||||||
|
narrative: string;
|
||||||
|
prevNarrative: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박 기본 정보
|
||||||
|
export interface ShipInfoResponse {
|
||||||
|
imoNo: string;
|
||||||
|
shipName: string;
|
||||||
|
shipStatus: string;
|
||||||
|
nationalityCode: string;
|
||||||
|
nationalityIsoCode: string | null;
|
||||||
|
nationality: string;
|
||||||
|
shipType: string;
|
||||||
|
dwt: string;
|
||||||
|
gt: string;
|
||||||
|
buildYear: string;
|
||||||
|
mmsiNo: string;
|
||||||
|
callSign: string;
|
||||||
|
shipTypeGroup: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사 기본 정보
|
||||||
|
export interface CompanyInfoResponse {
|
||||||
|
companyCode: string;
|
||||||
|
fullName: string;
|
||||||
|
abbreviation: string;
|
||||||
|
status: string;
|
||||||
|
parentCompanyCode: string | null;
|
||||||
|
parentCompanyName: string | null;
|
||||||
|
registrationCountry: string;
|
||||||
|
registrationCountryCode: string;
|
||||||
|
registrationCountryIsoCode: string | null;
|
||||||
|
controlCountry: string | null;
|
||||||
|
controlCountryCode: string | null;
|
||||||
|
controlCountryIsoCode: string | null;
|
||||||
|
foundedDate: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지표 현재 상태
|
||||||
|
export interface IndicatorStatusResponse {
|
||||||
|
columnName: string;
|
||||||
|
fieldName: string;
|
||||||
|
categoryCode: string;
|
||||||
|
category: string;
|
||||||
|
value: string | null;
|
||||||
|
narrative: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/snp-global/api/screening-guide';
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const screeningGuideApi = {
|
||||||
|
getRiskIndicators: (lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<RiskCategoryResponse[]>>(`${BASE}/risk-indicators?lang=${lang}`),
|
||||||
|
getComplianceIndicators: (lang = 'KO', type = 'SHIP') =>
|
||||||
|
fetchJson<ApiResponse<ComplianceCategoryResponse[]>>(`${BASE}/compliance-indicators?lang=${lang}&type=${type}`),
|
||||||
|
getMethodologyHistory: (lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<MethodologyHistoryResponse[]>>(`${BASE}/methodology-history?lang=${lang}`),
|
||||||
|
getMethodologyBanner: (lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<MethodologyHistoryResponse>>(`${BASE}/methodology-banner?lang=${lang}`),
|
||||||
|
getShipRiskHistory: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-risk?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getShipComplianceHistory: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/ship-compliance?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getCompanyComplianceHistory: (companyCode: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<ChangeHistoryResponse[]>>(`${BASE}/history/company-compliance?companyCode=${companyCode}&lang=${lang}`),
|
||||||
|
getShipInfo: (imoNo: string) =>
|
||||||
|
fetchJson<ApiResponse<ShipInfoResponse>>(`${BASE}/ship-info?imoNo=${imoNo}`),
|
||||||
|
getShipRiskStatus: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-risk-status?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getShipComplianceStatus: (imoNo: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/ship-compliance-status?imoNo=${imoNo}&lang=${lang}`),
|
||||||
|
getCompanyInfo: (companyCode: string) =>
|
||||||
|
fetchJson<ApiResponse<CompanyInfoResponse>>(`${BASE}/company-info?companyCode=${companyCode}`),
|
||||||
|
getCompanyComplianceStatus: (companyCode: string, lang = 'KO') =>
|
||||||
|
fetchJson<ApiResponse<IndicatorStatusResponse[]>>(`${BASE}/company-compliance-status?companyCode=${companyCode}&lang=${lang}`),
|
||||||
|
};
|
||||||
49
frontend/src/components/ConfirmModal.tsx
Normal file
49
frontend/src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmColor?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title = '확인',
|
||||||
|
message,
|
||||||
|
confirmLabel = '확인',
|
||||||
|
cancelLabel = '취소',
|
||||||
|
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
|
||||||
|
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/CopyButton.tsx
Normal file
48
frontend/src/components/CopyButton.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CopyButton({ text }: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={copied ? '복사됨!' : 'URI 복사'}
|
||||||
|
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/EmptyState.tsx
Normal file
15
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface Props {
|
||||||
|
icon?: string;
|
||||||
|
message: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({ icon = '📭', message, sub }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
|
||||||
|
<span className="text-4xl mb-3">{icon}</span>
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
{sub && <p className="text-xs mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/GuideModal.tsx
Normal file
92
frontend/src/components/GuideModal.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface GuideSection {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
pageTitle: string;
|
||||||
|
sections: GuideSection[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GuideModal({ open, pageTitle, sections, onClose }: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text">{pageTitle} 사용 가이드</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<GuideAccordion key={i} title={section.title} content={section.content} defaultOpen={i === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideAccordion({ title, content, defaultOpen }: { title: string; content: string; defaultOpen: boolean }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-wing-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`w-4 h-4 text-wing-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-3 text-sm text-wing-muted leading-relaxed whitespace-pre-line">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title="사용 가이드"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-wing-border text-wing-muted hover:text-wing-accent hover:border-wing-accent transition-colors text-sm font-semibold"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/InfoItem.tsx
Normal file
15
frontend/src/components/InfoItem.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
interface InfoItemProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoItem({ label, value }: InfoItemProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/InfoModal.tsx
Normal file
35
frontend/src/components/InfoModal.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoModal({
|
||||||
|
open,
|
||||||
|
title = '정보',
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
|
||||||
|
<div className="text-wing-muted text-sm mb-6">{children}</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/LoadingSpinner.tsx
Normal file
7
frontend/src/components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center py-12 ${className}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/Navbar.tsx
Normal file
137
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuSection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
defaultPath: string;
|
||||||
|
children: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_STRUCTURE: MenuSection[] = [
|
||||||
|
{
|
||||||
|
id: 'bypass',
|
||||||
|
label: 'S&P Global API',
|
||||||
|
shortLabel: 'S&P Global API',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
defaultPath: '/bypass-catalog',
|
||||||
|
children: [
|
||||||
|
{ id: 'bypass-catalog', label: 'API 카탈로그', path: '/bypass-catalog' },
|
||||||
|
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
|
||||||
|
{ id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' },
|
||||||
|
{ id: 'bypass-account-management', label: '계정 관리', path: '/bypass-account-management' },
|
||||||
|
{ id: 'bypass-access-request', label: 'API 계정 신청', path: '/bypass-access-request' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'risk',
|
||||||
|
label: 'S&P Risk & Compliance',
|
||||||
|
shortLabel: 'Risk & Compliance',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
defaultPath: '/risk-compliance-history',
|
||||||
|
children: [
|
||||||
|
{ id: 'risk-compliance-history', label: 'Change History', path: '/risk-compliance-history' },
|
||||||
|
{ id: 'screening-guide', label: 'Screening Guide', path: '/screening-guide' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getCurrentSection(pathname: string): MenuSection | null {
|
||||||
|
for (const section of MENU_STRUCTURE) {
|
||||||
|
if (section.children.some((c) => pathname === c.path || pathname.startsWith(c.path + '/'))) {
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { theme, toggle } = useThemeContext();
|
||||||
|
const currentSection = getCurrentSection(location.pathname);
|
||||||
|
|
||||||
|
// 메인 화면에서는 숨김
|
||||||
|
if (!currentSection) return null;
|
||||||
|
|
||||||
|
const isActivePath = (path: string) => {
|
||||||
|
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
{/* 1단: 섹션 탭 */}
|
||||||
|
<div className="bg-slate-900 px-6 pt-3 rounded-t-xl flex items-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-1 px-3 py-2.5 text-sm text-slate-400 hover:text-white no-underline transition-colors"
|
||||||
|
title="메인 메뉴"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1 flex items-center justify-start gap-1">
|
||||||
|
{MENU_STRUCTURE.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentSection?.id !== section.id) {
|
||||||
|
navigate(section.defaultPath);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-t-lg text-sm font-medium transition-all ${
|
||||||
|
currentSection?.id === section.id
|
||||||
|
? 'bg-wing-bg text-wing-text shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{section.icon}
|
||||||
|
<span>{section.shortLabel}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="px-2.5 py-1.5 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2단: 서브 탭 */}
|
||||||
|
<div className="bg-wing-surface border-b border-wing-border px-6 rounded-b-xl shadow-md">
|
||||||
|
<div className="flex gap-1 -mb-px justify-end">
|
||||||
|
{currentSection?.children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
onClick={() => navigate(child.path)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||||
|
isActivePath(child.path)
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-wing-muted hover:text-wing-text hover:border-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/components/Pagination.tsx
Normal file
145
frontend/src/components/Pagination.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시할 페이지 번호 목록 생성 (Truncated Page Number)
|
||||||
|
* - 총 7슬롯 이하면 전부 표시
|
||||||
|
* - 7슬롯 초과면 현재 페이지 기준 양쪽 1개 + 처음/끝 + ellipsis
|
||||||
|
*/
|
||||||
|
function getPageNumbers(current: number, total: number): (number | 'ellipsis')[] {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (number | 'ellipsis')[] = [];
|
||||||
|
const SIBLING = 1;
|
||||||
|
|
||||||
|
const leftSibling = Math.max(current - SIBLING, 0);
|
||||||
|
const rightSibling = Math.min(current + SIBLING, total - 1);
|
||||||
|
|
||||||
|
const showLeftEllipsis = leftSibling > 1;
|
||||||
|
const showRightEllipsis = rightSibling < total - 2;
|
||||||
|
|
||||||
|
pages.push(0);
|
||||||
|
|
||||||
|
if (showLeftEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < leftSibling; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = leftSibling; i <= rightSibling; i++) {
|
||||||
|
if (i !== 0 && i !== total - 1) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRightEllipsis) {
|
||||||
|
pages.push('ellipsis');
|
||||||
|
} else {
|
||||||
|
for (let i = rightSibling + 1; i < total - 1; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
pages.push(total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
totalElements,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const start = page * pageSize + 1;
|
||||||
|
const end = Math.min((page + 1) * pageSize, totalElements);
|
||||||
|
const pages = getPageNumbers(page, totalPages);
|
||||||
|
|
||||||
|
const btnBase =
|
||||||
|
'inline-flex items-center justify-center w-7 h-7 text-xs rounded transition-colors';
|
||||||
|
const btnEnabled = 'hover:bg-wing-hover text-wing-muted';
|
||||||
|
const btnDisabled = 'opacity-30 cursor-not-allowed text-wing-muted';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs text-wing-muted">
|
||||||
|
<span>
|
||||||
|
{totalElements.toLocaleString()}건 중 {start.toLocaleString()}~
|
||||||
|
{end.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{/* First */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="처음"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
{/* Prev */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className={`${btnBase} ${page === 0 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="이전"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === 'ellipsis' ? (
|
||||||
|
<span key={`e-${idx}`} className="w-7 h-7 inline-flex items-center justify-center text-wing-muted">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`${btnBase} ${
|
||||||
|
p === page
|
||||||
|
? 'bg-wing-accent text-white font-semibold'
|
||||||
|
: btnEnabled
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="다음"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
{/* Last */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className={`${btnBase} ${page >= totalPages - 1 ? btnDisabled : btnEnabled}`}
|
||||||
|
title="마지막"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/StatusBadge.tsx
Normal file
40
frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
|
||||||
|
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
|
||||||
|
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
|
||||||
|
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
|
||||||
|
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
|
||||||
|
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
|
||||||
|
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
|
||||||
|
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
|
||||||
|
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status, className = '' }: Props) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
|
||||||
|
<span>{config.label}</span>
|
||||||
|
{config.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED': return '#10b981';
|
||||||
|
case 'FAILED': return '#ef4444';
|
||||||
|
case 'STARTED': return '#3b82f6';
|
||||||
|
case 'STARTING': return '#06b6d4';
|
||||||
|
case 'STOPPED': return '#f59e0b';
|
||||||
|
case 'STOPPING': return '#f97316';
|
||||||
|
case 'SCHEDULED': return '#8b5cf6';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/src/components/Toast.tsx
Normal file
37
frontend/src/components/Toast.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { Toast as ToastType } from '../hooks/useToast';
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<ToastType['type'], string> = {
|
||||||
|
success: 'bg-emerald-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toasts: ToastType[];
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastContainer({ toasts, onRemove }: Props) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
|
||||||
|
flex items-center justify-between gap-3 animate-slide-in`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="text-white/80 hover:text-white text-lg leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
222
frontend/src/components/bypass/BypassConfigModal.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type {
|
||||||
|
BypassConfigRequest,
|
||||||
|
BypassConfigResponse,
|
||||||
|
BypassParamDto,
|
||||||
|
WebClientBeanInfo,
|
||||||
|
} from '../../api/bypassApi';
|
||||||
|
import BypassStepBasic from './BypassStepBasic';
|
||||||
|
import BypassStepParams from './BypassStepParams';
|
||||||
|
|
||||||
|
interface BypassConfigModalProps {
|
||||||
|
open: boolean;
|
||||||
|
editConfig: BypassConfigResponse | null;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
onSave: (data: BypassConfigRequest) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepNumber = 1 | 2;
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepNumber, string> = {
|
||||||
|
1: '기본 정보',
|
||||||
|
2: '파라미터',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FORM: Omit<BypassConfigRequest, 'params'> = {
|
||||||
|
domainName: '',
|
||||||
|
displayName: '',
|
||||||
|
webclientBean: '',
|
||||||
|
externalPath: '',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfigModal({
|
||||||
|
open,
|
||||||
|
editConfig,
|
||||||
|
webclientBeans,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: BypassConfigModalProps) {
|
||||||
|
const [step, setStep] = useState<StepNumber>(1);
|
||||||
|
const [domainName, setDomainName] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [webclientBean, setWebclientBean] = useState('');
|
||||||
|
const [externalPath, setExternalPath] = useState('');
|
||||||
|
const [httpMethod, setHttpMethod] = useState('GET');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [params, setParams] = useState<BypassParamDto[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep(1);
|
||||||
|
if (editConfig) {
|
||||||
|
setDomainName(editConfig.domainName);
|
||||||
|
setDisplayName(editConfig.displayName);
|
||||||
|
setWebclientBean(editConfig.webclientBean);
|
||||||
|
setExternalPath(editConfig.externalPath);
|
||||||
|
setHttpMethod(editConfig.httpMethod);
|
||||||
|
setDescription(editConfig.description);
|
||||||
|
setParams(editConfig.params);
|
||||||
|
} else {
|
||||||
|
setDomainName(DEFAULT_FORM.domainName);
|
||||||
|
setDisplayName(DEFAULT_FORM.displayName);
|
||||||
|
setWebclientBean(DEFAULT_FORM.webclientBean);
|
||||||
|
setExternalPath(DEFAULT_FORM.externalPath);
|
||||||
|
setHttpMethod(DEFAULT_FORM.httpMethod);
|
||||||
|
setDescription(DEFAULT_FORM.description);
|
||||||
|
setParams([]);
|
||||||
|
}
|
||||||
|
}, [open, editConfig]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleBasicChange = (field: string, value: string) => {
|
||||||
|
switch (field) {
|
||||||
|
case 'domainName': setDomainName(value); break;
|
||||||
|
case 'displayName': setDisplayName(value); break;
|
||||||
|
case 'webclientBean': setWebclientBean(value); break;
|
||||||
|
case 'externalPath': setExternalPath(value); break;
|
||||||
|
case 'httpMethod': setHttpMethod(value); break;
|
||||||
|
case 'description': setDescription(value); break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
description,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps: StepNumber[] = [1, 2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl w-full max-w-3xl mx-4 flex flex-col max-h-[90vh]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="px-6 pt-6 pb-4 border-b border-wing-border shrink-0">
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">
|
||||||
|
{editConfig ? 'Bypass API 수정' : 'Bypass API 등록'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 스텝 인디케이터 */}
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{steps.map((s, idx) => (
|
||||||
|
<div key={s} className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'w-7 h-7 rounded-full flex items-center justify-center text-sm font-semibold transition-colors',
|
||||||
|
step === s
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: step > s
|
||||||
|
? 'bg-wing-accent/30 text-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'text-sm font-medium',
|
||||||
|
step === s ? 'text-wing-text' : 'text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[s]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className="w-8 h-px bg-wing-border mx-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="px-6 py-5 overflow-y-auto flex-1">
|
||||||
|
{step === 1 && (
|
||||||
|
<BypassStepBasic
|
||||||
|
domainName={domainName}
|
||||||
|
displayName={displayName}
|
||||||
|
webclientBean={webclientBean}
|
||||||
|
externalPath={externalPath}
|
||||||
|
httpMethod={httpMethod}
|
||||||
|
description={description}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
isEdit={editConfig !== null}
|
||||||
|
onChange={handleBasicChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<BypassStepParams params={params} onChange={setParams} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 버튼 */}
|
||||||
|
<div className="px-6 py-4 border-t border-wing-border flex justify-between shrink-0">
|
||||||
|
<div>
|
||||||
|
{step > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s - 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
← 이전
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{step === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step < 2 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => (s + 1) as StepNumber)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
다음 →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
142
frontend/src/components/bypass/BypassStepBasic.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import type { WebClientBeanInfo } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepBasicProps {
|
||||||
|
domainName: string;
|
||||||
|
displayName: string;
|
||||||
|
webclientBean: string;
|
||||||
|
externalPath: string;
|
||||||
|
httpMethod: string;
|
||||||
|
description: string;
|
||||||
|
webclientBeans: WebClientBeanInfo[];
|
||||||
|
isEdit: boolean;
|
||||||
|
onChange: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepBasic({
|
||||||
|
domainName,
|
||||||
|
displayName,
|
||||||
|
webclientBean,
|
||||||
|
externalPath,
|
||||||
|
httpMethod,
|
||||||
|
description,
|
||||||
|
webclientBeans,
|
||||||
|
isEdit,
|
||||||
|
onChange,
|
||||||
|
}: BypassStepBasicProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
BYPASS API의 기본 정보를 입력하세요. 도메인명을 기반으로 코드가 생성됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 도메인명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
도메인명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domainName}
|
||||||
|
onChange={(e) => onChange('domainName', e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="예: riskByImo"
|
||||||
|
pattern="[a-zA-Z][a-zA-Z0-9]*"
|
||||||
|
className={[
|
||||||
|
'w-full px-3 py-2 text-sm rounded-lg border',
|
||||||
|
'border-wing-border bg-wing-card text-wing-text',
|
||||||
|
'placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50',
|
||||||
|
isEdit ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-wing-muted">영문 소문자/숫자 조합 (수정 불가)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시명 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
표시명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => onChange('displayName', e.target.value)}
|
||||||
|
placeholder="예: IMO 기반 리스크 조회"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WebClient */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
WebClient Bean <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={webclientBean}
|
||||||
|
onChange={(e) => onChange('webclientBean', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{webclientBeans.map((bean) => (
|
||||||
|
<option key={bean.name} value={bean.name}>
|
||||||
|
{bean.description || bean.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 API 경로 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
외부 API 경로 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={externalPath}
|
||||||
|
onChange={(e) => onChange('externalPath', e.target.value)}
|
||||||
|
placeholder="/RiskAndCompliance/RisksByImos"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP 메서드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
HTTP 메서드 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['GET', 'POST'].map((method) => (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('httpMethod', method)}
|
||||||
|
className={[
|
||||||
|
'flex-1 py-2 text-sm font-medium rounded-lg border transition-colors',
|
||||||
|
httpMethod === method
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:bg-wing-hover',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
설명
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => onChange('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="이 API에 대한 설명을 입력하세요"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
157
frontend/src/components/bypass/BypassStepParams.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import type { BypassParamDto } from '../../api/bypassApi';
|
||||||
|
|
||||||
|
interface BypassStepParamsProps {
|
||||||
|
params: BypassParamDto[];
|
||||||
|
onChange: (params: BypassParamDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAM_TYPES = ['STRING', 'INTEGER', 'LONG', 'BOOLEAN'];
|
||||||
|
const PARAM_IN_OPTIONS = ['PATH', 'QUERY', 'BODY'];
|
||||||
|
|
||||||
|
function createEmptyParam(sortOrder: number): BypassParamDto {
|
||||||
|
return {
|
||||||
|
paramName: '',
|
||||||
|
paramType: 'STRING',
|
||||||
|
paramIn: 'QUERY',
|
||||||
|
required: false,
|
||||||
|
description: '',
|
||||||
|
example: '',
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassStepParams({ params, onChange }: BypassStepParamsProps) {
|
||||||
|
const handleAdd = () => {
|
||||||
|
onChange([...params, createEmptyParam(params.length)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const updated = params
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((p, i) => ({ ...p, sortOrder: i }));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, field: keyof BypassParamDto, value: string | boolean | number) => {
|
||||||
|
const updated = params.map((p, i) =>
|
||||||
|
i === index ? { ...p, [field]: value } : p,
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
외부 API 호출에 필요한 파라미터를 정의하세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{params.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-wing-muted border border-dashed border-wing-border rounded-lg bg-wing-card">
|
||||||
|
파라미터가 없습니다. 추가 버튼을 클릭하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border">
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">이름</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[110px]">타입</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[100px]">위치</th>
|
||||||
|
<th className="pb-2 text-center font-medium text-wing-muted pr-3 w-14">필수</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3">설명</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-wing-muted pr-3 min-w-[120px]">Example</th>
|
||||||
|
<th className="pb-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{params.map((param, index) => (
|
||||||
|
<tr key={index} className="group">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.paramName}
|
||||||
|
onChange={(e) => handleChange(index, 'paramName', e.target.value)}
|
||||||
|
placeholder="paramName"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramType}
|
||||||
|
onChange={(e) => handleChange(index, 'paramType', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
value={param.paramIn}
|
||||||
|
onChange={(e) => handleChange(index, 'paramIn', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{PARAM_IN_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>{o}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required}
|
||||||
|
onChange={(e) => handleChange(index, 'required', e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-wing-border text-wing-accent focus:ring-wing-accent/50 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.description}
|
||||||
|
onChange={(e) => handleChange(index, 'description', e.target.value)}
|
||||||
|
placeholder="파라미터 설명"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={param.example}
|
||||||
|
onChange={(e) => handleChange(index, 'example', e.target.value)}
|
||||||
|
placeholder="예: 9876543"
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded border border-wing-border bg-wing-surface text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-1 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(index)}
|
||||||
|
className="p-1.5 text-wing-muted hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-wing-accent border border-wing-accent/30 rounded-lg hover:bg-wing-accent/10 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
파라미터 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
frontend/src/components/screening/ComplianceTab.tsx
Normal file
230
frontend/src/components/screening/ComplianceTab.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
screeningGuideApi,
|
||||||
|
type ComplianceCategoryResponse,
|
||||||
|
type ComplianceIndicatorResponse,
|
||||||
|
} from '../../api/screeningGuideApi';
|
||||||
|
import { t } from '../../constants/screeningTexts';
|
||||||
|
|
||||||
|
interface ComplianceTabProps {
|
||||||
|
lang: string;
|
||||||
|
indicatorType?: 'SHIP' | 'COMPANY';
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndicatorType = 'SHIP' | 'COMPANY';
|
||||||
|
type CacheKey = string;
|
||||||
|
|
||||||
|
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
// SHIP
|
||||||
|
'SANCTIONS_SHIP_US_OFAC': { bg: '#e8eef5', text: '#1e3a5f' },
|
||||||
|
'SANCTIONS_OWNERSHIP_US_OFAC': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
|
'SANCTIONS_SHIP_NON_US': { bg: '#d1fae5', text: '#065f46' },
|
||||||
|
'SANCTIONS_OWNERSHIP_NON_US': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
|
'SANCTIONS_FATF': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
|
'SANCTIONS_OTHER': { bg: '#fee2e2', text: '#991b1b' },
|
||||||
|
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
|
'STS_ACTIVITY': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
|
'SUSPICIOUS_BEHAVIOR': { bg: '#fef3c7', text: '#92400e' },
|
||||||
|
'OWNERSHIP_SCREENING': { bg: '#e0f2fe', text: '#0c4a6e' },
|
||||||
|
'COMPLIANCE_SCREENING_HISTORY': { bg: '#e5e7eb', text: '#374151' },
|
||||||
|
// COMPANY
|
||||||
|
'US_TREASURY_SANCTIONS': { bg: '#e8eef5', text: '#1e3a5f' },
|
||||||
|
'NON_US_SANCTIONS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
|
'FATF_JURISDICTION': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
|
'PARENT_COMPANY': { bg: '#fef3c7', text: '#92400e' },
|
||||||
|
'OVERALL_COMPLIANCE_STATUS': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
|
||||||
|
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComplianceTab({ lang, indicatorType: fixedType }: ComplianceTabProps) {
|
||||||
|
const [categories, setCategories] = useState<ComplianceCategoryResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [indicatorType, setIndicatorType] = useState<IndicatorType>(fixedType ?? 'SHIP');
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const cache = useRef<Map<CacheKey, ComplianceCategoryResponse[]>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string, type: IndicatorType) => {
|
||||||
|
return screeningGuideApi
|
||||||
|
.getComplianceIndicators(fetchLang, type)
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data ?? [];
|
||||||
|
cache.current.set(`${type}_${fetchLang}`, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setExpandedCategories(new Set());
|
||||||
|
cache.current.clear();
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetchData('KO', indicatorType),
|
||||||
|
fetchData('EN', indicatorType),
|
||||||
|
])
|
||||||
|
.then(() => {
|
||||||
|
setCategories(cache.current.get(`${indicatorType}_${lang}`) ?? []);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [indicatorType, fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(`${indicatorType}_${lang}`);
|
||||||
|
if (cached) {
|
||||||
|
setCategories(cached);
|
||||||
|
}
|
||||||
|
}, [lang, indicatorType]);
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(category)) {
|
||||||
|
next.delete(category);
|
||||||
|
} else {
|
||||||
|
next.add(category);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Ship / Company 토글 (fixedType이 없을 때만 표시) */}
|
||||||
|
{!fixedType && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['SHIP', 'COMPANY'] as const).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setIndicatorType(type)}
|
||||||
|
className={`px-5 py-2 rounded-full text-sm font-bold transition-all ${
|
||||||
|
indicatorType === type
|
||||||
|
? 'bg-wing-text text-wing-bg shadow-sm'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type === 'SHIP' ? 'Ship' : 'Company'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">{t(lang, 'loading')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>{t(lang, 'loadError')}</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isExpanded = expandedCategories.has(cat.categoryCode);
|
||||||
|
const badge = getBadgeColor(cat.categoryCode);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.categoryCode}
|
||||||
|
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* 아코디언 헤더 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCategory(cat.categoryCode)}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||||
|
style={{ background: badge.bg, color: badge.text }}
|
||||||
|
>
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text text-left flex-1">
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
|
||||||
|
{cat.indicators.length}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 아코디언 콘텐츠 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
{cat.indicators.map((ind) => (
|
||||||
|
<IndicatorCard key={ind.indicatorId} indicator={ind} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorCard({ indicator }: { indicator: ComplianceIndicatorResponse }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="font-bold text-sm text-wing-text">
|
||||||
|
{indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
{indicator.dataType && (
|
||||||
|
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
|
||||||
|
{indicator.dataType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{indicator.description && (
|
||||||
|
<div className="text-xs text-wing-muted leading-relaxed mb-3 whitespace-pre-line">
|
||||||
|
{indicator.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{indicator.conditionRed && (
|
||||||
|
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionAmber && (
|
||||||
|
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionGreen && (
|
||||||
|
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
954
frontend/src/components/screening/HistoryTab.tsx
Normal file
954
frontend/src/components/screening/HistoryTab.tsx
Normal file
@ -0,0 +1,954 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { screeningGuideApi, type ChangeHistoryResponse, type ShipInfoResponse, type CompanyInfoResponse, type IndicatorStatusResponse } from '../../api/screeningGuideApi';
|
||||||
|
import { t } from '../../constants/screeningTexts';
|
||||||
|
|
||||||
|
type HistoryType = 'ship-risk' | 'ship-compliance' | 'company-compliance';
|
||||||
|
|
||||||
|
interface HistoryTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryTypeConfig {
|
||||||
|
key: HistoryType;
|
||||||
|
labelKey: string;
|
||||||
|
searchLabelKey: string;
|
||||||
|
searchPlaceholderKey: string;
|
||||||
|
overallColumn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_TYPES: HistoryTypeConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'ship-risk',
|
||||||
|
labelKey: 'histTypeShipRisk',
|
||||||
|
searchLabelKey: 'searchLabelImo',
|
||||||
|
searchPlaceholderKey: 'searchPlaceholderImo',
|
||||||
|
overallColumn: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ship-compliance',
|
||||||
|
labelKey: 'histTypeShipCompliance',
|
||||||
|
searchLabelKey: 'searchLabelImo',
|
||||||
|
searchPlaceholderKey: 'searchPlaceholderImo',
|
||||||
|
overallColumn: 'lgl_snths_sanction',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'company-compliance',
|
||||||
|
labelKey: 'histTypeCompanyCompliance',
|
||||||
|
searchLabelKey: 'searchLabelCompany',
|
||||||
|
searchPlaceholderKey: 'searchPlaceholderCompany',
|
||||||
|
overallColumn: 'company_snths_compliance_status',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface GroupedHistory {
|
||||||
|
lastModifiedDate: string;
|
||||||
|
items: ChangeHistoryResponse[];
|
||||||
|
overallBefore: string | null;
|
||||||
|
overallAfter: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
'0': { label: 'All Clear', className: 'bg-green-100 text-green-800 border-green-300' },
|
||||||
|
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
|
||||||
|
'2': { label: 'Severe', className: 'bg-red-100 text-red-800 border-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
'0': '#22c55e',
|
||||||
|
'1': '#eab308',
|
||||||
|
'2': '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
'0': 'All Clear',
|
||||||
|
'1': 'Warning',
|
||||||
|
'2': 'Severe',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isNoDataValue(v: string | null | undefined): boolean {
|
||||||
|
return v === '-999' || v === '-999.0' || v === 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ value, lang = 'EN' }: { value: string | null; lang?: string }) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (isNoDataValue(value)) {
|
||||||
|
return (
|
||||||
|
<span className="inline-block rounded-full px-3 py-0.5 text-xs font-bold border bg-gray-100 text-gray-500 border-gray-300">
|
||||||
|
{t(lang, 'noData')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = STATUS_MAP[value];
|
||||||
|
if (!status) return <span className="text-xs text-wing-muted">{value}</span>;
|
||||||
|
return (
|
||||||
|
<span className={`inline-block rounded-full px-3 py-0.5 text-xs font-bold border ${status.className}`}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFlag(code: string | null | undefined): string {
|
||||||
|
if (!code || code.length < 2) return '';
|
||||||
|
const cc = code.slice(0, 2).toUpperCase();
|
||||||
|
const codePoints = [...cc].map((c) => 0x1F1E6 + c.charCodeAt(0) - 65);
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskValueCell({ value, narrative, lang = 'EN' }: { value: string | null; narrative?: string; lang?: string }) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (isNoDataValue(value)) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-start gap-1.5">
|
||||||
|
<span style={{ color: '#9ca3af' }} className="text-sm leading-tight">●</span>
|
||||||
|
<span className="text-xs text-gray-500 leading-relaxed text-left">{t(lang, 'noData')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const color = STATUS_COLORS[value] ?? '#6b7280';
|
||||||
|
const label = narrative || STATUS_LABELS[value] || value;
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-start gap-1.5">
|
||||||
|
<span style={{ color }} className="text-sm leading-tight">●</span>
|
||||||
|
<span className="text-xs text-wing-text leading-relaxed text-left">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPLIANCE_STATUS: Record<string, { label: string; className: string }> = {
|
||||||
|
'0': { label: 'No', className: 'bg-green-100 text-green-800' },
|
||||||
|
'1': { label: 'Warning', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
'2': { label: 'Yes', className: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRiskLabel(item: IndicatorStatusResponse, lang: string): string {
|
||||||
|
if (isNoDataValue(item.value)) return t(lang, 'noData');
|
||||||
|
// IUU Fishing: All Clear -> None recorded
|
||||||
|
if (item.columnName === 'ilgl_fshr_viol' && item.value === '0') return 'None recorded';
|
||||||
|
// Risk Data Maintained: 0 -> Yes, 1 -> Not Maintained
|
||||||
|
if (item.columnName === 'risk_data_maint') {
|
||||||
|
if (item.value === '0') return 'Yes';
|
||||||
|
if (item.value === '1') return 'Not Maintained';
|
||||||
|
}
|
||||||
|
return item.narrative || STATUS_LABELS[item.value ?? ''] || item.value || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskStatusGrid({ items, lang }: { items: IndicatorStatusResponse[]; lang: string }) {
|
||||||
|
// Risk Data Maintained 체크: Not Maintained(1)이면 해당 지표만 표시
|
||||||
|
const riskDataMaintained = items.find((i) => i.columnName === 'risk_data_maint');
|
||||||
|
const isNotMaintained = riskDataMaintained?.value === '1';
|
||||||
|
|
||||||
|
const displayItems = isNotMaintained
|
||||||
|
? items.filter((i) => i.columnName === 'risk_data_maint')
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of displayItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [displayItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isNotMaintained && (
|
||||||
|
<div className="mb-3 px-3 py-2 bg-yellow-50 border border-yellow-300 rounded-lg text-xs text-yellow-800 font-medium">
|
||||||
|
{t(lang, 'riskNotMaintained')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => {
|
||||||
|
const color = STATUS_COLORS[item.value ?? ''] ?? '#6b7280';
|
||||||
|
const label = getRiskLabel(item, lang);
|
||||||
|
return (
|
||||||
|
<div key={item.columnName} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||||
|
<span style={{ color }} className="shrink-0 text-sm">●</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-wing-text text-center bg-wing-surface border border-wing-border"
|
||||||
|
style={{ minWidth: '140px' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박 Compliance 탭 분류 (categoryCode 기반)
|
||||||
|
const SHIP_COMPLIANCE_TABS: { key: string; label: string; match: (categoryCode: string) => boolean }[] = [
|
||||||
|
{ key: 'sanctions', label: 'Sanctions', match: (code) => code.startsWith('SANCTIONS_') },
|
||||||
|
{ key: 'portcalls', label: 'Port Calls', match: (code) => code === 'PORT_CALLS' },
|
||||||
|
{ key: 'sts', label: 'STS Activity', match: (code) => code === 'STS_ACTIVITY' },
|
||||||
|
{ key: 'suspicious', label: 'Suspicious Behavior', match: (code) => code === 'SUSPICIOUS_BEHAVIOR' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compliance 예외 처리
|
||||||
|
function getComplianceLabel(item: IndicatorStatusResponse, isCompany: boolean): string | null {
|
||||||
|
// Parent Company 관련: null -> No Parent
|
||||||
|
if (item.value == null || item.value === '') {
|
||||||
|
if (item.fieldName.includes('Parent Company') || item.fieldName.includes('Parent company')) return 'No Parent';
|
||||||
|
if (isCompany && item.columnName === 'prnt_company_compliance_risk') return 'No Parent';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제외할 컬럼명
|
||||||
|
const SHIP_COMPLIANCE_EXCLUDE = ['lgl_snths_sanction']; // Overall은 토글 헤더에 표시
|
||||||
|
const COMPANY_COMPLIANCE_EXCLUDE = ['company_snths_compliance_status']; // Overall은 토글 헤더에 표시
|
||||||
|
|
||||||
|
function ComplianceStatusItem({ item, isCompany, lang = 'EN' }: { item: IndicatorStatusResponse; isCompany: boolean; lang?: string }) {
|
||||||
|
const isNoData = isNoDataValue(item.value);
|
||||||
|
const overrideLabel = isNoData ? t(lang, 'noData') : getComplianceLabel(item, isCompany);
|
||||||
|
const status = overrideLabel ? null : COMPLIANCE_STATUS[item.value ?? ''];
|
||||||
|
const displayLabel = overrideLabel || (status ? status.label : (item.value ?? '-'));
|
||||||
|
const displayClass = isNoData
|
||||||
|
? 'bg-gray-100 text-gray-500 border border-gray-300'
|
||||||
|
: overrideLabel
|
||||||
|
? 'bg-wing-surface text-wing-muted border border-wing-border'
|
||||||
|
: status ? status.className : 'bg-wing-surface text-wing-muted border border-wing-border';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-wing-text truncate flex-1">{item.fieldName}</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-bold text-center ${displayClass}`}
|
||||||
|
style={{ minWidth: '80px' }}
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComplianceStatusGrid({ items, isCompany, lang }: { items: IndicatorStatusResponse[]; isCompany: boolean; lang: string }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('sanctions');
|
||||||
|
|
||||||
|
// 제외 항목 필터링
|
||||||
|
const excludeList = isCompany ? COMPANY_COMPLIANCE_EXCLUDE : SHIP_COMPLIANCE_EXCLUDE;
|
||||||
|
const filteredItems = items.filter((i) => !excludeList.includes(i.columnName));
|
||||||
|
|
||||||
|
// 회사: 탭 없이 2컬럼 그리드
|
||||||
|
if (isCompany) {
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
{categories.length > 1 && (
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => (
|
||||||
|
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} lang={lang} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선박: 탭 기반 분류
|
||||||
|
const tabData = useMemo(() => {
|
||||||
|
const result: Record<string, IndicatorStatusResponse[]> = {};
|
||||||
|
for (const tab of SHIP_COMPLIANCE_TABS) {
|
||||||
|
result[tab.key] = filteredItems.filter((i) => tab.match(i.categoryCode));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
const currentItems = tabData[activeTab] ?? [];
|
||||||
|
|
||||||
|
// 현재 탭 내 카테고리별 그룹핑
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const map = new Map<string, IndicatorStatusResponse[]>();
|
||||||
|
for (const item of currentItems) {
|
||||||
|
const cat = item.category || 'Other';
|
||||||
|
if (!map.has(cat)) map.set(cat, []);
|
||||||
|
map.get(cat)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [currentItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 탭 버튼 */}
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{SHIP_COMPLIANCE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label} ({tabData[tab.key]?.length ?? 0})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 탭 내용 */}
|
||||||
|
{currentItems.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{categories.map(([category, catItems]) => (
|
||||||
|
<div key={category}>
|
||||||
|
{categories.length > 1 && (
|
||||||
|
<div className="text-xs font-bold text-wing-text mb-2 pb-1 border-b border-wing-border">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{catItems.map((item) => (
|
||||||
|
<ComplianceStatusItem key={item.columnName} item={item} isCompany={isCompany} lang={lang} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-xs text-wing-muted py-4">{t(lang, 'noItems')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryTab({ lang }: HistoryTabProps) {
|
||||||
|
const [historyType, setHistoryType] = useState<HistoryType>('ship-risk');
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [cache, setCache] = useState<Record<string, ChangeHistoryResponse[]>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||||
|
const [shipInfo, setShipInfo] = useState<ShipInfoResponse | null>(null);
|
||||||
|
const [companyInfo, setCompanyInfo] = useState<CompanyInfoResponse | null>(null);
|
||||||
|
const [riskStatusCache, setRiskStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||||
|
const [complianceStatusCache, setComplianceStatusCache] = useState<Record<string, IndicatorStatusResponse[]>>({});
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
|
||||||
|
const currentType = HISTORY_TYPES.find((ht) => ht.key === historyType)!;
|
||||||
|
const isRisk = historyType === 'ship-risk';
|
||||||
|
const data = cache[lang] ?? [];
|
||||||
|
const riskStatus = riskStatusCache[lang] ?? [];
|
||||||
|
const complianceStatus = complianceStatusCache[lang] ?? [];
|
||||||
|
|
||||||
|
const grouped: GroupedHistory[] = useMemo(() => {
|
||||||
|
const map = new Map<string, ChangeHistoryResponse[]>();
|
||||||
|
for (const item of data) {
|
||||||
|
const key = item.lastModifiedDate;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([date, items]) => {
|
||||||
|
const overallColumn = currentType.overallColumn;
|
||||||
|
if (overallColumn) {
|
||||||
|
const overallItem = items.find((item) => item.changedColumnName === overallColumn);
|
||||||
|
return {
|
||||||
|
lastModifiedDate: date,
|
||||||
|
items,
|
||||||
|
overallBefore: overallItem?.beforeValue ?? null,
|
||||||
|
overallAfter: overallItem?.afterValue ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { lastModifiedDate: date, items, overallBefore: null, overallAfter: null };
|
||||||
|
});
|
||||||
|
}, [data, currentType.overallColumn]);
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
const trimmed = searchValue.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSearched(true);
|
||||||
|
setExpandedDates(new Set());
|
||||||
|
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
|
||||||
|
const isShip = historyType !== 'company-compliance';
|
||||||
|
const showRisk = historyType === 'ship-risk';
|
||||||
|
|
||||||
|
const getHistoryCall = (l: string) =>
|
||||||
|
historyType === 'ship-risk'
|
||||||
|
? screeningGuideApi.getShipRiskHistory(trimmed, l)
|
||||||
|
: historyType === 'ship-compliance'
|
||||||
|
? screeningGuideApi.getShipComplianceHistory(trimmed, l)
|
||||||
|
: screeningGuideApi.getCompanyComplianceHistory(trimmed, l);
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [
|
||||||
|
getHistoryCall('KO'),
|
||||||
|
getHistoryCall('EN'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isShip) {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getShipInfo(trimmed),
|
||||||
|
screeningGuideApi.getShipComplianceStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getShipComplianceStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
if (showRisk) {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getShipRiskStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getShipRiskStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
promises.push(
|
||||||
|
screeningGuideApi.getCompanyInfo(trimmed),
|
||||||
|
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'KO'),
|
||||||
|
screeningGuideApi.getCompanyComplianceStatus(trimmed, 'EN'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then((results) => {
|
||||||
|
setCache({ KO: results[0].data ?? [], EN: results[1].data ?? [] });
|
||||||
|
if (isShip) {
|
||||||
|
setShipInfo(results[2].data);
|
||||||
|
setCompanyInfo(null);
|
||||||
|
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||||
|
if (showRisk) {
|
||||||
|
setRiskStatusCache({ KO: results[5].data ?? [], EN: results[6].data ?? [] });
|
||||||
|
} else {
|
||||||
|
setRiskStatusCache({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShipInfo(null);
|
||||||
|
setCompanyInfo(results[2].data);
|
||||||
|
setRiskStatusCache({});
|
||||||
|
setComplianceStatusCache({ KO: results[3].data ?? [], EN: results[4].data ?? [] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(type: HistoryType) {
|
||||||
|
setHistoryType(type);
|
||||||
|
setSearchValue('');
|
||||||
|
setCache({});
|
||||||
|
setError(null);
|
||||||
|
setSearched(false);
|
||||||
|
setExpandedDates(new Set());
|
||||||
|
setShipInfo(null);
|
||||||
|
setCompanyInfo(null);
|
||||||
|
setRiskStatusCache({});
|
||||||
|
setComplianceStatusCache({});
|
||||||
|
setExpandedSections(new Set(['info', 'risk', 'compliance', 'history']));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(section: string) {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(section)) next.delete(section);
|
||||||
|
else next.add(section);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDate(date: string) {
|
||||||
|
setExpandedDates((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(date)) next.delete(date);
|
||||||
|
else next.add(date);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 이력 유형 선택 (언더라인 탭) */}
|
||||||
|
<div className="border-b border-wing-border">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{HISTORY_TYPES.map((ht) => (
|
||||||
|
<button
|
||||||
|
key={ht.key}
|
||||||
|
onClick={() => handleTypeChange(ht.key)}
|
||||||
|
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
|
||||||
|
historyType === ht.key
|
||||||
|
? 'text-blue-600 border-blue-600'
|
||||||
|
: 'text-wing-muted border-transparent hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(lang, ht.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex gap-3 items-center justify-center">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t(lang, currentType.searchPlaceholderKey)}
|
||||||
|
className="w-full pl-10 pr-8 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||||
|
/>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchValue('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!searchValue.trim() || loading}
|
||||||
|
className="px-5 py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? t(lang, 'searching') : t(lang, 'search')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-4 text-red-800 text-sm">
|
||||||
|
<strong>{t(lang, 'loadError')}</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결과: 3개 섹션 */}
|
||||||
|
{searched && !loading && !error && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Section 1: 기본 정보 */}
|
||||||
|
{(shipInfo || companyInfo) && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('info')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('info') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{shipInfo ? t(lang, 'shipBasicInfo') : t(lang, 'companyBasicInfo')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('info') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
{shipInfo && (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 핵심 식별 정보 */}
|
||||||
|
<div className="min-w-[220px] space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-wing-text">{shipInfo.shipName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">IMO</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{shipInfo.imoNo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">MMSI</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{shipInfo.mmsiNo || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-12">Status</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.shipStatus || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px bg-wing-border" />
|
||||||
|
|
||||||
|
{/* 우측: 스펙 정보 */}
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'nationality')}</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(shipInfo.nationalityIsoCode)} {shipInfo.nationality || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'shipType')}</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.shipType || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">DWT</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.dwt || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">GT</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.gt || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'buildYear')}</span>
|
||||||
|
<span className="font-medium text-wing-text">{shipInfo.buildYear || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo && (
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 핵심 식별 정보 */}
|
||||||
|
<div className="min-w-[220px] space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-wing-text">{companyInfo.fullName || '-'}</div>
|
||||||
|
{companyInfo.abbreviation && (
|
||||||
|
<div className="text-xs text-wing-muted">{companyInfo.abbreviation}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">Code</span>
|
||||||
|
<span className="font-mono font-medium text-wing-text">{companyInfo.companyCode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">Status</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.status || '-'}</span>
|
||||||
|
</div>
|
||||||
|
{companyInfo.parentCompanyName && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'parentCompany')}</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.parentCompanyName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-px bg-wing-border" />
|
||||||
|
|
||||||
|
{/* 우측: 상세 정보 */}
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'regCountry')}</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(companyInfo.registrationCountryIsoCode)} {companyInfo.registrationCountry || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{companyInfo.controlCountry && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'ctrlCountry')}</span>
|
||||||
|
<span className="font-medium text-wing-text">
|
||||||
|
{countryFlag(companyInfo.controlCountryIsoCode)} {companyInfo.controlCountry}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.foundedDate && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'foundedDate')}</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.foundedDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.email && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'email')}</span>
|
||||||
|
<span className="font-medium text-wing-text truncate">{companyInfo.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.phone && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'phone')}</span>
|
||||||
|
<span className="font-medium text-wing-text">{companyInfo.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companyInfo.website && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-wing-muted w-16">{t(lang, 'website')}</span>
|
||||||
|
<a
|
||||||
|
href={companyInfo.website.startsWith('http') ? companyInfo.website : `https://${companyInfo.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-blue-600 hover:underline truncate"
|
||||||
|
>
|
||||||
|
{companyInfo.website}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 2: Current Risk Indicators (선박 탭만) */}
|
||||||
|
{riskStatus.length > 0 && (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('risk')}
|
||||||
|
className="flex-1 flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('risk') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">{t(lang, 'currentRiskIndicators')}</span>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/screening-guide?tab=risk"
|
||||||
|
className="shrink-0 mr-4 text-[11px] text-wing-muted hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t(lang, 'viewGuide')} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{expandedSections.has('risk') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
<RiskStatusGrid items={riskStatus} lang={lang} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 3: Current Compliance (선박 제재/회사 제재 탭만) */}
|
||||||
|
{complianceStatus.length > 0 && !isRisk && (() => {
|
||||||
|
const isCompany = historyType === 'company-compliance';
|
||||||
|
const overallItem = isCompany
|
||||||
|
? complianceStatus.find((i) => i.columnName === 'company_snths_compliance_status')
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('compliance')}
|
||||||
|
className="flex-1 flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('compliance') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">{t(lang, 'currentCompliance')}</span>
|
||||||
|
{overallItem && (
|
||||||
|
<StatusBadge value={overallItem.value} lang={lang} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/screening-guide?tab=${isCompany ? 'company-compliance' : 'ship-compliance'}`}
|
||||||
|
className="shrink-0 mr-4 text-[11px] text-wing-muted hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t(lang, 'viewGuide')} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{expandedSections.has('compliance') && (
|
||||||
|
<div className="border-t border-wing-border px-4 py-4">
|
||||||
|
<ComplianceStatusGrid items={complianceStatus} isCompany={isCompany} lang={lang} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Section 4: 값 변경 이력 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('history')}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`text-xs transition-transform ${expandedSections.has('history') ? 'rotate-90' : ''}`}>▶</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">{t(lang, 'valueChangeHistory')}</span>
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{grouped.length}{t(lang, 'dateCount')}, {data.length}{t(lang, 'changeCount')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedSections.has('history') && (
|
||||||
|
<div className="border-t border-wing-border">
|
||||||
|
{grouped.length > 0 ? (
|
||||||
|
<div className="space-y-2 p-2">
|
||||||
|
{grouped.map((group) => {
|
||||||
|
const isExpanded = expandedDates.has(group.lastModifiedDate);
|
||||||
|
const hasOverall =
|
||||||
|
group.overallBefore != null || group.overallAfter != null;
|
||||||
|
const displayItems = (currentType.overallColumn
|
||||||
|
? group.items.filter(
|
||||||
|
(item) => item.changedColumnName !== currentType.overallColumn,
|
||||||
|
)
|
||||||
|
: [...group.items]
|
||||||
|
).sort((a, b) => (a.sortOrder ?? Infinity) - (b.sortOrder ?? Infinity));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.lastModifiedDate}
|
||||||
|
className="bg-wing-surface rounded-xl shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleDate(group.lastModifiedDate)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={`text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-wing-muted">Change Date :</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text">
|
||||||
|
{group.lastModifiedDate}
|
||||||
|
</span>
|
||||||
|
{hasOverall ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<StatusBadge value={group.overallBefore} lang={lang} />
|
||||||
|
<span className="text-xs text-wing-muted">→</span>
|
||||||
|
<StatusBadge value={group.overallAfter} lang={lang} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{displayItems.length}{t(lang, 'changeCount')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasOverall && (
|
||||||
|
<span className="text-xs text-wing-muted">
|
||||||
|
{displayItems.length}{t(lang, 'changeCount')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isExpanded && displayItems.length > 0 && (
|
||||||
|
<div className="border-t border-wing-border">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900 dark:bg-slate-700 text-white">
|
||||||
|
<th
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
{t(lang, 'colFieldName')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
{t(lang, 'colBefore')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2 text-center font-semibold"
|
||||||
|
>
|
||||||
|
{t(lang, 'colAfter')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{displayItems.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.rowIndex}
|
||||||
|
className="border-b border-wing-border even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{ width: '50%' }}
|
||||||
|
className="px-4 py-2.5 text-wing-text"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{row.fieldName || row.changedColumnName}
|
||||||
|
</div>
|
||||||
|
{row.fieldName && (
|
||||||
|
<div className="text-wing-muted mt-0.5">
|
||||||
|
{row.changedColumnName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isRisk ? (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<RiskValueCell value={row.beforeValue} narrative={row.prevNarrative ?? undefined} lang={lang} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<RiskValueCell
|
||||||
|
value={row.afterValue}
|
||||||
|
narrative={row.narrative ?? undefined}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<StatusBadge value={row.beforeValue} lang={lang} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ width: '25%' }}
|
||||||
|
className="px-4 py-2.5 text-center"
|
||||||
|
>
|
||||||
|
<StatusBadge value={row.afterValue} lang={lang} />
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl mb-1">📭</div>
|
||||||
|
<div className="text-sm">{t(lang, 'noChangeHistory')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 초기 상태 */}
|
||||||
|
{!searched && (
|
||||||
|
<div className="flex items-center justify-center py-16 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-3">🔍</div>
|
||||||
|
<div className="text-sm">{t(lang, 'enterSearchKey').replace('{label}', t(lang, currentType.searchLabelKey))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
frontend/src/components/screening/MethodologyTab.tsx
Normal file
209
frontend/src/components/screening/MethodologyTab.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { screeningGuideApi, type MethodologyHistoryResponse } from '../../api/screeningGuideApi';
|
||||||
|
import { t } from '../../constants/screeningTexts';
|
||||||
|
|
||||||
|
interface MethodologyTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_TYPE_COLORS: Record<string, string> = {
|
||||||
|
Addition: '#065f46',
|
||||||
|
Update: '#1d4ed8',
|
||||||
|
Expansion: '#6b21a8',
|
||||||
|
Change: '#92400e',
|
||||||
|
Removal: '#991b1b',
|
||||||
|
New: '#0f766e',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getChangeTypeColor(changeType: string): string {
|
||||||
|
return CHANGE_TYPE_COLORS[changeType] ?? '#374151';
|
||||||
|
}
|
||||||
|
|
||||||
|
type LangKey = 'KO' | 'EN';
|
||||||
|
|
||||||
|
interface LangCache {
|
||||||
|
history: MethodologyHistoryResponse[];
|
||||||
|
banner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MethodologyTab({ lang }: MethodologyTabProps) {
|
||||||
|
const [history, setHistory] = useState<MethodologyHistoryResponse[]>([]);
|
||||||
|
const [banner, setBanner] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState('ALL');
|
||||||
|
const cache = useRef<Map<LangKey, LangCache>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string) => {
|
||||||
|
return Promise.all([
|
||||||
|
screeningGuideApi.getMethodologyHistory(fetchLang),
|
||||||
|
screeningGuideApi.getMethodologyBanner(fetchLang).catch(() => ({ data: null })),
|
||||||
|
]).then(([historyRes, bannerRes]) => {
|
||||||
|
const data: LangCache = {
|
||||||
|
history: historyRes.data ?? [],
|
||||||
|
banner: bannerRes.data?.description ?? '',
|
||||||
|
};
|
||||||
|
cache.current.set(fetchLang as LangKey, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 초기 로드: KO/EN 데이터 모두 가져와서 캐싱
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
cache.current.clear();
|
||||||
|
|
||||||
|
Promise.all([fetchData('KO'), fetchData('EN')])
|
||||||
|
.then(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setHistory(cached.history);
|
||||||
|
setBanner(cached.banner);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 언어 변경: 캐시에서 스위칭
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setHistory(cached.history);
|
||||||
|
setBanner(cached.banner);
|
||||||
|
}
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
const sortedHistory = [...history].sort((a, b) =>
|
||||||
|
b.changeDate.localeCompare(a.changeDate),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueTypes = Array.from(new Set(history.map((h) => h.changeType)));
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
selectedType === 'ALL'
|
||||||
|
? sortedHistory
|
||||||
|
: sortedHistory.filter((h) => h.changeType === selectedType);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">{t(lang, 'loading')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>{t(lang, 'loadError')}</strong> {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 주의사항 배너 */}
|
||||||
|
{banner && (
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded-xl px-4 py-3 text-amber-800 text-xs leading-relaxed">
|
||||||
|
{banner}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 변경 유형 필터 */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedType('ALL')}
|
||||||
|
className={`px-3 py-1 rounded-full text-[11px] font-bold transition-colors border ${
|
||||||
|
selectedType === 'ALL'
|
||||||
|
? 'bg-slate-900 text-white border-slate-900'
|
||||||
|
: 'bg-wing-card text-wing-muted border-wing-border hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(lang, 'all')} ({history.length})
|
||||||
|
</button>
|
||||||
|
{uniqueTypes.map((type) => {
|
||||||
|
const count = history.filter((h) => h.changeType === type).length;
|
||||||
|
const hex = getChangeTypeColor(type);
|
||||||
|
const isActive = selectedType === type;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setSelectedType(isActive ? 'ALL' : type)}
|
||||||
|
className="px-3 py-1 rounded-full text-[11px] font-bold transition-all border"
|
||||||
|
style={{
|
||||||
|
background: isActive ? hex : undefined,
|
||||||
|
borderColor: isActive ? hex : undefined,
|
||||||
|
color: isActive ? 'white' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-wing-muted">
|
||||||
|
{t(lang, 'showing')} <strong className="text-wing-text">{filtered.length}</strong>{t(lang, 'unit')} {t(lang, 'sortLatest')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 목록 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900 text-white">
|
||||||
|
{[t(lang, 'colDate'), t(lang, 'colChangeType'), t(lang, 'colDescription')].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((row, i) => {
|
||||||
|
const hex = getChangeTypeColor(row.changeType);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.historyId}-${i}`}
|
||||||
|
className="border-b border-wing-border align-top even:bg-wing-card"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-3 min-w-[110px]">
|
||||||
|
<div className="font-bold text-wing-text">
|
||||||
|
{row.changeDate}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[110px]">
|
||||||
|
<span
|
||||||
|
className="inline-block text-white rounded px-2 py-0.5 text-[10px] font-bold"
|
||||||
|
style={{ background: hex }}
|
||||||
|
>
|
||||||
|
{row.changeType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 min-w-[280px] leading-relaxed text-wing-text">
|
||||||
|
{row.description || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-wing-muted text-sm">
|
||||||
|
{t(lang, 'noMethodologyHistory')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
frontend/src/components/screening/RiskTab.tsx
Normal file
187
frontend/src/components/screening/RiskTab.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { screeningGuideApi, type RiskCategoryResponse, type RiskIndicatorResponse } from '../../api/screeningGuideApi';
|
||||||
|
import { t } from '../../constants/screeningTexts';
|
||||||
|
|
||||||
|
interface RiskTabProps {
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LangKey = 'KO' | 'EN';
|
||||||
|
|
||||||
|
const CAT_BADGE_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
'AIS': { bg: '#dbeafe', text: '#1e40af' },
|
||||||
|
'PORT_CALLS': { bg: '#d1fae5', text: '#065f46' },
|
||||||
|
'ASSOCIATED_WITH_RUSSIA': { bg: '#fee2e2', text: '#991b1b' },
|
||||||
|
'BEHAVIOURAL_RISK': { bg: '#fef3c7', text: '#92400e' },
|
||||||
|
'SAFETY_SECURITY_AND_INSPECTIONS': { bg: '#dbeafe', text: '#1d4ed8' },
|
||||||
|
'FLAG_RISK': { bg: '#ede9fe', text: '#6b21a8' },
|
||||||
|
'OWNER_AND_CLASSIFICATION': { bg: '#ccfbf1', text: '#0f766e' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBadgeColor(categoryCode: string): { bg: string; text: string } {
|
||||||
|
return CAT_BADGE_COLORS[categoryCode] ?? { bg: '#e5e7eb', text: '#374151' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RiskTab({ lang }: RiskTabProps) {
|
||||||
|
const [categories, setCategories] = useState<RiskCategoryResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const cache = useRef<Map<LangKey, RiskCategoryResponse[]>>(new Map());
|
||||||
|
|
||||||
|
const fetchData = useCallback((fetchLang: string) => {
|
||||||
|
return screeningGuideApi
|
||||||
|
.getRiskIndicators(fetchLang)
|
||||||
|
.then((res) => {
|
||||||
|
const data = res.data ?? [];
|
||||||
|
cache.current.set(fetchLang as LangKey, data);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
cache.current.clear();
|
||||||
|
|
||||||
|
Promise.all([fetchData('KO'), fetchData('EN')])
|
||||||
|
.then(() => {
|
||||||
|
setCategories(cache.current.get(lang as LangKey) ?? []);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = cache.current.get(lang as LangKey);
|
||||||
|
if (cached) {
|
||||||
|
setCategories(cached);
|
||||||
|
}
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
const toggleCategory = (code: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(code)) {
|
||||||
|
next.delete(code);
|
||||||
|
} else {
|
||||||
|
next.add(code);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⏳</div>
|
||||||
|
<div className="text-sm">{t(lang, 'loading')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-300 rounded-xl p-6 text-red-800 text-sm">
|
||||||
|
<strong>{t(lang, 'loadError')}</strong> {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const isExpanded = expandedCategories.has(cat.categoryCode);
|
||||||
|
const badge = getBadgeColor(cat.categoryCode);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.categoryCode}
|
||||||
|
className="bg-wing-surface rounded-xl border border-wing-border overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCategory(cat.categoryCode)}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-4 transition-colors hover:bg-wing-hover"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||||
|
style={{ background: badge.bg, color: badge.text }}
|
||||||
|
>
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-wing-text text-left flex-1">
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 w-8 h-8 flex items-center justify-center rounded-full bg-wing-card text-wing-muted text-xs font-bold">
|
||||||
|
{cat.indicators.length}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`shrink-0 w-4 h-4 text-wing-muted transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-5 pt-5 pb-5 border-t border-wing-border">
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
|
{cat.indicators.map((ind) => (
|
||||||
|
<IndicatorCard key={ind.indicatorId} indicator={ind} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorCard({ indicator }: { indicator: RiskIndicatorResponse }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-wing-card rounded-lg border border-wing-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="font-bold text-sm text-wing-text">
|
||||||
|
{indicator.fieldName}
|
||||||
|
</div>
|
||||||
|
{indicator.dataType && (
|
||||||
|
<span className="shrink-0 text-[10px] text-wing-muted bg-wing-surface px-2 py-0.5 rounded">
|
||||||
|
{indicator.dataType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{indicator.description && (
|
||||||
|
<div className="text-xs text-wing-muted leading-relaxed mb-3">
|
||||||
|
{indicator.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(indicator.conditionRed || indicator.conditionAmber || indicator.conditionGreen) && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{indicator.conditionRed && (
|
||||||
|
<div className="flex-1 bg-wing-rag-red-bg border border-red-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-red-text mb-1">🔴</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-red-text">{indicator.conditionRed}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionAmber && (
|
||||||
|
<div className="flex-1 bg-wing-rag-amber-bg border border-amber-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-amber-text mb-1">🟡</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-amber-text">{indicator.conditionAmber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{indicator.conditionGreen && (
|
||||||
|
<div className="flex-1 bg-wing-rag-green-bg border border-green-300 rounded-lg p-2">
|
||||||
|
<div className="text-[10px] font-bold text-wing-rag-green-text mb-1">🟢</div>
|
||||||
|
<div className="text-[11px] text-wing-rag-green-text">{indicator.conditionGreen}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
frontend/src/constants/screeningTexts.ts
Normal file
170
frontend/src/constants/screeningTexts.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Risk & Compliance 섹션 UI 고정 텍스트 다국어 메타
|
||||||
|
*
|
||||||
|
* lang prop('EN' | 'KO')에 따라 텍스트를 반환한다.
|
||||||
|
* 사용: t(lang, 'key') 또는 screeningTexts[lang].key
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TextMap {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN: TextMap = {
|
||||||
|
// --- 공통 ---
|
||||||
|
loading: 'Loading...',
|
||||||
|
loadError: 'Failed to load data:',
|
||||||
|
noData: 'No Data',
|
||||||
|
|
||||||
|
// --- RiskComplianceHistory (페이지) ---
|
||||||
|
changeHistoryTitle: 'Risk & Compliance Change History',
|
||||||
|
changeHistorySubtitle: 'S&P Risk Indicator and Compliance Value Change History',
|
||||||
|
|
||||||
|
// --- ScreeningGuide (페이지) ---
|
||||||
|
screeningGuideTitle: 'Risk & Compliance Screening Guide',
|
||||||
|
screeningGuideSubtitle: 'S&P Risk Indicators and Regulatory Compliance Screening Guide',
|
||||||
|
tabShipCompliance: 'Ship Compliance',
|
||||||
|
tabCompanyCompliance: 'Company Compliance',
|
||||||
|
tabRiskIndicators: 'Ship Risk Indicator',
|
||||||
|
tabMethodology: 'Methodology History',
|
||||||
|
|
||||||
|
// --- HistoryTab ---
|
||||||
|
histTypeShipRisk: 'Ship Risk Indicator',
|
||||||
|
histTypeShipCompliance: 'Ship Compliance',
|
||||||
|
histTypeCompanyCompliance: 'Company Compliance',
|
||||||
|
searchLabelImo: 'IMO Number',
|
||||||
|
searchLabelCompany: 'Company Code',
|
||||||
|
searchPlaceholderImo: 'IMO Number : 9672533',
|
||||||
|
searchPlaceholderCompany: 'Company Code : 1288896',
|
||||||
|
search: 'Search',
|
||||||
|
searching: 'Searching...',
|
||||||
|
shipBasicInfo: 'Ship Basic Info',
|
||||||
|
companyBasicInfo: 'Company Basic Info',
|
||||||
|
valueChangeHistory: 'Value Change History',
|
||||||
|
colFieldName: 'Field Name',
|
||||||
|
colBefore: 'Before',
|
||||||
|
colAfter: 'After',
|
||||||
|
noChangeHistory: 'No change history.',
|
||||||
|
noItems: 'No items found.',
|
||||||
|
dateCount: 'dates',
|
||||||
|
changeCount: 'changes',
|
||||||
|
enterSearchKey: 'Enter {label} to search.',
|
||||||
|
|
||||||
|
// 선박/회사 기본 정보 라벨
|
||||||
|
nationality: 'Nationality',
|
||||||
|
shipType: 'Ship Type',
|
||||||
|
buildYear: 'Build Year',
|
||||||
|
regCountry: 'Reg. Country',
|
||||||
|
ctrlCountry: 'Ctrl Country',
|
||||||
|
foundedDate: 'Founded',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Phone',
|
||||||
|
website: 'Website',
|
||||||
|
parentCompany: 'Parent',
|
||||||
|
|
||||||
|
// --- MethodologyTab ---
|
||||||
|
all: 'All',
|
||||||
|
showing: 'Showing:',
|
||||||
|
unit: '',
|
||||||
|
sortLatest: '| Latest first',
|
||||||
|
colDate: 'Date',
|
||||||
|
colChangeType: 'Change Type',
|
||||||
|
colDescription: 'Description',
|
||||||
|
noMethodologyHistory: 'No history for this type.',
|
||||||
|
|
||||||
|
// --- HistoryTab 현재 상태 ---
|
||||||
|
currentRiskIndicators: 'Current Risk Indicators',
|
||||||
|
currentCompliance: 'Current Compliance',
|
||||||
|
riskNotMaintained: 'Risk Data is not maintained for this vessel. Only the maintenance status is shown.',
|
||||||
|
|
||||||
|
// --- Screening Guide 링크 ---
|
||||||
|
viewGuide: 'View Guide',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KO: TextMap = {
|
||||||
|
// --- 공통 ---
|
||||||
|
loading: '데이터를 불러오는 중...',
|
||||||
|
loadError: '데이터 로딩 실패:',
|
||||||
|
noData: '데이터 없음',
|
||||||
|
|
||||||
|
// --- RiskComplianceHistory (페이지) ---
|
||||||
|
changeHistoryTitle: 'Risk & Compliance Change History',
|
||||||
|
changeHistorySubtitle: 'S&P 위험 지표 및 규정 준수 값 변경 이력',
|
||||||
|
|
||||||
|
// --- ScreeningGuide (페이지) ---
|
||||||
|
screeningGuideTitle: 'Risk & Compliance Screening Guide',
|
||||||
|
screeningGuideSubtitle: 'S&P 위험 지표 및 규정 준수 Screening Guide',
|
||||||
|
tabShipCompliance: '선박 규정 준수',
|
||||||
|
tabCompanyCompliance: '회사 규정 준수',
|
||||||
|
tabRiskIndicators: '선박 위험 지표',
|
||||||
|
tabMethodology: '방법론 변경 이력',
|
||||||
|
|
||||||
|
// --- HistoryTab ---
|
||||||
|
histTypeShipRisk: '선박 위험 지표',
|
||||||
|
histTypeShipCompliance: '선박 규정 준수',
|
||||||
|
histTypeCompanyCompliance: '회사 규정 준수',
|
||||||
|
searchLabelImo: 'IMO 번호',
|
||||||
|
searchLabelCompany: '회사 코드',
|
||||||
|
searchPlaceholderImo: 'IMO 번호 : 9290933',
|
||||||
|
searchPlaceholderCompany: '회사 코드 : 1288896',
|
||||||
|
search: '조회',
|
||||||
|
searching: '조회 중...',
|
||||||
|
shipBasicInfo: '선박 기본 정보',
|
||||||
|
companyBasicInfo: '회사 기본 정보',
|
||||||
|
valueChangeHistory: '값 변경 이력',
|
||||||
|
colFieldName: '필드명',
|
||||||
|
colBefore: '이전값',
|
||||||
|
colAfter: '이후값',
|
||||||
|
noChangeHistory: '변경 이력이 없습니다.',
|
||||||
|
noItems: '해당 항목이 없습니다.',
|
||||||
|
dateCount: '개 일시',
|
||||||
|
changeCount: '건 변동',
|
||||||
|
enterSearchKey: '{label}을(를) 입력하고 조회하세요.',
|
||||||
|
|
||||||
|
// 선박/회사 기본 정보 라벨
|
||||||
|
nationality: '국적',
|
||||||
|
shipType: '선종',
|
||||||
|
buildYear: '건조연도',
|
||||||
|
regCountry: '등록국가',
|
||||||
|
ctrlCountry: '관리국가',
|
||||||
|
foundedDate: '설립일',
|
||||||
|
email: '이메일',
|
||||||
|
phone: '전화',
|
||||||
|
website: '웹사이트',
|
||||||
|
parentCompany: '모회사',
|
||||||
|
|
||||||
|
// --- MethodologyTab ---
|
||||||
|
all: '전체',
|
||||||
|
showing: '표시:',
|
||||||
|
unit: '건',
|
||||||
|
sortLatest: '| 최신순 정렬',
|
||||||
|
colDate: '날짜',
|
||||||
|
colChangeType: '변경 유형',
|
||||||
|
colDescription: '설명',
|
||||||
|
noMethodologyHistory: '해당 유형의 변경 이력이 없습니다.',
|
||||||
|
|
||||||
|
// --- HistoryTab 현재 상태 ---
|
||||||
|
currentRiskIndicators: '현재 위험 지표 상태',
|
||||||
|
currentCompliance: '현재 규정 준수 상태',
|
||||||
|
riskNotMaintained: '이 선박의 위험 지표 데이터가 관리되지 않습니다. 관리 대상만 위첨 지표가 표시됩니다.',
|
||||||
|
|
||||||
|
// --- Screening Guide 링크 ---
|
||||||
|
viewGuide: '가이드 보기',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXTS: Record<string, TextMap> = { EN, KO };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lang에 맞는 텍스트를 반환. 키가 없으면 키 자체를 반환.
|
||||||
|
*/
|
||||||
|
export function t(lang: string, key: string): string {
|
||||||
|
return TEXTS[lang]?.[key] ?? TEXTS['EN']?.[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -999 값을 lang에 맞는 "No Data" 텍스트로 변환.
|
||||||
|
* -999가 아니면 원래 값을 그대로 반환.
|
||||||
|
*/
|
||||||
|
export function resolveNoData(lang: string, value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === '-999' || value === '-999.0') return t(lang, 'noData');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
26
frontend/src/contexts/ThemeContext.tsx
Normal file
26
frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'dark',
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const value = useTheme();
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useThemeContext() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
29
frontend/src/contexts/ToastContext.tsx
Normal file
29
frontend/src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import { useToast, type Toast } from '../hooks/useToast';
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: Toast[];
|
||||||
|
showToast: (message: string, type?: Toast['type']) => void;
|
||||||
|
removeToast: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { toasts, showToast, removeToast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useToastContext(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useToastContext must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
53
frontend/src/hooks/usePoller.ts
Normal file
53
frontend/src/hooks/usePoller.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주기적 폴링 훅
|
||||||
|
* - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복
|
||||||
|
* - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개
|
||||||
|
* - deps 변경 시 타이머 재설정
|
||||||
|
*/
|
||||||
|
export function usePoller(
|
||||||
|
fn: () => Promise<void> | void,
|
||||||
|
intervalMs: number,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
) {
|
||||||
|
const fnRef = useRef(fn);
|
||||||
|
fnRef.current = fn;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
fnRef.current();
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
run();
|
||||||
|
timer = setInterval(run, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [intervalMs, ...deps]);
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useTheme.ts
Normal file
27
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'snp-batch-theme';
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'dark';
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getInitialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { theme, toggle } as const;
|
||||||
|
}
|
||||||
27
frontend/src/hooks/useToast.ts
Normal file
27
frontend/src/hooks/useToast.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||||
|
const id = nextId++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, showToast, removeToast };
|
||||||
|
}
|
||||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./theme/tokens.css";
|
||||||
|
@import "./theme/base.css";
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
563
frontend/src/pages/BypassAccessRequest.tsx
Normal file
563
frontend/src/pages/BypassAccessRequest.tsx
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
bypassAccountApi,
|
||||||
|
type BypassRequestSubmitRequest,
|
||||||
|
} from '../api/bypassAccountApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
applicantName: string;
|
||||||
|
organization: string;
|
||||||
|
purpose: string;
|
||||||
|
email: string;
|
||||||
|
requestedAccessPeriod: string;
|
||||||
|
projectName: string;
|
||||||
|
serviceIp: string;
|
||||||
|
servicePurpose: string;
|
||||||
|
expectedCallVolume: string;
|
||||||
|
serviceDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorState {
|
||||||
|
applicantName?: string;
|
||||||
|
email?: string;
|
||||||
|
requestedAccessPeriod?: string;
|
||||||
|
projectName?: string;
|
||||||
|
serviceIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TouchedState {
|
||||||
|
applicantName: boolean;
|
||||||
|
email: boolean;
|
||||||
|
requestedAccessPeriod: boolean;
|
||||||
|
projectName: boolean;
|
||||||
|
serviceIp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
|
||||||
|
|
||||||
|
const INITIAL_FORM: FormState = {
|
||||||
|
applicantName: '',
|
||||||
|
organization: '',
|
||||||
|
purpose: '',
|
||||||
|
email: '',
|
||||||
|
requestedAccessPeriod: '',
|
||||||
|
projectName: '',
|
||||||
|
serviceIp: '',
|
||||||
|
servicePurpose: 'DEV_PC',
|
||||||
|
expectedCallVolume: 'LOW',
|
||||||
|
serviceDescription: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_TOUCHED: TouchedState = {
|
||||||
|
applicantName: false,
|
||||||
|
email: false,
|
||||||
|
requestedAccessPeriod: false,
|
||||||
|
projectName: false,
|
||||||
|
serviceIp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function toDateString(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setMonth(result.getMonth() + months);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addYears(date: Date, years: number): Date {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setFullYear(result.getFullYear() + years);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcPresetPeriod(preset: PeriodPreset): string {
|
||||||
|
const today = new Date();
|
||||||
|
const from = toDateString(today);
|
||||||
|
let to: string;
|
||||||
|
switch (preset) {
|
||||||
|
case '3개월': to = toDateString(addMonths(today, 3)); break;
|
||||||
|
case '6개월': to = toDateString(addMonths(today, 6)); break;
|
||||||
|
case '9개월': to = toDateString(addMonths(today, 9)); break;
|
||||||
|
case '1년': to = toDateString(addYears(today, 1)); break;
|
||||||
|
}
|
||||||
|
return `${from} ~ ${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form: FormState): ErrorState {
|
||||||
|
const errors: ErrorState = {};
|
||||||
|
|
||||||
|
if (!form.applicantName.trim()) {
|
||||||
|
errors.applicantName = '신청자명을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email.trim()) {
|
||||||
|
errors.email = '이메일을 입력해주세요.';
|
||||||
|
} else if (!EMAIL_REGEX.test(form.email.trim())) {
|
||||||
|
errors.email = '올바른 이메일 형식을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.requestedAccessPeriod.trim()) {
|
||||||
|
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.projectName.trim()) {
|
||||||
|
errors.projectName = '프로젝트/서비스명을 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.serviceIp.trim()) {
|
||||||
|
errors.serviceIp = '서비스 IP를 입력해주세요.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassAccessRequest() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submittedId, setSubmittedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Validation state
|
||||||
|
const [errors, setErrors] = useState<ErrorState>({});
|
||||||
|
const [touched, setTouched] = useState<TouchedState>(INITIAL_TOUCHED);
|
||||||
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
|
|
||||||
|
// Period mode
|
||||||
|
const [periodManual, setPeriodManual] = useState(false);
|
||||||
|
const [selectedPreset, setSelectedPreset] = useState<PeriodPreset | null>(null);
|
||||||
|
const [periodFrom, setPeriodFrom] = useState('');
|
||||||
|
const [periodTo, setPeriodTo] = useState('');
|
||||||
|
const [periodRangeError, setPeriodRangeError] = useState('');
|
||||||
|
|
||||||
|
const handleChange = (field: keyof FormState, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (field: keyof TouchedState) => {
|
||||||
|
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||||
|
const currentErrors = validateForm(form);
|
||||||
|
setErrors(currentErrors);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetClick = (preset: PeriodPreset) => {
|
||||||
|
setSelectedPreset(preset);
|
||||||
|
const period = calcPresetPeriod(preset);
|
||||||
|
handleChange('requestedAccessPeriod', period);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriodFromChange = (value: string) => {
|
||||||
|
setPeriodFrom(value);
|
||||||
|
setPeriodRangeError('');
|
||||||
|
if (value && periodTo) {
|
||||||
|
if (value >= periodTo) {
|
||||||
|
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
|
||||||
|
} else {
|
||||||
|
handleChange('requestedAccessPeriod', `${value} ~ ${periodTo}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriodToChange = (value: string) => {
|
||||||
|
setPeriodTo(value);
|
||||||
|
setPeriodRangeError('');
|
||||||
|
if (periodFrom && value) {
|
||||||
|
if (periodFrom >= value) {
|
||||||
|
setPeriodRangeError('시작일은 종료일보다 이전이어야 합니다.');
|
||||||
|
} else {
|
||||||
|
handleChange('requestedAccessPeriod', `${periodFrom} ~ ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleManual = () => {
|
||||||
|
setPeriodManual((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (!next) {
|
||||||
|
// Switching back to preset: clear manual inputs
|
||||||
|
setPeriodFrom('');
|
||||||
|
setPeriodTo('');
|
||||||
|
setPeriodRangeError('');
|
||||||
|
setSelectedPreset(null);
|
||||||
|
handleChange('requestedAccessPeriod', '');
|
||||||
|
} else {
|
||||||
|
// Switching to manual: clear preset selection
|
||||||
|
setSelectedPreset(null);
|
||||||
|
handleChange('requestedAccessPeriod', '');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitAttempted(true);
|
||||||
|
setTouched({ applicantName: true, email: true, requestedAccessPeriod: true, projectName: true, serviceIp: true });
|
||||||
|
|
||||||
|
const currentErrors = validateForm(form);
|
||||||
|
|
||||||
|
if (periodManual && periodRangeError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(currentErrors).length > 0) {
|
||||||
|
setErrors(currentErrors);
|
||||||
|
// 첫 번째 에러 필드로 스크롤
|
||||||
|
const firstErrorField = Object.keys(currentErrors)[0];
|
||||||
|
const el = document.getElementById(`field-${firstErrorField}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const serviceIpEntry = form.serviceIp.trim() ? [{
|
||||||
|
ip: form.serviceIp.trim(),
|
||||||
|
purpose: form.servicePurpose,
|
||||||
|
expectedCallVolume: form.expectedCallVolume,
|
||||||
|
description: form.serviceDescription,
|
||||||
|
}] : [];
|
||||||
|
const requestData: BypassRequestSubmitRequest = {
|
||||||
|
applicantName: form.applicantName,
|
||||||
|
organization: form.organization,
|
||||||
|
purpose: form.purpose,
|
||||||
|
email: form.email,
|
||||||
|
phone: '',
|
||||||
|
requestedAccessPeriod: form.requestedAccessPeriod,
|
||||||
|
projectName: form.projectName,
|
||||||
|
expectedCallVolume: form.expectedCallVolume,
|
||||||
|
serviceIps: JSON.stringify(serviceIpEntry),
|
||||||
|
};
|
||||||
|
const res = await bypassAccountApi.submitRequest(requestData);
|
||||||
|
setSubmittedId(res.data.id);
|
||||||
|
showToast('신청이 완료되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('신청 제출 실패. 다시 시도해주세요.', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (field: keyof TouchedState): boolean =>
|
||||||
|
(touched[field] || submitAttempted) && Boolean(errors[field]);
|
||||||
|
|
||||||
|
const inputClass = (field: keyof TouchedState) =>
|
||||||
|
`w-full px-3 py-2 text-sm rounded-lg border ${
|
||||||
|
showError(field) ? 'border-red-400' : 'border-wing-border'
|
||||||
|
} bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 ${
|
||||||
|
showError(field) ? 'focus:ring-red-400/50' : 'focus:ring-wing-accent/50'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const PRESETS: PeriodPreset[] = ['3개월', '6개월', '9개월', '1년'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">S&P API 계정 신청</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
S&P API 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submittedId !== null ? (
|
||||||
|
/* 제출 완료 화면 */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-8 text-center">
|
||||||
|
<div className="w-14 h-14 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-7 h-7 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-wing-text mb-2">신청이 완료되었습니다</h2>
|
||||||
|
<p className="text-sm text-wing-muted mb-1">
|
||||||
|
신청 번호: <span className="font-semibold text-wing-text">#{submittedId}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-wing-muted">
|
||||||
|
검토 후 입력하신 이메일로 안내 드리겠습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setErrors({});
|
||||||
|
setTouched(INITIAL_TOUCHED);
|
||||||
|
setSubmitAttempted(false);
|
||||||
|
setSelectedPreset(null);
|
||||||
|
setPeriodManual(false);
|
||||||
|
setPeriodFrom('');
|
||||||
|
setPeriodTo('');
|
||||||
|
setPeriodRangeError('');
|
||||||
|
setSubmittedId(null);
|
||||||
|
}}
|
||||||
|
className="mt-6 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
새 신청 작성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/bypass-catalog')}
|
||||||
|
className="mt-3 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
S&P API 목록 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 신청 폼 */
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Row 1: 신청자명 + 기관 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div id="field-applicantName">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
신청자명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.applicantName}
|
||||||
|
onChange={(e) => handleChange('applicantName', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('applicantName')}
|
||||||
|
placeholder="홍길동"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('applicantName')}
|
||||||
|
/>
|
||||||
|
{showError('applicantName') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.applicantName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
기관
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.organization}
|
||||||
|
onChange={(e) => handleChange('organization', e.target.value)}
|
||||||
|
placeholder="소속 기관명"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 이메일 + 프로젝트/서비스명 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div id="field-email">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
이메일 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('email')}
|
||||||
|
placeholder="example@domain.com"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('email')}
|
||||||
|
/>
|
||||||
|
{showError('email') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div id="field-projectName">
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
프로젝트/서비스명 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.projectName}
|
||||||
|
onChange={(e) => handleChange('projectName', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('projectName')}
|
||||||
|
placeholder="사용할 프로젝트 또는 서비스명"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('projectName')}
|
||||||
|
/>
|
||||||
|
{showError('projectName') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.projectName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 사용 기간 (full width) */}
|
||||||
|
<div id="field-requestedAccessPeriod">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium text-wing-text">
|
||||||
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleManual}
|
||||||
|
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
|
||||||
|
>
|
||||||
|
<span>직접 선택</span>
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
|
||||||
|
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
|
||||||
|
periodManual ? 'translate-x-4' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!periodManual ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetClick(preset)}
|
||||||
|
disabled={submitting}
|
||||||
|
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
selectedPreset === preset
|
||||||
|
? 'bg-wing-accent text-white border-wing-accent'
|
||||||
|
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={periodFrom}
|
||||||
|
onChange={(e) => handlePeriodFromChange(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={periodTo}
|
||||||
|
onChange={(e) => handlePeriodToChange(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.requestedAccessPeriod && !periodRangeError && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
선택된 기간: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{periodRangeError && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
|
||||||
|
)}
|
||||||
|
{showError('requestedAccessPeriod') && !periodRangeError && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: 서비스 IP (단건) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-1 text-[10px] text-wing-muted font-medium">
|
||||||
|
<span>IP 주소</span>
|
||||||
|
<span>용도</span>
|
||||||
|
<span>예상 호출량</span>
|
||||||
|
<span>설명</span>
|
||||||
|
</div>
|
||||||
|
<div id="field-serviceIp" className="grid grid-cols-4 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.serviceIp}
|
||||||
|
onChange={(e) => handleChange('serviceIp', e.target.value)}
|
||||||
|
onBlur={() => handleBlur('serviceIp')}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
disabled={submitting}
|
||||||
|
className={inputClass('serviceIp')}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={form.servicePurpose}
|
||||||
|
onChange={(e) => handleChange('servicePurpose', e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
<option value="DEV_PC">개발 PC</option>
|
||||||
|
<option value="PROD_SERVER">운영 서버</option>
|
||||||
|
<option value="TEST_SERVER">테스트 서버</option>
|
||||||
|
<option value="ETC">기타</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={form.expectedCallVolume}
|
||||||
|
onChange={(e) => handleChange('expectedCallVolume', e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
<option value="LOW">100건 이하/일</option>
|
||||||
|
<option value="MEDIUM">1,000건 이하/일</option>
|
||||||
|
<option value="HIGH">10,000건 이하/일</option>
|
||||||
|
<option value="VERY_HIGH">10,000건 이상/일</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.serviceDescription}
|
||||||
|
onChange={(e) => handleChange('serviceDescription', e.target.value)}
|
||||||
|
placeholder="설명 (선택)"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showError('serviceIp') && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{errors.serviceIp}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-wing-muted">
|
||||||
|
여러 IP가 필요한 경우 IP별로 별도 신청해주세요. (1신청 = 1IP = 1계정)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 5: 사용 목적 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
사용 목적
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={form.purpose}
|
||||||
|
onChange={(e) => handleChange('purpose', e.target.value)}
|
||||||
|
placeholder="Bypass API를 사용하려는 목적을 간략히 설명해주세요."
|
||||||
|
rows={4}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? '제출 중...' : '신청 제출'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
frontend/src/pages/BypassAccountManagement.tsx
Normal file
486
frontend/src/pages/BypassAccountManagement.tsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
bypassAccountApi,
|
||||||
|
type BypassAccountResponse,
|
||||||
|
type BypassAccountUpdateRequest,
|
||||||
|
type PageResponse,
|
||||||
|
} from '../api/bypassAccountApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'ACTIVE', label: 'ACTIVE' },
|
||||||
|
{ value: 'SUSPENDED', label: 'SUSPENDED' },
|
||||||
|
{ value: 'EXPIRED', label: 'EXPIRED' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STATUS_BADGE_COLORS: Record<string, string> = {
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
SUSPENDED: 'bg-amber-100 text-amber-700',
|
||||||
|
EXPIRED: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCOUNT_STATUS_OPTIONS = ['ACTIVE', 'SUSPENDED', 'EXPIRED'];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
interface EditFormState {
|
||||||
|
displayName: string;
|
||||||
|
organization: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
status: string;
|
||||||
|
accessStartDate: string;
|
||||||
|
accessEndDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassAccountManagement() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [pageData, setPageData] = useState<PageResponse<BypassAccountResponse> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// Edit modal
|
||||||
|
const [editTarget, setEditTarget] = useState<BypassAccountResponse | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<EditFormState>({
|
||||||
|
displayName: '',
|
||||||
|
organization: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
status: '',
|
||||||
|
accessStartDate: '',
|
||||||
|
accessEndDate: '',
|
||||||
|
});
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Delete confirm
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<BypassAccountResponse | null>(null);
|
||||||
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Password reset confirm + credential modal
|
||||||
|
const [resetTarget, setResetTarget] = useState<BypassAccountResponse | null>(null);
|
||||||
|
const [resetSubmitting, setResetSubmitting] = useState(false);
|
||||||
|
const [credentialAccount, setCredentialAccount] = useState<BypassAccountResponse | null>(null);
|
||||||
|
|
||||||
|
const loadAccounts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await bypassAccountApi.getAccounts(statusFilter || undefined, page, PAGE_SIZE);
|
||||||
|
setPageData(res.data ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('계정 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast, statusFilter, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAccounts();
|
||||||
|
}, [loadAccounts]);
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (value: string) => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (account: BypassAccountResponse) => {
|
||||||
|
setEditTarget(account);
|
||||||
|
setEditForm({
|
||||||
|
displayName: account.displayName ?? '',
|
||||||
|
organization: account.organization ?? '',
|
||||||
|
email: account.email ?? '',
|
||||||
|
phone: account.phone ?? '',
|
||||||
|
status: account.status,
|
||||||
|
accessStartDate: account.accessStartDate ?? '',
|
||||||
|
accessEndDate: account.accessEndDate ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async () => {
|
||||||
|
if (!editTarget) return;
|
||||||
|
setEditSubmitting(true);
|
||||||
|
try {
|
||||||
|
const updateData: BypassAccountUpdateRequest = {
|
||||||
|
status: editForm.status || undefined,
|
||||||
|
accessStartDate: editForm.accessStartDate || undefined,
|
||||||
|
accessEndDate: editForm.accessEndDate || undefined,
|
||||||
|
};
|
||||||
|
await bypassAccountApi.updateAccount(editTarget.id, updateData);
|
||||||
|
setEditTarget(null);
|
||||||
|
showToast('계정 정보가 수정되었습니다.', 'success');
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('계정 수정 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleteSubmitting(true);
|
||||||
|
try {
|
||||||
|
await bypassAccountApi.deleteAccount(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
showToast('계정이 삭제되었습니다.', 'success');
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('계정 삭제 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setDeleteSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPasswordConfirm = async () => {
|
||||||
|
if (!resetTarget) return;
|
||||||
|
setResetSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await bypassAccountApi.resetPassword(resetTarget.id);
|
||||||
|
setResetTarget(null);
|
||||||
|
setCredentialAccount(res.data);
|
||||||
|
showToast('비밀번호가 재설정되었습니다.', 'success');
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('비밀번호 재설정 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setResetSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !pageData) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const accounts = pageData?.content ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">계정 관리</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
Bypass API 계정을 조회하고 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 탭 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusFilterChange(tab.value)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === tab.value
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text border border-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Username</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">표시명</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">등록일</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">사용 기간</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">기관</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이메일</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm">
|
||||||
|
등록된 계정이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
accounts.map((account) => (
|
||||||
|
<tr key={account.id} className="hover:bg-wing-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
STATUS_BADGE_COLORS[account.status] ?? 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{account.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
||||||
|
{account.username}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text">
|
||||||
|
{account.displayName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{account.createdAt
|
||||||
|
? new Date(account.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{account.accessStartDate && account.accessEndDate
|
||||||
|
? `${account.accessStartDate} ~ ${account.accessEndDate}`
|
||||||
|
: account.accessStartDate ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{account.organization ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{account.email ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditModal(account)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetTarget(account)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-amber-600 hover:bg-amber-50 border border-amber-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
비밀번호 재설정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(account)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageData && pageData.totalPages > 1 && (
|
||||||
|
<div className="px-4 py-3 border-t border-wing-border">
|
||||||
|
<Pagination
|
||||||
|
page={pageData.number}
|
||||||
|
totalPages={pageData.totalPages}
|
||||||
|
totalElements={pageData.totalElements}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수정 모달 */}
|
||||||
|
{editTarget && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setEditTarget(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-1">계정 수정</h3>
|
||||||
|
<p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p>
|
||||||
|
|
||||||
|
{/* 신청자 정보 (읽기 전용) */}
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 text-xs space-y-1.5">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">표시명: </span>
|
||||||
|
<span className="text-wing-text font-medium">{editTarget.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">기관: </span>
|
||||||
|
<span className="text-wing-text">{editTarget.organization ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{editTarget.serviceIps && (() => {
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(editTarget.serviceIps); } catch { /* empty */ }
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">상태</label>
|
||||||
|
<select
|
||||||
|
value={editForm.status}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, status: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
>
|
||||||
|
{ACCOUNT_STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">사용 시작일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editForm.accessStartDate}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, accessStartDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">사용 종료일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editForm.accessEndDate}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, accessEndDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditTarget(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEditSubmit}
|
||||||
|
disabled={editSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{editSubmitting ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="계정 삭제"
|
||||||
|
message={`"${deleteTarget?.username}"을(를) 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
|
confirmLabel={deleteSubmitting ? '삭제 중...' : '삭제'}
|
||||||
|
confirmColor="bg-red-500 hover:bg-red-600"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 비밀번호 재설정 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={resetTarget !== null}
|
||||||
|
title="비밀번호 재설정"
|
||||||
|
message={`"${resetTarget?.username}" 계정의 비밀번호를 재설정하시겠습니까?\n새 비밀번호가 생성됩니다.`}
|
||||||
|
confirmLabel={resetSubmitting ? '처리 중...' : '재설정'}
|
||||||
|
confirmColor="bg-amber-500 hover:bg-amber-600"
|
||||||
|
onConfirm={handleResetPasswordConfirm}
|
||||||
|
onCancel={() => setResetTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 계정 발급 완료 모달 */}
|
||||||
|
{credentialAccount && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setCredentialAccount(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-bold text-wing-text mb-4">비밀번호 재설정 완료</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded-lg p-3 text-amber-800 text-xs mb-4">
|
||||||
|
이 화면을 닫으면 비밀번호를 다시 확인할 수 없습니다.
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">사용자명</div>
|
||||||
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
||||||
|
<code className="text-sm font-mono flex-1">{credentialAccount.username}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(credentialAccount.username);
|
||||||
|
showToast('복사됨', 'success');
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">새 비밀번호</div>
|
||||||
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
||||||
|
<code className="text-sm font-mono flex-1">{credentialAccount.plainPassword}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(credentialAccount.plainPassword!);
|
||||||
|
showToast('복사됨', 'success');
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCredentialAccount(null)}
|
||||||
|
className="mt-6 w-full py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
700
frontend/src/pages/BypassAccountRequests.tsx
Normal file
700
frontend/src/pages/BypassAccountRequests.tsx
Normal file
@ -0,0 +1,700 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
bypassAccountApi,
|
||||||
|
type BypassRequestResponse,
|
||||||
|
type BypassAccountResponse,
|
||||||
|
type PageResponse,
|
||||||
|
} from '../api/bypassAccountApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ value: '', label: '전체' },
|
||||||
|
{ value: 'PENDING', label: 'PENDING' },
|
||||||
|
{ value: 'APPROVED', label: 'APPROVED' },
|
||||||
|
{ value: 'REJECTED', label: 'REJECTED' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STATUS_BADGE_COLORS: Record<string, string> = {
|
||||||
|
PENDING: 'bg-amber-100 text-amber-700',
|
||||||
|
APPROVED: 'bg-emerald-100 text-emerald-700',
|
||||||
|
REJECTED: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
interface ApproveFormState {
|
||||||
|
reviewedBy: string;
|
||||||
|
accessStartDate: string;
|
||||||
|
accessEndDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RejectFormState {
|
||||||
|
reviewedBy: string;
|
||||||
|
rejectReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassAccountRequests() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [pageData, setPageData] = useState<PageResponse<BypassRequestResponse> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// Approve modal
|
||||||
|
const [approveTarget, setApproveTarget] = useState<BypassRequestResponse | null>(null);
|
||||||
|
const [approveForm, setApproveForm] = useState<ApproveFormState>({
|
||||||
|
reviewedBy: '',
|
||||||
|
accessStartDate: '',
|
||||||
|
accessEndDate: '',
|
||||||
|
});
|
||||||
|
const [approveSubmitting, setApproveSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Reject modal
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<BypassRequestResponse | null>(null);
|
||||||
|
const [rejectForm, setRejectForm] = useState<RejectFormState>({ reviewedBy: '', rejectReason: '' });
|
||||||
|
const [rejectSubmitting, setRejectSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Detail modal
|
||||||
|
const [detailTarget, setDetailTarget] = useState<BypassRequestResponse | null>(null);
|
||||||
|
|
||||||
|
// Credential modal
|
||||||
|
const [credentialAccount, setCredentialAccount] = useState<BypassAccountResponse | null>(null);
|
||||||
|
|
||||||
|
const loadRequests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await bypassAccountApi.getRequests(statusFilter || undefined, page, PAGE_SIZE);
|
||||||
|
setPageData(res.data ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('신청 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast, statusFilter, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRequests();
|
||||||
|
}, [loadRequests]);
|
||||||
|
|
||||||
|
const handleStatusFilterChange = (value: string) => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApproveModal = (req: BypassRequestResponse) => {
|
||||||
|
setApproveTarget(req);
|
||||||
|
setApproveForm({ reviewedBy: '', accessStartDate: '', accessEndDate: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRejectModal = (req: BypassRequestResponse) => {
|
||||||
|
setRejectTarget(req);
|
||||||
|
setRejectForm({ reviewedBy: '', rejectReason: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveSubmit = async () => {
|
||||||
|
if (!approveTarget) return;
|
||||||
|
if (!approveForm.reviewedBy.trim()) {
|
||||||
|
showToast('승인자을 입력해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApproveSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await bypassAccountApi.approveRequest(approveTarget.id, {
|
||||||
|
reviewedBy: approveForm.reviewedBy,
|
||||||
|
accessStartDate: approveForm.accessStartDate || undefined,
|
||||||
|
accessEndDate: approveForm.accessEndDate || undefined,
|
||||||
|
});
|
||||||
|
setApproveTarget(null);
|
||||||
|
setCredentialAccount(res.data);
|
||||||
|
showToast('신청이 승인되었습니다.', 'success');
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('승인 처리 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setApproveSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectSubmit = async () => {
|
||||||
|
if (!rejectTarget) return;
|
||||||
|
if (!rejectForm.reviewedBy.trim()) {
|
||||||
|
showToast('승인자을 입력해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRejectSubmitting(true);
|
||||||
|
try {
|
||||||
|
await bypassAccountApi.rejectRequest(rejectTarget.id, {
|
||||||
|
reviewedBy: rejectForm.reviewedBy,
|
||||||
|
rejectReason: rejectForm.rejectReason || undefined,
|
||||||
|
});
|
||||||
|
setRejectTarget(null);
|
||||||
|
showToast('신청이 거절되었습니다.', 'success');
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('거절 처리 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setRejectSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !pageData) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const requests = pageData?.content ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">계정 신청 관리</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
Bypass API 접근 신청을 검토하고 계정을 발급합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 탭 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStatusFilterChange(tab.value)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === tab.value
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text border border-wing-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">상태</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">신청자명</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">신청일</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">요청 기간</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">기관</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">이메일</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-wing-muted text-sm">
|
||||||
|
신청 내역이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
requests.map((req) => (
|
||||||
|
<tr key={req.id} className="hover:bg-wing-hover transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
STATUS_BADGE_COLORS[req.status] ?? 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{req.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text">
|
||||||
|
{req.applicantName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{req.createdAt
|
||||||
|
? new Date(req.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{req.requestedAccessPeriod ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{req.organization ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{req.email ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetailTarget(req)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageData && pageData.totalPages > 1 && (
|
||||||
|
<div className="px-4 py-3 border-t border-wing-border">
|
||||||
|
<Pagination
|
||||||
|
page={pageData.number}
|
||||||
|
totalPages={pageData.totalPages}
|
||||||
|
totalElements={pageData.totalElements}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 승인 모달 */}
|
||||||
|
{approveTarget && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setApproveTarget(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 승인</h3>
|
||||||
|
|
||||||
|
{/* 신청 상세 정보 */}
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청자명: </span>
|
||||||
|
<span className="text-wing-text font-medium">{approveTarget.applicantName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">기관: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.organization ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{approveTarget.projectName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.projectName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
||||||
|
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{approveTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(approveTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{approveTarget.purpose && (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{approveTarget.purpose}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
승인자 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={approveForm.reviewedBy}
|
||||||
|
onChange={(e) => setApproveForm((f) => ({ ...f, reviewedBy: e.target.value }))}
|
||||||
|
placeholder="검토자 이름 입력"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
사용 시작일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={approveForm.accessStartDate}
|
||||||
|
onChange={(e) => setApproveForm((f) => ({ ...f, accessStartDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
사용 종료일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={approveForm.accessEndDate}
|
||||||
|
onChange={(e) => setApproveForm((f) => ({ ...f, accessEndDate: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDetailTarget(approveTarget); setApproveTarget(null); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
돌아가기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApproveSubmit}
|
||||||
|
disabled={approveSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{approveSubmitting ? '처리 중...' : '승인'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 거절 모달 */}
|
||||||
|
{rejectTarget && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setRejectTarget(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 거절</h3>
|
||||||
|
|
||||||
|
{/* 신청 상세 정보 */}
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청자명: </span>
|
||||||
|
<span className="text-wing-text font-medium">{rejectTarget.applicantName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">기관: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.organization ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">이메일: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{rejectTarget.projectName && (
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">프로젝트/서비스명: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.projectName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-wing-muted">신청 사용 기간: </span>
|
||||||
|
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
{rejectTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(rejectTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{rejectTarget.purpose && (
|
||||||
|
<div>
|
||||||
|
<div className="text-wing-muted mb-1">신청 사유</div>
|
||||||
|
<div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{rejectTarget.purpose}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
승인자 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rejectForm.reviewedBy}
|
||||||
|
onChange={(e) => setRejectForm((f) => ({ ...f, reviewedBy: e.target.value }))}
|
||||||
|
placeholder="검토자 이름 입력"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||||
|
거절 사유
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectForm.rejectReason}
|
||||||
|
onChange={(e) => setRejectForm((f) => ({ ...f, rejectReason: e.target.value }))}
|
||||||
|
placeholder="거절 사유를 입력하세요 (선택)"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDetailTarget(rejectTarget); setRejectTarget(null); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
돌아가기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRejectSubmit}
|
||||||
|
disabled={rejectSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{rejectSubmitting ? '처리 중...' : '거절'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 신청 상세 모달 */}
|
||||||
|
{detailTarget && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setDetailTarget(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-wing-text mb-4">신청 상세</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">신청자명</div>
|
||||||
|
<div className="text-wing-text font-medium">{detailTarget.applicantName}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">기관</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.organization ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">이메일</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">프로젝트/서비스명</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.projectName ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">신청 사용 기간</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
{detailTarget.serviceIps && (() => {
|
||||||
|
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
|
||||||
|
try { ips = JSON.parse(detailTarget.serviceIps); } catch {}
|
||||||
|
if (ips.length === 0) return null;
|
||||||
|
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
|
||||||
|
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">서비스 IP</div>
|
||||||
|
<div className="bg-wing-card rounded-lg border border-wing-border p-2 space-y-1">
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs">
|
||||||
|
<code className="font-mono text-wing-text">{ip.ip}</code>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
|
||||||
|
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
|
||||||
|
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">신청 사유</div>
|
||||||
|
<div className="text-wing-text whitespace-pre-wrap bg-wing-card rounded-lg p-3 border border-wing-border max-h-48 overflow-y-auto">
|
||||||
|
{detailTarget.purpose || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailTarget.status !== 'PENDING' && (
|
||||||
|
<div className="border-t border-wing-border pt-3 space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">처리 상태</div>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${STATUS_BADGE_COLORS[detailTarget.status] ?? ''}`}>
|
||||||
|
{detailTarget.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">검토자</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.reviewedBy ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailTarget.rejectReason && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-0.5">거절 사유</div>
|
||||||
|
<div className="text-wing-text">{detailTarget.rejectReason}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetailTarget(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
{detailTarget.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDetailTarget(null); openRejectModal(detailTarget); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDetailTarget(null); openApproveModal(detailTarget); }}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
승인
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{detailTarget.status === 'REJECTED' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await bypassAccountApi.reopenRequest(detailTarget.id);
|
||||||
|
setDetailTarget(null);
|
||||||
|
showToast('재심사 상태로 변경되었습니다.', 'success');
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('재심사 처리 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
재심사
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 계정 발급 완료 모달 */}
|
||||||
|
{credentialAccount && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
|
||||||
|
onClick={() => setCredentialAccount(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="text-lg font-bold text-wing-text mb-4">계정 발급 완료</div>
|
||||||
|
<div className="bg-amber-50 border border-amber-300 rounded-lg p-3 text-amber-800 text-xs mb-4">
|
||||||
|
이 화면을 닫으면 비밀번호를 다시 확인할 수 없습니다.
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">사용자명</div>
|
||||||
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
||||||
|
<code className="text-sm font-mono flex-1">{credentialAccount.username}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(credentialAccount.username);
|
||||||
|
showToast('복사됨', 'success');
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-wing-muted mb-1">비밀번호</div>
|
||||||
|
<div className="flex items-center gap-2 bg-wing-card rounded-lg p-3 border border-wing-border">
|
||||||
|
<code className="text-sm font-mono flex-1">{credentialAccount.plainPassword}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(credentialAccount.plainPassword!);
|
||||||
|
showToast('복사됨', 'success');
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCredentialAccount(null)}
|
||||||
|
className="mt-6 w-full py-2 rounded-lg bg-slate-900 text-white text-sm font-bold hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
frontend/src/pages/BypassCatalog.tsx
Normal file
320
frontend/src/pages/BypassCatalog.tsx
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface BypassParam {
|
||||||
|
paramName: string;
|
||||||
|
paramType: string;
|
||||||
|
paramIn: string;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BypassConfig {
|
||||||
|
id: number;
|
||||||
|
domainName: string;
|
||||||
|
endpointName: string;
|
||||||
|
displayName: string;
|
||||||
|
httpMethod: string;
|
||||||
|
externalPath: string;
|
||||||
|
description: string;
|
||||||
|
generated: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
params: BypassParam[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
const METHOD_COLORS: Record<string, string> = {
|
||||||
|
GET: 'bg-emerald-100 text-emerald-700',
|
||||||
|
POST: 'bg-blue-100 text-blue-700',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700',
|
||||||
|
DELETE: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWAGGER_BASE = '/snp-global/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API';
|
||||||
|
|
||||||
|
function buildSwaggerDeepLink(config: BypassConfig): string {
|
||||||
|
// Swagger UI deep link: #/{Tag}/{operationId}
|
||||||
|
// Tag = domainName 첫글자 대문자 (예: compliance → Compliance)
|
||||||
|
// operationId = get{EndpointName}Data (SpringDoc 기본 패턴)
|
||||||
|
const tag = config.domainName.charAt(0).toUpperCase() + config.domainName.slice(1);
|
||||||
|
const operationId = `get${config.endpointName}Data`;
|
||||||
|
return `${SWAGGER_BASE}#/${tag}/${operationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BypassCatalog() {
|
||||||
|
const [configs, setConfigs] = useState<BypassConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/snp-global/api/bypass-config')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then((res: ApiResponse<BypassConfig[]>) => setConfigs((res.data ?? []).filter(c => c.generated)))
|
||||||
|
.catch(() => setConfigs([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const domainNames = useMemo(() => {
|
||||||
|
const names = [...new Set(configs.map((c) => c.domainName))];
|
||||||
|
return names.sort();
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return configs.filter((c) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(c.description || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
||||||
|
return matchesSearch && matchesDomain;
|
||||||
|
});
|
||||||
|
}, [configs, searchTerm, selectedDomain]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-wing-muted">
|
||||||
|
<div className="text-sm">API 목록을 불러오는 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">S&P Global API 카탈로그</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={SWAGGER_BASE}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors no-underline"
|
||||||
|
>
|
||||||
|
Swagger UI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 + 필터 + 뷰 전환 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="도메인명, 표시명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인 드롭다운 필터 */}
|
||||||
|
<select
|
||||||
|
value={selectedDomain}
|
||||||
|
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
|
||||||
|
>
|
||||||
|
<option value="">전체 도메인</option>
|
||||||
|
{domainNames.map((name) => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 뷰 전환 토글 */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchTerm || selectedDomain) && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filtered.length}개 API 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{configs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">등록된 API가 없습니다.</p>
|
||||||
|
<p className="text-sm">관리자에게 문의해주세요.</p>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">검색 결과가 없습니다.</p>
|
||||||
|
<p className="text-sm">다른 검색어를 사용해 보세요.</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* 카드 뷰 */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filtered.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
|
||||||
|
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
|
||||||
|
</div>
|
||||||
|
<span className={['shrink-0 px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-wing-muted font-mono truncate">{config.externalPath}</p>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{config.params.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-wing-muted mb-1 font-medium">Parameters</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{config.params.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.paramName}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted"
|
||||||
|
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
||||||
|
>
|
||||||
|
{p.paramName}
|
||||||
|
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1 border-t border-wing-border mt-auto">
|
||||||
|
<a
|
||||||
|
href={buildSwaggerDeepLink(config)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||||
|
>
|
||||||
|
Swagger에서 테스트 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 테이블 뷰 */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">도메인명</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">표시명</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">HTTP</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">외부 경로</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">파라미터</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Swagger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filtered.map((config) => (
|
||||||
|
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">{config.domainName}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text">{config.displayName}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={['px-1.5 py-0.5 text-xs font-bold rounded', METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted'].join(' ')}>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[250px] truncate" title={config.externalPath}>
|
||||||
|
{config.externalPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{config.params.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p.paramName}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-wing-card text-wing-muted"
|
||||||
|
title={`${p.paramIn} · ${p.paramType}${p.required ? ' · 필수' : ''}`}
|
||||||
|
>
|
||||||
|
{p.paramName}
|
||||||
|
{p.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<a
|
||||||
|
href={buildSwaggerDeepLink(config)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-blue-500 hover:text-blue-600 no-underline transition-colors"
|
||||||
|
>
|
||||||
|
테스트 →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
frontend/src/pages/BypassConfig.tsx
Normal file
524
frontend/src/pages/BypassConfig.tsx
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
bypassApi,
|
||||||
|
type BypassConfigRequest,
|
||||||
|
type BypassConfigResponse,
|
||||||
|
type CodeGenerationResult,
|
||||||
|
type WebClientBeanInfo,
|
||||||
|
} from '../api/bypassApi';
|
||||||
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
|
import BypassConfigModal from '../components/bypass/BypassConfigModal';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
import InfoModal from '../components/InfoModal';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
interface ConfirmAction {
|
||||||
|
type: 'delete' | 'generate';
|
||||||
|
config: BypassConfigResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = 'card' | 'table';
|
||||||
|
|
||||||
|
const HTTP_METHOD_COLORS: Record<string, string> = {
|
||||||
|
GET: 'bg-emerald-100 text-emerald-700',
|
||||||
|
POST: 'bg-blue-100 text-blue-700',
|
||||||
|
PUT: 'bg-amber-100 text-amber-700',
|
||||||
|
DELETE: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BypassConfig() {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<BypassConfigResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [webclientBeans, setWebclientBeans] = useState<WebClientBeanInfo[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState('');
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editConfig, setEditConfig] = useState<BypassConfigResponse | null>(null);
|
||||||
|
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [generationResult, setGenerationResult] = useState<CodeGenerationResult | null>(null);
|
||||||
|
const [codeGenEnabled, setCodeGenEnabled] = useState(true);
|
||||||
|
|
||||||
|
const loadConfigs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.getConfigs();
|
||||||
|
setConfigs(res.data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Bypass API 목록 조회 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigs();
|
||||||
|
bypassApi.getWebclientBeans()
|
||||||
|
.then((res) => setWebclientBeans(res.data ?? []))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
fetch('/snp-global/api/bypass-config/environment')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(res => setCodeGenEnabled(res.data?.codeGenerationEnabled ?? true))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [loadConfigs]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditConfig(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (config: BypassConfigResponse) => {
|
||||||
|
setEditConfig(config);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: BypassConfigRequest) => {
|
||||||
|
if (editConfig) {
|
||||||
|
await bypassApi.updateConfig(editConfig.id, data);
|
||||||
|
showToast('Bypass API가 수정되었습니다.', 'success');
|
||||||
|
} else {
|
||||||
|
await bypassApi.createConfig(data);
|
||||||
|
showToast('Bypass API가 등록되었습니다.', 'success');
|
||||||
|
}
|
||||||
|
await loadConfigs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'delete') return;
|
||||||
|
try {
|
||||||
|
await bypassApi.deleteConfig(confirmAction.config.id);
|
||||||
|
showToast('Bypass API가 삭제되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('삭제 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setConfirmAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateConfirm = async () => {
|
||||||
|
if (!confirmAction || confirmAction.type !== 'generate') return;
|
||||||
|
const targetConfig = confirmAction.config;
|
||||||
|
setConfirmAction(null);
|
||||||
|
try {
|
||||||
|
const res = await bypassApi.generateCode(targetConfig.id, targetConfig.generated);
|
||||||
|
setGenerationResult(res.data);
|
||||||
|
showToast('코드가 생성되었습니다.', 'success');
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('코드 생성 실패', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainNames = useMemo(() => {
|
||||||
|
const names = [...new Set(configs.map((c) => c.domainName))];
|
||||||
|
return names.sort();
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
const filteredConfigs = useMemo(() => {
|
||||||
|
return configs.filter((c) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchTerm.trim() ||
|
||||||
|
c.domainName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.displayName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
const matchesDomain = !selectedDomain || c.domainName === selectedDomain;
|
||||||
|
return matchesSearch && matchesDomain;
|
||||||
|
});
|
||||||
|
}, [configs, searchTerm, selectedDomain]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">Bypass API 관리</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
외부 Maritime API를 직접 프록시하는 Bypass API를 등록하고 코드를 생성합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent hover:bg-wing-accent/80 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
+ 새 API 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 + 뷰 전환 */}
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md p-4">
|
||||||
|
<div className="flex gap-3 items-center flex-wrap">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="도메인명, 표시명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none bg-wing-surface text-wing-text"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도메인 드롭다운 필터 */}
|
||||||
|
<select
|
||||||
|
value={selectedDomain}
|
||||||
|
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface text-wing-text"
|
||||||
|
>
|
||||||
|
<option value="">전체 도메인</option>
|
||||||
|
{domainNames.map((name) => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 뷰 전환 토글 */}
|
||||||
|
<div className="flex rounded-lg border border-wing-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
title="테이블 보기"
|
||||||
|
className={`px-3 py-2 transition-colors ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setViewMode('card')}
|
||||||
|
title="카드 보기"
|
||||||
|
className={`px-3 py-2 transition-colors border-l border-wing-border ${
|
||||||
|
viewMode === 'card'
|
||||||
|
? 'bg-wing-accent text-white'
|
||||||
|
: 'bg-wing-surface text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchTerm || selectedDomain) && (
|
||||||
|
<p className="mt-2 text-xs text-wing-muted">
|
||||||
|
{filteredConfigs.length}개 API 검색됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{configs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">등록된 BYPASS API가 없습니다.</p>
|
||||||
|
<p className="text-sm">위 버튼을 눌러 새 API를 등록하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : filteredConfigs.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-wing-muted border border-dashed border-wing-border rounded-xl bg-wing-card">
|
||||||
|
<p className="text-base font-medium mb-1">검색 결과가 없습니다.</p>
|
||||||
|
<p className="text-sm">다른 검색어를 사용해 보세요.</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
/* 카드 뷰 */
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className="bg-wing-card border border-wing-border rounded-xl p-5 flex flex-col gap-3 hover:border-wing-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-wing-text truncate">{config.displayName}</p>
|
||||||
|
<p className="text-xs text-wing-muted font-mono mt-0.5">{config.domainName}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 정보 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-1.5 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-wing-muted font-mono truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-wing-muted">
|
||||||
|
<span className="font-medium text-wing-text">WebClient:</span>{' '}
|
||||||
|
{config.webclientBean}
|
||||||
|
</p>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-wing-muted line-clamp-2">{config.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 액션 */}
|
||||||
|
<div className="flex gap-2 pt-1 border-t border-wing-border mt-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium text-wing-text bg-wing-surface hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
disabled={!codeGenEnabled}
|
||||||
|
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
|
||||||
|
className={`flex-1 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
codeGenEnabled
|
||||||
|
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
|
||||||
|
: 'text-wing-muted bg-wing-card cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="py-1.5 px-3 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 테이블 뷰 */
|
||||||
|
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-wing-border bg-wing-card">
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
도메인명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
표시명
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
HTTP 메서드
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
WebClient
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
외부 경로
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
생성 상태
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
등록일
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
|
||||||
|
액션
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-wing-border">
|
||||||
|
{filteredConfigs.map((config) => (
|
||||||
|
<tr key={config.id} className="hover:bg-wing-hover transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-text">
|
||||||
|
{config.domainName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-wing-text">
|
||||||
|
{config.displayName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-bold rounded',
|
||||||
|
HTTP_METHOD_COLORS[config.httpMethod] ?? 'bg-wing-card text-wing-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.httpMethod}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted">
|
||||||
|
{config.webclientBean}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-wing-muted max-w-[200px] truncate">
|
||||||
|
{config.externalPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||||
|
config.generated
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: 'bg-wing-card text-wing-muted border border-wing-border',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{config.generated ? '생성 완료' : '미생성'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
|
||||||
|
{config.createdAt
|
||||||
|
? new Date(config.createdAt).toLocaleDateString('ko-KR')
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(config)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'generate', config })}
|
||||||
|
disabled={!codeGenEnabled}
|
||||||
|
title={!codeGenEnabled ? '운영 환경에서는 코드 생성이 불가합니다' : ''}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
codeGenEnabled
|
||||||
|
? 'text-white bg-wing-accent hover:bg-wing-accent/80'
|
||||||
|
: 'text-wing-muted bg-wing-card cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.generated ? '재생성' : '코드 생성'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmAction({ type: 'delete', config })}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<BypassConfigModal
|
||||||
|
open={modalOpen}
|
||||||
|
editConfig={editConfig}
|
||||||
|
webclientBeans={webclientBeans}
|
||||||
|
onSave={handleSave}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'delete'}
|
||||||
|
title="삭제 확인"
|
||||||
|
message={`"${confirmAction?.config.displayName}"을(를) 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`}
|
||||||
|
confirmLabel="삭제"
|
||||||
|
confirmColor="bg-red-500 hover:bg-red-600"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 확인 모달 */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmAction?.type === 'generate'}
|
||||||
|
title={confirmAction?.config.generated ? '코드 재생성 확인' : '코드 생성 확인'}
|
||||||
|
message={
|
||||||
|
confirmAction?.config.generated
|
||||||
|
? `"${confirmAction?.config.displayName}" 코드를 재생성합니다.\n기존 생성된 파일이 덮어씌워집니다. 계속하시겠습니까?`
|
||||||
|
: `"${confirmAction?.config.displayName}" 코드를 생성합니다.\n계속하시겠습니까?`
|
||||||
|
}
|
||||||
|
confirmLabel={confirmAction?.config.generated ? '재생성' : '생성'}
|
||||||
|
onConfirm={handleGenerateConfirm}
|
||||||
|
onCancel={() => setConfirmAction(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 코드 생성 결과 모달 */}
|
||||||
|
<InfoModal
|
||||||
|
open={generationResult !== null}
|
||||||
|
title="코드 생성 완료"
|
||||||
|
onClose={() => setGenerationResult(null)}
|
||||||
|
>
|
||||||
|
{generationResult && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-wing-text">{generationResult.message}</p>
|
||||||
|
<div className="bg-wing-card rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-wing-text mb-1">생성된 파일</p>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">Controller</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{generationResult.controllerPath}</span>
|
||||||
|
</div>
|
||||||
|
{generationResult.servicePaths.map((path, idx) => (
|
||||||
|
<div key={`service-${idx}`} className="flex gap-2 text-xs">
|
||||||
|
<span className="w-20 font-medium text-wing-accent shrink-0">
|
||||||
|
Service {generationResult.servicePaths.length > 1 ? idx + 1 : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-wing-muted break-all">{path}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 bg-amber-50 text-amber-700 rounded-lg p-3 text-xs">
|
||||||
|
<span className="shrink-0">⚠</span>
|
||||||
|
<span>서버를 재시작하면 새 API가 활성화됩니다.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/pages/MainMenu.tsx
Normal file
60
frontend/src/pages/MainMenu.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useThemeContext } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'S&P Global API',
|
||||||
|
description: 'S&P Global Maritime API',
|
||||||
|
detail: 'API 카탈로그, API 계정 신청',
|
||||||
|
path: '/bypass-catalog',
|
||||||
|
icon: '🌐',
|
||||||
|
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||||
|
menuCount: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'S&P Risk & Compliance',
|
||||||
|
description: 'S&P 위험 지표 및 규정 준수',
|
||||||
|
detail: '위험 지표 및 규정 준수 가이드, 변경 이력 조회',
|
||||||
|
path: '/risk-compliance-history',
|
||||||
|
icon: '⚖️',
|
||||||
|
iconClass: 'gc-card-icon gc-card-icon-nexus',
|
||||||
|
menuCount: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MainMenu() {
|
||||||
|
const { theme, toggle } = useThemeContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[70vh] flex flex-col items-center justify-center">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-3xl font-bold text-wing-text mb-2">S&P Data Platform</h1>
|
||||||
|
<p className="text-sm text-wing-muted">해양 데이터 통합 관리 플랫폼</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 카드 */}
|
||||||
|
<div className="gc-cards">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Link key={section.path} to={section.path} className="gc-card">
|
||||||
|
<div className={section.iconClass}>
|
||||||
|
<span className="text-5xl">{section.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{section.title}</h3>
|
||||||
|
<p>{section.description}<br />{section.detail}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테마 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="mt-8 px-3 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
|
||||||
|
hover:text-wing-text border border-wing-border transition-colors"
|
||||||
|
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? '☀️ 라이트 모드' : '🌙 다크 모드'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/pages/RiskComplianceHistory.tsx
Normal file
44
frontend/src/pages/RiskComplianceHistory.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import HistoryTab from '../components/screening/HistoryTab';
|
||||||
|
import { t } from '../constants/screeningTexts';
|
||||||
|
|
||||||
|
const LANG_KEY = 'screening-lang';
|
||||||
|
|
||||||
|
export default function RiskComplianceHistory() {
|
||||||
|
const [lang, setLangState] = useState(() => localStorage.getItem(LANG_KEY) || 'KO');
|
||||||
|
const setLang = useCallback((l: string) => {
|
||||||
|
setLangState(l);
|
||||||
|
localStorage.setItem(LANG_KEY, l);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 + 언어 토글 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">{t(lang, 'changeHistoryTitle')}</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
{t(lang, 'changeHistorySubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
|
||||||
|
{(['EN', 'KO'] as const).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() => setLang(l)}
|
||||||
|
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
|
||||||
|
lang === l
|
||||||
|
? 'bg-wing-text text-wing-bg'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HistoryTab lang={lang} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/pages/ScreeningGuide.tsx
Normal file
87
frontend/src/pages/ScreeningGuide.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import RiskTab from '../components/screening/RiskTab';
|
||||||
|
import ComplianceTab from '../components/screening/ComplianceTab';
|
||||||
|
import MethodologyTab from '../components/screening/MethodologyTab';
|
||||||
|
import { t } from '../constants/screeningTexts';
|
||||||
|
|
||||||
|
type ActiveTab = 'risk' | 'ship-compliance' | 'company-compliance' | 'methodology';
|
||||||
|
const VALID_TABS: ActiveTab[] = ['risk', 'ship-compliance', 'company-compliance', 'methodology'];
|
||||||
|
const LANG_KEY = 'screening-lang';
|
||||||
|
|
||||||
|
export default function ScreeningGuide() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialTab = VALID_TABS.includes(searchParams.get('tab') as ActiveTab)
|
||||||
|
? (searchParams.get('tab') as ActiveTab)
|
||||||
|
: 'risk';
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>(initialTab);
|
||||||
|
const [lang, setLangState] = useState(() => localStorage.getItem(LANG_KEY) || 'EN');
|
||||||
|
const setLang = useCallback((l: string) => {
|
||||||
|
setLangState(l);
|
||||||
|
localStorage.setItem(LANG_KEY, l);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabs: { key: ActiveTab; label: string }[] = [
|
||||||
|
{ key: 'risk', label: t(lang, 'tabRiskIndicators') },
|
||||||
|
{ key: 'ship-compliance', label: t(lang, 'tabShipCompliance') },
|
||||||
|
{ key: 'company-compliance', label: t(lang, 'tabCompanyCompliance') },
|
||||||
|
{ key: 'methodology', label: t(lang, 'tabMethodology') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-wing-text">
|
||||||
|
{t(lang, 'screeningGuideTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
|
{t(lang, 'screeningGuideSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 언어 토글 */}
|
||||||
|
<div className="flex border border-wing-border rounded-lg overflow-hidden shrink-0">
|
||||||
|
{(['EN', 'KO'] as const).map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() => setLang(l)}
|
||||||
|
className={`px-4 py-1.5 text-sm font-bold transition-colors ${
|
||||||
|
lang === l
|
||||||
|
? 'bg-wing-text text-wing-bg'
|
||||||
|
: 'bg-wing-card text-wing-muted hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 언더라인 탭 */}
|
||||||
|
<div className="border-b border-wing-border">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`pb-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'text-blue-600 border-blue-600'
|
||||||
|
: 'text-wing-muted border-transparent hover:text-wing-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 내용 */}
|
||||||
|
{activeTab === 'risk' && <RiskTab lang={lang} />}
|
||||||
|
{activeTab === 'ship-compliance' && <ComplianceTab lang={lang} indicatorType="SHIP" />}
|
||||||
|
{activeTab === 'company-compliance' && <ComplianceTab lang={lang} indicatorType="COMPANY" />}
|
||||||
|
{activeTab === 'methodology' && <MethodologyTab lang={lang} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/theme/base.css
Normal file
100
frontend/src/theme/base.css
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
background: var(--wing-bg);
|
||||||
|
color: var(--wing-text);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for dark mode */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--wing-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--wing-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--wing-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Menu Cards */
|
||||||
|
.gc-cards {
|
||||||
|
padding: 2rem 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 300px));
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 80%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gc-cards {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
border: 1px solid var(--wing-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--wing-surface);
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: inherit !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card:hover {
|
||||||
|
border-color: #4183c4;
|
||||||
|
box-shadow: 0 4px 16px rgba(65, 131, 196, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon {
|
||||||
|
color: #4183c4;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon-guide {
|
||||||
|
color: #21ba45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-icon-nexus {
|
||||||
|
color: #f2711c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--wing-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--wing-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4183c4;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gc-card:hover .gc-card-link {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
84
frontend/src/theme/tokens.css
Normal file
84
frontend/src/theme/tokens.css
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/* Dark theme (default) */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--wing-bg: #020617;
|
||||||
|
--wing-surface: #0f172a;
|
||||||
|
--wing-card: #1e293b;
|
||||||
|
--wing-border: #1e3a5f;
|
||||||
|
--wing-text: #e2e8f0;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #3b82f6;
|
||||||
|
--wing-danger: #ef4444;
|
||||||
|
--wing-warning: #f59e0b;
|
||||||
|
--wing-success: #22c55e;
|
||||||
|
--wing-glass: rgba(15, 23, 42, 0.92);
|
||||||
|
--wing-glass-dense: rgba(15, 23, 42, 0.95);
|
||||||
|
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||||
|
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||||
|
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
--wing-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--wing-input-bg: #0f172a;
|
||||||
|
--wing-input-border: #334155;
|
||||||
|
--wing-rag-red-bg: rgba(127, 29, 29, 0.15);
|
||||||
|
--wing-rag-red-text: #fca5a5;
|
||||||
|
--wing-rag-amber-bg: rgba(120, 53, 15, 0.15);
|
||||||
|
--wing-rag-amber-text: #fcd34d;
|
||||||
|
--wing-rag-green-bg: rgba(5, 46, 22, 0.15);
|
||||||
|
--wing-rag-green-text: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
[data-theme='light'] {
|
||||||
|
--wing-bg: #e2e8f0;
|
||||||
|
--wing-surface: #ffffff;
|
||||||
|
--wing-card: #f1f5f9;
|
||||||
|
--wing-border: #94a3b8;
|
||||||
|
--wing-text: #0f172a;
|
||||||
|
--wing-muted: #64748b;
|
||||||
|
--wing-accent: #2563eb;
|
||||||
|
--wing-danger: #dc2626;
|
||||||
|
--wing-warning: #d97706;
|
||||||
|
--wing-success: #16a34a;
|
||||||
|
--wing-glass: rgba(255, 255, 255, 0.92);
|
||||||
|
--wing-glass-dense: rgba(255, 255, 255, 0.95);
|
||||||
|
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||||
|
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||||
|
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||||
|
--wing-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--wing-input-bg: #ffffff;
|
||||||
|
--wing-input-border: #cbd5e1;
|
||||||
|
--wing-rag-red-bg: #fef2f2;
|
||||||
|
--wing-rag-red-text: #b91c1c;
|
||||||
|
--wing-rag-amber-bg: #fffbeb;
|
||||||
|
--wing-rag-amber-text: #b45309;
|
||||||
|
--wing-rag-green-bg: #f0fdf4;
|
||||||
|
--wing-rag-green-text: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-wing-bg: var(--wing-bg);
|
||||||
|
--color-wing-surface: var(--wing-surface);
|
||||||
|
--color-wing-card: var(--wing-card);
|
||||||
|
--color-wing-border: var(--wing-border);
|
||||||
|
--color-wing-text: var(--wing-text);
|
||||||
|
--color-wing-muted: var(--wing-muted);
|
||||||
|
--color-wing-accent: var(--wing-accent);
|
||||||
|
--color-wing-danger: var(--wing-danger);
|
||||||
|
--color-wing-warning: var(--wing-warning);
|
||||||
|
--color-wing-success: var(--wing-success);
|
||||||
|
--color-wing-glass: var(--wing-glass);
|
||||||
|
--color-wing-glass-dense: var(--wing-glass-dense);
|
||||||
|
--color-wing-overlay: var(--wing-overlay);
|
||||||
|
--color-wing-card-alpha: var(--wing-card-alpha);
|
||||||
|
--color-wing-subtle: var(--wing-subtle);
|
||||||
|
--color-wing-hover: var(--wing-hover);
|
||||||
|
--color-wing-input-bg: var(--wing-input-bg);
|
||||||
|
--color-wing-input-border: var(--wing-input-border);
|
||||||
|
--color-wing-rag-red-bg: var(--wing-rag-red-bg);
|
||||||
|
--color-wing-rag-red-text: var(--wing-rag-red-text);
|
||||||
|
--color-wing-rag-amber-bg: var(--wing-rag-amber-bg);
|
||||||
|
--color-wing-rag-amber-text: var(--wing-rag-amber-text);
|
||||||
|
--color-wing-rag-green-bg: var(--wing-rag-green-bg);
|
||||||
|
--color-wing-rag-green-text: var(--wing-rag-green-text);
|
||||||
|
--font-sans: 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
58
frontend/src/utils/formatters.ts
Normal file
58
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export function formatDateTime(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d} ${h}:${min}:${s}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const h = String(date.getHours()).padStart(2, '0');
|
||||||
|
const min = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${m}/${d} ${h}:${min}`;
|
||||||
|
} catch {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms == null || ms < 0) return '-';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||||
|
if (minutes > 0) return `${minutes}분 ${seconds}초`;
|
||||||
|
return `${seconds}초`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDuration(
|
||||||
|
startTime: string | null | undefined,
|
||||||
|
endTime: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!startTime) return '-';
|
||||||
|
const start = new Date(startTime).getTime();
|
||||||
|
if (isNaN(start)) return '-';
|
||||||
|
|
||||||
|
if (!endTime) return '실행 중...';
|
||||||
|
const end = new Date(endTime).getTime();
|
||||||
|
if (isNaN(end)) return '-';
|
||||||
|
|
||||||
|
return formatDuration(end - start);
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/snp-global/api': {
|
||||||
|
target: 'http://localhost:8031',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: '/snp-global/',
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "snp-batch-validation",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
190
pom.xml
Normal file
190
pom.xml
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.snp</groupId>
|
||||||
|
<artifactId>snp-global</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>SNP Global</name>
|
||||||
|
<description>S&P Global API - Risk & Compliance 서비스</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
|
||||||
|
<!-- Dependency versions -->
|
||||||
|
<spring-boot.version>3.2.1</spring-boot.version>
|
||||||
|
<postgresql.version>42.7.6</postgresql.version>
|
||||||
|
<lombok.version>1.18.30</lombok.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starter Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Mail -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>${postgresql.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Thymeleaf (for Web GUI) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON processing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok for reducing boilerplate code -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot DevTools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Actuator for monitoring -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebClient for REST API calls -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Springdoc OpenAPI (Swagger) for API Documentation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Test Dependencies -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.findbugs</groupId>
|
||||||
|
<artifactId>jsr305</artifactId>
|
||||||
|
<version>3.0.2</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>1.15.1</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<nodeVersion>v20.19.0</nodeVersion>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
14
src/main/java/com/snp/batch/SnpGlobalApplication.java
Normal file
14
src/main/java/com/snp/batch/SnpGlobalApplication.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package com.snp.batch;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@ConfigurationPropertiesScan
|
||||||
|
public class SnpGlobalApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(SnpGlobalApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
package com.snp.batch.api.logging;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||||
|
import org.springframework.web.util.ContentCachingResponseWrapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 요청/응답 로깅 필터
|
||||||
|
*
|
||||||
|
* 로그 파일: logs/api-access.log
|
||||||
|
* 기록 내용: 요청 IP, HTTP Method, URI, 파라미터, 응답 상태, 처리 시간
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class ApiAccessLoggingFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final int MAX_PAYLOAD_LENGTH = 1000;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
// 정적 리소스 및 actuator 제외
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (shouldSkip(uri)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요청 래핑 (body 읽기용)
|
||||||
|
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
|
||||||
|
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
|
||||||
|
|
||||||
|
String requestId = UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(requestWrapper, responseWrapper);
|
||||||
|
} finally {
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
logRequest(requestId, requestWrapper, responseWrapper, duration);
|
||||||
|
responseWrapper.copyBodyToResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldSkip(String uri) {
|
||||||
|
return uri.startsWith("/actuator")
|
||||||
|
|| uri.startsWith("/css")
|
||||||
|
|| uri.startsWith("/js")
|
||||||
|
|| uri.startsWith("/images")
|
||||||
|
|| uri.startsWith("/favicon")
|
||||||
|
|| uri.endsWith(".html")
|
||||||
|
|| uri.endsWith(".css")
|
||||||
|
|| uri.endsWith(".js")
|
||||||
|
|| uri.endsWith(".ico");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logRequest(String requestId,
|
||||||
|
ContentCachingRequestWrapper request,
|
||||||
|
ContentCachingResponseWrapper response,
|
||||||
|
long duration) {
|
||||||
|
|
||||||
|
String clientIp = getClientIp(request);
|
||||||
|
String method = request.getMethod();
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
String queryString = request.getQueryString();
|
||||||
|
int status = response.getStatus();
|
||||||
|
|
||||||
|
StringBuilder logMessage = new StringBuilder();
|
||||||
|
logMessage.append(String.format("[%s] %s %s %s",
|
||||||
|
requestId, clientIp, method, uri));
|
||||||
|
|
||||||
|
// Query String
|
||||||
|
if (queryString != null && !queryString.isEmpty()) {
|
||||||
|
logMessage.append("?").append(truncate(queryString, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request Body (POST/PUT/PATCH)
|
||||||
|
if (isBodyRequest(method)) {
|
||||||
|
String body = getRequestBody(request);
|
||||||
|
if (!body.isEmpty()) {
|
||||||
|
logMessage.append(" | body=").append(truncate(body, MAX_PAYLOAD_LENGTH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
logMessage.append(String.format(" | status=%d | %dms", status, duration));
|
||||||
|
|
||||||
|
// 상태에 따른 로그 레벨
|
||||||
|
if (status >= 500) {
|
||||||
|
log.error(logMessage.toString());
|
||||||
|
} else if (status >= 400) {
|
||||||
|
log.warn(logMessage.toString());
|
||||||
|
} else {
|
||||||
|
log.info(logMessage.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp(HttpServletRequest request) {
|
||||||
|
String ip = request.getHeader("X-Forwarded-For");
|
||||||
|
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||||
|
ip = request.getHeader("X-Real-IP");
|
||||||
|
}
|
||||||
|
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||||
|
ip = request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
// 여러 IP가 있는 경우 첫 번째만
|
||||||
|
if (ip != null && ip.contains(",")) {
|
||||||
|
ip = ip.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBodyRequest(String method) {
|
||||||
|
return "POST".equalsIgnoreCase(method)
|
||||||
|
|| "PUT".equalsIgnoreCase(method)
|
||||||
|
|| "PATCH".equalsIgnoreCase(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRequestBody(ContentCachingRequestWrapper request) {
|
||||||
|
byte[] content = request.getContentAsByteArray();
|
||||||
|
if (content.length == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return new String(content, StandardCharsets.UTF_8)
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String str, int maxLength) {
|
||||||
|
if (str == null) return "";
|
||||||
|
if (str.length() <= maxLength) return str;
|
||||||
|
return str.substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
75
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package com.snp.batch.common.web;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통일된 API 응답 형식
|
||||||
|
*
|
||||||
|
* @param <T> 응답 데이터 타입
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "공통 API 응답 래퍼")
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
@Schema(description = "성공 여부", example = "true")
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
@Schema(description = "응답 메시지", example = "Success")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "응답 데이터")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
@Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true)
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message("Success")
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성 (메시지 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message(message)
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성 (에러 코드 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.errorCode(errorCode)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.snp.batch.common.web.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassController {
|
||||||
|
|
||||||
|
protected <T> ResponseEntity<ApiResponse<T>> execute(Supplier<T> action) {
|
||||||
|
try {
|
||||||
|
T result = action.get();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
log.error("외부 API 호출 실패 - status: {}, body: {}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
return ResponseEntity.status(e.getStatusCode())
|
||||||
|
.body(ApiResponse.error("외부 API 호출 실패: " + e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API 처리 중 오류", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error("처리 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API 응답을 ApiResponse 래핑 없이 원본 그대로 반환
|
||||||
|
*/
|
||||||
|
protected <T> ResponseEntity<T> executeRaw(Supplier<T> action) {
|
||||||
|
try {
|
||||||
|
T result = action.get();
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
log.error("외부 API 호출 실패 - status: {}, body: {}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
return ResponseEntity.status(e.getStatusCode()).body(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("API 처리 중 오류", e);
|
||||||
|
return ResponseEntity.internalServerError().body(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.common.web.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.util.UriBuilder;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseBypassService<T> {
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final String apiPath;
|
||||||
|
private final String displayName;
|
||||||
|
private final ParameterizedTypeReference<List<T>> listTypeRef;
|
||||||
|
private final ParameterizedTypeReference<T> singleTypeRef;
|
||||||
|
|
||||||
|
protected BaseBypassService(WebClient webClient, String apiPath, String displayName,
|
||||||
|
ParameterizedTypeReference<List<T>> listTypeRef,
|
||||||
|
ParameterizedTypeReference<T> singleTypeRef) {
|
||||||
|
this.webClient = webClient;
|
||||||
|
this.apiPath = apiPath;
|
||||||
|
this.displayName = displayName;
|
||||||
|
this.listTypeRef = listTypeRef;
|
||||||
|
this.singleTypeRef = singleTypeRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchGetList(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출", displayName);
|
||||||
|
List<T> response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchGetOne(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (단건)", displayName);
|
||||||
|
T response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<T> fetchPostList(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출", displayName);
|
||||||
|
List<T> response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(listTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null || response.isEmpty()) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 - 건수: {}", displayName, response.size());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T fetchPostOne(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (단건)", displayName);
|
||||||
|
T response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(singleTypeRef)
|
||||||
|
.block();
|
||||||
|
if (response == null) {
|
||||||
|
log.warn("{} API 응답 없음", displayName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.info("{} API 응답 완료 (단건)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW GET 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawGet(Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API GET 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.get()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAW POST 요청 → JsonNode 반환 (응답 구조 그대로 패스스루)
|
||||||
|
*/
|
||||||
|
protected JsonNode fetchRawPost(Object body, Function<UriBuilder, URI> uriFunction) {
|
||||||
|
log.info("{} API POST 호출 (RAW)", displayName);
|
||||||
|
JsonNode response = webClient.post()
|
||||||
|
.uri(uriFunction)
|
||||||
|
.body(BodyInserters.fromValue(body))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.block();
|
||||||
|
log.info("{} API 응답 완료 (RAW)", displayName);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getApiPath() {
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected String getDisplayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import com.snp.batch.global.model.AccountStatus;
|
||||||
|
import com.snp.batch.global.model.BypassApiAccount;
|
||||||
|
import com.snp.batch.global.repository.BypassApiAccountRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass API 계정 인증용 UserDetailsService
|
||||||
|
*
|
||||||
|
* bypass_api_account 테이블에서 사용자를 로드하고,
|
||||||
|
* 계정 상태 및 접근 기간을 검증한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BypassApiUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final BypassApiAccountRepository accountRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
BypassApiAccount account = accountRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
boolean withinPeriod = true;
|
||||||
|
if (account.getAccessStartDate() != null && today.isBefore(account.getAccessStartDate())) {
|
||||||
|
withinPeriod = false;
|
||||||
|
}
|
||||||
|
if (account.getAccessEndDate() != null && today.isAfter(account.getAccessEndDate())) {
|
||||||
|
withinPeriod = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean accountEnabled = account.getStatus() == AccountStatus.ACTIVE && withinPeriod;
|
||||||
|
|
||||||
|
return User.builder()
|
||||||
|
.username(account.getUsername())
|
||||||
|
.password(account.getPasswordHash())
|
||||||
|
.disabled(!accountEnabled)
|
||||||
|
.accountLocked(account.getStatus() == AccountStatus.SUSPENDED)
|
||||||
|
.authorities("ROLE_BYPASS_API")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.authentication.DisabledException;
|
||||||
|
import org.springframework.security.authentication.LockedException;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic Auth 인증 실패 시 계정 상태에 따라 구체적인 오류 정보를 JSON으로 반환하는 EntryPoint.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>LockedException (accountLocked=true, 즉 SUSPENDED): ACCOUNT_SUSPENDED</li>
|
||||||
|
* <li>DisabledException (disabled=true, 즉 비활성/기간 만료): ACCOUNT_DISABLED</li>
|
||||||
|
* <li>그 외 (잘못된 자격증명 등): INVALID_CREDENTIALS</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* WWW-Authenticate 헤더를 유지하여 Swagger UI의 Basic Auth 다이얼로그와 호환성을 보장한다.
|
||||||
|
*/
|
||||||
|
public class BypassAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
AuthenticationException authException) throws IOException {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
// WWW-Authenticate 헤더를 보내지 않음 — 브라우저 네이티브 로그인 다이얼로그 방지
|
||||||
|
// Swagger UI는 자체 Authorize 메커니즘으로 Basic Auth를 처리함
|
||||||
|
|
||||||
|
String error;
|
||||||
|
String message;
|
||||||
|
|
||||||
|
if (authException instanceof LockedException) {
|
||||||
|
error = "ACCOUNT_SUSPENDED";
|
||||||
|
message = "정지된 계정입니다. 관리자에게 문의하세요.";
|
||||||
|
} else if (authException instanceof DisabledException) {
|
||||||
|
error = "ACCOUNT_DISABLED";
|
||||||
|
message = "비활성화된 계정입니다. 접근 기간이 만료되었거나 계정이 비활성 상태입니다.";
|
||||||
|
} else {
|
||||||
|
error = "INVALID_CREDENTIALS";
|
||||||
|
message = "사용자명 또는 비밀번호가 올바르지 않습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
objectMapper.writeValue(response.getOutputStream(),
|
||||||
|
Map.of("error", error, "message", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import io.netty.channel.ChannelOption;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maritime API WebClient 설정
|
||||||
|
*
|
||||||
|
* 목적:
|
||||||
|
* - Maritime API 서버에 대한 WebClient Bean 등록
|
||||||
|
* - 동일한 API 서버를 사용하는 여러 Job에서 재사용
|
||||||
|
* - 설정 변경 시 한 곳에서만 수정
|
||||||
|
*
|
||||||
|
* 사용 Job:
|
||||||
|
* - 각 도메인 Job에서 공통으로 재사용
|
||||||
|
*
|
||||||
|
* 다른 API 서버 추가 시:
|
||||||
|
* - 새로운 Config 클래스 생성 (예: OtherApiWebClientConfig)
|
||||||
|
* - Bean 이름을 다르게 지정 (예: @Bean(name = "otherApiWebClient"))
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
public class MaritimeApiWebClientConfig {
|
||||||
|
|
||||||
|
@Value("${app.batch.ship-api.url}")
|
||||||
|
private String maritimeApiUrl;
|
||||||
|
|
||||||
|
@Value("${app.batch.ais-api.url}")
|
||||||
|
private String maritimeAisApiUrl;
|
||||||
|
|
||||||
|
@Value("${app.batch.webservice-api.url}")
|
||||||
|
private String maritimeServiceApiUrl;
|
||||||
|
|
||||||
|
|
||||||
|
@Value("${app.batch.api-auth.username}")
|
||||||
|
private String maritimeApiUsername;
|
||||||
|
|
||||||
|
@Value("${app.batch.api-auth.password}")
|
||||||
|
private String maritimeApiPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maritime API용 WebClient Bean
|
||||||
|
*
|
||||||
|
* 설정:
|
||||||
|
* - Base URL: Maritime API 서버 주소
|
||||||
|
* - 인증: Basic Authentication
|
||||||
|
* - 버퍼: 20MB (대용량 응답 처리)
|
||||||
|
*
|
||||||
|
* @return Maritime API WebClient
|
||||||
|
*/
|
||||||
|
@Bean(name = "maritimeApiWebClient")
|
||||||
|
public WebClient maritimeApiWebClient() {
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Maritime API WebClient 생성");
|
||||||
|
log.info("Base URL: {}", maritimeApiUrl);
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초
|
||||||
|
.responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초
|
||||||
|
|
||||||
|
return WebClient.builder()
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.baseUrl(maritimeApiUrl)
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||||
|
.codecs(configurer -> configurer
|
||||||
|
.defaultCodecs()
|
||||||
|
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "maritimeAisApiWebClient")
|
||||||
|
public WebClient maritimeAisApiWebClient(){
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Maritime AIS API WebClient 생성");
|
||||||
|
log.info("Base URL: {}", maritimeAisApiUrl);
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃 10초
|
||||||
|
.responseTimeout(Duration.ofSeconds(60)); // 응답 대기 60초
|
||||||
|
|
||||||
|
return WebClient.builder()
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.baseUrl(maritimeAisApiUrl)
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||||
|
.codecs(configurer -> configurer
|
||||||
|
.defaultCodecs()
|
||||||
|
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "maritimeServiceApiWebClient")
|
||||||
|
public WebClient maritimeServiceApiWebClient(){
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Maritime Service API WebClient 생성");
|
||||||
|
log.info("Base URL: {}", maritimeServiceApiUrl);
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000)
|
||||||
|
.responseTimeout(Duration.ofMinutes(5));
|
||||||
|
|
||||||
|
return WebClient.builder()
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.baseUrl(maritimeServiceApiUrl)
|
||||||
|
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
|
||||||
|
.codecs(configurer -> configurer
|
||||||
|
.defaultCodecs()
|
||||||
|
.maxInMemorySize(256 * 1024 * 1024)) // 256MB 버퍼
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ========================================
|
||||||
|
* 다른 API 서버 추가 예시
|
||||||
|
* ========================================
|
||||||
|
*
|
||||||
|
* 1. 새로운 Config 클래스 생성:
|
||||||
|
*
|
||||||
|
* @Configuration
|
||||||
|
* public class ExternalApiWebClientConfig {
|
||||||
|
*
|
||||||
|
* @Bean(name = "externalApiWebClient")
|
||||||
|
* public WebClient externalApiWebClient(
|
||||||
|
* @Value("${app.batch.external-api.url}") String url,
|
||||||
|
* @Value("${app.batch.external-api.token}") String token) {
|
||||||
|
*
|
||||||
|
* return WebClient.builder()
|
||||||
|
* .baseUrl(url)
|
||||||
|
* .defaultHeader("Authorization", "Bearer " + token)
|
||||||
|
* .build();
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 2. JobConfig에서 사용:
|
||||||
|
*
|
||||||
|
* public ExternalJobConfig(
|
||||||
|
* ...,
|
||||||
|
* @Qualifier("externalApiWebClient") WebClient externalApiWebClient) {
|
||||||
|
* this.webClient = externalApiWebClient;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 3. application.yml에 설정 추가:
|
||||||
|
*
|
||||||
|
* app:
|
||||||
|
* batch:
|
||||||
|
* external-api:
|
||||||
|
* url: https://external-api.example.com
|
||||||
|
* token: ${EXTERNAL_API_TOKEN}
|
||||||
|
*/
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 설정
|
||||||
|
*
|
||||||
|
* Bypass 데이터 API만 Basic Auth 인증을 적용하고,
|
||||||
|
* 나머지(배치관리, Swagger, 프론트엔드 등)는 기존처럼 오픈 유지.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(session ->
|
||||||
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/compliance/**").authenticated()
|
||||||
|
.requestMatchers("/api/risk/**").authenticated()
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
)
|
||||||
|
.httpBasic(basic -> basic
|
||||||
|
.authenticationEntryPoint(new BypassAuthenticationEntryPoint()));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
154
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import org.springdoc.core.models.GroupedOpenApi;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger/OpenAPI 3.0 설정
|
||||||
|
*
|
||||||
|
* Swagger UI 접속 URL:
|
||||||
|
* - Swagger UI: http://localhost:8031/snp-global/swagger-ui/index.html
|
||||||
|
* - API 문서 (JSON): http://localhost:8031/snp-global/v3/api-docs
|
||||||
|
* - API 문서 (YAML): http://localhost:8031/snp-global/v3/api-docs.yaml
|
||||||
|
*
|
||||||
|
* 환경별 노출:
|
||||||
|
* - dev: 모든 API 그룹 노출
|
||||||
|
* - prod: Bypass API 그룹만 노출
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Value("${server.port:8081}")
|
||||||
|
private int serverPort;
|
||||||
|
|
||||||
|
@Value("${server.servlet.context-path:}")
|
||||||
|
private String contextPath;
|
||||||
|
|
||||||
|
@Value("${app.environment:dev}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true)
|
||||||
|
public GroupedOpenApi bypassConfigApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("2. Bypass Config")
|
||||||
|
.pathsToMatch("/api/bypass-config/**")
|
||||||
|
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
|
||||||
|
.title("Bypass Config API")
|
||||||
|
.description("Bypass API 설정 및 코드 생성 관리 API")
|
||||||
|
.version("v1.0.0")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true)
|
||||||
|
public GroupedOpenApi screeningGuideApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("4. Screening Guide")
|
||||||
|
.pathsToMatch("/api/screening-guide/**")
|
||||||
|
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
|
||||||
|
.title("Screening Guide API")
|
||||||
|
.description("Risk & Compliance Screening Guide 조회 API")
|
||||||
|
.version("v1.0.0")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public GroupedOpenApi bypassApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("3. Bypass API")
|
||||||
|
.pathsToMatch("/api/**")
|
||||||
|
.pathsToExclude("/api/bypass-config/**", "/api/screening-guide/**", "/api/bypass-account/**")
|
||||||
|
.addOpenApiCustomizer(openApi -> {
|
||||||
|
openApi.info(new Info()
|
||||||
|
.title("Bypass API")
|
||||||
|
.description("S&P Global 선박/해운 데이터를 제공합니다.")
|
||||||
|
.version("v1.0.0"));
|
||||||
|
openApi.addSecurityItem(new SecurityRequirement().addList("basicAuth"));
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "app.environment", havingValue = "dev", matchIfMissing = true)
|
||||||
|
public GroupedOpenApi bypassAccountApi() {
|
||||||
|
return GroupedOpenApi.builder()
|
||||||
|
.group("5. Bypass Account")
|
||||||
|
.pathsToMatch("/api/bypass-account/**")
|
||||||
|
.addOpenApiCustomizer(openApi -> openApi.info(new Info()
|
||||||
|
.title("Bypass Account Management API")
|
||||||
|
.description("Bypass API 계정 및 신청 관리 API")
|
||||||
|
.version("v1.0.0")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
List<Server> servers = "prod".equals(environment)
|
||||||
|
? List.of(
|
||||||
|
new Server()
|
||||||
|
.url("https://guide.gc-si.dev" + contextPath)
|
||||||
|
.description("GC 도메인"))
|
||||||
|
: List.of(
|
||||||
|
new Server()
|
||||||
|
.url("http://localhost:" + serverPort + contextPath)
|
||||||
|
.description("로컬 개발 서버"),
|
||||||
|
new Server()
|
||||||
|
.url("http://211.208.115.83:" + serverPort + contextPath)
|
||||||
|
.description("중계 서버"),
|
||||||
|
new Server()
|
||||||
|
.url("https://guide.gc-si.dev" + contextPath)
|
||||||
|
.description("GC 도메인"));
|
||||||
|
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(defaultApiInfo())
|
||||||
|
.servers(servers)
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("basicAuth",
|
||||||
|
new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("basic")
|
||||||
|
.description("Bypass API 접근 계정 (발급된 ID/PW 사용)")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Info defaultApiInfo() {
|
||||||
|
return new Info()
|
||||||
|
.title("SNP Global REST API")
|
||||||
|
.description("""
|
||||||
|
## S&P Global API - Risk & Compliance 서비스
|
||||||
|
|
||||||
|
S&P Global Maritime 데이터 서비스 REST API 문서입니다.
|
||||||
|
|
||||||
|
### 제공 API
|
||||||
|
- **Bypass API**: S&P Global 선박/해운 데이터 조회
|
||||||
|
- **Bypass Config API**: Bypass API 설정 관리
|
||||||
|
- **Screening Guide API**: Risk & Compliance 스크리닝 가이드
|
||||||
|
- **Bypass Account API**: API 계정 관리
|
||||||
|
|
||||||
|
### 버전 정보
|
||||||
|
- API Version: v1.0.0
|
||||||
|
- Spring Boot: 3.2.1
|
||||||
|
""")
|
||||||
|
.version("v1.0.0")
|
||||||
|
.contact(new Contact()
|
||||||
|
.name("SNP Batch Team")
|
||||||
|
.email("support@snp-batch.com")
|
||||||
|
.url("https://github.com/snp-batch"))
|
||||||
|
.license(new License()
|
||||||
|
.name("Apache 2.0")
|
||||||
|
.url("https://www.apache.org/licenses/LICENSE-2.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassAccountResponse;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassRequestResponse;
|
||||||
|
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
|
||||||
|
import com.snp.batch.global.dto.bypass.ServiceIpDto;
|
||||||
|
import com.snp.batch.service.BypassApiAccountService;
|
||||||
|
import com.snp.batch.service.BypassApiRequestService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypass API 계정 및 신청 관리 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/bypass-account")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Bypass Account", description = "Bypass API 계정 및 신청 관리")
|
||||||
|
public class BypassAccountController {
|
||||||
|
|
||||||
|
private final BypassApiAccountService accountService;
|
||||||
|
private final BypassApiRequestService requestService;
|
||||||
|
|
||||||
|
// --- Account CRUD ---
|
||||||
|
|
||||||
|
@GetMapping("/accounts")
|
||||||
|
@Operation(summary = "계정 목록 조회")
|
||||||
|
public ResponseEntity<ApiResponse<Page<BypassAccountResponse>>> getAccounts(
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.getAccounts(status, page, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/accounts/{id}")
|
||||||
|
@Operation(summary = "계정 상세 조회")
|
||||||
|
public ResponseEntity<ApiResponse<BypassAccountResponse>> getAccount(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.getAccount(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/accounts/{id}")
|
||||||
|
@Operation(summary = "계정 수정")
|
||||||
|
public ResponseEntity<ApiResponse<BypassAccountResponse>> updateAccount(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassAccountUpdateRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.updateAccount(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/accounts/{id}")
|
||||||
|
@Operation(summary = "계정 삭제")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteAccount(@PathVariable Long id) {
|
||||||
|
accountService.deleteAccount(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/accounts/{id}/reset-password")
|
||||||
|
@Operation(summary = "비밀번호 재설정")
|
||||||
|
public ResponseEntity<ApiResponse<BypassAccountResponse>> resetPassword(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.resetPassword(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request management ---
|
||||||
|
|
||||||
|
@PostMapping("/requests")
|
||||||
|
@Operation(summary = "API 접근 신청 (공개)")
|
||||||
|
public ResponseEntity<ApiResponse<BypassRequestResponse>> submitRequest(
|
||||||
|
@RequestBody BypassRequestSubmitRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.submitRequest(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/requests")
|
||||||
|
@Operation(summary = "신청 목록 조회")
|
||||||
|
public ResponseEntity<ApiResponse<Page<BypassRequestResponse>>> getRequests(
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.getRequests(status, page, size)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/requests/{id}")
|
||||||
|
@Operation(summary = "신청 상세 조회")
|
||||||
|
public ResponseEntity<ApiResponse<BypassRequestResponse>> getRequest(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.getRequest(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/requests/{id}/approve")
|
||||||
|
@Operation(summary = "신청 승인 (계정 자동 생성)")
|
||||||
|
public ResponseEntity<ApiResponse<BypassAccountResponse>> approveRequest(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassRequestReviewRequest review) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.approveRequest(id, review)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/requests/{id}/reject")
|
||||||
|
@Operation(summary = "신청 거절")
|
||||||
|
public ResponseEntity<ApiResponse<BypassRequestResponse>> rejectRequest(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassRequestReviewRequest review) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.rejectRequest(id, review)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/requests/{id}/reopen")
|
||||||
|
@Operation(summary = "신청 재심사 (거절 → 대기)")
|
||||||
|
public ResponseEntity<ApiResponse<BypassRequestResponse>> reopenRequest(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(requestService.reopenRequest(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigRequest;
|
||||||
|
import com.snp.batch.global.dto.BypassConfigResponse;
|
||||||
|
import com.snp.batch.global.dto.CodeGenerationResult;
|
||||||
|
import com.snp.batch.global.model.BypassApiConfig;
|
||||||
|
import com.snp.batch.global.repository.BypassApiConfigRepository;
|
||||||
|
import com.snp.batch.service.BypassCodeGenerator;
|
||||||
|
import com.snp.batch.service.BypassConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 관리 및 코드 생성 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/bypass-config")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Bypass Config", description = "BYPASS API 설정 관리 및 코드 생성")
|
||||||
|
public class BypassConfigController {
|
||||||
|
|
||||||
|
private final BypassConfigService bypassConfigService;
|
||||||
|
private final BypassCodeGenerator bypassCodeGenerator;
|
||||||
|
private final BypassApiConfigRepository configRepository;
|
||||||
|
|
||||||
|
@Value("${app.environment:dev}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
@Operation(summary = "설정 목록 조회")
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<BypassConfigResponse>>> getConfigs() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfigs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 상세 조회")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> getConfig(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.getConfig(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 등록")
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> createConfig(
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.createConfig(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 수정")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BypassConfigResponse>> updateConfig(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody BypassConfigRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bypassConfigService.updateConfig(id, request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "설정 삭제")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteConfig(@PathVariable Long id) {
|
||||||
|
bypassConfigService.deleteConfig(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("삭제 완료", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "코드 생성",
|
||||||
|
description = "등록된 설정의 도메인 전체를 기반으로 Controller, Service 소스 코드를 생성합니다. 같은 도메인의 모든 설정을 하나의 Controller로 합칩니다."
|
||||||
|
)
|
||||||
|
@PostMapping("/{id}/generate")
|
||||||
|
public ResponseEntity<ApiResponse<CodeGenerationResult>> generateCode(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "false") boolean force) {
|
||||||
|
if ("prod".equals(environment)) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(ApiResponse.error("운영 환경에서는 코드 생성이 불가합니다. 개발 환경에서 생성 후 배포해주세요."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BypassApiConfig config = configRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("설정을 찾을 수 없습니다: " + id));
|
||||||
|
|
||||||
|
List<BypassApiConfig> domainConfigs = configRepository.findByDomainNameOrderById(config.getDomainName());
|
||||||
|
|
||||||
|
CodeGenerationResult result = bypassCodeGenerator.generate(domainConfigs, force);
|
||||||
|
|
||||||
|
domainConfigs.forEach(c -> bypassConfigService.markAsGenerated(c.getId()));
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("코드 생성 실패", e);
|
||||||
|
return ResponseEntity.internalServerError().body(ApiResponse.error("코드 생성 실패: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "환경 정보", description = "현재 서버 환경 정보를 반환합니다 (dev/prod).")
|
||||||
|
@GetMapping("/environment")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getEnvironment() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
|
"environment", environment,
|
||||||
|
"codeGenerationEnabled", !"prod".equals(environment)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "WebClient 빈 목록", description = "사용 가능한 WebClient 빈 이름 목록을 반환합니다.")
|
||||||
|
@GetMapping("/webclient-beans")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, String>>>> getWebclientBeans() {
|
||||||
|
List<Map<String, String>> beans = List.of(
|
||||||
|
Map.of("name", "maritimeApiWebClient", "description", "Ship API (shipsapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeAisApiWebClient", "description", "AIS API (aisapi.maritime.spglobal.com)"),
|
||||||
|
Map.of("name", "maritimeServiceApiWebClient", "description", "Web Service API (webservices.maritime.spglobal.com)")
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(beans));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,146 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import com.snp.batch.common.web.ApiResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ChangeHistoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.CompanyInfoResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ComplianceCategoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.IndicatorStatusResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.MethodologyHistoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.RiskCategoryResponse;
|
||||||
|
import com.snp.batch.global.dto.screening.ShipInfoResponse;
|
||||||
|
import com.snp.batch.service.ScreeningGuideService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk & Compliance Screening 가이드 조회 컨트롤러
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/screening-guide")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Screening Guide", description = "Risk & Compliance Screening 가이드")
|
||||||
|
public class ScreeningGuideController {
|
||||||
|
|
||||||
|
private final ScreeningGuideService screeningGuideService;
|
||||||
|
|
||||||
|
@Operation(summary = "Risk 지표 목록 조회", description = "카테고리별 Risk 지표 목록을 조회합니다.")
|
||||||
|
@GetMapping("/risk-indicators")
|
||||||
|
public ResponseEntity<ApiResponse<List<RiskCategoryResponse>>> getRiskIndicators(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getRiskIndicators(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Compliance 지표 목록 조회", description = "카테고리별 Compliance 지표 목록을 조회합니다.")
|
||||||
|
@GetMapping("/compliance-indicators")
|
||||||
|
public ResponseEntity<ApiResponse<List<ComplianceCategoryResponse>>> getComplianceIndicators(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang,
|
||||||
|
@Parameter(description = "지표 유형 (SHIP/COMPANY)", example = "SHIP")
|
||||||
|
@RequestParam(defaultValue = "SHIP") String type) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getComplianceIndicators(lang, type)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "방법론 변경 이력 조회", description = "방법론 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/methodology-history")
|
||||||
|
public ResponseEntity<ApiResponse<List<MethodologyHistoryResponse>>> getMethodologyHistory(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyHistory(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "방법론 배너 조회", description = "방법론 변경 이력 페이지의 안내 배너 텍스트를 조회합니다.")
|
||||||
|
@GetMapping("/methodology-banner")
|
||||||
|
public ResponseEntity<ApiResponse<MethodologyHistoryResponse>> getMethodologyBanner(
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getMethodologyBanner(lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 위험지표 값 변경 이력", description = "IMO 번호로 선박 위험지표 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/ship-risk")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipRiskDetailHistory(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9330019", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskDetailHistory(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 제재 값 변경 이력", description = "IMO 번호로 선박 제재 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/ship-compliance")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getShipComplianceHistory(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9330019", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceHistory(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 제재 값 변경 이력", description = "회사 코드로 회사 제재 값 변경 이력을 조회합니다.")
|
||||||
|
@GetMapping("/history/company-compliance")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChangeHistoryResponse>>> getCompanyComplianceHistory(
|
||||||
|
@Parameter(description = "회사 코드", example = "5765290", required = true)
|
||||||
|
@RequestParam String companyCode,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceHistory(companyCode, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 기본 정보", description = "IMO 번호로 선박 기본 정보를 조회합니다.")
|
||||||
|
@GetMapping("/ship-info")
|
||||||
|
public ResponseEntity<ApiResponse<ShipInfoResponse>> getShipInfo(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipInfo(imoNo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 현재 Risk 지표 상태", description = "IMO 번호로 선박의 현재 Risk 지표 상태를 조회합니다.")
|
||||||
|
@GetMapping("/ship-risk-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipRiskStatus(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipRiskStatus(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "선박 현재 Compliance 상태", description = "IMO 번호로 선박의 현재 Compliance 상태를 조회합니다.")
|
||||||
|
@GetMapping("/ship-compliance-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getShipComplianceStatus(
|
||||||
|
@Parameter(description = "IMO 번호", example = "9672533", required = true)
|
||||||
|
@RequestParam String imoNo,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getShipComplianceStatus(imoNo, lang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 기본 정보", description = "회사 코드로 회사 기본 정보를 조회합니다.")
|
||||||
|
@GetMapping("/company-info")
|
||||||
|
public ResponseEntity<ApiResponse<CompanyInfoResponse>> getCompanyInfo(
|
||||||
|
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||||
|
@RequestParam String companyCode) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyInfo(companyCode)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "회사 현재 Compliance 상태", description = "회사 코드로 회사의 현재 Compliance 상태를 조회합니다.")
|
||||||
|
@GetMapping("/company-compliance-status")
|
||||||
|
public ResponseEntity<ApiResponse<List<IndicatorStatusResponse>>> getCompanyComplianceStatus(
|
||||||
|
@Parameter(description = "회사 코드", example = "1288896", required = true)
|
||||||
|
@RequestParam String companyCode,
|
||||||
|
@Parameter(description = "언어 코드", example = "KO")
|
||||||
|
@RequestParam(defaultValue = "KO") String lang) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(screeningGuideService.getCompanyComplianceStatus(companyCode, lang)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.snp.batch.global.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class WebViewController {
|
||||||
|
|
||||||
|
@GetMapping({"/",
|
||||||
|
"/bypass-catalog", "/bypass-config", "/screening-guide", "/risk-compliance-history",
|
||||||
|
"/bypass-account-requests", "/bypass-account-management", "/bypass-access-request",
|
||||||
|
"/bypass-catalog/**", "/bypass-config/**", "/screening-guide/**", "/risk-compliance-history/**",
|
||||||
|
"/bypass-account-requests/**", "/bypass-account-management/**", "/bypass-access-request/**"})
|
||||||
|
public String forward() {
|
||||||
|
return "forward:/index.html";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 등록/수정 요청 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigRequest {
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 설정 조회 응답 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassConfigResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 도메인명 (패키지명/URL 경로) */
|
||||||
|
private String domainName;
|
||||||
|
|
||||||
|
/** 엔드포인트명 (externalPath 마지막 세그먼트) */
|
||||||
|
private String endpointName;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/** WebClient 빈 이름 */
|
||||||
|
private String webclientBean;
|
||||||
|
|
||||||
|
/** 외부 API 경로 */
|
||||||
|
private String externalPath;
|
||||||
|
|
||||||
|
/** HTTP 메서드 */
|
||||||
|
private String httpMethod;
|
||||||
|
|
||||||
|
/** 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 코드 생성 완료 여부 */
|
||||||
|
private Boolean generated;
|
||||||
|
|
||||||
|
/** 코드 생성 일시 */
|
||||||
|
private LocalDateTime generatedAt;
|
||||||
|
|
||||||
|
/** 생성 일시 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 수정 일시 */
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 파라미터 목록 */
|
||||||
|
private List<BypassParamDto> params;
|
||||||
|
}
|
||||||
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
39
src/main/java/com/snp/batch/global/dto/BypassParamDto.java
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BYPASS API 파라미터 정보 DTO
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassParamDto {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 파라미터 이름 */
|
||||||
|
private String paramName;
|
||||||
|
|
||||||
|
/** 파라미터 타입 (STRING, INTEGER, LONG, BOOLEAN) */
|
||||||
|
private String paramType;
|
||||||
|
|
||||||
|
/** 파라미터 위치 (PATH, QUERY, BODY) */
|
||||||
|
private String paramIn;
|
||||||
|
|
||||||
|
/** 필수 여부 */
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
/** 파라미터 설명 */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 정렬 순서 */
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
/** Swagger @Parameter example 값 */
|
||||||
|
private String example;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.snp.batch.global.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 자동 생성 결과 DTO
|
||||||
|
* 같은 도메인에 N개의 엔드포인트를 지원하므로 Service/DTO는 목록으로 반환
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CodeGenerationResult {
|
||||||
|
|
||||||
|
/** 생성된 Controller 파일 경로 */
|
||||||
|
private String controllerPath;
|
||||||
|
|
||||||
|
/** 생성된 Service 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> servicePaths;
|
||||||
|
|
||||||
|
/** 생성된 DTO 파일 경로 목록 (엔드포인트별) */
|
||||||
|
private List<String> dtoPaths;
|
||||||
|
|
||||||
|
/** 결과 메시지 */
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class BypassAccountResponse {
|
||||||
|
private Long id;
|
||||||
|
private String username;
|
||||||
|
private String displayName;
|
||||||
|
private String organization;
|
||||||
|
private String projectName;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String status;
|
||||||
|
private LocalDate accessStartDate;
|
||||||
|
private LocalDate accessEndDate;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private String plainPassword;
|
||||||
|
private String serviceIps; // JSON string of registered IPs
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassAccountUpdateRequest {
|
||||||
|
private String displayName;
|
||||||
|
private String organization;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String status;
|
||||||
|
private LocalDate accessStartDate;
|
||||||
|
private LocalDate accessEndDate;
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassRequestResponse {
|
||||||
|
private Long id;
|
||||||
|
private String applicantName;
|
||||||
|
private String organization;
|
||||||
|
private String purpose;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String requestedAccessPeriod;
|
||||||
|
private String projectName;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
private String serviceIps;
|
||||||
|
private String status;
|
||||||
|
private String reviewedBy;
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
private String rejectReason;
|
||||||
|
private Long accountId;
|
||||||
|
private String accountUsername;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassRequestReviewRequest {
|
||||||
|
private String reviewedBy;
|
||||||
|
private String rejectReason;
|
||||||
|
private LocalDate accessStartDate;
|
||||||
|
private LocalDate accessEndDate;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class BypassRequestSubmitRequest {
|
||||||
|
private String applicantName;
|
||||||
|
private String organization;
|
||||||
|
private String purpose;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String requestedAccessPeriod;
|
||||||
|
private String projectName;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
private String serviceIps; // JSON string: [{"ip":"...","purpose":"...","description":"..."}]
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.snp.batch.global.dto.bypass;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ServiceIpDto {
|
||||||
|
private String ip;
|
||||||
|
private String purpose;
|
||||||
|
private String description;
|
||||||
|
private String expectedCallVolume;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ChangeHistoryResponse {
|
||||||
|
|
||||||
|
private Long rowIndex;
|
||||||
|
private String searchKey;
|
||||||
|
private String lastModifiedDate;
|
||||||
|
private String changedColumnName;
|
||||||
|
private String beforeValue;
|
||||||
|
private String afterValue;
|
||||||
|
private String fieldName;
|
||||||
|
private String narrative;
|
||||||
|
private String prevNarrative;
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CompanyInfoResponse {
|
||||||
|
private String companyCode;
|
||||||
|
private String fullName;
|
||||||
|
private String abbreviation;
|
||||||
|
private String status;
|
||||||
|
private String parentCompanyCode;
|
||||||
|
private String parentCompanyName;
|
||||||
|
private String registrationCountry;
|
||||||
|
private String registrationCountryCode;
|
||||||
|
private String registrationCountryIsoCode;
|
||||||
|
private String controlCountry;
|
||||||
|
private String controlCountryCode;
|
||||||
|
private String controlCountryIsoCode;
|
||||||
|
private String foundedDate;
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String website;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.snp.batch.global.dto.screening;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ComplianceCategoryResponse {
|
||||||
|
|
||||||
|
private String categoryCode;
|
||||||
|
private String categoryName;
|
||||||
|
private String indicatorType;
|
||||||
|
private List<ComplianceIndicatorResponse> indicators;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user