Spaces:
Running
Running
Commit ·
cc7330c
0
Parent(s):
Deploy to Hugging Face
Browse files- .dockerignore +14 -0
- .gitattributes +3 -0
- .gitignore +11 -0
- Dockerfile +31 -0
- README.md +123 -0
- core/api.py +166 -0
- core/core.py +439 -0
- core/data/Paperlogy-3Light.ttf +3 -0
- core/data/Paperlogy-4Regular.ttf +3 -0
- core/data/output_template.pptx +3 -0
- core/data/sw_competency.pkl +3 -0
- core/data/sw_wage.pkl +3 -0
- core/ppt.py +241 -0
- metadata.json +5 -0
- next-env.d.ts +6 -0
- next.config.mjs +7 -0
- package-lock.json +0 -0
- package.json +39 -0
- postcss.config.mjs +5 -0
- requirements.txt +7 -0
- src/app/LayoutContent.tsx +47 -0
- src/app/contexts/DiagnosisContext.tsx +136 -0
- src/app/globals.css +108 -0
- src/app/layout.tsx +27 -0
- src/app/page.tsx +17 -0
- src/app/result/page.tsx +33 -0
- src/app/search/page.tsx +24 -0
- src/app/survey/page.tsx +33 -0
- src/components/common/States.tsx +48 -0
- src/components/features/Intro.tsx +56 -0
- src/components/features/Result.tsx +136 -0
- src/components/features/ResultCharts.tsx +122 -0
- src/components/features/ResultComponents.tsx +83 -0
- src/components/features/Search.tsx +183 -0
- src/components/features/SearchComponents.tsx +110 -0
- src/components/features/Survey.tsx +135 -0
- src/components/features/SurveyComponents.tsx +46 -0
- src/components/layout/Common.tsx +75 -0
- src/lib/utils.ts +11 -0
- src/services/diagnosisService.ts +58 -0
- src/services/mockDiagnosisService.ts +105 -0
- src/types/diagnosis.ts +56 -0
- tsconfig.json +41 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
out
|
| 4 |
+
.git
|
| 5 |
+
.gitignore
|
| 6 |
+
.dockerignore
|
| 7 |
+
Dockerfile
|
| 8 |
+
README.md
|
| 9 |
+
venv
|
| 10 |
+
__pycache__
|
| 11 |
+
tmp
|
| 12 |
+
*.pptx
|
| 13 |
+
*.pdf
|
| 14 |
+
*.log
|
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.pptx filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
build/
|
| 3 |
+
dist/
|
| 4 |
+
coverage/
|
| 5 |
+
.DS_Store
|
| 6 |
+
*.log
|
| 7 |
+
.env*
|
| 8 |
+
!.env.example
|
| 9 |
+
.next
|
| 10 |
+
node_modules
|
| 11 |
+
out
|
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-slim AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package*.json ./
|
| 4 |
+
RUN npm ci
|
| 5 |
+
COPY . .
|
| 6 |
+
RUN npm run build
|
| 7 |
+
|
| 8 |
+
FROM python:3.10-slim
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
fontconfig \
|
| 11 |
+
curl \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# Install custom fonts
|
| 15 |
+
COPY core/data/*.ttf /usr/share/fonts/truetype/
|
| 16 |
+
RUN fc-cache -f -v
|
| 17 |
+
|
| 18 |
+
# Create a non-root user required by Hugging Face Spaces
|
| 19 |
+
RUN useradd -m -u 1000 user
|
| 20 |
+
USER user
|
| 21 |
+
ENV HOME=/home/user \
|
| 22 |
+
PATH=/home/user/.local/bin:$PATH
|
| 23 |
+
|
| 24 |
+
WORKDIR $HOME/app
|
| 25 |
+
COPY --chown=user requirements.txt .
|
| 26 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 27 |
+
COPY --chown=user . .
|
| 28 |
+
COPY --from=builder --chown=user /app/out ./out
|
| 29 |
+
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
CMD ["uvicorn", "core.api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: IT Wage Self Diagnosis
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
fullWidth: true
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# IT Wage Self Diagnosis Platform
|
| 12 |
+
|
| 13 |
+
IT 직무 임금 자가진단 플랫폼입니다.
|
| 14 |
+
이 저장소는 Next.js 프런트엔드와 FastAPI 백엔드를 하나의 Docker 컨테이너로 실행하도록 구성되어 있어 Hugging Face Docker Space에 배포하기 적합합니다.
|
| 15 |
+
|
| 16 |
+
## Stack
|
| 17 |
+
|
| 18 |
+
- Frontend: Next.js
|
| 19 |
+
- Backend: FastAPI + Uvicorn
|
| 20 |
+
- Container: Docker
|
| 21 |
+
|
| 22 |
+
## External API Usage
|
| 23 |
+
|
| 24 |
+
이 앱은 Gemini API를 사용하지 않습니다.
|
| 25 |
+
|
| 26 |
+
- 사용자 요청은 내부 FastAPI 엔드포인트(`/api/*`)로 처리됩니다.
|
| 27 |
+
- Hugging Face 배포를 위해 Gemini API 키를 설정할 필요가 없습니다.
|
| 28 |
+
|
| 29 |
+
## Local Run
|
| 30 |
+
|
| 31 |
+
### 1. Frontend dependencies
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
npm install
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
### 2. Python dependencies
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
pip install -r requirements.txt
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 3. Frontend build
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
npm run build
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 4. API server run
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
uvicorn core.api:app --host 0.0.0.0 --port 7860
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
브라우저에서 `http://localhost:7860` 으로 접속합니다.
|
| 56 |
+
|
| 57 |
+
## Docker Run
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
docker build -t it-wage-self-diagnosis .
|
| 61 |
+
docker run -p 7860:7860 it-wage-self-diagnosis
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
브라우저에서 `http://localhost:7860` 으로 접속합니다.
|
| 65 |
+
|
| 66 |
+
## Deploy To Hugging Face Docker Space
|
| 67 |
+
|
| 68 |
+
### 1. Create a new Space
|
| 69 |
+
|
| 70 |
+
Hugging Face에서 새 Space를 만들 때 다음처럼 선택합니다.
|
| 71 |
+
|
| 72 |
+
- SDK: `Docker`
|
| 73 |
+
- Visibility: 원하는 공개 범위 선택
|
| 74 |
+
- Hardware: 처음에는 `CPU Basic` 권장
|
| 75 |
+
|
| 76 |
+
### 2. Connect this folder to the Space repo
|
| 77 |
+
|
| 78 |
+
현재 폴더에서 아래 명령을 실행합니다.
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
git init
|
| 82 |
+
git branch -M main
|
| 83 |
+
git add .
|
| 84 |
+
git commit -m "Initial commit for Hugging Face Space"
|
| 85 |
+
git remote add origin https://huggingface.co/spaces/<YOUR_USERNAME>/<YOUR_SPACE_NAME>
|
| 86 |
+
git push -u origin main
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
`<YOUR_USERNAME>` 과 `<YOUR_SPACE_NAME>` 은 실제 Hugging Face 계정과 Space 이름으로 바꿔야 합니다.
|
| 90 |
+
|
| 91 |
+
### 3. Check build logs
|
| 92 |
+
|
| 93 |
+
푸시 후 Hugging Face Space의 `Build logs` 와 `Container logs` 를 확인합니다.
|
| 94 |
+
|
| 95 |
+
정상 동작 조건:
|
| 96 |
+
|
| 97 |
+
- 컨테이너가 `7860` 포트에서 실행될 것
|
| 98 |
+
- FastAPI가 `/api/*` 경로를 처리할 것
|
| 99 |
+
- Next 정적 빌드 결과가 `out/` 폴더에 생성될 것
|
| 100 |
+
|
| 101 |
+
## Environment Variables
|
| 102 |
+
|
| 103 |
+
현재 기준으로 이 앱 실행에 필수 환경변수는 없습니다.
|
| 104 |
+
|
| 105 |
+
추후 환경변수나 API 키가 생기면 Hugging Face Space의 아래 위치에서 관리하면 됩니다.
|
| 106 |
+
|
| 107 |
+
- `Settings > Variables`
|
| 108 |
+
- `Settings > Secrets`
|
| 109 |
+
|
| 110 |
+
민감한 값은 코드에 직접 넣지 말고 `Secrets` 에 저장하는 것을 권장합니다.
|
| 111 |
+
|
| 112 |
+
## Project Notes
|
| 113 |
+
|
| 114 |
+
- Hugging Face Docker Space는 `README.md` 상단 YAML의 `sdk: docker` 설정을 사용합니다.
|
| 115 |
+
- 외부에 노출되는 포트는 `app_port: 7860` 이어야 하며, 컨테이너 내부 서버 포트와 일치해야 합니다.
|
| 116 |
+
- Docker Space 환경에서는 컨테이너가 `user id 1000` 기준으로 동작하므로 현재 Dockerfile처럼 non-root user 구성이 권장됩니다.
|
| 117 |
+
|
| 118 |
+
## Troubleshooting
|
| 119 |
+
|
| 120 |
+
- 화면이 안 뜨면: `npm run build` 결과로 `out/` 폴더가 생성됐는지 확인
|
| 121 |
+
- API 호출이 실패하면: `core.api:app` 이 정상 실행되는지 확인
|
| 122 |
+
- 빌드 실패 시: Hugging Face의 `Build logs` 에서 누락 패키지나 권한 오류 확인
|
| 123 |
+
- 인증 오류 시: Hugging Face Access Token으로 Git 인증 확인
|
core/api.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 3 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing import List, Optional, Dict, Any
|
| 8 |
+
import os
|
| 9 |
+
import uuid
|
| 10 |
+
from matplotlib import font_manager
|
| 11 |
+
from . import core, ppt
|
| 12 |
+
|
| 13 |
+
app = FastAPI()
|
| 14 |
+
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=["*"],
|
| 18 |
+
allow_credentials=True,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# --- Pydantic 모델 ---
|
| 24 |
+
|
| 25 |
+
class OptionsRequest(BaseModel):
|
| 26 |
+
job: Optional[str] = None
|
| 27 |
+
bm: Optional[str] = None
|
| 28 |
+
sales: Optional[str] = None
|
| 29 |
+
|
| 30 |
+
class SearchParamsModel(BaseModel):
|
| 31 |
+
job: str
|
| 32 |
+
bm: str
|
| 33 |
+
sales: str
|
| 34 |
+
emp: str
|
| 35 |
+
userBase: str
|
| 36 |
+
userValue: int
|
| 37 |
+
|
| 38 |
+
class DiagnosisRequest(BaseModel):
|
| 39 |
+
params: SearchParamsModel
|
| 40 |
+
answers: List[int]
|
| 41 |
+
|
| 42 |
+
# Get BASE_DIR for the project root (one level up from this 'core' folder)
|
| 43 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 44 |
+
|
| 45 |
+
# --- 폰트 설치 확인 (디버깅용) ---
|
| 46 |
+
font_path = os.path.join(BASE_DIR, "core", "data", "Paperlogy-4Regular.ttf")
|
| 47 |
+
if os.path.exists(font_path):
|
| 48 |
+
font_name = font_manager.FontProperties(fname=font_path).get_name()
|
| 49 |
+
print(f"Loaded font: {font_name}")
|
| 50 |
+
else:
|
| 51 |
+
print(f"Warning: Font file not found at {font_path}")
|
| 52 |
+
|
| 53 |
+
# --- 엔드포인트 ---
|
| 54 |
+
|
| 55 |
+
@app.post("/api/options")
|
| 56 |
+
def get_options_endpoint(req: OptionsRequest):
|
| 57 |
+
return core.get_step_options(core.conds_df, job=req.job, bm=req.bm, sales=req.sales)
|
| 58 |
+
|
| 59 |
+
@app.post("/api/survey")
|
| 60 |
+
def get_survey_items_endpoint(req: SearchParamsModel):
|
| 61 |
+
levels = core.get_levels_by_wage(core.avg_df, req.job, req.bm, req.sales, req.emp, req.userBase, req.userValue)
|
| 62 |
+
if not levels: levels = ["L3", "L4"]
|
| 63 |
+
bars_indicator, _ = core.make_bars_table(core.bars_df, req.job, levels)
|
| 64 |
+
return bars_indicator.to_dict(orient='records')
|
| 65 |
+
|
| 66 |
+
@app.post("/api/diagnosis")
|
| 67 |
+
def post_diagnosis_endpoint(req: DiagnosisRequest):
|
| 68 |
+
p = req.params
|
| 69 |
+
levels = core.get_levels_by_wage(core.avg_df, p.job, p.bm, p.sales, p.emp, p.userBase, p.userValue)
|
| 70 |
+
if not levels: levels = ["L3", "L4"]
|
| 71 |
+
_, ppt_tables = core.make_bars_table(core.bars_df, p.job, levels)
|
| 72 |
+
levels_def = core.get_levels_definition(p.job, levels, core.job_def)
|
| 73 |
+
final_lv, level_out = core.judge_level(req.answers, core.factors, levels, levels_def)
|
| 74 |
+
head_msg, text1, text2, pct, c3, table3 = core.judge_wage(core.raw_df, p.job, p.bm, p.sales, p.emp, final_lv, p.userBase, p.userValue)
|
| 75 |
+
|
| 76 |
+
table1, chart1 = core.format_table(ppt_tables[0], core.factors, req.answers, cut=2)
|
| 77 |
+
table2, chart2 = core.format_table(ppt_tables[1], core.factors, req.answers, cut=4)
|
| 78 |
+
|
| 79 |
+
level_out.columns = ['left', 'right']
|
| 80 |
+
|
| 81 |
+
level_info = {
|
| 82 |
+
"left": level_out['left'].to_dict(),
|
| 83 |
+
"right": level_out['right'].to_dict(),
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
for chart in [chart1, chart2]:
|
| 87 |
+
chart.columns = ['subject', 'target', 'user']
|
| 88 |
+
subjects = [
|
| 89 |
+
'기술 전문성', '도메인 이해', '데이터 리터러시', '고객중심 사고',
|
| 90 |
+
'문제해결능력', '애자일 실행', '협업', '책임감', '영향범위', '리더십'
|
| 91 |
+
]
|
| 92 |
+
chart['subject'] = subjects
|
| 93 |
+
|
| 94 |
+
wage_records = table3.to_dict(orient='records')
|
| 95 |
+
for rec in wage_records:
|
| 96 |
+
rec['description'] = text1
|
| 97 |
+
|
| 98 |
+
level_chart = {
|
| 99 |
+
"left": chart1.to_dict(orient='records'),
|
| 100 |
+
"right": chart2.to_dict(orient='records')
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
info_pool = [p.job, final_lv, p.userBase, p.bm, p.sales, p.emp]
|
| 104 |
+
|
| 105 |
+
result_data = {
|
| 106 |
+
"headMessage": head_msg.split('\n'),
|
| 107 |
+
"levelInfo": level_info,
|
| 108 |
+
"levelChart": level_chart,
|
| 109 |
+
"percentile": pct,
|
| 110 |
+
"wageOutput": wage_records,
|
| 111 |
+
"info": [x for x in info_pool if x != '전체']
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return result_data
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.post("/api/report")
|
| 118 |
+
def download_report(req: DiagnosisRequest):
|
| 119 |
+
p = req.params
|
| 120 |
+
levels = core.get_levels_by_wage(core.avg_df, p.job, p.bm, p.sales, p.emp, p.userBase, p.userValue)
|
| 121 |
+
if not levels: levels = ["L3", "L4"]
|
| 122 |
+
_, ppt_tables = core.make_bars_table(core.bars_df, p.job, levels)
|
| 123 |
+
levels_def = core.get_levels_definition(p.job, levels, core.job_def)
|
| 124 |
+
final_lv, lv_out = core.judge_level(req.answers, core.factors, levels, levels_def)
|
| 125 |
+
head_msg, blk1, blk2, pct, c3, t3 = core.judge_wage(core.raw_df, p.job, p.bm, p.sales, p.emp, final_lv, p.userBase, p.userValue)
|
| 126 |
+
|
| 127 |
+
table1, chart1 = core.format_table(ppt_tables[0], core.factors, req.answers, cut=2)
|
| 128 |
+
table2, chart2 = core.format_table(ppt_tables[1], core.factors, req.answers, cut=4)
|
| 129 |
+
|
| 130 |
+
ppt_dataset = {
|
| 131 |
+
0 : {"head_message": head_msg, "texts": [blk1, blk2, f"{pct:.1f}%"], "tables": [lv_out.iloc[:2], lv_out.iloc[2:], t3]},
|
| 132 |
+
1 : {"title": lv_out.columns[0], "table": [table1]},
|
| 133 |
+
2 : {"title": lv_out.columns[1], "table": [table2]}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
out_path = os.path.join(core.BASE_DIR, "tmp", f"report_{uuid.uuid4().hex[:8]}.pptx")
|
| 137 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 138 |
+
ppt.update_ppt_with_data(ppt_dataset, out_path)
|
| 139 |
+
|
| 140 |
+
return FileResponse(out_path, filename="진단리포트.pptx", media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# --- 정적 파일 서빙 ---
|
| 144 |
+
# api.py가 core/ 폴더 안에 있으므로 BASE_DIR는 상위 디렉토리(루트)입니다.
|
| 145 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 146 |
+
OUT_DIR = os.path.join(BASE_DIR, "out")
|
| 147 |
+
|
| 148 |
+
if os.path.exists(OUT_DIR):
|
| 149 |
+
app.mount("/_next", StaticFiles(directory=os.path.join(OUT_DIR, "_next")), name="next_static")
|
| 150 |
+
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"])
|
| 151 |
+
async def serve_spa(full_path: str):
|
| 152 |
+
if full_path.startswith("api"): raise HTTPException(status_code=404)
|
| 153 |
+
|
| 154 |
+
file_path = os.path.join(OUT_DIR, full_path)
|
| 155 |
+
if os.path.isfile(file_path): return FileResponse(file_path)
|
| 156 |
+
|
| 157 |
+
html_file = os.path.join(OUT_DIR, f"{full_path}.html")
|
| 158 |
+
if os.path.isfile(html_file): return FileResponse(html_file)
|
| 159 |
+
|
| 160 |
+
index_file = os.path.join(file_path, "index.html")
|
| 161 |
+
if os.path.isdir(file_path) and os.path.isfile(index_file): return FileResponse(index_file)
|
| 162 |
+
|
| 163 |
+
return FileResponse(os.path.join(OUT_DIR, "index.html"))
|
| 164 |
+
|
| 165 |
+
if __name__ == "__main__":
|
| 166 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
core/core.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import math
|
| 4 |
+
import re
|
| 5 |
+
import pickle
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# --- 데이터 경로 설정 ---
|
| 9 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
+
DATA_DIR = os.path.join(BASE_DIR, "core", "data")
|
| 11 |
+
|
| 12 |
+
# --- 전역 데이터 로드 ---
|
| 13 |
+
try:
|
| 14 |
+
with open(os.path.join(DATA_DIR, 'sw_competency.pkl'), 'rb') as f:
|
| 15 |
+
sw_competency = pickle.load(f)
|
| 16 |
+
job_def = sw_competency['직무레벨']
|
| 17 |
+
factor_def = sw_competency['평가요소']
|
| 18 |
+
factors = factor_def.columns.to_list()[2:]
|
| 19 |
+
bars_df = sw_competency['평가지표']
|
| 20 |
+
|
| 21 |
+
with open(os.path.join(DATA_DIR, 'sw_wage.pkl'), 'rb') as f:
|
| 22 |
+
sw_wage = pickle.load(f)
|
| 23 |
+
raw_df = sw_wage['raw']
|
| 24 |
+
avg_df = sw_wage['avg']
|
| 25 |
+
conds_df = sw_wage['conds']
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"Warning: Could not load data files: {e}")
|
| 28 |
+
|
| 29 |
+
# --- 핵심 비즈니스 로직 ---
|
| 30 |
+
|
| 31 |
+
def get_options(df, selected, target_col):
|
| 32 |
+
# 1. 불필요한 copy() 제거
|
| 33 |
+
filtered = df
|
| 34 |
+
for col, val in selected.items():
|
| 35 |
+
if val is not None and val != "":
|
| 36 |
+
filtered = filtered[filtered[col] == val]
|
| 37 |
+
|
| 38 |
+
options = filtered[target_col].dropna().drop_duplicates().tolist()
|
| 39 |
+
|
| 40 |
+
# 2. 콘크리트 옵션들만 추출
|
| 41 |
+
concrete_options = [x for x in options if x != '전체']
|
| 42 |
+
|
| 43 |
+
if '전체' in options:
|
| 44 |
+
if len(concrete_options) > 1:
|
| 45 |
+
options = ['전체'] + concrete_options
|
| 46 |
+
else:
|
| 47 |
+
# 1개만 남았을 때도 구체적인 옵션을 보여주고 싶다면 return concrete_options
|
| 48 |
+
options = ['전체']
|
| 49 |
+
else:
|
| 50 |
+
options = concrete_options
|
| 51 |
+
|
| 52 |
+
# 4. 정렬 방식 개선
|
| 53 |
+
if target_col == '직원규모':
|
| 54 |
+
sort_map = {'전체': 0, '300~999인': 1, '100~299인': 2, '50~99인': 3, '49인 이하': 4}
|
| 55 |
+
options.sort(key=lambda x: sort_map.get(x, 999))
|
| 56 |
+
|
| 57 |
+
return options
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_step_options(df, job=None, bm=None, sales=None):
|
| 61 |
+
result = {'job_options': df['ITSQF 직무(변환)'].dropna().drop_duplicates().tolist()}
|
| 62 |
+
if job:
|
| 63 |
+
result['bm_options'] = get_options(df, {'ITSQF 직무(변환)': job}, 'BM')
|
| 64 |
+
if job and bm:
|
| 65 |
+
result['sales_options'] = get_options(df, {'ITSQF 직무(변환)': job, 'BM': bm}, '매출규모')
|
| 66 |
+
if job and bm and sales:
|
| 67 |
+
result['emp_options'] = get_options(df, {'ITSQF 직무(변환)': job, 'BM': bm, '매출규모': sales}, '직원규모')
|
| 68 |
+
result['base_options'] = ['지급총액', '고정급']
|
| 69 |
+
|
| 70 |
+
return result
|
| 71 |
+
|
| 72 |
+
def get_wage_result(df, job, bm, sales='전체', emp='전체'):
|
| 73 |
+
mask = (
|
| 74 |
+
(df['ITSQF 직무(변환)'] == job) &
|
| 75 |
+
(df['BM'] == bm) &
|
| 76 |
+
(df['매출규모'] == sales) &
|
| 77 |
+
(df['직원규모'] == emp)
|
| 78 |
+
)
|
| 79 |
+
return df[mask].copy()
|
| 80 |
+
|
| 81 |
+
def apply_option_to_wage(avg_df, job, bm, sales, emp, w_base=0.5):
|
| 82 |
+
|
| 83 |
+
selected_df = get_wage_result(avg_df, job=job, bm=bm, sales=sales, emp=emp)
|
| 84 |
+
base_df = get_wage_result(avg_df, job=job, bm=bm)
|
| 85 |
+
|
| 86 |
+
final_df = pd.concat([base_df, selected_df], ignore_index=True)
|
| 87 |
+
|
| 88 |
+
w_opt = 1 - w_base
|
| 89 |
+
dfs = []
|
| 90 |
+
|
| 91 |
+
for base_type in ['고정급', '지급총액']:
|
| 92 |
+
df = final_df.pivot_table(
|
| 93 |
+
index='ITSQF 수준',
|
| 94 |
+
values=base_type,
|
| 95 |
+
aggfunc='mean'
|
| 96 |
+
).reset_index()
|
| 97 |
+
|
| 98 |
+
base_val = base_df.pivot_table(
|
| 99 |
+
index='ITSQF 수준',
|
| 100 |
+
values=base_type,
|
| 101 |
+
aggfunc='mean'
|
| 102 |
+
).reset_index().rename(columns={base_type: '전체값'})
|
| 103 |
+
|
| 104 |
+
opt_val = selected_df.pivot_table(
|
| 105 |
+
index='ITSQF 수준',
|
| 106 |
+
values=base_type,
|
| 107 |
+
aggfunc='mean'
|
| 108 |
+
).reset_index().rename(columns={base_type: '옵션값'})
|
| 109 |
+
|
| 110 |
+
df = df[['ITSQF 수준']].drop_duplicates()
|
| 111 |
+
df = df.merge(base_val, on='ITSQF 수준', how='left')
|
| 112 |
+
df = df.merge(opt_val, on='ITSQF 수준', how='left')
|
| 113 |
+
|
| 114 |
+
df['평균연봉'] = df['전체값'] * w_base + df['옵션값'] * w_opt
|
| 115 |
+
df['기준'] = base_type
|
| 116 |
+
|
| 117 |
+
dfs.append(df)
|
| 118 |
+
|
| 119 |
+
job_wage_df = pd.concat(dfs, ignore_index=True)
|
| 120 |
+
|
| 121 |
+
num_cols = ['전체값', '옵션값', '평균연봉']
|
| 122 |
+
existing_num_cols = [c for c in num_cols if c in job_wage_df.columns]
|
| 123 |
+
job_wage_df[existing_num_cols] = (
|
| 124 |
+
job_wage_df[existing_num_cols].astype(float).round(-3) / 10000
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return job_wage_df
|
| 128 |
+
|
| 129 |
+
def get_levels_by_wage(avg_df, job, bm, sales, emp, base_type, target_wage, w_base = 0.5, top_n=2):
|
| 130 |
+
|
| 131 |
+
job_wage_df = apply_option_to_wage(avg_df, job, bm, sales, emp, w_base)
|
| 132 |
+
df = job_wage_df[job_wage_df["기준"] == base_type].copy()
|
| 133 |
+
if df.empty:
|
| 134 |
+
return []
|
| 135 |
+
|
| 136 |
+
df["편차"] = (df["평균연봉"] - target_wage).abs()
|
| 137 |
+
|
| 138 |
+
# 편차 기준 오름차순 정렬 후 상위 2개
|
| 139 |
+
cands = df.sort_values("편차").head(top_n)["ITSQF 수준"].tolist()
|
| 140 |
+
def level_num(level: str) -> int:
|
| 141 |
+
m = re.search(r'(\d+)', str(level))
|
| 142 |
+
return int(m.group(1)) if m else -1
|
| 143 |
+
|
| 144 |
+
# 정렬(낮은 레벨 -> 높은 레벨)
|
| 145 |
+
return sorted(cands, key=level_num)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def make_bars_table(bars_df, job, levels):
|
| 149 |
+
target_df = bars_df[(bars_df['직무']==job) & (bars_df['레벨'].isin(levels))]
|
| 150 |
+
target_table = target_df.pivot_table(index='평가요소', columns='레벨', values='지표정의', aggfunc='sum').reset_index()
|
| 151 |
+
|
| 152 |
+
# 평가요소 순서대로 정렬
|
| 153 |
+
target_table['평가요소'] = pd.Categorical(
|
| 154 |
+
target_table['평가요소'],
|
| 155 |
+
categories=factors,
|
| 156 |
+
ordered=True
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
target_table = target_table.sort_values('평가요소').reset_index(drop=True)
|
| 160 |
+
|
| 161 |
+
ppt_tables = []
|
| 162 |
+
for level in levels:
|
| 163 |
+
table = target_table[['평가요소', level]].copy()
|
| 164 |
+
cols = ["평가요소", f"{level} 수준"]
|
| 165 |
+
table.columns = cols
|
| 166 |
+
ppt_tables.append(table)
|
| 167 |
+
|
| 168 |
+
target_table = target_table.rename(columns={levels[0]:'2수준', levels[1]:'4수준'})
|
| 169 |
+
bars_indicator = target_table.copy()
|
| 170 |
+
bars_indicator = bars_indicator.reset_index(names='id')
|
| 171 |
+
bars_indicator['id'] = bars_indicator['id'] + 1
|
| 172 |
+
bars_indicator['평가요소'] = bars_indicator['id'].astype(str).str.zfill(2) + '. ' + bars_indicator['평가요소'].astype(str)
|
| 173 |
+
|
| 174 |
+
for col in ['2수준', '4수준']:
|
| 175 |
+
bars_indicator[col] = bars_indicator[col].apply(lambda x: x.split('\n'))
|
| 176 |
+
|
| 177 |
+
bars_indicator.columns = ['id', 'title', 'level2', 'level4']
|
| 178 |
+
|
| 179 |
+
return bars_indicator, ppt_tables
|
| 180 |
+
|
| 181 |
+
def get_levels_definition(job, levels, job_def):
|
| 182 |
+
|
| 183 |
+
job_pool = job_def[job_def['직무'] == job]
|
| 184 |
+
|
| 185 |
+
texts = []
|
| 186 |
+
for level in levels:
|
| 187 |
+
level_text = job_pool[job_pool['수준'] == level]['수준 정의'].values[0]
|
| 188 |
+
texts.append(level_text)
|
| 189 |
+
|
| 190 |
+
return texts
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def format_text_wrap(text: str, max_len: int = 45, delimiter: str = " ") -> str:
|
| 194 |
+
if not text: return ""
|
| 195 |
+
lines = []
|
| 196 |
+
for paragraph in text.split("\n"):
|
| 197 |
+
paragraph = paragraph.strip()
|
| 198 |
+
while len(paragraph) > max_len:
|
| 199 |
+
# max_len 안에서 가장 마지막 공백 위치 찾기
|
| 200 |
+
split_pos = paragraph.rfind(delimiter, 0, max_len)
|
| 201 |
+
|
| 202 |
+
# delimiter가 없으면 그냥 max_len에서 자름 (예외 케이스)
|
| 203 |
+
if split_pos == -1:
|
| 204 |
+
split_pos = max_len
|
| 205 |
+
else:
|
| 206 |
+
split_pos += len(delimiter) # delimiter 포함해서 자르기
|
| 207 |
+
|
| 208 |
+
lines.append(paragraph[:split_pos].strip())
|
| 209 |
+
paragraph = paragraph[split_pos:].strip()
|
| 210 |
+
|
| 211 |
+
if paragraph:
|
| 212 |
+
lines.append(paragraph)
|
| 213 |
+
|
| 214 |
+
return "\n".join(lines)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def judge_level(user_scores, factors, levels, levels_def, low_cut=2, high_cut=4):
|
| 218 |
+
"""
|
| 219 |
+
단일 레벨 판정(Yes/No 성격):
|
| 220 |
+
- 총점이 36점이상이면 high_level
|
| 221 |
+
- 그 외, low_level로 보되 '보완 필요' 상태(낙인 표현 지양)
|
| 222 |
+
|
| 223 |
+
개선항목:
|
| 224 |
+
- low_set: low_cut 미만 항목
|
| 225 |
+
- middle_set: low_cut 이상 high_cut 미만 항목
|
| 226 |
+
- high_set:또는 high_cut이상 항목
|
| 227 |
+
"""
|
| 228 |
+
s = pd.Series(user_scores, dtype="float")
|
| 229 |
+
low_level, high_level = levels[0], levels[1]
|
| 230 |
+
level_def_map = {low_level: levels_def[0], high_level: levels_def[1]}
|
| 231 |
+
|
| 232 |
+
low_set = s[s < low_cut].sort_values().index.tolist()
|
| 233 |
+
middle_set = s[(s >= low_cut) & (s < high_cut)].sort_values().index.tolist()
|
| 234 |
+
high_set = s[s >= high_cut].sort_values(ascending=False).index.tolist()
|
| 235 |
+
|
| 236 |
+
def trim_items(index_set, max_item=3):
|
| 237 |
+
if len(index_set) == 0:
|
| 238 |
+
text = "-"
|
| 239 |
+
elif len(index_set) > max_item:
|
| 240 |
+
text = ", ".join([factors[i] for i in index_set[:max_item]]) + " 등"
|
| 241 |
+
else:
|
| 242 |
+
text = ", ".join([factors[i] for i in index_set])
|
| 243 |
+
|
| 244 |
+
return format_text_wrap(text, max_len=33, delimiter=",")
|
| 245 |
+
|
| 246 |
+
final_level = high_level if sum(user_scores) >= 36 else low_level
|
| 247 |
+
|
| 248 |
+
if final_level == high_level:
|
| 249 |
+
output = {
|
| 250 |
+
'하위 레벨' : [
|
| 251 |
+
f"하위 레벨: {low_level}", level_def_map.get(low_level, "-"),
|
| 252 |
+
"아래 역량은 현재 레벨 안착을 위해 보완해보면 좋겠습니다.",
|
| 253 |
+
trim_items(middle_set),
|
| 254 |
+
],
|
| 255 |
+
'현재 레벨' : [
|
| 256 |
+
f"현재 레벨: {high_level}", level_def_map.get(high_level, "-"),
|
| 257 |
+
"다음 역량은 현재 안정적으로 발휘되고 있는 강점입니다.",
|
| 258 |
+
trim_items(high_set)
|
| 259 |
+
]
|
| 260 |
+
}
|
| 261 |
+
else:
|
| 262 |
+
output = {
|
| 263 |
+
'현재 레벨' :[
|
| 264 |
+
f"현재 레벨: {low_level}", level_def_map.get(low_level, "-"),
|
| 265 |
+
"아래 역량은 현재 레벨 기준에 비추어 보완해보면 좋겠습니다.",
|
| 266 |
+
trim_items(low_set)
|
| 267 |
+
],
|
| 268 |
+
'상위 레벨' :[
|
| 269 |
+
f"상위 레벨: {high_level}", level_def_map.get(high_level, "-"),
|
| 270 |
+
"다음 역량을 강화하면 Level-Up 성장을 기대할 수 있습니다.",
|
| 271 |
+
trim_items(middle_set)
|
| 272 |
+
],
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
return final_level, pd.DataFrame(output, index=["title", "definition", "guide", "items"])
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def describe_percentile(p):
|
| 280 |
+
|
| 281 |
+
p = max(0, min(100, float(p)))
|
| 282 |
+
p = round(p, 1)
|
| 283 |
+
top = round(100 - p, 1)
|
| 284 |
+
|
| 285 |
+
if top > 60:
|
| 286 |
+
pos = f"하위 {int(math.ceil(p / 10.0) * 10)}% 이내"
|
| 287 |
+
pos_text = f"하위 {p:.1f}% 수준"
|
| 288 |
+
else:
|
| 289 |
+
pos = f"상위 {int(math.ceil(top / 10.0) * 10)}% 이내" if top > 5 else f"상위 {int(top)}% 이내"
|
| 290 |
+
pos_text = f"상위 {top:.1f}% 수준"
|
| 291 |
+
|
| 292 |
+
if p >= 70: desc = "높은"
|
| 293 |
+
elif p >= 60: desc = "평균 이상"
|
| 294 |
+
elif p >= 40: desc = "평균"
|
| 295 |
+
elif p >= 20: desc = "다소 낮은"
|
| 296 |
+
else: desc = "낮은"
|
| 297 |
+
|
| 298 |
+
return pos, desc, pos_text
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def judge_wage(
|
| 302 |
+
raw_df: pd.DataFrame,
|
| 303 |
+
job: str,
|
| 304 |
+
bm: str,
|
| 305 |
+
sales: str,
|
| 306 |
+
emp: str,
|
| 307 |
+
final_level: str,
|
| 308 |
+
base_type: str,
|
| 309 |
+
target_wage: int,
|
| 310 |
+
k_std: float = 20.0,
|
| 311 |
+
k_shrink: float = 20.0,
|
| 312 |
+
z_clip: float = 2.5,
|
| 313 |
+
n_switch: int = 20,
|
| 314 |
+
alpha_denominator: int = 30, # n>=n_switch일 때 raw percentile 비중 증가 속도 (추천 30)
|
| 315 |
+
):
|
| 316 |
+
|
| 317 |
+
# 입력 파싱: 만원 -> 원
|
| 318 |
+
x = float(target_wage) * 10000.0
|
| 319 |
+
|
| 320 |
+
# 1) job_pool (직무 + 레벨 필터)
|
| 321 |
+
job_pool_df = raw_df[(raw_df['ITSQF 직무(변환)'] == job)&(raw_df['ITSQF 수준'] == final_level)]
|
| 322 |
+
job_pool_vals = pd.to_numeric(job_pool_df[base_type], errors="coerce").dropna().to_numpy(dtype=float)
|
| 323 |
+
|
| 324 |
+
std_pool = float(np.std(job_pool_vals, ddof=1))
|
| 325 |
+
mean_job = float(np.mean(job_pool_vals))
|
| 326 |
+
|
| 327 |
+
# 2) cohort (직무 + 레벨 + 옵션 필터)
|
| 328 |
+
def get_cohort_df(df, bm, sales, emp):
|
| 329 |
+
mask = (df['BM'] == bm)
|
| 330 |
+
if sales != '전체':
|
| 331 |
+
mask = mask & (df['매출규모'] == sales)
|
| 332 |
+
if emp != '전체':
|
| 333 |
+
mask = mask & (df['직원규모'] == emp)
|
| 334 |
+
|
| 335 |
+
return df[mask].copy()
|
| 336 |
+
|
| 337 |
+
cohort_df = get_cohort_df(job_pool_df, bm, sales, emp)
|
| 338 |
+
cohort_vals = pd.to_numeric(cohort_df[base_type], errors="coerce").dropna().to_numpy(dtype=float)
|
| 339 |
+
n = int(cohort_vals.size)
|
| 340 |
+
|
| 341 |
+
mean_cohort = float(np.mean(cohort_vals)) if n >= 1 else mean_job
|
| 342 |
+
std_cohort = float(np.std(cohort_vals, ddof=1)) if n >= 2 else 0.0
|
| 343 |
+
|
| 344 |
+
# 3) std 풀링 (분산 기준 혼합)
|
| 345 |
+
w_std = (n / (n + k_std)) if n > 0 else 0.0
|
| 346 |
+
var_eff = w_std * (std_cohort ** 2) + (1.0 - w_std) * (std_pool ** 2)
|
| 347 |
+
std_eff = math.sqrt(max(var_eff, 1e-9)) # 0 방어
|
| 348 |
+
|
| 349 |
+
# 4) z-score 보정
|
| 350 |
+
z_raw = (x - mean_cohort) / std_eff
|
| 351 |
+
w_n = (n / (n + k_shrink)) if n > 0 else 0.0 # 표본 수 수축
|
| 352 |
+
z_adj = float(np.clip(w_n * z_raw, -z_clip, z_clip)) # 클리핑
|
| 353 |
+
|
| 354 |
+
def normal_cdf(z: float) -> float:
|
| 355 |
+
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
|
| 356 |
+
|
| 357 |
+
def percentile_of_score(arr: np.ndarray, x: float) -> float:
|
| 358 |
+
"""퍼센타일: arr 중 x 이하 비율 * 100"""
|
| 359 |
+
if arr.size == 0:
|
| 360 |
+
return float("nan")
|
| 361 |
+
return float((arr <= x).mean() * 100.0)
|
| 362 |
+
|
| 363 |
+
# 5) 퍼센타일 (z 기반 + 필요시 raw와 블렌딩)
|
| 364 |
+
p_z = normal_cdf(z_adj) * 100.0
|
| 365 |
+
p_raw = percentile_of_score(cohort_vals, x) if n >= 3 else float("nan")
|
| 366 |
+
|
| 367 |
+
# n이 커질수록 raw에 더 무게, 완만한 전이를 위해 alpha_denominator 추천 30
|
| 368 |
+
if n >= n_switch and not math.isnan(p_raw):
|
| 369 |
+
alpha = min(1.0, n / float(alpha_denominator))
|
| 370 |
+
p_final = alpha * p_raw + (1.0 - alpha) * p_z
|
| 371 |
+
else:
|
| 372 |
+
p_final = p_z
|
| 373 |
+
|
| 374 |
+
p = round(float(p_final), 1)
|
| 375 |
+
pos, desc, pos_text = describe_percentile(p)
|
| 376 |
+
|
| 377 |
+
head_message = f"""진단 결과, 현재 귀하의 직무 역량 수준은 {final_level}에 가장 가까운 것으로 보입니다.
|
| 378 |
+
동일 직무 레벨 및 조건 대비 보상 경쟁력은 {pos}로 {desc} 수준입니다."""
|
| 379 |
+
|
| 380 |
+
# 평균 대비 차이 (cohort 평균 기준; cohort가 비면 job 평균)
|
| 381 |
+
diff = x - mean_cohort
|
| 382 |
+
sign = "+" if diff >= 0 else "-"
|
| 383 |
+
gap = f"{sign}{abs(diff)/10000:,.0f}"
|
| 384 |
+
|
| 385 |
+
if diff == 0:
|
| 386 |
+
comp_text = "시장 평균과 동일한 수준으로 나타났습니다."
|
| 387 |
+
else:
|
| 388 |
+
direction = "더 높게" if diff > 0 else "더 낮게"
|
| 389 |
+
comp_text = f"시장 평균 대비 {gap}만원 {direction} 나타났습니다."
|
| 390 |
+
|
| 391 |
+
block1 = f"""현재 보상 경쟁력은 시장 {pos_text}으로,
|
| 392 |
+
{comp_text}"""
|
| 393 |
+
|
| 394 |
+
block2 = f"""직무: {job}
|
| 395 |
+
레벨: {final_level}
|
| 396 |
+
보상수준: ({base_type}) {target_wage:,.0f}만원
|
| 397 |
+
준거집단:
|
| 398 |
+
- (BM) {bm}
|
| 399 |
+
- (매출규모) {sales}
|
| 400 |
+
- (직원규모) {emp}
|
| 401 |
+
"""
|
| 402 |
+
table3 = pd.DataFrame({
|
| 403 |
+
"user": f"{target_wage:,.0f}",
|
| 404 |
+
"marketAverage": f"{mean_cohort/10000:,.0f}",
|
| 405 |
+
"gap": gap
|
| 406 |
+
}, index=["값"])
|
| 407 |
+
|
| 408 |
+
def make_guage_chart(p):
|
| 409 |
+
value = round(p / 100 * 180, 1)
|
| 410 |
+
guage = [180, value, 180 - value]
|
| 411 |
+
return pd.DataFrame(
|
| 412 |
+
{"값": guage},
|
| 413 |
+
index=["항목1", "항목2", "항목3"]
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
chart3 = make_guage_chart(p)
|
| 417 |
+
|
| 418 |
+
return head_message, block1, block2, p, chart3, table3
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def format_table(table, factors, user_scores, cut=2):
|
| 422 |
+
label = table.columns[1]
|
| 423 |
+
s = pd.DataFrame({
|
| 424 |
+
"평가요소": factors,
|
| 425 |
+
label: [3]*10,
|
| 426 |
+
"User": user_scores
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
s['User'] = s['User'].apply(lambda x: 2 if x < cut else (3 if x == cut else 4))
|
| 430 |
+
s['부족'] = s['User'].apply(lambda x : "●" if x == 2 else "")
|
| 431 |
+
s['충족'] = s['User'].apply(lambda x : "●" if x == 3 else "")
|
| 432 |
+
s['초과'] = s['User'].apply(lambda x : "●" if x == 4 else "")
|
| 433 |
+
|
| 434 |
+
table = table.merge(s[['평가요소', '부족', '충족', '초과']], on='평가요소', how='left')
|
| 435 |
+
chart = s[['평가요소', label, "User"]]
|
| 436 |
+
|
| 437 |
+
return [table, chart]
|
| 438 |
+
|
| 439 |
+
|
core/data/Paperlogy-3Light.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a5a4c9acdbaa99b7ca72dd3f428ba962953201d39abc7181104953919d9cf60
|
| 3 |
+
size 680656
|
core/data/Paperlogy-4Regular.ttf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fde17e6e5acabcfabdad79f37fedc01405a0d6298c965b3f99378dd89a25b1ba
|
| 3 |
+
size 679480
|
core/data/output_template.pptx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d4260ba8c199a48fbaa96da1915c52ab72d2564d6bac58008c6173e93ee0124d
|
| 3 |
+
size 310268
|
core/data/sw_competency.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:458ef43d05c9b43f4c61f87bec497183c3a11e2931d664110eb6dec8e62d062a
|
| 3 |
+
size 147208
|
core/data/sw_wage.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b6078cb6538353dceb2af451b5e54f5d3d518237ee33b27020cb2cf6d7839004
|
| 3 |
+
size 217706
|
core/ppt.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from pptx import Presentation
|
| 4 |
+
from pptx.util import Pt
|
| 5 |
+
from pptx.enum.text import PP_ALIGN
|
| 6 |
+
from pptx.dml.color import RGBColor
|
| 7 |
+
from pptx.chart.data import CategoryChartData
|
| 8 |
+
from pptx.oxml.xmlchemy import OxmlElement
|
| 9 |
+
from pptx.oxml.ns import qn
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def set_cell_letter_spacing(cell, spacing=-100):
|
| 13 |
+
"""
|
| 14 |
+
테이블 셀 내부 텍스트 자간 조절 (비공식 XML hack)
|
| 15 |
+
|
| 16 |
+
spacing:
|
| 17 |
+
0 = 기본
|
| 18 |
+
-50 = 약간 좁게
|
| 19 |
+
-100 = 좁게 (추천)
|
| 20 |
+
-200 = 매우 좁게 (주의)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
tf = cell.text_frame
|
| 24 |
+
|
| 25 |
+
for p in tf.paragraphs:
|
| 26 |
+
for run in p.runs:
|
| 27 |
+
try:
|
| 28 |
+
rPr = run._r.get_or_add_rPr()
|
| 29 |
+
rPr.set("spc", str(spacing))
|
| 30 |
+
except Exception as e:
|
| 31 |
+
print("⚠️ letter spacing 적용 실패:", e)
|
| 32 |
+
|
| 33 |
+
def set_text_style(target, align='center', font_size=9, line_spacing=1.0,
|
| 34 |
+
space_before=0, space_after=0, color=(0,0,0), font_name=None):
|
| 35 |
+
# 정렬 방식 설정
|
| 36 |
+
if align == 'center':
|
| 37 |
+
alignment = PP_ALIGN.CENTER
|
| 38 |
+
else:
|
| 39 |
+
alignment = PP_ALIGN.LEFT # 기본값은 왼쪽 정렬
|
| 40 |
+
|
| 41 |
+
# target이 셀인지 텍스트 프레임인지 확인
|
| 42 |
+
if hasattr(target, 'text_frame'): # target이 셀인 경우
|
| 43 |
+
text_frame = target.text_frame
|
| 44 |
+
else: # target이 텍스트 프레임인 경우
|
| 45 |
+
text_frame = target
|
| 46 |
+
|
| 47 |
+
for paragraph in text_frame.paragraphs:
|
| 48 |
+
paragraph.alignment = alignment # 설정한 정렬 적용
|
| 49 |
+
|
| 50 |
+
# 줄 간격 (글자 줄간격)
|
| 51 |
+
paragraph.line_spacing = line_spacing
|
| 52 |
+
|
| 53 |
+
# 문단 간격
|
| 54 |
+
paragraph.space_before = Pt(space_before)
|
| 55 |
+
paragraph.space_after = Pt(space_after)
|
| 56 |
+
|
| 57 |
+
for run in paragraph.runs:
|
| 58 |
+
run.font.size = Pt(font_size) # 폰트 크기 설정
|
| 59 |
+
run.font.color.rgb = RGBColor(color[0], color[1], color[2]) # 폰트 색상을 설정
|
| 60 |
+
|
| 61 |
+
if font_name:
|
| 62 |
+
run.font.name = font_name
|
| 63 |
+
|
| 64 |
+
# 파워포인트 내 한글(동아시아 문자) 폰트 적용을 위한 XML 조작
|
| 65 |
+
try:
|
| 66 |
+
ea = run.font._element.find(qn('a:ea'))
|
| 67 |
+
if ea is None:
|
| 68 |
+
ea = OxmlElement('a:ea')
|
| 69 |
+
run.font._element.append(ea)
|
| 70 |
+
ea.set('typeface', font_name)
|
| 71 |
+
except Exception:
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
def update_category_chart(chart, chart_info):
|
| 75 |
+
category_data = CategoryChartData()
|
| 76 |
+
category_data.categories = chart_info["categories"]
|
| 77 |
+
|
| 78 |
+
for series_name, series_values in chart_info["values"].items():
|
| 79 |
+
category_data.add_series(series_name, series_values)
|
| 80 |
+
|
| 81 |
+
chart.replace_data(category_data)
|
| 82 |
+
|
| 83 |
+
def df_to_chart_info_radar(df):
|
| 84 |
+
# chart1, chart2 용
|
| 85 |
+
# 첫 컬럼 = 카테고리, 나머지 컬럼 = 시리즈
|
| 86 |
+
categories = df.iloc[:, 0].astype(str).tolist()
|
| 87 |
+
values = {}
|
| 88 |
+
|
| 89 |
+
for col in df.columns[1:]:
|
| 90 |
+
values[str(col)] = pd.to_numeric(df[col], errors="coerce").fillna(0).tolist()
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"categories": categories,
|
| 94 |
+
"values": values
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def df_to_chart_info_gauge(df):
|
| 98 |
+
"""
|
| 99 |
+
chart3용
|
| 100 |
+
컬럼 → categories
|
| 101 |
+
값 → 하나의 series
|
| 102 |
+
"""
|
| 103 |
+
categories = list(df.index)
|
| 104 |
+
|
| 105 |
+
values = {"Value": df.iloc[:, 0].astype(float).tolist()}
|
| 106 |
+
|
| 107 |
+
return {
|
| 108 |
+
"categories": categories,
|
| 109 |
+
"values": values
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
def update_ppt_with_data(slides_data, output_path):
|
| 113 |
+
# Get BASE_DIR to handle relative paths
|
| 114 |
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 115 |
+
prs_path = os.path.join(BASE_DIR, "core", "data", "output_template.pptx")
|
| 116 |
+
prs = Presentation(prs_path)
|
| 117 |
+
|
| 118 |
+
for slide_idx, slide_content in slides_data.items():
|
| 119 |
+
slide = prs.slides[slide_idx]
|
| 120 |
+
|
| 121 |
+
for shape in slide.shapes:
|
| 122 |
+
name = shape.name
|
| 123 |
+
|
| 124 |
+
# 1. 텍스트 처리
|
| 125 |
+
if shape.has_text_frame:
|
| 126 |
+
|
| 127 |
+
# title, head_message
|
| 128 |
+
if name in slide_content:
|
| 129 |
+
shape.text = str(slide_content[name])
|
| 130 |
+
if shape.name == "title":
|
| 131 |
+
set_text_style(shape, align="left", font_size=18)
|
| 132 |
+
else:
|
| 133 |
+
set_text_style(shape, align="left", font_size=10.5, line_spacing=1, space_before=5, space_after=0)
|
| 134 |
+
|
| 135 |
+
# text1, text2, text3 (첫번째 슬라이드)
|
| 136 |
+
elif slide_idx == 0 and name.startswith("text"):
|
| 137 |
+
texts = slide_content.get("texts", [])
|
| 138 |
+
try:
|
| 139 |
+
i = int(name.replace("text", "")) - 1
|
| 140 |
+
if i < len(texts):
|
| 141 |
+
shape.text = str(texts[i])
|
| 142 |
+
if shape.name == "text3":
|
| 143 |
+
set_text_style(shape, align="center", font_size=18, font_name="페이퍼로지 4 Regular", color=(75,149,168))
|
| 144 |
+
elif shape.name == "text1":
|
| 145 |
+
set_text_style(shape, align="center", font_size=9, line_spacing=1.5)
|
| 146 |
+
else:
|
| 147 |
+
set_text_style(shape, align="left", font_size=9, line_spacing=1.5)
|
| 148 |
+
except:
|
| 149 |
+
pass
|
| 150 |
+
|
| 151 |
+
# 2. 차트 처리
|
| 152 |
+
if slide_idx == 0 and shape.has_chart and name.startswith("chart"):
|
| 153 |
+
charts = slide_content.get("charts", [])
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
i = int(name.replace("chart", "")) - 1
|
| 157 |
+
if i < len(charts):
|
| 158 |
+
df = charts[i]
|
| 159 |
+
if name in ["chart1", "chart2"]:
|
| 160 |
+
chart_info = df_to_chart_info_radar(df)
|
| 161 |
+
elif name == "chart3":
|
| 162 |
+
chart_info = df_to_chart_info_gauge(df)
|
| 163 |
+
else:
|
| 164 |
+
continue
|
| 165 |
+
update_category_chart(shape.chart, chart_info)
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
print(f"[CHART ERROR] {name}: {e}")
|
| 169 |
+
|
| 170 |
+
# 3. 테이블 처리
|
| 171 |
+
if shape.has_table:
|
| 172 |
+
table = shape.table
|
| 173 |
+
|
| 174 |
+
# 슬라이드 0 → tables (넘버링)
|
| 175 |
+
if slide_idx == 0 and name.startswith("table"):
|
| 176 |
+
tables = slide_content.get("tables", [])
|
| 177 |
+
try:
|
| 178 |
+
i = int(name.replace("table", "")) - 1
|
| 179 |
+
if i < len(tables):
|
| 180 |
+
df = tables[i]
|
| 181 |
+
else:
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
if name == "table3":
|
| 185 |
+
start_row = 1 # 헤더 제외 (아랫줄만)
|
| 186 |
+
else:
|
| 187 |
+
start_row = 0
|
| 188 |
+
except:
|
| 189 |
+
continue
|
| 190 |
+
|
| 191 |
+
# 슬라이드 1,2 → table (단일)
|
| 192 |
+
elif slide_idx != 0:
|
| 193 |
+
tables = slide_content.get("table", [])
|
| 194 |
+
if len(tables) == 0:
|
| 195 |
+
continue
|
| 196 |
+
df = tables[0]
|
| 197 |
+
start_row = 1
|
| 198 |
+
else:
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
# 데이터 삽입
|
| 202 |
+
rows = len(table.rows)
|
| 203 |
+
cols = len(table.columns)
|
| 204 |
+
for r in range(start_row, min(rows, df.shape[0] + start_row)):
|
| 205 |
+
for c in range(min(cols, df.shape[1])):
|
| 206 |
+
cell = table.cell(r, c)
|
| 207 |
+
cell.text = str(df.iloc[r - start_row, c])
|
| 208 |
+
if shape.name == "table1":
|
| 209 |
+
if r == 0:
|
| 210 |
+
set_text_style(cell, "center", 14, font_name="페이퍼로지 4 Regular")
|
| 211 |
+
else:
|
| 212 |
+
set_text_style(cell, "center", 9, line_spacing=1.3, space_before=0, space_after=0)
|
| 213 |
+
elif shape.name == "table2":
|
| 214 |
+
if r == 0:
|
| 215 |
+
set_text_style(cell, "center", 9, line_spacing=1.3)
|
| 216 |
+
else:
|
| 217 |
+
set_text_style(cell, "center", 9, line_spacing=1.3, color=(89,89,89))
|
| 218 |
+
elif shape.name == "table3":
|
| 219 |
+
if r == 1:
|
| 220 |
+
set_text_style(cell, "center", 11, font_name="페이퍼로지 4 Regular")
|
| 221 |
+
else:
|
| 222 |
+
continue
|
| 223 |
+
else:
|
| 224 |
+
if r == 0:
|
| 225 |
+
set_text_style(cell, "center", 9, line_spacing=1.2, space_before=0, space_after=0)
|
| 226 |
+
else:
|
| 227 |
+
if c >= cols - 3: # 🔥 마지막 3개 열
|
| 228 |
+
set_text_style(cell, "center", 9, line_spacing=1.2, space_before=0, space_after=0)
|
| 229 |
+
else:
|
| 230 |
+
set_text_style(cell, "left", 9, line_spacing=1.2, space_before=0, space_after=0)
|
| 231 |
+
|
| 232 |
+
# 파일명 검수
|
| 233 |
+
if output_path == None:
|
| 234 |
+
output_path = "result.pptx"
|
| 235 |
+
base, ext = os.path.splitext(output_path)
|
| 236 |
+
if ext.lower() != ".pptx":
|
| 237 |
+
output_path = base + ".pptx"
|
| 238 |
+
|
| 239 |
+
prs.save(output_path)
|
| 240 |
+
print(f"{output_path} 업데이트 완료!")
|
| 241 |
+
return output_path
|
metadata.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "IT 임금 자가진단 플랫폼",
|
| 3 |
+
"description": "IT 산업 직무 역량 진단 및 보상 경쟁력 분석 서비스",
|
| 4 |
+
"requestFramePermissions": []
|
| 5 |
+
}
|
next-env.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="next" />
|
| 2 |
+
/// <reference types="next/image-types/global" />
|
| 3 |
+
import "./.next/dev/types/routes.d.ts";
|
| 4 |
+
|
| 5 |
+
// NOTE: This file should not be edited
|
| 6 |
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
next.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
output: 'export',
|
| 4 |
+
trailingSlash: true,
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default nextConfig;
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "react-example",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev",
|
| 8 |
+
"build": "next build",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"clean": "rm -rf dist",
|
| 11 |
+
"lint": "tsc --noEmit"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@google/genai": "^1.29.0",
|
| 15 |
+
"@tailwindcss/postcss": "^4.2.2",
|
| 16 |
+
"clsx": "^2.1.1",
|
| 17 |
+
"dotenv": "^17.2.3",
|
| 18 |
+
"express": "^4.21.2",
|
| 19 |
+
"framer-motion": "^12.38.0",
|
| 20 |
+
"lucide-react": "^0.546.0",
|
| 21 |
+
"motion": "^12.23.24",
|
| 22 |
+
"next": "^16.2.1",
|
| 23 |
+
"postcss": "^8.5.8",
|
| 24 |
+
"react": "^19.2.4",
|
| 25 |
+
"react-dom": "^19.2.4",
|
| 26 |
+
"recharts": "^3.8.1",
|
| 27 |
+
"tailwind-merge": "^3.5.0"
|
| 28 |
+
},
|
| 29 |
+
"devDependencies": {
|
| 30 |
+
"@types/express": "^4.17.21",
|
| 31 |
+
"@types/node": "^25.5.0",
|
| 32 |
+
"@types/react": "^19.2.14",
|
| 33 |
+
"@types/react-dom": "^19.2.3",
|
| 34 |
+
"autoprefixer": "^10.4.27",
|
| 35 |
+
"tailwindcss": "^4.1.14",
|
| 36 |
+
"tsx": "^4.21.0",
|
| 37 |
+
"typescript": "~5.8.2"
|
| 38 |
+
}
|
| 39 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pandas
|
| 4 |
+
numpy
|
| 5 |
+
python-pptx
|
| 6 |
+
pydantic
|
| 7 |
+
matplotlib
|
src/app/LayoutContent.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { usePathname } from 'next/navigation';
|
| 4 |
+
import { Header, SurveyHeader, LoadingOverlay, ErrorMessage } from '../components/layout/Common';
|
| 5 |
+
import { useDiagnosis } from './contexts/DiagnosisContext';
|
| 6 |
+
|
| 7 |
+
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
| 8 |
+
const pathname = usePathname();
|
| 9 |
+
const { currentIndex, surveyItems, loading, error, setError } = useDiagnosis();
|
| 10 |
+
|
| 11 |
+
const isSurvey = pathname === '/survey';
|
| 12 |
+
const isResult = pathname === '/result';
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="min-h-screen flex flex-col bg-slate-50 font-sans text-slate-900 selection:bg-cyan-100 selection:text-cyan-900">
|
| 16 |
+
{loading && <LoadingOverlay />}
|
| 17 |
+
|
| 18 |
+
{isSurvey ? (
|
| 19 |
+
<SurveyHeader current={currentIndex + 1} total={surveyItems.length || 10} />
|
| 20 |
+
) : isResult ? null : (
|
| 21 |
+
<Header />
|
| 22 |
+
)}
|
| 23 |
+
|
| 24 |
+
<main className="relative flex-grow bg-slate-50 px-3 sm:px-4 py-6 sm:py-8">
|
| 25 |
+
{error ? (
|
| 26 |
+
<div className="max-w-xl mx-auto mt-20">
|
| 27 |
+
<ErrorMessage
|
| 28 |
+
message={error}
|
| 29 |
+
onRetry={() => {
|
| 30 |
+
setError(null);
|
| 31 |
+
window.location.reload();
|
| 32 |
+
}}
|
| 33 |
+
/>
|
| 34 |
+
</div>
|
| 35 |
+
) : (
|
| 36 |
+
children
|
| 37 |
+
)}
|
| 38 |
+
</main>
|
| 39 |
+
|
| 40 |
+
<footer className="min-h-20 border-t border-slate-100 bg-white flex items-center justify-center shrink-0 px-4 py-4">
|
| 41 |
+
<p className="text-center text-xs sm:text-sm font-medium text-slate-500">
|
| 42 |
+
© 2026 시앤피컨설팅 주식회사. All rights reserved.
|
| 43 |
+
</p>
|
| 44 |
+
</footer>
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
src/app/contexts/DiagnosisContext.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
| 9 |
+
import { SurveyItem, SearchParams, ResultData } from '../../types/diagnosis';
|
| 10 |
+
import { diagnosisService } from '../../services/diagnosisService';
|
| 11 |
+
import { useRouter } from 'next/navigation';
|
| 12 |
+
|
| 13 |
+
interface DiagnosisContextType {
|
| 14 |
+
step: 'intro' | 'search' | 'survey' | 'result';
|
| 15 |
+
setStep: (step: 'intro' | 'search' | 'survey' | 'result') => void;
|
| 16 |
+
surveyItems: SurveyItem[];
|
| 17 |
+
searchParams: SearchParams;
|
| 18 |
+
setSearchParams: (params: SearchParams) => void;
|
| 19 |
+
answers: number[];
|
| 20 |
+
setAnswers: (answers: number[]) => void;
|
| 21 |
+
result: ResultData | null;
|
| 22 |
+
loading: boolean;
|
| 23 |
+
setLoading: (loading: boolean) => void;
|
| 24 |
+
error: string | null;
|
| 25 |
+
setError: (error: string | null) => void;
|
| 26 |
+
submitDiagnosis: () => Promise<void>;
|
| 27 |
+
reset: () => void;
|
| 28 |
+
clearSearchParams: () => void;
|
| 29 |
+
currentIndex: number;
|
| 30 |
+
setCurrentIndex: (index: number) => void;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const DiagnosisContext = createContext<DiagnosisContextType | undefined>(undefined);
|
| 34 |
+
|
| 35 |
+
export function DiagnosisProvider({ children }: { children: ReactNode }) {
|
| 36 |
+
const router = useRouter();
|
| 37 |
+
const [step, setStep] = useState<'intro' | 'search' | 'survey' | 'result'>('intro');
|
| 38 |
+
const [surveyItems, setSurveyItems] = useState<SurveyItem[]>([]);
|
| 39 |
+
const [searchParams, setSearchParams] = useState<SearchParams>({
|
| 40 |
+
job: '',
|
| 41 |
+
bm: '',
|
| 42 |
+
sales: '',
|
| 43 |
+
emp: '',
|
| 44 |
+
userBase: '지급총액',
|
| 45 |
+
userValue: 0
|
| 46 |
+
});
|
| 47 |
+
const [answers, setAnswers] = useState<number[]>([]);
|
| 48 |
+
const [result, setResult] = useState<ResultData | null>(null);
|
| 49 |
+
const [loading, setLoading] = useState(false);
|
| 50 |
+
const [error, setError] = useState<string | null>(null);
|
| 51 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
if (step === 'survey') {
|
| 55 |
+
loadSurvey();
|
| 56 |
+
}
|
| 57 |
+
}, [step]);
|
| 58 |
+
|
| 59 |
+
const loadSurvey = async () => {
|
| 60 |
+
try {
|
| 61 |
+
setLoading(true);
|
| 62 |
+
const items = await diagnosisService.getSurveyItems(searchParams);
|
| 63 |
+
setSurveyItems(items);
|
| 64 |
+
setAnswers(new Array(items.length).fill(2));
|
| 65 |
+
} catch (err) {
|
| 66 |
+
setError('진단 항목을 불러오는데 실패했습니다.');
|
| 67 |
+
} finally {
|
| 68 |
+
setLoading(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const clearSearchParams = () => {
|
| 73 |
+
setSearchParams({
|
| 74 |
+
job: '',
|
| 75 |
+
bm: '',
|
| 76 |
+
sales: '',
|
| 77 |
+
emp: '',
|
| 78 |
+
userBase: '지급총액',
|
| 79 |
+
userValue: 0
|
| 80 |
+
});
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const submitDiagnosis = async () => {
|
| 84 |
+
try {
|
| 85 |
+
setLoading(true);
|
| 86 |
+
const data = await diagnosisService.getDiagnosisResult(searchParams, answers);
|
| 87 |
+
setResult(data);
|
| 88 |
+
setStep('result');
|
| 89 |
+
router.push('/result');
|
| 90 |
+
} catch (err) {
|
| 91 |
+
console.error("Diagnosis Submission Error:", err);
|
| 92 |
+
setError('진단 결과를 분석하는데 실패했습니다.');
|
| 93 |
+
} finally {
|
| 94 |
+
setLoading(false);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const reset = () => {
|
| 99 |
+
setStep('intro');
|
| 100 |
+
setResult(null);
|
| 101 |
+
setAnswers([]);
|
| 102 |
+
clearSearchParams();
|
| 103 |
+
setCurrentIndex(0);
|
| 104 |
+
router.push('/');
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<DiagnosisContext.Provider
|
| 109 |
+
value={{
|
| 110 |
+
step, setStep,
|
| 111 |
+
surveyItems,
|
| 112 |
+
searchParams, setSearchParams,
|
| 113 |
+
answers, setAnswers,
|
| 114 |
+
result,
|
| 115 |
+
loading,
|
| 116 |
+
setLoading,
|
| 117 |
+
error,
|
| 118 |
+
setError,
|
| 119 |
+
submitDiagnosis,
|
| 120 |
+
reset,
|
| 121 |
+
clearSearchParams,
|
| 122 |
+
currentIndex, setCurrentIndex
|
| 123 |
+
}}
|
| 124 |
+
>
|
| 125 |
+
{children}
|
| 126 |
+
</DiagnosisContext.Provider>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export function useDiagnosis() {
|
| 131 |
+
const context = useContext(DiagnosisContext);
|
| 132 |
+
if (context === undefined) {
|
| 133 |
+
throw new Error('useDiagnosis must be used within a DiagnosisProvider');
|
| 134 |
+
}
|
| 135 |
+
return context;
|
| 136 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@font-face {
|
| 3 |
+
font-family: 'Pretendard';
|
| 4 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Thin.woff2') format('woff2');
|
| 5 |
+
font-weight: 100;
|
| 6 |
+
font-display: swap;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
@font-face {
|
| 10 |
+
font-family: 'Pretendard';
|
| 11 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-ExtraLight.woff2') format('woff2');
|
| 12 |
+
font-weight: 200;
|
| 13 |
+
font-display: swap;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
@font-face {
|
| 17 |
+
font-family: 'Pretendard';
|
| 18 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Light.woff2') format('woff2');
|
| 19 |
+
font-weight: 300;
|
| 20 |
+
font-display: swap;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
@font-face {
|
| 24 |
+
font-family: 'Pretendard';
|
| 25 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Regular.woff2') format('woff2');
|
| 26 |
+
font-weight: 400;
|
| 27 |
+
font-display: swap;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@font-face {
|
| 31 |
+
font-family: 'Pretendard';
|
| 32 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Medium.woff2') format('woff2');
|
| 33 |
+
font-weight: 500;
|
| 34 |
+
font-display: swap;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
@font-face {
|
| 38 |
+
font-family: 'Pretendard';
|
| 39 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-SemiBold.woff2') format('woff2');
|
| 40 |
+
font-weight: 600;
|
| 41 |
+
font-display: swap;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@font-face {
|
| 45 |
+
font-family: 'Pretendard';
|
| 46 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Bold.woff2') format('woff2');
|
| 47 |
+
font-weight: 700;
|
| 48 |
+
font-display: swap;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
@font-face {
|
| 52 |
+
font-family: 'Pretendard';
|
| 53 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-ExtraBold.woff2') format('woff2');
|
| 54 |
+
font-weight: 800;
|
| 55 |
+
font-display: swap;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@font-face {
|
| 59 |
+
font-family: 'Pretendard';
|
| 60 |
+
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/pretendard@1.0/Pretendard-Black.woff2') format('woff2');
|
| 61 |
+
font-weight: 900;
|
| 62 |
+
font-display: swap;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@theme {
|
| 66 |
+
--font-sans: "Pretendard", ui-sans-serif, system-ui, sans-serif;
|
| 67 |
+
|
| 68 |
+
--color-cyan-50: #ecfeff;
|
| 69 |
+
--color-cyan-100: #cffafe;
|
| 70 |
+
--color-cyan-200: #a5f3fc;
|
| 71 |
+
--color-cyan-300: #67e8f9;
|
| 72 |
+
--color-cyan-400: #22d3ee;
|
| 73 |
+
--color-cyan-500: #06b6d4;
|
| 74 |
+
--color-cyan-600: #0891b2;
|
| 75 |
+
--color-cyan-700: #0e7490;
|
| 76 |
+
--color-cyan-800: #155e75;
|
| 77 |
+
--color-cyan-900: #164e63;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
html {
|
| 81 |
+
scroll-behavior: smooth;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
html,
|
| 85 |
+
body {
|
| 86 |
+
max-width: 100%;
|
| 87 |
+
overflow-x: hidden;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
body {
|
| 91 |
+
-webkit-font-smoothing: antialiased;
|
| 92 |
+
-moz-osx-font-smoothing: grayscale;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Custom scrollbar */
|
| 96 |
+
::-webkit-scrollbar {
|
| 97 |
+
width: 8px;
|
| 98 |
+
}
|
| 99 |
+
::-webkit-scrollbar-track {
|
| 100 |
+
background: #f8fafc;
|
| 101 |
+
}
|
| 102 |
+
::-webkit-scrollbar-thumb {
|
| 103 |
+
background: #e2e8f0;
|
| 104 |
+
border-radius: 10px;
|
| 105 |
+
}
|
| 106 |
+
::-webkit-scrollbar-thumb:hover {
|
| 107 |
+
background: #cbd5e1;
|
| 108 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
import { DiagnosisProvider } from "./contexts/DiagnosisContext";
|
| 4 |
+
import LayoutContent from "./LayoutContent";
|
| 5 |
+
|
| 6 |
+
export const metadata: Metadata = {
|
| 7 |
+
title: "IT 커리어밸류체크",
|
| 8 |
+
description: "2025년 IT산업 임금체계 개선 길라잡이를 기반으로 개발한 보상경쟁력 자가진단 서비스",
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export default function RootLayout({
|
| 12 |
+
children,
|
| 13 |
+
}: {
|
| 14 |
+
children: React.ReactNode;
|
| 15 |
+
}) {
|
| 16 |
+
return (
|
| 17 |
+
<html lang="ko">
|
| 18 |
+
<body className="antialiased">
|
| 19 |
+
<DiagnosisProvider>
|
| 20 |
+
<LayoutContent>
|
| 21 |
+
{children}
|
| 22 |
+
</LayoutContent>
|
| 23 |
+
</DiagnosisProvider>
|
| 24 |
+
</body>
|
| 25 |
+
</html>
|
| 26 |
+
);
|
| 27 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { IntroView } from "../components/features/Intro";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import { useDiagnosis } from "./contexts/DiagnosisContext";
|
| 6 |
+
|
| 7 |
+
export default function HomePage() {
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
const { setStep } = useDiagnosis();
|
| 10 |
+
|
| 11 |
+
const handleStart = () => {
|
| 12 |
+
setStep('search');
|
| 13 |
+
router.push('/search');
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
return <IntroView onStart={handleStart} />;
|
| 17 |
+
}
|
src/app/result/page.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useDiagnosis } from "../contexts/DiagnosisContext";
|
| 4 |
+
import { ResultView } from "../../components/features/Result";
|
| 5 |
+
import { useRouter } from "next/navigation";
|
| 6 |
+
import { useEffect } from "react";
|
| 7 |
+
import { diagnosisService } from "../../services/diagnosisService";
|
| 8 |
+
|
| 9 |
+
export default function ResultPage() {
|
| 10 |
+
const router = useRouter();
|
| 11 |
+
const { result, reset, searchParams, answers, setLoading, setError } = useDiagnosis();
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
if (!result) {
|
| 15 |
+
router.replace('/');
|
| 16 |
+
}
|
| 17 |
+
}, [result, router]);
|
| 18 |
+
|
| 19 |
+
const handleDownload = async () => {
|
| 20 |
+
try {
|
| 21 |
+
setLoading(true);
|
| 22 |
+
await diagnosisService.downloadReport(searchParams, answers);
|
| 23 |
+
} catch (err) {
|
| 24 |
+
setError('리포트 다운로드에 실패했습니다.');
|
| 25 |
+
} finally {
|
| 26 |
+
setLoading(false);
|
| 27 |
+
}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
if (!result) return null;
|
| 31 |
+
|
| 32 |
+
return <ResultView data={result} onReset={reset} onDownload={handleDownload} />;
|
| 33 |
+
}
|
src/app/search/page.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useDiagnosis } from "../contexts/DiagnosisContext";
|
| 5 |
+
import { SearchView } from "../../components/features/Search";
|
| 6 |
+
|
| 7 |
+
export default function SearchPage() {
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
const { searchParams, setSearchParams, setStep, clearSearchParams } = useDiagnosis();
|
| 10 |
+
|
| 11 |
+
const handleNext = () => {
|
| 12 |
+
setStep('survey');
|
| 13 |
+
router.push('/survey');
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<SearchView
|
| 18 |
+
params={searchParams}
|
| 19 |
+
setParams={setSearchParams}
|
| 20 |
+
onNext={handleNext}
|
| 21 |
+
onReset={clearSearchParams}
|
| 22 |
+
/>
|
| 23 |
+
);
|
| 24 |
+
}
|
src/app/survey/page.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useRouter } from "next/navigation";
|
| 4 |
+
import { useDiagnosis } from "../contexts/DiagnosisContext";
|
| 5 |
+
import { SurveyView } from "../../components/features/Survey";
|
| 6 |
+
|
| 7 |
+
export default function SurveyPage() {
|
| 8 |
+
const router = useRouter();
|
| 9 |
+
const {
|
| 10 |
+
surveyItems,
|
| 11 |
+
answers,
|
| 12 |
+
setAnswers,
|
| 13 |
+
submitDiagnosis,
|
| 14 |
+
currentIndex,
|
| 15 |
+
setCurrentIndex
|
| 16 |
+
} = useDiagnosis();
|
| 17 |
+
|
| 18 |
+
const handleSubmit = async () => {
|
| 19 |
+
await submitDiagnosis();
|
| 20 |
+
router.push('/result');
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<SurveyView
|
| 25 |
+
items={surveyItems}
|
| 26 |
+
answers={answers}
|
| 27 |
+
setAnswers={setAnswers}
|
| 28 |
+
onSubmit={handleSubmit}
|
| 29 |
+
currentIndex={currentIndex}
|
| 30 |
+
setCurrentIndex={setCurrentIndex}
|
| 31 |
+
/>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/components/common/States.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @license
|
| 3 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { motion } from 'motion/react';
|
| 7 |
+
import { Loader2, AlertCircle, Inbox } from 'lucide-react';
|
| 8 |
+
|
| 9 |
+
export function LoadingSpinner() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="flex flex-col items-center justify-center p-12 space-y-4">
|
| 12 |
+
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
|
| 13 |
+
<p className="text-sm text-gray-500 font-medium">데이터를 불러오는 중...</p>
|
| 14 |
+
</div>
|
| 15 |
+
);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function ErrorState({ message }: { message: string }) {
|
| 19 |
+
return (
|
| 20 |
+
<motion.div
|
| 21 |
+
initial={{ opacity: 0, y: 10 }}
|
| 22 |
+
animate={{ opacity: 1, y: 0 }}
|
| 23 |
+
className="flex flex-col items-center justify-center p-12 bg-red-50 rounded-2xl border border-red-100"
|
| 24 |
+
>
|
| 25 |
+
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
| 26 |
+
<h3 className="text-lg font-semibold text-red-800">오류가 발생했습니다</h3>
|
| 27 |
+
<p className="text-sm text-red-600 mt-1">{message}</p>
|
| 28 |
+
<button
|
| 29 |
+
onClick={() => window.location.reload()}
|
| 30 |
+
className="mt-6 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
| 31 |
+
>
|
| 32 |
+
다시 시도하기
|
| 33 |
+
</button>
|
| 34 |
+
</motion.div>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function EmptyState({ title, description }: { title: string, description: string }) {
|
| 39 |
+
return (
|
| 40 |
+
<div className="flex flex-col items-center justify-center p-16 text-center border-2 border-dashed border-gray-200 rounded-2xl">
|
| 41 |
+
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
|
| 42 |
+
<Inbox className="w-8 h-8 text-gray-400" />
|
| 43 |
+
</div>
|
| 44 |
+
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
| 45 |
+
<p className="text-sm text-gray-500 mt-2 max-w-xs">{description}</p>
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
src/components/features/Intro.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { motion } from 'framer-motion';
|
| 9 |
+
import { ChevronRight, Target, BarChart3, ShieldCheck } from 'lucide-react';
|
| 10 |
+
|
| 11 |
+
export function IntroView({ onStart }: { onStart: () => void }) {
|
| 12 |
+
return (
|
| 13 |
+
<div className="max-w-4xl mx-auto py-8 sm:py-12 px-4 sm:px-6">
|
| 14 |
+
<motion.div
|
| 15 |
+
initial={{ opacity: 0, y: 20 }}
|
| 16 |
+
animate={{ opacity: 1, y: 0 }}
|
| 17 |
+
className="text-center mb-12 sm:mb-16"
|
| 18 |
+
>
|
| 19 |
+
<span className="inline-block px-4 py-1.5 bg-cyan-50 text-cyan-600 rounded-full border border-cyan-100 text-sm sm:text-base font-medium mb-6">
|
| 20 |
+
IT산업 종사자 보상경쟁력 자가진단 서비스
|
| 21 |
+
</span>
|
| 22 |
+
<h2 className="text-3xl sm:text-4xl md:text-[52px] font-bold text-slate-900 leading-tight mb-6 sm:mb-8">
|
| 23 |
+
나의 직무 역량과<br />
|
| 24 |
+
<span className="text-cyan-500 underline decoration-cyan-200 underline-offset-8">임금 수준</span>을 진단하세요
|
| 25 |
+
</h2>
|
| 26 |
+
<p className="text-sm sm:text-lg leading-6 sm:leading-normal text-slate-600 max-w-2xl mx-auto mb-10 sm:mb-12 break-keep">
|
| 27 |
+
10가지 핵심 역량 지표를 통해 당신의 전문성을 분석하고,<br />
|
| 28 |
+
동일 직무/레벨 대비 시장 가치를 확인합니다.
|
| 29 |
+
</p>
|
| 30 |
+
<button
|
| 31 |
+
onClick={onStart}
|
| 32 |
+
className="group relative w-full sm:w-auto px-8 sm:px-10 py-4 text-base sm:text-lg md:px-8 md:py-3 md:text-base bg-slate-900 text-white rounded-2xl font-bold shadow-2xl shadow-slate-200 hover:bg-cyan-600 transition-all active:scale-95"
|
| 33 |
+
>
|
| 34 |
+
진단 시작하기
|
| 35 |
+
<ChevronRight className="inline-block ml-2 group-hover:translate-x-1 transition-transform" />
|
| 36 |
+
</button>
|
| 37 |
+
</motion.div>
|
| 38 |
+
|
| 39 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 sm:gap-8">
|
| 40 |
+
{[
|
| 41 |
+
{ icon: Target, title: "정밀 역량 진단", desc: "10개 지표 기반 수직 계층형 분석" },
|
| 42 |
+
{ icon: BarChart3, title: "시장 데이터 비교", desc: "업종/매출 규모별 보상 경쟁력 확인" },
|
| 43 |
+
{ icon: ShieldCheck, title: "신뢰할 수 있는 지표", desc: "IT 산업 표준 직무 체계 적용" }
|
| 44 |
+
].map((item, i) => (
|
| 45 |
+
<div key={i} className="p-6 sm:p-8 bg-white border border-slate-100 rounded-3xl shadow-sm">
|
| 46 |
+
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center mb-6">
|
| 47 |
+
<item.icon className="w-6 h-6 text-slate-400" />
|
| 48 |
+
</div>
|
| 49 |
+
<h3 className="text-lg font-bold text-slate-900 mb-2">{item.title}</h3>
|
| 50 |
+
<p className="text-sm text-slate-600 leading-relaxed">{item.desc}</p>
|
| 51 |
+
</div>
|
| 52 |
+
))}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
src/components/features/Result.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { ResultData } from '../../types/diagnosis';
|
| 9 |
+
import { Download, RotateCcw, CheckCircle2, Info, Users } from 'lucide-react';
|
| 10 |
+
import { MarketPositionGaugeChart } from './ResultCharts';
|
| 11 |
+
import { SummaryBadge, LevelAnalysisColumn } from './ResultComponents';
|
| 12 |
+
|
| 13 |
+
export function ResultView({
|
| 14 |
+
data,
|
| 15 |
+
onReset,
|
| 16 |
+
onDownload
|
| 17 |
+
}: {
|
| 18 |
+
data: ResultData;
|
| 19 |
+
onReset: () => void;
|
| 20 |
+
onDownload?: () => void;
|
| 21 |
+
}) {
|
| 22 |
+
return (
|
| 23 |
+
<div className="w-screen sm:w-auto relative left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] sm:static sm:left-auto sm:right-auto sm:mx-auto sm:my-0 sm:max-w-[1400px] pt-2 pb-12 px-0 sm:px-6 space-y-4 font-sans text-slate-900">
|
| 24 |
+
{/* Summary Card Section */}
|
| 25 |
+
<div
|
| 26 |
+
className="bg-white border border-slate-200 rounded-[1.5rem] pt-4 sm:pt-6 pb-4 sm:pb-6 px-4 sm:px-12 mb-6 sm:mb-9 shadow-xl shadow-slate-100/50 flex flex-col md:flex-row items-start md:items-center gap-4 sm:gap-8 md:gap-16"
|
| 27 |
+
>
|
| 28 |
+
<div className="shrink-0 border-l-4 border-cyan-500 pl-4 sm:pl-6">
|
| 29 |
+
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-slate-900 tracking-tight leading-tight">
|
| 30 |
+
진단 결과 요약
|
| 31 |
+
</h1>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div className="flex-grow min-w-0 space-y-2">
|
| 35 |
+
{data.headMessage.map((s, i) => (
|
| 36 |
+
<div key={i} className="flex items-start space-x-3">
|
| 37 |
+
<div className="w-6 h-6 bg-cyan-50 rounded-full flex items-center justify-center shrink-0 mt-0.5">
|
| 38 |
+
<CheckCircle2 className="w-4 h-4 text-cyan-500" />
|
| 39 |
+
</div>
|
| 40 |
+
<p className="text-sm sm:text-base md:text-md font-medium text-slate-700 leading-relaxed break-keep">
|
| 41 |
+
{s}
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
{/* Main Content Card */}
|
| 49 |
+
<div className="bg-white border border-slate-200 rounded-[1.5rem] shadow-xl shadow-slate-100/50 overflow-hidden">
|
| 50 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 divide-y lg:divide-y-0 lg:divide-x divide-slate-100">
|
| 51 |
+
|
| 52 |
+
{/* Column 1: Left Analysis (Current) */}
|
| 53 |
+
<LevelAnalysisColumn
|
| 54 |
+
levelInfo={data.levelInfo.left}
|
| 55 |
+
radarChartData={data.levelChart.left}
|
| 56 |
+
isCurrent
|
| 57 |
+
/>
|
| 58 |
+
|
| 59 |
+
{/* Column 2: Right Analysis (Next) */}
|
| 60 |
+
<LevelAnalysisColumn
|
| 61 |
+
levelInfo={data.levelInfo.right}
|
| 62 |
+
radarChartData={data.levelChart.right}
|
| 63 |
+
/>
|
| 64 |
+
|
| 65 |
+
{/* Column 3: Compensation */}
|
| 66 |
+
<div className="p-4 sm:p-8 space-y-4 sm:space-y-5 bg-white text-center min-w-0">
|
| 67 |
+
<div className="space-y-4">
|
| 68 |
+
<h3 className="text-xl font-bold text-slate-900">보상 경쟁력</h3>
|
| 69 |
+
<p className="text-sm text-slate-600 font-medium leading-relaxed whitespace-pre-line break-keep">
|
| 70 |
+
{data.wageOutput[0].description}
|
| 71 |
+
</p>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="space-y-2">
|
| 75 |
+
<div className="flex flex-col items-center justify-center">
|
| 76 |
+
<MarketPositionGaugeChart percentile={data.percentile} />
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="grid grid-cols-3 gap-2 sm:gap-3 pb-2">
|
| 80 |
+
<SummaryBadge label="User" value={data.wageOutput[0].user} />
|
| 81 |
+
<SummaryBadge label="시장 평균" value={data.wageOutput[0].marketAverage} />
|
| 82 |
+
<SummaryBadge label="Gap" value={data.wageOutput[0].gap} />
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
<div className="border-t border-dashed border-slate-200 pt-8 space-y-3">
|
| 88 |
+
<div className="flex items-center space-x-2">
|
| 89 |
+
<Users className="w-5 h-5 text-cyan-500" />
|
| 90 |
+
<h4 className="text-sm font-bold text-slate-900">직무 레벨 및 분석 조건</h4>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* 분석 조건 상세 배지 */}
|
| 94 |
+
<div className="flex flex-wrap justify-center pt-2 gap-2 sm:gap-3 mt-3">
|
| 95 |
+
{data.info.map((text, i) => (
|
| 96 |
+
<span
|
| 97 |
+
key={i}
|
| 98 |
+
className="bg-slate-50 text-xs sm:text-sm text-slate-600 font-medium leading-relaxed whitespace-pre-line px-3 py-1 rounded-full border border-slate-200 break-keep"
|
| 99 |
+
>
|
| 100 |
+
{text}
|
| 101 |
+
</span>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{/* Footer Disclaimer */}
|
| 110 |
+
<div className="flex items-start space-x-3 p-4 sm:p-6">
|
| 111 |
+
<Info className="w-5 h-5 text-slate-500 mt-0.5 shrink-0" />
|
| 112 |
+
<div className="text-sm text-slate-600 leading-relaxed font-medium space-y-1">
|
| 113 |
+
<p>본 진단 결과는 입력하신 정보를 바탕으로 한 통계적 추정치이므로 참고용으로만 활용해 주십시오.</p>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Bottom Action Bar */}
|
| 118 |
+
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3 sm:gap-4">
|
| 119 |
+
<button
|
| 120 |
+
onClick={onReset}
|
| 121 |
+
className="flex items-center justify-center space-x-2 px-6 sm:px-8 py-4 md:px-6 md:py-3 border border-slate-200 rounded-2xl hover:bg-slate-50 font-bold text-slate-600 transition-colors"
|
| 122 |
+
>
|
| 123 |
+
<RotateCcw className="w-5 h-5" />
|
| 124 |
+
<span>처음으로</span>
|
| 125 |
+
</button>
|
| 126 |
+
<button
|
| 127 |
+
onClick={onDownload}
|
| 128 |
+
className="w-full sm:max-w-[320px] flex items-center justify-center space-x-3 py-4 text-base sm:text-lg md:py-3 md:text-base bg-slate-900 text-white rounded-2xl font-bold shadow-2xl shadow-slate-200 hover:bg-cyan-600 transition-all active:scale-95"
|
| 129 |
+
>
|
| 130 |
+
<Download className="w-5 h-5" />
|
| 131 |
+
<span>진단 리포트 다운로드</span>
|
| 132 |
+
</button>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
src/components/features/ResultCharts.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, PieChart, Pie, Legend } from 'recharts';
|
| 9 |
+
|
| 10 |
+
interface RadarData {
|
| 11 |
+
subject: string;
|
| 12 |
+
target: number;
|
| 13 |
+
user: number;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function DiagnosisRadarChart({ data }: { data: RadarData[] }) {
|
| 17 |
+
const renderCustomAxisTick = ({ x, y, payload, cx, cy, ...rest }: any) => {
|
| 18 |
+
const { value } = payload;
|
| 19 |
+
const words = value.split(' ');
|
| 20 |
+
|
| 21 |
+
// Calculate alignment based on position relative to center
|
| 22 |
+
const textAnchor = x > cx ? 'start' : x < cx ? 'end' : 'middle';
|
| 23 |
+
const dy = y > cy ? 14 : y < cy ? -4 : 4;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<text
|
| 27 |
+
x={x}
|
| 28 |
+
y={y}
|
| 29 |
+
textAnchor={textAnchor}
|
| 30 |
+
fill="#64748b"
|
| 31 |
+
fontSize={10}
|
| 32 |
+
fontWeight={600}
|
| 33 |
+
className="recharts-polar-angle-axis-tick-value"
|
| 34 |
+
>
|
| 35 |
+
{words.map((word: string, index: number) => (
|
| 36 |
+
<tspan x={x} dy={index === 0 ? dy : 11} key={index}>
|
| 37 |
+
{word}
|
| 38 |
+
</tspan>
|
| 39 |
+
))}
|
| 40 |
+
</text>
|
| 41 |
+
);
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const renderLegend = (props: any) => {
|
| 45 |
+
const { payload } = props;
|
| 46 |
+
return (
|
| 47 |
+
<div className="flex flex-wrap justify-center gap-3 sm:gap-6 mt-5 sm:mt-6 px-2">
|
| 48 |
+
{payload.map((entry: any, index: number) => {
|
| 49 |
+
const fillOpacity = entry.dataKey === 'target' ? 0.1 : 0.4;
|
| 50 |
+
// Target: #fbbf24 -> rgba(251, 191, 36)
|
| 51 |
+
// User: #06b6d4 -> rgba(6, 182, 212)
|
| 52 |
+
const bgColor = entry.dataKey === 'target'
|
| 53 |
+
? `rgba(251, 191, 36, ${fillOpacity})`
|
| 54 |
+
: `rgba(6, 182, 212, ${fillOpacity})`;
|
| 55 |
+
|
| 56 |
+
return (
|
| 57 |
+
<div key={`item-${index}`} className="flex items-center gap-2">
|
| 58 |
+
<div
|
| 59 |
+
className="w-3 h-3 rounded-full"
|
| 60 |
+
style={{
|
| 61 |
+
backgroundColor: bgColor,
|
| 62 |
+
border: `1.5px solid ${entry.color}`
|
| 63 |
+
}}
|
| 64 |
+
/>
|
| 65 |
+
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider">{entry.value}</span>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
})}
|
| 69 |
+
</div>
|
| 70 |
+
);
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="h-[260px] sm:h-[320px] w-full bg-white rounded-xl pb-4 sm:pb-5">
|
| 75 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 76 |
+
<RadarChart cx="50%" cy="45%" outerRadius="75%" data={data}>
|
| 77 |
+
<PolarGrid stroke="#e2e8f0" />
|
| 78 |
+
<PolarAngleAxis
|
| 79 |
+
dataKey="subject"
|
| 80 |
+
tick={renderCustomAxisTick}
|
| 81 |
+
/>
|
| 82 |
+
<PolarRadiusAxis domain={[1, 5]} tick={false} axisLine={false} />
|
| 83 |
+
<Radar name="Target" dataKey="target" stroke="#fbbf24" fill="#fbbf24" fillOpacity={0.1} />
|
| 84 |
+
<Radar name="User" dataKey="user" stroke="#06b6d4" fill="#06b6d4" fillOpacity={0.4} />
|
| 85 |
+
<Legend content={renderLegend} verticalAlign="bottom" />
|
| 86 |
+
</RadarChart>
|
| 87 |
+
</ResponsiveContainer>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export function MarketPositionGaugeChart({ percentile }: { percentile: number }) {
|
| 93 |
+
const data = [
|
| 94 |
+
{ name: 'Value', value: percentile, fill: '#134e6f' },
|
| 95 |
+
{ name: 'Remaining', value: 100 - percentile, fill: '#f1f5f9' },
|
| 96 |
+
];
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="h-[132px] sm:h-[148px] w-full relative flex items-center justify-center overflow-visible">
|
| 100 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 101 |
+
<PieChart>
|
| 102 |
+
<Pie
|
| 103 |
+
dataKey="value"
|
| 104 |
+
startAngle={180}
|
| 105 |
+
endAngle={0}
|
| 106 |
+
data={data}
|
| 107 |
+
cx="50%"
|
| 108 |
+
cy="84%"
|
| 109 |
+
innerRadius={75}
|
| 110 |
+
outerRadius={95}
|
| 111 |
+
stroke="none"
|
| 112 |
+
paddingAngle={0}
|
| 113 |
+
/>
|
| 114 |
+
</PieChart>
|
| 115 |
+
</ResponsiveContainer>
|
| 116 |
+
<div className="absolute top-[46%] left-1/2 -translate-x-1/2 text-center">
|
| 117 |
+
<p className="text-[12px] font-bold text-slate-500">시장 내 위치</p>
|
| 118 |
+
<p className="text-xl sm:text-2xl font-bold text-slate-800 tracking-tight">{percentile.toFixed(1)}%</p>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
}
|
src/components/features/ResultComponents.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { DiagnosisRadarChart } from './ResultCharts';
|
| 9 |
+
|
| 10 |
+
interface LevelDisplayProps {
|
| 11 |
+
title: string;
|
| 12 |
+
definition: string;
|
| 13 |
+
isCurrent?: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Used in ResultView to show Level definition
|
| 18 |
+
*/
|
| 19 |
+
export function LevelDisplay({ title, definition }: LevelDisplayProps) {
|
| 20 |
+
return (
|
| 21 |
+
<div className="space-y-2 text-center">
|
| 22 |
+
<h3 className="text-xl font-bold text-slate-900">
|
| 23 |
+
{title}
|
| 24 |
+
</h3>
|
| 25 |
+
<p className="text-sm text-slate-600 leading-relaxed font-medium">
|
| 26 |
+
{definition}
|
| 27 |
+
</p>
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
interface SummaryBadgeProps {
|
| 33 |
+
label: string;
|
| 34 |
+
value: string | number;
|
| 35 |
+
className?: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Used in ResultView to show compensation summary items (User, Market Average, Gap).
|
| 40 |
+
*/
|
| 41 |
+
export function SummaryBadge({ label, value, className = "" }: SummaryBadgeProps) {
|
| 42 |
+
return (
|
| 43 |
+
<div className={`bg-[#f4f9fb] rounded-lg sm:rounded-xl pt-1 pb-1.5 sm:py-2 px-1.5 sm:px-2 text-center space-y-0 sm:space-y-1 min-w-0 ${className}`}>
|
| 44 |
+
<span className="text-[12px] sm:text-[12px] font-medium text-slate-500">{label}</span>
|
| 45 |
+
<p className="text-[13px] sm:text-lg font-bold text-slate-900 break-all">{value}</p>
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
interface LevelAnalysisColumnProps {
|
| 51 |
+
levelInfo: {
|
| 52 |
+
title: string;
|
| 53 |
+
definition: string;
|
| 54 |
+
guide: string;
|
| 55 |
+
items: string;
|
| 56 |
+
};
|
| 57 |
+
radarChartData: { subject: string; target: number; user: number }[];
|
| 58 |
+
isCurrent?: boolean;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Used in ResultView to display each level's analysis column.
|
| 63 |
+
*/
|
| 64 |
+
export function LevelAnalysisColumn({ levelInfo, radarChartData, isCurrent }: LevelAnalysisColumnProps) {
|
| 65 |
+
return (
|
| 66 |
+
<div className="p-5 sm:p-8 space-y-6 min-w-0">
|
| 67 |
+
<LevelDisplay
|
| 68 |
+
title={levelInfo.title}
|
| 69 |
+
definition={levelInfo.definition}
|
| 70 |
+
isCurrent={isCurrent}
|
| 71 |
+
/>
|
| 72 |
+
|
| 73 |
+
<DiagnosisRadarChart data={radarChartData} />
|
| 74 |
+
|
| 75 |
+
<div className="space-y-3 text-center">
|
| 76 |
+
<h4 className="text-sm font-bold text-slate-900">{levelInfo.guide}</h4>
|
| 77 |
+
<div className="p-4 bg-slate-50 rounded-xl text-sm text-slate-600 font-medium whitespace-pre-wrap min-h-[80px] flex items-center justify-center text-center break-keep">
|
| 78 |
+
{levelInfo.items}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
}
|
src/components/features/Search.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { useState, useEffect } from 'react';
|
| 9 |
+
import { SearchParams } from '../../types/diagnosis';
|
| 10 |
+
import { motion } from 'framer-motion';
|
| 11 |
+
import { SearchSelect, SearchRadioGroup, SearchInput } from './SearchComponents';
|
| 12 |
+
import { diagnosisService } from '../../services/diagnosisService';
|
| 13 |
+
|
| 14 |
+
export function SearchView({
|
| 15 |
+
params,
|
| 16 |
+
setParams,
|
| 17 |
+
onNext,
|
| 18 |
+
onReset
|
| 19 |
+
}: {
|
| 20 |
+
params: SearchParams;
|
| 21 |
+
setParams: (p: SearchParams) => void;
|
| 22 |
+
onNext: () => void;
|
| 23 |
+
onReset?: () => void;
|
| 24 |
+
}) {
|
| 25 |
+
const [options, setOptions] = useState<{
|
| 26 |
+
jobs: string[];
|
| 27 |
+
bms: string[];
|
| 28 |
+
sales: string[];
|
| 29 |
+
emps: string[];
|
| 30 |
+
userBases: string[];
|
| 31 |
+
}>({
|
| 32 |
+
jobs: [],
|
| 33 |
+
bms: [],
|
| 34 |
+
sales: [],
|
| 35 |
+
emps: [],
|
| 36 |
+
userBases: ['지급총액', '고정급']
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
async function fetchOpts() {
|
| 41 |
+
try {
|
| 42 |
+
const data = await diagnosisService.getOptions({
|
| 43 |
+
job: params.job,
|
| 44 |
+
bm: params.bm,
|
| 45 |
+
sales: params.sales
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
setOptions(prev => ({
|
| 49 |
+
jobs: data.job_options?.length ? data.job_options : prev.jobs,
|
| 50 |
+
bms: data.bm_options?.length ? data.bm_options : prev.bms,
|
| 51 |
+
sales: data.sales_options?.length ? data.sales_options : prev.sales,
|
| 52 |
+
emps: data.emp_options?.length ? data.emp_options : prev.emps,
|
| 53 |
+
userBases: ['지급총액', '고정급'],
|
| 54 |
+
}));
|
| 55 |
+
|
| 56 |
+
let newParams = { ...params };
|
| 57 |
+
let modified = false;
|
| 58 |
+
|
| 59 |
+
if (data.job_options?.length && newParams.job && !data.job_options.includes(newParams.job)) {
|
| 60 |
+
newParams.job = '';
|
| 61 |
+
modified = true;
|
| 62 |
+
}
|
| 63 |
+
if (data.bm_options?.length && !data.bm_options.includes(newParams.bm)) {
|
| 64 |
+
newParams.bm = data.bm_options[0];
|
| 65 |
+
modified = true;
|
| 66 |
+
}
|
| 67 |
+
if (data.sales_options?.length && !data.sales_options.includes(newParams.sales)) {
|
| 68 |
+
newParams.sales = data.sales_options[0];
|
| 69 |
+
modified = true;
|
| 70 |
+
}
|
| 71 |
+
if (data.emp_options?.length && !data.emp_options.includes(newParams.emp)) {
|
| 72 |
+
newParams.emp = data.emp_options[0];
|
| 73 |
+
modified = true;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (modified && newParams.job) {
|
| 77 |
+
setParams(newParams);
|
| 78 |
+
}
|
| 79 |
+
} catch (e) {
|
| 80 |
+
console.error("Failed to load dynamic options", e);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
fetchOpts();
|
| 85 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 86 |
+
}, [params.job, params.bm, params.sales, setParams]);
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<div className="w-screen sm:w-auto relative left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] sm:static sm:left-auto sm:right-auto sm:mx-auto sm:my-0 sm:max-w-3xl py-6 sm:py-12 px-0 sm:px-6">
|
| 90 |
+
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-10">
|
| 91 |
+
<div className="text-center space-y-4">
|
| 92 |
+
<h2 className="text-3xl font-bold text-slate-900 tracking-tight">상세 분석 조건 설정</h2>
|
| 93 |
+
<p className="text-slate-500 font-medium">정확한 시장 임금 비교를 위해 현재 상태를 입력해주세요.</p>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="bg-white p-4 sm:p-8 md:p-12 border border-slate-100 rounded-[2rem] sm:rounded-[2.5rem] shadow-2xl shadow-slate-100 space-y-7 sm:space-y-10">
|
| 97 |
+
<SearchRadioGroup
|
| 98 |
+
label="직무 선택"
|
| 99 |
+
name="job"
|
| 100 |
+
value={params.job}
|
| 101 |
+
options={options.jobs}
|
| 102 |
+
onChange={val => setParams({
|
| 103 |
+
...params,
|
| 104 |
+
job: val,
|
| 105 |
+
bm: '',
|
| 106 |
+
sales: '',
|
| 107 |
+
emp: ''
|
| 108 |
+
})}
|
| 109 |
+
/>
|
| 110 |
+
|
| 111 |
+
<div className="space-y-10">
|
| 112 |
+
{/* 1row : 비즈니스 모델 */}
|
| 113 |
+
<SearchSelect
|
| 114 |
+
label="비즈니스 모델"
|
| 115 |
+
value={params.bm}
|
| 116 |
+
options={options.bms}
|
| 117 |
+
onChange={val => setParams({ ...params, bm: val })}
|
| 118 |
+
/>
|
| 119 |
+
|
| 120 |
+
{/* 2row : 매출규모 + 직원규모 */}
|
| 121 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 122 |
+
<SearchSelect
|
| 123 |
+
label="매출 규모"
|
| 124 |
+
value={params.sales}
|
| 125 |
+
options={options.sales}
|
| 126 |
+
onChange={val => setParams({ ...params, sales: val })}
|
| 127 |
+
/>
|
| 128 |
+
<SearchSelect
|
| 129 |
+
label="직원 규모"
|
| 130 |
+
value={params.emp}
|
| 131 |
+
options={options.emps}
|
| 132 |
+
onChange={val => setParams({ ...params, emp: val })}
|
| 133 |
+
/>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* 3row : 보상단위 + 보상액 */}
|
| 137 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-end">
|
| 138 |
+
<SearchSelect
|
| 139 |
+
label="보상 단위"
|
| 140 |
+
value={params.userBase}
|
| 141 |
+
options={options.userBases}
|
| 142 |
+
onChange={val => setParams({ ...params, userBase: val })}
|
| 143 |
+
/>
|
| 144 |
+
<SearchInput
|
| 145 |
+
label="현재 보상액 (세���)"
|
| 146 |
+
value={params.userValue}
|
| 147 |
+
unit="만원"
|
| 148 |
+
onChange={val => setParams({ ...params, userValue: val })}
|
| 149 |
+
/>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 sm:gap-4 mt-8">
|
| 154 |
+
<button
|
| 155 |
+
onClick={() => {
|
| 156 |
+
if (onReset) onReset();
|
| 157 |
+
setOptions(prev => ({
|
| 158 |
+
...prev,
|
| 159 |
+
bms: [],
|
| 160 |
+
sales: [],
|
| 161 |
+
emps: [],
|
| 162 |
+
}));
|
| 163 |
+
}}
|
| 164 |
+
className="md:col-span-1 h-14 text-base bg-white text-slate-400 border border-slate-100 rounded-[1.5rem] font-bold hover:bg-slate-50 hover:text-slate-600 transition-all active:scale-[0.98]"
|
| 165 |
+
>
|
| 166 |
+
초기화
|
| 167 |
+
</button>
|
| 168 |
+
<button
|
| 169 |
+
onClick={onNext}
|
| 170 |
+
disabled={!params.job}
|
| 171 |
+
className={`md:col-span-3 h-14 text-base rounded-[1.5rem] font-bold shadow-2xl transition-all active:scale-[0.98] ${!params.job
|
| 172 |
+
? 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
| 173 |
+
: 'bg-slate-900 text-white shadow-slate-200 hover:bg-cyan-600 hover:shadow-cyan-200'
|
| 174 |
+
}`}
|
| 175 |
+
>
|
| 176 |
+
진단 시작하기
|
| 177 |
+
</button>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
</motion.div>
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
src/components/features/SearchComponents.tsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
interface SearchSelectProps {
|
| 10 |
+
label: string;
|
| 11 |
+
value: string;
|
| 12 |
+
options: string[];
|
| 13 |
+
onChange: (value: string) => void;
|
| 14 |
+
className?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function SearchSelect({ label, value, options, onChange, className = "" }: SearchSelectProps) {
|
| 18 |
+
return (
|
| 19 |
+
<div className={`space-y-4 ${className}`}>
|
| 20 |
+
<label className="text-sm font-bold text-slate-400 uppercase tracking-[0.1em] block">
|
| 21 |
+
{label}
|
| 22 |
+
</label>
|
| 23 |
+
<div className="relative">
|
| 24 |
+
<select
|
| 25 |
+
value={value}
|
| 26 |
+
onChange={e => onChange(e.target.value)}
|
| 27 |
+
className="w-full h-14 px-5 bg-slate-50 border border-slate-100 rounded-2xl text-slate-700 text-base font-bold appearance-none focus:bg-white focus:border-cyan-500 focus:ring-4 focus:ring-cyan-50/50 transition-all outline-none"
|
| 28 |
+
>
|
| 29 |
+
<option value="" disabled hidden>선택해주세요</option>
|
| 30 |
+
{options.map(opt => <option key={opt} value={opt} className="text-base">{opt}</option>)}
|
| 31 |
+
</select>
|
| 32 |
+
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
| 33 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 34 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M19 9l-7 7-7-7" />
|
| 35 |
+
</svg>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface SearchRadioGroupProps {
|
| 43 |
+
label: string;
|
| 44 |
+
value: string;
|
| 45 |
+
options: string[];
|
| 46 |
+
onChange: (value: string) => void;
|
| 47 |
+
name: string;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function SearchRadioGroup({ label, value, options, onChange, name }: SearchRadioGroupProps) {
|
| 51 |
+
return (
|
| 52 |
+
<div className="space-y-5">
|
| 53 |
+
<label className="text-sm font-bold text-slate-400 uppercase tracking-[0.1em] block">
|
| 54 |
+
{label}
|
| 55 |
+
</label>
|
| 56 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 sm:gap-5">
|
| 57 |
+
{options.map(opt => (
|
| 58 |
+
<label
|
| 59 |
+
key={opt}
|
| 60 |
+
className={`
|
| 61 |
+
flex items-center justify-center min-h-14 px-4 py-3 rounded-2xl text-sm sm:text-base text-center break-keep font-bold border transition-all cursor-pointer
|
| 62 |
+
${value === opt
|
| 63 |
+
? 'border-cyan-500 bg-cyan-50 text-cyan-700 ring-4 ring-cyan-50'
|
| 64 |
+
: 'border-slate-100 bg-slate-50 text-slate-400 hover:border-slate-200'}
|
| 65 |
+
`}
|
| 66 |
+
>
|
| 67 |
+
<input
|
| 68 |
+
type="radio"
|
| 69 |
+
name={name}
|
| 70 |
+
value={opt}
|
| 71 |
+
checked={value === opt}
|
| 72 |
+
onChange={() => onChange(opt)}
|
| 73 |
+
className="sr-only"
|
| 74 |
+
/>
|
| 75 |
+
{opt}
|
| 76 |
+
</label>
|
| 77 |
+
))}
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
interface SearchInputProps {
|
| 84 |
+
label: string;
|
| 85 |
+
value: number;
|
| 86 |
+
onChange: (value: number) => void;
|
| 87 |
+
unit: string;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export function SearchInput({ label, value, onChange, unit }: SearchInputProps) {
|
| 91 |
+
return (
|
| 92 |
+
<div className="space-y-4">
|
| 93 |
+
<div className="flex items-center justify-between">
|
| 94 |
+
<label className="text-sm font-bold text-slate-400 uppercase tracking-[0.1em]">
|
| 95 |
+
{label}
|
| 96 |
+
</label>
|
| 97 |
+
</div>
|
| 98 |
+
<div className="relative">
|
| 99 |
+
<input
|
| 100 |
+
type="number"
|
| 101 |
+
value={value === 0 ? '' : value}
|
| 102 |
+
onChange={e => onChange(Number(e.target.value))}
|
| 103 |
+
className="w-full h-14 pl-5 pr-16 bg-slate-50 border border-slate-100 rounded-2xl text-base font-bold text-slate-800 focus:bg-white focus:border-cyan-500 focus:ring-4 focus:ring-cyan-50/50 transition-all outline-none"
|
| 104 |
+
placeholder="0"
|
| 105 |
+
/>
|
| 106 |
+
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none font-bold text-slate-400">{unit}</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
);
|
| 110 |
+
}
|
src/components/features/Survey.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { SurveyItem } from '../../types/diagnosis';
|
| 9 |
+
import { ChevronLeft, ChevronRight, CheckCircle } from 'lucide-react';
|
| 10 |
+
import { LevelIndicator } from './SurveyComponents';
|
| 11 |
+
|
| 12 |
+
export function SurveyView({
|
| 13 |
+
items,
|
| 14 |
+
answers,
|
| 15 |
+
setAnswers,
|
| 16 |
+
onSubmit,
|
| 17 |
+
currentIndex,
|
| 18 |
+
setCurrentIndex
|
| 19 |
+
}: {
|
| 20 |
+
items: SurveyItem[];
|
| 21 |
+
answers: number[];
|
| 22 |
+
setAnswers: (a: number[]) => void;
|
| 23 |
+
onSubmit: () => void;
|
| 24 |
+
currentIndex: number;
|
| 25 |
+
setCurrentIndex: (i: number) => void;
|
| 26 |
+
}) {
|
| 27 |
+
const updateAnswer = (index: number, value: number) => {
|
| 28 |
+
const newAnswers = [...answers];
|
| 29 |
+
newAnswers[index] = value;
|
| 30 |
+
setAnswers(newAnswers);
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const nextQuestion = () => {
|
| 34 |
+
if (currentIndex < items.length - 1) {
|
| 35 |
+
setCurrentIndex(currentIndex + 1);
|
| 36 |
+
}
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const prevQuestion = () => {
|
| 40 |
+
if (currentIndex > 0) {
|
| 41 |
+
setCurrentIndex(currentIndex - 1);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const currentItem = items[currentIndex];
|
| 46 |
+
|
| 47 |
+
if (!currentItem) {
|
| 48 |
+
return null;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="w-screen sm:w-auto relative left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] sm:static sm:left-auto sm:right-auto sm:mx-auto sm:my-0 sm:max-w-4xl py-6 sm:py-12 px-0 sm:px-6 min-h-[80vh] flex flex-col">
|
| 53 |
+
<div className="flex-grow flex flex-col justify-center">
|
| 54 |
+
<div className="bg-white border border-slate-100 rounded-[2rem] sm:rounded-[2.5rem] shadow-xl shadow-slate-100/50 overflow-hidden">
|
| 55 |
+
<div className="p-4 sm:p-8 md:p-12 space-y-5 sm:space-y-6">
|
| 56 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 sm:gap-6 border-l-4 border-cyan-500 pl-4 sm:pl-6">
|
| 57 |
+
<h3 className="text-xl sm:text-2xl font-bold text-slate-900 leading-tight break-keep">{currentItem.title}</h3>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div className="space-y-6">
|
| 61 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 62 |
+
<LevelIndicator
|
| 63 |
+
level={2}
|
| 64 |
+
content={currentItem.level2}
|
| 65 |
+
isActive={answers[currentIndex] <= 2}
|
| 66 |
+
type="base"
|
| 67 |
+
/>
|
| 68 |
+
<LevelIndicator
|
| 69 |
+
level={4}
|
| 70 |
+
content={currentItem.level4}
|
| 71 |
+
isActive={answers[currentIndex] >= 4}
|
| 72 |
+
type="expert"
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div className="flex flex-col items-center space-y-8 pt-2">
|
| 78 |
+
<div className="grid grid-cols-5 gap-2 sm:gap-4 md:gap-8 w-full max-w-3xl">
|
| 79 |
+
{[
|
| 80 |
+
{ val: 1, label: '2수준보다 부족함' },
|
| 81 |
+
{ val: 2, label: '2수준' },
|
| 82 |
+
{ val: 3, label: '중간 수준' },
|
| 83 |
+
{ val: 4, label: '4수준' },
|
| 84 |
+
{ val: 5, label: '4수준보다 뛰어남' }
|
| 85 |
+
].map((item) => (
|
| 86 |
+
<div key={item.val} className="flex flex-col items-center space-y-2 sm:space-y-5 min-w-0">
|
| 87 |
+
<button
|
| 88 |
+
onClick={() => updateAnswer(currentIndex, item.val)}
|
| 89 |
+
className={`w-10 h-10 sm:w-14 sm:h-14 md:w-12 md:h-12 rounded-lg sm:rounded-xl flex items-center justify-center text-base sm:text-xl md:text-2xl font-bold transition-all ${answers[currentIndex] === item.val ? 'bg-cyan-500 text-white shadow-xl shadow-cyan-100 scale-105' : 'bg-white text-slate-400 hover:bg-slate-50 border-2 border-slate-200 hover:border-slate-200'}`}
|
| 90 |
+
>
|
| 91 |
+
{item.val}
|
| 92 |
+
</button>
|
| 93 |
+
<span className={`text-[10px] sm:text-xs md:text-xs font-medium text-center leading-[1.25] break-words max-w-full min-h-[2.5em] ${answers[currentIndex] === item.val ? 'text-cyan-600' : 'text-slate-400'}`}>
|
| 94 |
+
{item.label}
|
| 95 |
+
</span>
|
| 96 |
+
</div>
|
| 97 |
+
))}
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-3 sm:gap-4 pt-8 sm:pt-10 border-t border-slate-100">
|
| 102 |
+
<button
|
| 103 |
+
onClick={prevQuestion}
|
| 104 |
+
disabled={currentIndex === 0}
|
| 105 |
+
className={`flex items-center justify-center w-full sm:w-14 h-12 sm:h-14 md:w-12 md:h-12 rounded-2xl border ${currentIndex === 0 ? 'bg-slate-50 border-slate-100 text-slate-300 cursor-not-allowed' : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
| 106 |
+
>
|
| 107 |
+
<ChevronLeft size={24} />
|
| 108 |
+
</button>
|
| 109 |
+
|
| 110 |
+
{currentIndex === items.length - 1 ? (
|
| 111 |
+
<button
|
| 112 |
+
onClick={onSubmit}
|
| 113 |
+
disabled={!answers[currentIndex]}
|
| 114 |
+
className={`w-full sm:max-w-[320px] py-4 text-base sm:text-lg md:py-3 md:text-base rounded-2xl font-bold shadow-2xl flex items-center justify-center gap-3 transition-all ${!answers[currentIndex] ? 'bg-slate-200 text-slate-500 cursor-not-allowed' : 'bg-slate-900 text-white shadow-slate-200 hover:bg-cyan-600'}`}
|
| 115 |
+
>
|
| 116 |
+
<CheckCircle size={20} />
|
| 117 |
+
진단 완료 및 결과 분석
|
| 118 |
+
</button>
|
| 119 |
+
) : (
|
| 120 |
+
<button
|
| 121 |
+
onClick={nextQuestion}
|
| 122 |
+
disabled={!answers[currentIndex]}
|
| 123 |
+
className={`w-full sm:max-w-[320px] py-4 text-base sm:text-lg md:py-3 md:text-base rounded-2xl font-bold shadow-2xl flex items-center justify-center gap-3 transition-all ${!answers[currentIndex] ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'bg-cyan-500 text-white shadow-cyan-100 hover:bg-cyan-600'}`}
|
| 124 |
+
>
|
| 125 |
+
다음 항목으로
|
| 126 |
+
<ChevronRight size={20} />
|
| 127 |
+
</button>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
}
|
src/components/features/SurveyComponents.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @license
|
| 5 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
interface LevelIndicatorProps {
|
| 9 |
+
level: number;
|
| 10 |
+
content: string[];
|
| 11 |
+
isActive: boolean;
|
| 12 |
+
type: 'base' | 'expert';
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Used in SurveyView to show Level 2 and Level 4 descriptions.
|
| 17 |
+
*/
|
| 18 |
+
export function LevelIndicator({ level, content, isActive }: LevelIndicatorProps) {
|
| 19 |
+
const activeClasses = 'bg-cyan-50 border-cyan-300 ring-4 ring-cyan-50';
|
| 20 |
+
const inactiveClasses = 'bg-slate-50 border-slate-200';
|
| 21 |
+
|
| 22 |
+
const dotActiveClasses = 'bg-cyan-600';
|
| 23 |
+
const dotInactiveClasses = 'bg-slate-400';
|
| 24 |
+
|
| 25 |
+
const labelClasses = 'text-cyan-700';
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className={`px-5 sm:px-8 py-5 rounded-3xl border transition-all duration-300 ${isActive ? activeClasses : inactiveClasses}`}>
|
| 29 |
+
<div className="flex items-center justify-center space-x-2 mb-4">
|
| 30 |
+
<span className={`text-[16px] font-bold uppercase tracking-widest ${labelClasses}`}>
|
| 31 |
+
{level}수준
|
| 32 |
+
</span>
|
| 33 |
+
</div>
|
| 34 |
+
<div className={`space-y-3 transition-colors duration-300 ${isActive ? 'text-cyan-900 font-medium' : 'text-slate-800 font-normal'}`}>
|
| 35 |
+
{content.map((item, index) => (
|
| 36 |
+
<div key={index} className="flex items-start space-x-2">
|
| 37 |
+
<span className={`mt-1.5 w-1 h-1 rounded-full shrink-0 transition-colors duration-300 ${isActive ? dotActiveClasses : dotInactiveClasses}`} />
|
| 38 |
+
<p className="text-sm md:text-base leading-relaxed break-keep">
|
| 39 |
+
{item}
|
| 40 |
+
</p>
|
| 41 |
+
</div>
|
| 42 |
+
))}
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
src/components/layout/Common.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @license
|
| 3 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { Loader2, AlertCircle } from 'lucide-react';
|
| 7 |
+
|
| 8 |
+
export function LoadingOverlay({ message = "분석 중입니다..." }: { message?: string }) {
|
| 9 |
+
return (
|
| 10 |
+
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex flex-col items-center justify-center">
|
| 11 |
+
<Loader2 className="w-12 h-12 text-cyan-500 animate-spin mb-4" />
|
| 12 |
+
<p className="text-lg font-bold text-slate-700">{message}</p>
|
| 13 |
+
</div>
|
| 14 |
+
);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
| 18 |
+
return (
|
| 19 |
+
<div className="p-6 bg-red-50 border border-red-200 rounded-2xl flex flex-col items-center text-center">
|
| 20 |
+
<AlertCircle className="w-10 h-10 text-red-500 mb-2" />
|
| 21 |
+
<p className="text-red-700 font-medium mb-4">{message}</p>
|
| 22 |
+
{onRetry && (
|
| 23 |
+
<button
|
| 24 |
+
onClick={onRetry}
|
| 25 |
+
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
| 26 |
+
>
|
| 27 |
+
다시 시도
|
| 28 |
+
</button>
|
| 29 |
+
)}
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
import Link from 'next/link';
|
| 35 |
+
|
| 36 |
+
export function Header() {
|
| 37 |
+
return (
|
| 38 |
+
<header className="min-h-16 border-b border-slate-100 bg-white sticky top-0 z-40 px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
|
| 39 |
+
<Link href="/" className="flex min-w-0 items-center space-x-2 hover:opacity-80 transition-opacity">
|
| 40 |
+
<div className="w-8 h-8 bg-cyan-500 rounded-lg flex items-center justify-center">
|
| 41 |
+
<div className="w-4 h-4 bg-white rounded-sm rotate-45" />
|
| 42 |
+
</div>
|
| 43 |
+
<h1 className="text-xl font-bold text-slate-900 tracking-tight">IT 커리어밸류체크</h1>
|
| 44 |
+
</Link>
|
| 45 |
+
<div className="hidden md:flex shrink-0 items-center space-x-6 text-sm font-medium text-slate-500">
|
| 46 |
+
<span className="text-cyan-600">2025 IT산업 임금체계 개선 길라잡이 연계</span>
|
| 47 |
+
</div>
|
| 48 |
+
</header>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
import { motion } from 'framer-motion';
|
| 53 |
+
|
| 54 |
+
export function SurveyHeader({ current, total }: { current: number; total: number }) {
|
| 55 |
+
const progress = (current / total) * 100;
|
| 56 |
+
return (
|
| 57 |
+
<header className="h-16 border-b border-slate-100 bg-white sticky top-0 z-40 flex flex-col justify-end">
|
| 58 |
+
<div className="px-4 sm:px-6 flex items-center justify-between h-full gap-3">
|
| 59 |
+
<div className="flex items-center space-x-2">
|
| 60 |
+
<span className="text-xs font-bold text-cyan-600 tracking-widest">Step {current} of {total}</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="text-right">
|
| 63 |
+
<span className="text-sm font-bold text-slate-900">{Math.round(progress)}%</span>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
<div className="w-full h-1 bg-slate-100 overflow-hidden">
|
| 67 |
+
<motion.div
|
| 68 |
+
initial={{ width: 0 }}
|
| 69 |
+
animate={{ width: `${progress}%` }}
|
| 70 |
+
className="h-full bg-cyan-500"
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
</header>
|
| 74 |
+
);
|
| 75 |
+
}
|
src/lib/utils.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @license
|
| 3 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { clsx, type ClassValue } from "clsx";
|
| 7 |
+
import { twMerge } from "tailwind-merge";
|
| 8 |
+
|
| 9 |
+
export function cn(...inputs: ClassValue[]) {
|
| 10 |
+
return twMerge(clsx(inputs));
|
| 11 |
+
}
|
src/services/diagnosisService.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SearchParams, SurveyItem, ResultData } from '../types/diagnosis';
|
| 2 |
+
|
| 3 |
+
const API_BASE = process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8000/api' : '/api';
|
| 4 |
+
|
| 5 |
+
export const diagnosisService = {
|
| 6 |
+
getOptions: async (params: { job?: string; bm?: string; sales?: string }) => {
|
| 7 |
+
const res = await fetch(`${API_BASE}/options`, {
|
| 8 |
+
method: 'POST',
|
| 9 |
+
headers: { 'Content-Type': 'application/json' },
|
| 10 |
+
body: JSON.stringify(params),
|
| 11 |
+
});
|
| 12 |
+
if (!res.ok) throw new Error('Failed to fetch options');
|
| 13 |
+
return res.json();
|
| 14 |
+
},
|
| 15 |
+
|
| 16 |
+
getSurveyItems: async (params: SearchParams): Promise<SurveyItem[]> => {
|
| 17 |
+
const res = await fetch(`${API_BASE}/survey`, {
|
| 18 |
+
method: 'POST',
|
| 19 |
+
headers: { 'Content-Type': 'application/json' },
|
| 20 |
+
body: JSON.stringify(params),
|
| 21 |
+
});
|
| 22 |
+
if (!res.ok) throw new Error('Failed to fetch survey items');
|
| 23 |
+
return res.json();
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
getDiagnosisResult: async (
|
| 27 |
+
params: SearchParams,
|
| 28 |
+
answers: number[]
|
| 29 |
+
): Promise<ResultData> => {
|
| 30 |
+
const res = await fetch(`${API_BASE}/diagnosis`, {
|
| 31 |
+
method: 'POST',
|
| 32 |
+
headers: { 'Content-Type': 'application/json' },
|
| 33 |
+
body: JSON.stringify({ params, answers }),
|
| 34 |
+
});
|
| 35 |
+
if (!res.ok) throw new Error('Failed to fetch diagnosis result');
|
| 36 |
+
return res.json();
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
downloadReport: async (params: SearchParams, answers: number[]) => {
|
| 40 |
+
const res = await fetch(`${API_BASE}/report`, {
|
| 41 |
+
method: 'POST',
|
| 42 |
+
headers: { 'Content-Type': 'application/json' },
|
| 43 |
+
body: JSON.stringify({ params, answers }),
|
| 44 |
+
});
|
| 45 |
+
if (!res.ok) throw new Error('Failed to download report');
|
| 46 |
+
|
| 47 |
+
// Convert response to blob
|
| 48 |
+
const blob = await res.blob();
|
| 49 |
+
const url = window.URL.createObjectURL(blob);
|
| 50 |
+
const a = document.createElement('a');
|
| 51 |
+
a.href = url;
|
| 52 |
+
a.download = '진단리포트.pptx';
|
| 53 |
+
document.body.appendChild(a);
|
| 54 |
+
a.click();
|
| 55 |
+
window.URL.revokeObjectURL(url);
|
| 56 |
+
document.body.removeChild(a);
|
| 57 |
+
}
|
| 58 |
+
};
|
src/services/mockDiagnosisService.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { SearchParams, SurveyItem, ResultData } from '../types/diagnosis';
|
| 2 |
+
|
| 3 |
+
export const mockDiagnosisService = {
|
| 4 |
+
getOptions: async (params: { job?: string; bm?: string; sales?: string }) => {
|
| 5 |
+
// Cascadable Mock Options
|
| 6 |
+
const allJobs = ['소프트웨어 개발', '데이터 엔지니어링', '보안/인프라'];
|
| 7 |
+
const jobBms: Record<string, string[]> = {
|
| 8 |
+
'소프트웨어 개발': ['SaaS', '커머스', '게임'],
|
| 9 |
+
'데이터 엔지니어링': ['플랫폼', 'AI 서비스'],
|
| 10 |
+
'보안/인프라': ['전체', '클라우드']
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
if (!params.job) return { job_options: allJobs };
|
| 14 |
+
|
| 15 |
+
return {
|
| 16 |
+
job_options: allJobs,
|
| 17 |
+
bm_options: jobBms[params.job] || ['전체'],
|
| 18 |
+
sales_options: ['100억 미만', '100억~500억', '500억 이상'],
|
| 19 |
+
emp_options: ['전체', '300~999인', '100~299인', '50~99인', '49인 이하']
|
| 20 |
+
};
|
| 21 |
+
},
|
| 22 |
+
|
| 23 |
+
getSurveyItems: async (params: SearchParams): Promise<SurveyItem[]> => {
|
| 24 |
+
return [
|
| 25 |
+
{ id: 1, title: '01. 기술 전문성 및 학습 민첩성', level2: ['기초적인 문법 이해', '가이드에 따른 개발 가능'], level4: ['아키텍처 설계 가능', '신기술 도입 주도'] },
|
| 26 |
+
{ id: 2, title: '02. 도메인 및 제품 이해', level2: ['비즈니스 용어 익숙함', '담당 기능 이해'], level4: ['비즈니스 임팩트 분석', '제품 로드맵 기여'] },
|
| 27 |
+
{ id: 3, title: '03. 데이터 리터러시', level2: ['데이터 확인 가능', '기본 쿼리 작성'], level4: ['데이터 기반 의사결정', '지표 설계 및 검증'] },
|
| 28 |
+
{ id: 4, title: '04. 고객중심 사고', level2: ['고객 요구사항 실행', 'VOC 응대'], level4: ['고객 가치 정의', '사용자 경험 주도적 개선'] },
|
| 29 |
+
{ id: 5, title: '05. 문제해결의 자율성·복잡성', level2: ['단순 버그 수정', '명확한 문제 해결'], level4: ['복합 장기 과제 주도', '기술적 난제 해결'] },
|
| 30 |
+
{ id: 6, title: '06. 애자일 실행 및 관리', level2: ['스크럼 참여', '태스크 관리'], level4: ['프로세스 최적화', '팀 생산성 극대화'] },
|
| 31 |
+
{ id: 7, title: '07. 커뮤니케이션 및 협업', level2: ['원활한 의사소통', '팀 내 협력'], level4: ['타 부서 이해관계 조정', '협업 문화 전파'] },
|
| 32 |
+
{ id: 8, title: '08. End-to-End 책임감', level2: ['코드 작성 및 테스트', '배포 지원'], level4: ['운영 장애 대응 주도', '품질 전체 관리'] },
|
| 33 |
+
{ id: 9, title: '09. 영향범위', level2: ['본인 업무 집중', '동료 업무 지원'], level4: ['전사 기술 표준 수립', '타 팀 영향력 행사'] },
|
| 34 |
+
{ id: 10, title: '10. 리더십 및 문화 기여', level2: ['조직 문화 순응', '자기계발 집중'], level4: ['멘토링 및 코칭', '핵심 인재 확보 기여'] }
|
| 35 |
+
];
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
getDiagnosisResult: async (params: SearchParams, answers: number[]): Promise<ResultData> => {
|
| 39 |
+
const job = params.job || '데이터 엔지니어링';
|
| 40 |
+
const totalScore = answers.reduce((a, b) => a + b, 0);
|
| 41 |
+
const level = totalScore >= 30 ? 'L4' : 'L2';
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
headMessage: [
|
| 45 |
+
`진단 결과, 현재 귀하의 직무 역량 수준은 ${level}에 가장 가까운 것으로 보입니다.`,
|
| 46 |
+
`동일 직무 레벨 및 조건 대비 보상 경쟁력은 상위 15.4%로 높은 수준입니다.`
|
| 47 |
+
],
|
| 48 |
+
levelInfo: {
|
| 49 |
+
left: {
|
| 50 |
+
title: `하위 레벨: ${level === 'L4' ? 'L3' : 'L1'}`,
|
| 51 |
+
definition: "주어진 과업에 대해 안정적인 결과물을 산출하며 효율적인 코드 작성이 가능함",
|
| 52 |
+
guide: "아래 역량은 현재 레벨 안착을 위해 보완해보면 좋겠습니다.",
|
| 53 |
+
items: "도메인 이해, 애자일 실행 및 관리"
|
| 54 |
+
},
|
| 55 |
+
right: {
|
| 56 |
+
title: `현재 레벨: ${level}`,
|
| 57 |
+
definition: "복잡한 기술적 과제를 주도적으로 해결하며 아키텍처 관점의 의사결정에 참여함",
|
| 58 |
+
guide: "다음 역량은 현재 안정적으로 발휘되고 있는 강점입니다.",
|
| 59 |
+
items: "기술 전문성, 문제해결, 책임감"
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
levelChart: {
|
| 63 |
+
left: [
|
| 64 |
+
{ subject: "기술 전문성", target: 3, user: 2.5 },
|
| 65 |
+
{ subject: "도메인 이해", target: 3, user: 2.0 },
|
| 66 |
+
{ subject: "데이터 리터러시", target: 3, user: 3.5 },
|
| 67 |
+
{ subject: "고객중심 사고", target: 3, user: 3.0 },
|
| 68 |
+
{ subject: "문제해결", target: 3, user: 2.0 },
|
| 69 |
+
{ subject: "애자일", target: 3, user: 2.5 },
|
| 70 |
+
{ subject: "협업", target: 3, user: 3.0 },
|
| 71 |
+
{ subject: "책임감", target: 3, user: 3.5 },
|
| 72 |
+
{ subject: "영향범위", target: 3, user: 2.0 },
|
| 73 |
+
{ subject: "리더십", target: 3, user: 2.5 }
|
| 74 |
+
],
|
| 75 |
+
right: [
|
| 76 |
+
{ subject: "기술 전문성", target: 4, user: 3.5 },
|
| 77 |
+
{ subject: "도메인 이해", target: 4, user: 3.0 },
|
| 78 |
+
{ subject: "데이터 리터러시", target: 4, user: 4.5 },
|
| 79 |
+
{ subject: "고객중심 사고", target: 4, user: 4.0 },
|
| 80 |
+
{ subject: "문제해결", target: 4, user: 3.0 },
|
| 81 |
+
{ subject: "애자일", target: 4, user: 3.5 },
|
| 82 |
+
{ subject: "협업", target: 4, user: 4.0 },
|
| 83 |
+
{ subject: "책임감", target: 4, user: 4.5 },
|
| 84 |
+
{ subject: "영향범위", target: 4, user: 3.0 },
|
| 85 |
+
{ subject: "리더십", target: 4, user: 3.5 }
|
| 86 |
+
]
|
| 87 |
+
},
|
| 88 |
+
percentile: 84.6,
|
| 89 |
+
wageOutput: [
|
| 90 |
+
{
|
| 91 |
+
user: `${params.userValue.toLocaleString()}`,
|
| 92 |
+
marketAverage: "5,840",
|
| 93 |
+
gap: "+1,660",
|
| 94 |
+
description: `시장 상위 15.4% 수준으로, 평균 대비 1,660만원 더 높게 나타났습니다.`
|
| 95 |
+
}
|
| 96 |
+
],
|
| 97 |
+
info: [job, level, params.bm, params.sales, params.emp]
|
| 98 |
+
};
|
| 99 |
+
},
|
| 100 |
+
|
| 101 |
+
downloadReport: async (params: SearchParams, answers: number[]) => {
|
| 102 |
+
console.log("Mock Downloading Report for", params);
|
| 103 |
+
alert("Mock: 리포트 다운로드가 시작되었습니다 (PDF 변환 중...)");
|
| 104 |
+
}
|
| 105 |
+
};
|
src/types/diagnosis.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* @license
|
| 3 |
+
* SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface SurveyItem {
|
| 7 |
+
id: number;
|
| 8 |
+
title: string;
|
| 9 |
+
level2: string[];
|
| 10 |
+
level4: string[];
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export interface SearchParams {
|
| 14 |
+
job: string;
|
| 15 |
+
bm: string;
|
| 16 |
+
sales: string;
|
| 17 |
+
emp: string;
|
| 18 |
+
userBase: string;
|
| 19 |
+
userValue: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface LevelInfo {
|
| 23 |
+
title: string;
|
| 24 |
+
definition: string;
|
| 25 |
+
guide: string;
|
| 26 |
+
items: string;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface RadarData {
|
| 30 |
+
subject: string;
|
| 31 |
+
target: number;
|
| 32 |
+
user: number;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface WageOutput {
|
| 36 |
+
user: string;
|
| 37 |
+
marketAverage: string;
|
| 38 |
+
gap: string;
|
| 39 |
+
description: string;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface ResultData {
|
| 43 |
+
headMessage: string[];
|
| 44 |
+
levelInfo: {
|
| 45 |
+
left: LevelInfo;
|
| 46 |
+
right: LevelInfo;
|
| 47 |
+
};
|
| 48 |
+
levelChart: {
|
| 49 |
+
left: RadarData[];
|
| 50 |
+
right: RadarData[];
|
| 51 |
+
};
|
| 52 |
+
percentile: number;
|
| 53 |
+
wageOutput: WageOutput[];
|
| 54 |
+
info: string[];
|
| 55 |
+
}
|
| 56 |
+
|
tsconfig.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"lib": [
|
| 5 |
+
"dom",
|
| 6 |
+
"dom.iterable",
|
| 7 |
+
"esnext"
|
| 8 |
+
],
|
| 9 |
+
"allowJs": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"strict": true,
|
| 12 |
+
"noEmit": true,
|
| 13 |
+
"esModuleInterop": true,
|
| 14 |
+
"module": "esnext",
|
| 15 |
+
"moduleResolution": "bundler",
|
| 16 |
+
"resolveJsonModule": true,
|
| 17 |
+
"isolatedModules": true,
|
| 18 |
+
"jsx": "react-jsx",
|
| 19 |
+
"incremental": true,
|
| 20 |
+
"plugins": [
|
| 21 |
+
{
|
| 22 |
+
"name": "next"
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
"paths": {
|
| 26 |
+
"@/*": [
|
| 27 |
+
"./src/*"
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"include": [
|
| 32 |
+
"next-env.d.ts",
|
| 33 |
+
"**/*.ts",
|
| 34 |
+
"**/*.tsx",
|
| 35 |
+
".next/types/**/*.ts",
|
| 36 |
+
".next/dev/types/**/*.ts"
|
| 37 |
+
],
|
| 38 |
+
"exclude": [
|
| 39 |
+
"node_modules"
|
| 40 |
+
]
|
| 41 |
+
}
|