5minbetter commited on
Commit
cc7330c
·
0 Parent(s):

Deploy to Hugging Face

Browse files
.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
+ }