Compare commits

...

10 Commits

Author SHA1 Message Date
EENE Dashboard
b3f2da203b EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:59:34 +09:00
EENE Dashboard
cf72281c6d feat: quarter board theme, hub column, and team panel UX
Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:09:46 +09:00
EENE Dashboard
525a4fc1f2 feat: 3-section dashboard, reference dual-monitor layout, and detail dock
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 11:13:40 +09:00
EENE Dashboard
5f16515dab fix: allow cross-origin team photos on Render for Vercel frontend
Helmet CORP blocked /uploads images from eene-dashboard.vercel.app.
Also add photo file upload to db:push-remote and db:push-photos script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 01:53:01 +09:00
EENE Dashboard
fb2956b0ac feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync
Add TeamMember model and APIs, team status UI, /admin page, local server bats,
and scripts to sync data between local PostgreSQL and Render.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 01:41:00 +09:00
EENE Dashboard
d14ff1997c feat: implement multi-select status filter logic from reference hub
Support combined active chips for all/progress/hold/done and restore prior filters when toggling issue view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 00:33:58 +09:00
EENE Dashboard
288e05f691 fix: match side-polygon-stats curved corners to reference hub
Correct pseudo-element border-radius direction and allow header overflow so side curves render.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 00:24:21 +09:00
EENE Dashboard
6ab2dae634 feat: replace header emoji buttons with reference Lucide-style icons
Add users, plus, and dual-monitor SVG icons with matching 32px circular button styles from Design Planning Hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 00:19:45 +09:00
EENE Dashboard
64d0314873 fix: align stat chip layout with reference JS inline styles
Match poly-stat-item gap/font-size, chip padding via inline style, and 27px chip height from line-height 21px.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 00:16:22 +09:00
EENE Dashboard
ecb897009a fix: match center stats bar dimensions to reference F12
Apply exact 40px bar height, 48px padding, inline-flex chips, and span-based stat controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 00:09:08 +09:00
147 changed files with 21971 additions and 1510 deletions

View File

@@ -1,18 +1,17 @@
# ─── Database ───────────────────────────────────────────────
# ─── Database (Docker → data/postgres 영구 저장, 포트 54320) ───
DB_USER=eee_admin
DB_PASSWORD=eee_password
DB_NAME=eee_dashboard
DB_PORT=5432
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
DB_PORT=54320
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:54320/eee_dashboard"
# ─── Backend ─────────────────────────────────────────────────
PORT=4000
FRONTEND_URL=http://172.16.8.248:3000
FRONTEND_URL=http://localhost:3000
# JWT 시크릿 (운영 시 반드시 강력한 랜덤 문자열로 교체)
JWT_SECRET=change_this_secret_in_production
JWT_EXPIRES_IN=7d
# ─── File Upload ─────────────────────────────────────────────
# ─── Paths (프로젝트 루트 기준, backend/.env 와 동일) ────────
UPLOAD_DIR=../uploads
MAX_FILE_SIZE_MB=20
HR_DATA_PATH=../data/seed/hr-data.json

7
.gitignore vendored
View File

@@ -2,9 +2,14 @@ node_modules/
dist/
build/
.env
data/
backend/.env
data/postgres/
!data/.gitkeep
!data/seed/
!data/seed/**
uploads/*
!uploads/.gitkeep
*.log
.DS_Store
Thumbs.db
frontend/.lan-ip

5
Gitea업로드.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\gitea-upload.ps1"
pause

33
PC이전.txt Normal file
View File

@@ -0,0 +1,33 @@
EENE Dashboard — 이 폴더만 통째로 옮기면 됩니다
============================================
■ 반드시 함께 복사
data\postgres\ DB (업무·팀원·허브 설정·첨부 정보)
data\seed\ HR seed (hr-data.json — 최초 import용)
uploads\ 첨부·팀 사진
backend\.env 설정 (없으면 서버시작 시 자동 생성)
■ 새 PC 준비
- Node.js 20+
- PostgreSQL 16 (Windows) 또는 Docker Desktop
■ 실행 (2개 파일)
서버시작.bat → DB(data\postgres) + 서버 + 화면
서버종료.bat → API/WEB만 종료 (DB 유지)
서버종료.bat db → DB까지 중지
■ 첫 실행 시
예전 Windows PostgreSQL(5432)에 데이터가 있으면
자동으로 data\postgres 로 옮깁니다.
■ 접속
http://localhost:3000
■ PC 옮긴 뒤 [2/4] DB에서 멈출 때
1. PostgreSQL 16 설치 (5432 기본값 그대로 OK)
2. 서버종료.bat db 실행 후 서버시작.bat 재실행
3. 안 되면 data\postgres\postmaster.pid 삭제 후 재실행
4. data\postgres\pg.log 마지막 줄 확인
- 버전 불일치면 새 PC도 PostgreSQL 16 사용
자세한 내용: README.md

230
README.md
View File

@@ -1,162 +1,88 @@
# EENE 인재성장팀 대시보드
# EENE 인재성장팀 대시보드 (로컬 전용)
## 사전 설치 필요
**이 폴더 통째로** USB·OneDrive 등으로 옮겨도 됩니다.
`data/postgres/` + `uploads/` 를 함께 복사하면 데이터가 그대로 유지됩니다.
---
## 실행 (루트 bat 2개)
| 파일 | 용도 |
|------|------|
| **`서버시작.bat`** | DB 시작 → 스키마 → (빈 DB면 샘플) → API + 화면 |
| **`서버종료.bat`** | API/WEB 종료 (DB·데이터 유지) |
| **`서버종료.bat db`** | DB까지 중지 |
접속: **http://localhost:3000**
PC 이동 요약: **`PC이전.txt`**
---
## 데이터 저장 위치
| 내용 | 경로 |
|------|------|
| DB | `data/postgres/` (업무·팀원·허브 설정·첨부 정보) |
| HR seed (최초 import) | `data/seed/hr-data.json` |
| 첨부 | `uploads/` |
| 팀 사진 | `uploads/team/` |
| API 설정 | `backend/.env` (없으면 `서버시작.bat`이 자동 생성) |
---
## 최초 1회 (수동)
- [Node.js 20+](https://nodejs.org)
- [Docker Desktop](https://www.docker.com/products/docker-desktop/)
- **PostgreSQL 16** (Windows 설치) **또는** [Docker Desktop](https://www.docker.com/products/docker-desktop/)
DB는 **항상 `data/postgres/`** 에 저장됩니다 (포트 **54320**).
Windows에 PostgreSQL이 이미 5432로 돌아가도 충돌하지 않습니다.
그다음 **`서버시작.bat`** 더블클릭.
---
## 최초 실행 순서
## 기본 계정 (샘플)
### 1. 환경 변수 설정
| 이메일 | 비밀번호 |
|--------|----------|
| admin@eene.com | admin1234! |
| member@eene.com | member1234! |
---
## 폴더 구조
```
EENE_Dashboard_0608/
├── 서버시작.bat / 서버종료.bat
├── data/
│ ├── postgres/ ← DB (실행 데이터)
│ └── seed/hr-data.json ← HR 원본 (seed·import)
├── uploads/ ← 파일
├── backend/ ← API
├── frontend/ ← 화면
└── _archive/ ← (나중) 배포용 보관
```
배포는 추후 별도 구축. `_archive/`에 예전 배포 설정만 보관.
---
## 듀얼 모니터
- 왼쪽: `http://localhost:3000`
- 오른쪽: `http://localhost:3000/detail`
---
## npm (터미널 선호 시)
```bash
# 루트에 .env 파일이 없을 경우 복사
copy .env.example .env
# 백엔드 .env 확인
# backend\.env 파일은 이미 기본값으로 생성되어 있음
npm run local:db # Docker DB (data/postgres)
npm run local:db:stop # Docker DB 중지
npm run local:setup # 스키마 + 빈 DB seed
npm run local:api # :4000
npm run local:web # :3000
```
### 2. PostgreSQL 실행 (Docker)
```bash
# 프로젝트 루트에서 실행
docker compose up -d
# 실행 확인
docker compose ps
```
### 3. 백엔드 설치 및 실행
```bash
cd backend
# 패키지 설치
npm install
# DB 마이그레이션 (테이블 생성)
npm run db:migrate
# 샘플 데이터 입력
npm run db:seed
# 개발 서버 실행
npm run dev
```
백엔드가 `http://localhost:4000` 에서 실행됩니다.
### 4. 프론트엔드 설치 및 실행
새 터미널을 열고:
```bash
cd frontend
# 패키지 설치
npm install
# 개발 서버 실행
npm run dev
```
프론트엔드가 `http://localhost:3000` 에서 실행됩니다.
---
## 서버(이 PC) IP 주소 확인
```powershell
# CMD 또는 PowerShell 에서 실행
ipconfig
# → "이더넷" 또는 "Wi-Fi" 항목의 IPv4 주소 확인
# 예: 192.168.1.100
```
팀원들이 접속할 주소:
- 대시보드: `http://172.16.8.248:3000`
- 업무 상세: `http://172.16.8.248:3000/detail`
### Windows 방화벽 포트 열기 (관리자 권한 CMD)
```cmd
netsh advfirewall firewall add rule name="EEE Dashboard Frontend" dir=in action=allow protocol=TCP localport=3000
netsh advfirewall firewall add rule name="EEE Dashboard Backend" dir=in action=allow protocol=TCP localport=4000
```
---
## 듀얼 모니터 사용 방법
1. 브라우저 창을 두 개 엽니다.
2. **왼쪽 모니터**: `http://localhost:3000` (업무 목록 대시보드)
3. **오른쪽 모니터**: `http://localhost:3000/detail` (업무 상세 패널)
4. 왼쪽에서 업무를 클릭하면 오른쪽 창이 자동으로 해당 상세 내용을 표시합니다.
---
## 기본 계정 (seed 데이터)
| 이메일 | 비밀번호 | 역할 |
|---|---|---|
| admin@eee.com | admin1234! | 관리자 |
| member@eee.com | member1234! | 팀원 |
> 운영 시 반드시 비밀번호를 변경하세요.
---
## 프로젝트 구조
```
D:\EENE_Dashboard\
├── backend\ # Express + TypeScript API 서버
│ ├── prisma\
│ │ ├── schema.prisma # DB 스키마 (테이블 정의)
│ │ └── seed.ts # 초기 데이터
│ └── src\
│ ├── index.ts # 서버 진입점
│ ├── app.ts # Express 앱 설정
│ ├── socket.ts # Socket.io 핸들러
│ ├── lib\ # Prisma 클라이언트
│ ├── middleware\ # 파일 업로드, 에러 처리
│ └── routes\ # API 라우터
│ ├── tasks.ts # 업무 CRUD
│ ├── users.ts # 사용자 관리
│ ├── files.ts # 파일 업로드
│ └── kpi.ts # KPI 관리
├── frontend\ # React + TypeScript 앱
│ └── src\
│ ├── contexts\ # Socket 컨텍스트
│ ├── lib\ # API 클라이언트, 듀얼모니터 유틸
│ ├── pages\ # 각 페이지 컴포넌트
│ ├── types\ # TypeScript 타입 정의
│ └── router.tsx # 라우팅 설정
├── uploads\ # 업로드 파일 저장소
├── 서버시작.bat # 서버 실행
└── 서버종료.bat # 서버 종료
```
---
## API 엔드포인트
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /api/auth/login | 로그인 |
| GET | /api/auth/me | 내 정보 조회 |
| GET | /api/tasks | 업무 목록 |
| POST | /api/tasks | 업무 등록 |
| PATCH | /api/tasks/:id | 업무 수정 |
| DELETE | /api/tasks/:id | 업무 삭제 |
| GET | /api/users | 사용자 목록 (관리자) |
| POST | /api/users | 사용자 생성 (관리자) |
| POST | /api/files/upload/:taskId | 파일 업로드 |
| DELETE | /api/files/:id | 파일 삭제 |
| GET | /api/kpi | KPI 조회 |
| POST | /api/kpi | KPI 등록 |

View File

@@ -0,0 +1,12 @@
# Docker Compose 사용 시 (선택)
# 1. Windows PostgreSQL 서비스 중지 (5432 포트 충돌 방지)
# 2. backend\.env 의 DATABASE_URL 을 아래로 변경
# 3. docker compose up -d
DB_USER=eee_admin
DB_PASSWORD=eee_password
DB_NAME=eee_dashboard
DB_PORT=5432
# backend\.env:
# DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"

17
backend/.env.example Normal file
View File

@@ -0,0 +1,17 @@
# 로컬 전용 — backend/.env 로 복사 후 사용 (서버시작.bat 이 자동 갱신)
# 모든 경로는 프로젝트 루트(EENE_Dashboard_0608) 기준
# DB — data/postgres (포트 54320)
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:54320/eee_dashboard"
PORT=4000
FRONTEND_URL=http://localhost:3000
JWT_SECRET=change_this_secret_in_production
JWT_EXPIRES_IN=7d
# 첨부 — uploads/ (프로젝트 루트, 파일 크기 상한 없음 — 디스크 여유만큼)
UPLOAD_DIR=../uploads
# HR seed — data/seed/hr-data.json (최초 seed·import-hr 공통)
HR_DATA_PATH=../data/seed/hr-data.json

View File

@@ -8,6 +8,7 @@
"name": "eene-dashboard-backend",
"version": "1.0.0",
"dependencies": {
"@ohah/hwpjs": "^0.1.0-rc.10",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
@@ -17,6 +18,7 @@
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"prisma": "^6.0.0",
"socket.io": "^4.8.0",
"uuid": "^10.0.0"
},
@@ -29,11 +31,44 @@
"@types/multer": "^1.4.12",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
@@ -496,6 +531,163 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@ohah/hwpjs": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs/-/hwpjs-0.1.0-rc.10.tgz",
"integrity": "sha512-eHtpBzqyj/qTr4HHUMNwBlZJBjRQ0gUJdUP7i6csGn0uKPhUu0LlV9l6eI2yB68NVD9T5OqLsmGDbLSv8RUDzQ==",
"license": "MIT",
"dependencies": {
"commander": "^14.0.0"
},
"bin": {
"hwpjs": "bin/hwpjs.js"
},
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
},
"optionalDependencies": {
"@ohah/hwpjs-darwin-arm64": "0.1.0-rc.10",
"@ohah/hwpjs-darwin-x64": "0.1.0-rc.10",
"@ohah/hwpjs-linux-x64-gnu": "0.1.0-rc.10",
"@ohah/hwpjs-wasm32-wasi": "0.1.0-rc.10",
"@ohah/hwpjs-win32-arm64-msvc": "0.1.0-rc.10",
"@ohah/hwpjs-win32-ia32-msvc": "0.1.0-rc.10",
"@ohah/hwpjs-win32-x64-msvc": "0.1.0-rc.10"
}
},
"node_modules/@ohah/hwpjs-darwin-arm64": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-darwin-arm64/-/hwpjs-darwin-arm64-0.1.0-rc.10.tgz",
"integrity": "sha512-9CGqsI2XdhLLtEJeIP8JKGPYlALSIGcpoyp2Be0QYzzW6J4LVN1q1We58srxSwn3pfdyumIDEAgXU3FskyRSFQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@ohah/hwpjs-darwin-x64": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-darwin-x64/-/hwpjs-darwin-x64-0.1.0-rc.10.tgz",
"integrity": "sha512-DW2r/SVCNIevGxA48dti3icWXkZmj+W3pq34VURp5FPTO8r2brMKhkywOWejThI7KI213ihlvt/+YIp3d5PrOg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@ohah/hwpjs-linux-x64-gnu": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-linux-x64-gnu/-/hwpjs-linux-x64-gnu-0.1.0-rc.10.tgz",
"integrity": "sha512-Ph5ntwnufbBtvcJTRhtp/+W98Efvr3D4ZpTQ+mr5uzDE4h2g2K3yOGP8MAJD7YEzBF2WXQgWCRtD9jFxVDo/eA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@ohah/hwpjs-wasm32-wasi": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-wasm32-wasi/-/hwpjs-wasm32-wasi-0.1.0-rc.10.tgz",
"integrity": "sha512-3icQYI6xmEXwYRqsiHitPTK14UvvYJHHF1LzcgT8gXBnbRk5CAAgQC6Z31gqmXbYRCV3LjGdnhZz4bTyFxEfZQ==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@ohah/hwpjs-win32-arm64-msvc": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-arm64-msvc/-/hwpjs-win32-arm64-msvc-0.1.0-rc.10.tgz",
"integrity": "sha512-EN6x0+VKZMnKRbImuSSCTk0oi5uA5802qNQf3VJ+p+VjZO0UbijthoNI7oT4F07F3hp4JYsqaXvi5KuXs0UTOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@ohah/hwpjs-win32-ia32-msvc": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-ia32-msvc/-/hwpjs-win32-ia32-msvc-0.1.0-rc.10.tgz",
"integrity": "sha512-1dV3LF1vxAI5+MAw07izoF2YD1if3dUDRt+7E1C6zIoZk91l+5qyUCe8Z3UJOVTNfzl9ZT+ecpgSWevk7KaW3g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@ohah/hwpjs-win32-x64-msvc": {
"version": "0.1.0-rc.10",
"resolved": "https://registry.npmjs.org/@ohah/hwpjs-win32-x64-msvc/-/hwpjs-win32-x64-msvc-0.1.0-rc.10.tgz",
"integrity": "sha512-PekyIPNmCB1h5iAPm4jIji1bERtFQ2bpXZhpha6XcbaPg8ExWWABViDzF8F/l8YKt4IFaf0AGaGXp03XDMvbhA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -522,7 +714,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -535,14 +726,12 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -556,14 +745,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -575,7 +762,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -591,9 +777,18 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcrypt": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
@@ -990,7 +1185,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -1048,7 +1242,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -1073,7 +1266,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -1088,6 +1280,15 @@
"color-support": "bin.js"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1113,14 +1314,12 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -1204,7 +1403,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -1214,7 +1412,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delegates": {
@@ -1236,7 +1433,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/destroy": {
@@ -1303,7 +1499,6 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -1320,7 +1515,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -1525,14 +1719,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -1703,7 +1895,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -1905,7 +2096,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -2254,7 +2444,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/nopt": {
@@ -2289,7 +2478,6 @@
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
@@ -2307,7 +2495,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -2335,7 +2522,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
@@ -2396,21 +2582,18 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@@ -2422,7 +2605,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2467,7 +2649,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -2523,7 +2704,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -2555,7 +2735,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -2956,7 +3135,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -2977,6 +3155,13 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tsx": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",

View File

@@ -4,7 +4,8 @@
"description": "EENE 인재성장팀 대시보드 - Backend API",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:serve": "tsx watch src/index.ts",
"dev": "npm run db:sync && npm run db:seed-if-empty && npm run db:seed-team-if-empty && npm run dev:serve",
"db:sync": "prisma migrate deploy || prisma db push",
"build": "prisma generate && tsc",
"start": "npm run db:sync && node dist/index.js",
@@ -12,9 +13,17 @@
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:import-hr": "tsx scripts/import-hr-data.ts"
"db:rebuild": "tsx prisma/seed.ts",
"db:seed-if-empty": "tsx scripts/seed-if-empty.ts",
"db:seed-team-if-empty": "tsx scripts/seed-team-if-empty.ts",
"db:sync-team-photos": "tsx scripts/sync-team-photos.ts",
"db:cleanup-legacy-routine": "tsx scripts/cleanup-legacy-routine.ts",
"db:import-hr": "tsx scripts/import-hr-data.ts",
"db:migrate-sections": "tsx scripts/migrate-sections.ts",
"db:migrate-milestone-period-notes": "tsx scripts/migrate-milestone-period-notes.ts"
},
"dependencies": {
"@ohah/hwpjs": "^0.1.0-rc.10",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import type { Priority, TaskStatus } from '@prisma/client';
import { getHrSeedPath, getUploadDir } from '../src/lib/projectPaths';
export interface HrProject {
idx?: number;
@@ -26,6 +27,21 @@ export interface HrProject {
subPhases?: { name: string; status?: string; text?: string }[];
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
showOnDashboard?: boolean;
owners?: string[];
}
export interface HrTeamEntry {
name: string;
photo?: string;
}
export interface ParsedHrTeamMember {
name: string;
rank: string | null;
role: string | null;
cell: string | null;
photoUrl: string | null;
sortOrder: number;
}
export interface MappedTask {
@@ -41,7 +57,6 @@ export interface MappedTask {
issueNote: string | null;
startDate: Date | null;
dueDate: Date | null;
keywords: string | null;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
@@ -59,8 +74,9 @@ export interface MappedTask {
const SECTION_MAP: Record<string, string> = {
: '인사관리',
: '학습성장',
: '운영지원',
: '전산관리',
: '운영관리',
: '운영관리',
: '운영관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {
@@ -77,7 +93,7 @@ const PHASE_PROGRESS: Record<string, number> = {
};
export function defaultHrDataPath(): string {
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
return getHrSeedPath();
}
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
@@ -86,6 +102,72 @@ export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
}
export function loadHrTeam(filePath = defaultHrDataPath()): HrTeamEntry[] {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as { TEAM?: HrTeamEntry[] };
return data.TEAM ?? [];
}
/** "조태희 수석(팀장)" → name / rank / role */
export function parseHrTeamLabel(label: string, sortOrder: number): ParsedHrTeamMember {
const s = label.trim();
const withRole = s.match(/^(.+?)\s+(.+?)\((.+)\)$/);
if (withRole) {
const role = withRole[3].trim();
return {
name: withRole[1].trim(),
rank: withRole[2].trim(),
role,
cell: role === '팀장' ? null : 'HR',
photoUrl: null,
sortOrder,
};
}
const plain = s.match(/^(.+?)\s+(.+)$/);
if (plain) {
return {
name: plain[1].trim(),
rank: plain[2].trim(),
role: null,
cell: 'HR',
photoUrl: null,
sortOrder,
};
}
return { name: s, rank: null, role: null, cell: 'HR', photoUrl: null, sortOrder };
}
export function mapHrTeamMembers(filePath = defaultHrDataPath()): ParsedHrTeamMember[] {
return loadHrTeam(filePath).map((entry, i) => {
const parsed = parseHrTeamLabel(entry.name, i);
const photo = resolveTeamPhotoPath(entry.photo?.trim() || null);
return {
...parsed,
photoUrl: photo,
};
});
}
/** seed용 — 파일이 프로젝트 uploads/ 에 실제 있을 때만 경로 사용 */
export function resolveTeamPhotoPath(photo: string | null): string | null {
if (!photo?.trim()) return null;
const trimmed = photo.trim();
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('data:')) return null;
const uploadDir = getUploadDir();
const relative = trimmed.replace(/^\//, '').replace(/^uploads\//, '');
const abs = path.join(uploadDir, relative);
if (fs.existsSync(abs)) {
return trimmed.startsWith('/') ? trimmed : `/uploads/${relative}`;
}
return null;
}
/** PM·담당자 문자열 → team_members.name 매칭용 */
export function normalizePersonName(value: string): string {
return value.trim().replace(/\s+/g, '');
}
function parseDate(value?: string): Date | null {
if (!value?.trim()) return null;
const d = new Date(value);
@@ -96,6 +178,14 @@ function mapSection(category: string): string {
return SECTION_MAP[category] ?? category;
}
function mapBoardSection(p: HrProject): string {
const name = p.name.trim();
if (/회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i.test(name)) {
return '조직문화';
}
return mapSection(p.category);
}
function mapStatus(status?: string, isRoutine = false): TaskStatus {
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
return STATUS_MAP[status] ?? 'IN_PROGRESS';
@@ -188,14 +278,13 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
status: mapStatus(p.status, isRoutine),
priority: mapPriority(p.priority),
quarter,
category: mapSection(p.category),
section: mapSection(p.category),
category: mapBoardSection(p),
section: mapBoardSection(p),
taskType,
progress: mapProgress(p.progress),
issueNote: pickIssueNote(p),
startDate: parseDate(p.startDate),
dueDate: parseDate(p.endDate),
keywords: p.keywords?.length ? p.keywords.join(', ') : null,
showDate: visible,
showDescription: visible,
showStatus: visible,
@@ -207,5 +296,7 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
}
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
return loadHrProjects(filePath).map((p) => mapHrProjectToTask(p, quarter));
return loadHrProjects(filePath)
.filter((p) => p.priority !== '상시')
.map((p) => mapHrProjectToTask(p, quarter));
}

View File

@@ -0,0 +1,28 @@
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "pmMemberId" TEXT;
CREATE TABLE IF NOT EXISTS "milestone_assignees" (
"milestoneId" TEXT NOT NULL,
"memberId" TEXT NOT NULL,
CONSTRAINT "milestone_assignees_pkey" PRIMARY KEY ("milestoneId","memberId")
);
CREATE INDEX IF NOT EXISTS "milestone_assignees_memberId_idx" ON "milestone_assignees"("memberId");
CREATE INDEX IF NOT EXISTS "milestones_pmMemberId_idx" ON "milestones"("pmMemberId");
DO $$ BEGIN
ALTER TABLE "milestones" ADD CONSTRAINT "milestones_pmMemberId_fkey"
FOREIGN KEY ("pmMemberId") REFERENCES "team_members"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_milestoneId_fkey"
FOREIGN KEY ("milestoneId") REFERENCES "milestones"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "milestone_assignees" ADD CONSTRAINT "milestone_assignees_memberId_fkey"
FOREIGN KEY ("memberId") REFERENCES "team_members"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

View File

@@ -0,0 +1,3 @@
-- 키워드 필드 제거 (데이터 삭제 후 컬럼 drop)
UPDATE "tasks" SET "keywords" = NULL WHERE "keywords" IS NOT NULL;
ALTER TABLE "tasks" DROP COLUMN IF EXISTS "keywords";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "issueEntries" JSONB;

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "hub_configs" (
"id" TEXT NOT NULL,
"config" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hub_configs_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "milestones" ADD COLUMN "periodEntries" JSONB;

View File

@@ -29,6 +29,31 @@ model User {
@@map("users")
}
// ─── 팀 인원 (조직도 마스터) ─────────────────────────────────
model TeamMember {
id String @id @default(cuid())
name String
rank String? // 직급 (수석연구원, 선임연구원 등)
role String? // 직책 (팀장, 셀장, 팀원 등)
cell String? // 셀 (HR, 총무, 리더 — 리더/빈값이면 상단 팀장 영역)
contact String?
photoUrl String?
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
pmTasks Task[] @relation("PmTasks")
taskAssignees TaskAssignee[]
milestonePmTasks Milestone[] @relation("MilestonePm")
milestoneAssignees MilestoneAssignee[]
@@index([cell])
@@index([isActive])
@@map("team_members")
}
enum Role {
ADMIN
MANAGER
@@ -49,7 +74,8 @@ model Task {
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
taskType String? // 상시업무 | 프로젝트
progress Int @default(0)
issueNote String?
issueNote String?
issueEntries Json?
startDate DateTime?
dueDate DateTime?
showDate Boolean @default(true)
@@ -57,14 +83,16 @@ model Task {
showStatus Boolean @default(true)
showIssue Boolean @default(true)
showProgress Boolean @default(true)
keywords String?
creatorId String
assigneeId String?
pmMemberId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
pmMember TeamMember? @relation("PmTasks", fields: [pmMemberId], references: [id])
taskAssignees TaskAssignee[]
details TaskDetail[]
kpiMetrics KpiMetric[]
files File[]
@@ -73,10 +101,23 @@ model Task {
@@index([quarter])
@@index([status])
@@index([assigneeId])
@@index([pmMemberId])
@@index([section])
@@map("tasks")
}
model TaskAssignee {
taskId String
memberId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@id([taskId, memberId])
@@index([memberId])
@@map("task_assignees")
}
enum TaskStatus {
TODO
IN_PROGRESS
@@ -162,24 +203,42 @@ model Milestone {
id String @id @default(cuid())
taskId String
title String
subtitle String?
description String?
startDate DateTime?
dueDate DateTime?
periodEntries Json?
progress Int @default(0)
links String? // JSON: [{ "label": string, "url": string }]
completedAt DateTime?
order Int @default(0)
pmMemberId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
details TaskDetail[]
files File[]
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
pmMember TeamMember? @relation("MilestonePm", fields: [pmMemberId], references: [id])
milestoneAssignees MilestoneAssignee[]
details TaskDetail[]
files File[]
@@index([taskId])
@@index([pmMemberId])
@@map("milestones")
}
model MilestoneAssignee {
milestoneId String
memberId String
milestone Milestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@id([milestoneId, memberId])
@@index([memberId])
@@map("milestone_assignees")
}
// ─── 컬럼 설정 ───────────────────────────────────────────────
model ColumnConfig {
@@ -193,6 +252,16 @@ model ColumnConfig {
@@map("column_configs")
}
// ─── 허브 설정 (분기 중점 과제·일정·상시 라벨) ─────────────────
model HubConfig {
id String @id @default("default")
config Json
updatedAt DateTime @updatedAt
@@map("hub_configs")
}
// ─── 감사 로그 ───────────────────────────────────────────────
model AuditLog {

View File

@@ -1,67 +1,273 @@
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from './mapHrProjects';
import {
loadHrProjects,
mapAllHrProjects,
mapHrProjectToTask,
mapHrTeamMembers,
normalizePersonName,
} from './mapHrProjects';
const prisma = new PrismaClient();
const HUB_CONFIG = {
sloganTitle: '분기 중점 과제',
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
scheduleTitle: '분기 주요 일정',
scheduleItems: [
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
};
async function main() {
console.log('🌱 Seeding database...');
console.log('🌱 Seeding database from data/seed/hr-data.json ...');
const adminPw = await bcrypt.hash('admin1234!', 12);
const memberPw = await bcrypt.hash('member1234!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@eene.com' },
update: {},
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
});
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: { name: '정성호' },
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
});
console.log('✅ Users ready');
const mapped = mapAllHrProjects();
await prisma.milestoneAssignee.deleteMany({});
await prisma.taskAssignee.deleteMany({});
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
for (const t of mapped) {
const { milestones, detailContent, ...taskData } = t;
const task = await prisma.task.create({
await prisma.teamMember.deleteMany({});
const teamParsed = mapHrTeamMembers();
const memberIdByName = new Map<string, string>();
for (const tm of teamParsed) {
const created = await prisma.teamMember.create({
data: {
...taskData,
creatorId: admin.id,
assigneeId: member.id,
name: tm.name,
rank: tm.rank,
role: tm.role,
cell: tm.cell,
photoUrl: tm.photoUrl,
sortOrder: tm.sortOrder,
isActive: true,
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
memberIdByName.set(normalizePersonName(tm.name), created.id);
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: admin.id,
},
});
}
}
console.log(`✅ Tasks created: ${mapped.length}개 (HR_Dashboard 데이터)`);
console.log(`✅ Team members: ${teamParsed.length}명 (hr-data.json TEAM)`);
const hrProjects = loadHrProjects();
let taskCount = 0;
for (const hp of hrProjects) {
if (hp.priority === '상시') continue;
const t = mapHrProjectToTask(hp);
const { milestones, detailContent, ...taskData } = t;
const pmKey = hp.pm?.trim() ? normalizePersonName(hp.pm) : '';
const pmMemberId = pmKey ? memberIdByName.get(pmKey) ?? null : null;
const ownerIds = [...new Set(
(hp.owners ?? [])
.map((o) => normalizePersonName(o))
.filter(Boolean)
.map((key) => memberIdByName.get(key))
.filter((id): id is string => !!id),
)];
const task = await prisma.task.create({
data: {
...taskData,
creatorId: admin.id,
assigneeId: member.id,
pmMemberId,
...(ownerIds.length > 0
? { taskAssignees: { create: ownerIds.map((memberId) => ({ memberId })) } }
: {}),
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: admin.id,
},
});
}
taskCount += 1;
}
await prisma.hubConfig.upsert({
where: { id: 'default' },
update: { config: HUB_CONFIG },
create: { id: 'default', config: HUB_CONFIG },
});
console.log(`✅ Tasks: ${taskCount}개 (PROJECTS)`);
console.log('✅ Hub config reset (5 대분류 상시업무)');
console.log('🎉 Seeding complete!');
console.log(' → 브라우저에서 Ctrl+F5 후, 필요 시 DevTools에서 localStorage 허브 키 삭제');
}
main().catch(console.error).finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,420 @@
/**
* 배포 서버(Render) 데이터 → 로컬 PostgreSQL 복사
* 사용: npm run db:sync-remote
* 환경변수 SOURCE_API_URL 로 원본 변경 가능
*/
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client';
const prisma = new PrismaClient();
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
function normalizeSection(section: string | null | undefined): string | null {
if (!section) return null;
if (section === '전산관리' || section === '운영지원') return '운영관리';
if (section === '성장지원') return '학습성장';
return section;
}
type RemoteUser = { id: string; name: string; department?: string | null };
type RemoteTask = {
id: string;
title: string;
description?: string | null;
status: TaskStatus;
priority: Priority;
quarter: string;
category?: string | null;
section?: string | null;
tag?: string | null;
taskType?: string | null;
progress: number;
issueNote?: string | null;
startDate?: string | null;
dueDate?: string | null;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
creatorId: string;
assigneeId?: string | null;
pmMemberId?: string | null;
creator?: RemoteUser;
assignee?: RemoteUser | null;
assigneeMembers?: Array<{
id: string;
name: string;
rank?: string | null;
role?: string | null;
cell?: string | null;
contact?: string | null;
photoUrl?: string | null;
sortOrder?: number;
}>;
pmMember?: {
id: string;
name: string;
rank?: string | null;
role?: string | null;
cell?: string | null;
contact?: string | null;
photoUrl?: string | null;
sortOrder?: number;
} | null;
milestones?: Array<{
id: string;
title: string;
description?: string | null;
startDate?: string | null;
dueDate?: string | null;
progress: number;
links?: string | null;
completedAt?: string | null;
order: number;
}>;
details?: Array<{
id: string;
content: string;
authorName?: string | null;
milestoneId?: string | null;
updatedBy: string;
createdAt: string;
updatedAt: string;
author?: RemoteUser;
}>;
kpiMetrics?: Array<{
id: string;
quarter: string;
target: number;
actual: number;
unit?: string | null;
}>;
files?: Array<{
id: string;
filename: string;
originalName: string;
displayName?: string | null;
sortOrder: number;
mimetype: string;
size: number;
path: string;
milestoneId?: string | null;
uploadedBy: string;
createdAt: string;
}>;
};
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${SOURCE}${path}`);
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
return res.json() as Promise<T>;
}
async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise<string> {
const pw = await bcrypt.hash('imported!', 10);
const email = fallbackEmail || `${(remote?.name || 'user').replace(/\s/g, '')}@import.local`;
const user = await prisma.user.upsert({
where: { email },
update: { name: remote?.name || email },
create: {
email,
password: pw,
name: remote?.name || '사용자',
role: 'MEMBER',
department: remote?.department || 'EENE',
},
});
return user.id;
}
async function ensureTeamMember(data: {
name: string;
rank?: string | null;
role?: string | null;
cell?: string | null;
contact?: string | null;
photoUrl?: string | null;
sortOrder?: number;
}): Promise<string> {
const existing = await prisma.teamMember.findFirst({
where: { name: data.name, cell: data.cell ?? null },
});
if (existing) {
return prisma.teamMember
.update({
where: { id: existing.id },
data: {
rank: data.rank ?? existing.rank,
role: data.role ?? existing.role,
contact: data.contact ?? existing.contact,
photoUrl: data.photoUrl ?? existing.photoUrl,
sortOrder: data.sortOrder ?? existing.sortOrder,
isActive: true,
},
})
.then((m) => m.id);
}
const created = await prisma.teamMember.create({
data: {
name: data.name,
rank: data.rank ?? null,
role: data.role ?? null,
cell: data.cell ?? null,
contact: data.contact ?? null,
photoUrl: data.photoUrl ?? null,
sortOrder: data.sortOrder ?? 0,
},
});
return created.id;
}
async function clearLocalData() {
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.taskAssignee.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
}
async function syncColumnConfigs() {
for (const key of SECTIONS) {
try {
const config = await fetchJson<{
key: string;
title: string;
titleEn?: string | null;
subtitle?: string | null;
cardOrder?: string | null;
}>(`/api/columns/${encodeURIComponent(key)}`);
await prisma.columnConfig.upsert({
where: { key },
create: {
key,
title: config.title,
titleEn: config.titleEn ?? '',
subtitle: config.subtitle ?? '',
cardOrder: config.cardOrder ?? null,
},
update: {
title: config.title,
titleEn: config.titleEn ?? '',
subtitle: config.subtitle ?? '',
cardOrder: config.cardOrder ?? null,
},
});
console.log(` ✓ column: ${key}`);
} catch (err) {
console.warn(` ⚠ column skip ${key}:`, (err as Error).message);
}
}
}
async function syncTeamMembersFromTasks(tasks: RemoteTask[]) {
const seen = new Set<string>();
for (const task of tasks) {
const members = [
...(task.assigneeMembers ?? []),
...(task.pmMember ? [task.pmMember] : []),
];
for (const m of members) {
const key = `${m.name}|${m.cell ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
await ensureTeamMember(m);
}
}
}
async function main() {
console.log(`📡 Source: ${SOURCE}`);
console.log('📥 Fetching remote tasks...');
const list = await fetchJson<RemoteTask[]>('/api/tasks');
console.log(` Found ${list.length} tasks`);
const fullTasks: RemoteTask[] = [];
for (const item of list) {
const full = await fetchJson<RemoteTask>(`/api/tasks/${item.id}`);
fullTasks.push(full);
}
console.log('🗑️ Clearing local task data...');
await clearLocalData();
const adminId = await ensureUser({ id: '', name: '관리자' }, 'admin@eene.com');
const userMap = new Map<string, string>();
userMap.set('admin', adminId);
console.log('👥 Syncing team members...');
try {
const remoteMembers = await fetchJson<
Array<{
name: string;
rank?: string | null;
role?: string | null;
cell?: string | null;
contact?: string | null;
photoUrl?: string | null;
sortOrder?: number;
}>
>('/api/team-members');
for (const m of remoteMembers) await ensureTeamMember(m);
console.log(` ${remoteMembers.length} team members from API`);
} catch {
await syncTeamMembersFromTasks(fullTasks);
console.log(' team members inferred from tasks');
}
console.log('📋 Importing tasks...');
for (const remote of fullTasks) {
let creatorId = userMap.get(remote.creatorId);
if (!creatorId) {
creatorId = await ensureUser(remote.creator, `creator-${remote.creatorId}@import.local`);
userMap.set(remote.creatorId, creatorId);
}
let assigneeId: string | null = null;
if (remote.assignee) {
let mapped = userMap.get(remote.assignee.id);
if (!mapped) {
mapped = await ensureUser(remote.assignee, `assignee-${remote.assignee.id}@import.local`);
userMap.set(remote.assignee.id, mapped);
}
assigneeId = mapped;
}
let pmMemberId: string | null = null;
if (remote.pmMember) {
pmMemberId = await ensureTeamMember(remote.pmMember);
}
const task = await prisma.task.create({
data: {
title: remote.title,
description: remote.description ?? null,
status: remote.status,
priority: remote.priority,
quarter: remote.quarter,
category: remote.category ?? null,
section: normalizeSection(remote.section),
tag: remote.tag ?? null,
taskType: remote.taskType ?? null,
progress: remote.progress ?? 0,
issueNote: remote.issueNote ?? null,
startDate: remote.startDate ? new Date(remote.startDate) : null,
dueDate: remote.dueDate ? new Date(remote.dueDate) : null,
showDate: remote.showDate,
showDescription: remote.showDescription,
showStatus: remote.showStatus,
showIssue: remote.showIssue,
showProgress: remote.showProgress,
creatorId,
assigneeId,
pmMemberId,
},
});
const milestoneIdMap = new Map<string, string>();
for (const ms of remote.milestones ?? []) {
const created = await prisma.milestone.create({
data: {
taskId: task.id,
title: ms.title,
description: ms.description ?? null,
startDate: ms.startDate ? new Date(ms.startDate) : null,
dueDate: ms.dueDate ? new Date(ms.dueDate) : null,
progress: ms.progress ?? 0,
links: ms.links ?? null,
completedAt: ms.completedAt ? new Date(ms.completedAt) : null,
order: ms.order ?? 0,
},
});
milestoneIdMap.set(ms.id, created.id);
}
for (const d of remote.details ?? []) {
let authorId = userMap.get(d.updatedBy);
if (!authorId) {
authorId = await ensureUser(d.author, `author-${d.updatedBy}@import.local`);
userMap.set(d.updatedBy, authorId);
}
await prisma.taskDetail.create({
data: {
taskId: task.id,
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
content: d.content,
authorName: d.authorName ?? null,
updatedBy: authorId,
createdAt: new Date(d.createdAt),
updatedAt: new Date(d.updatedAt),
},
});
}
for (const k of remote.kpiMetrics ?? []) {
await prisma.kpiMetric.create({
data: {
taskId: task.id,
quarter: k.quarter,
target: k.target,
actual: k.actual,
unit: k.unit ?? null,
},
});
}
const assigneeMemberIds: string[] = [];
for (const m of remote.assigneeMembers ?? []) {
assigneeMemberIds.push(await ensureTeamMember(m));
}
if (assigneeMemberIds.length > 0) {
await prisma.taskAssignee.createMany({
data: [...new Set(assigneeMemberIds)].map((memberId) => ({ taskId: task.id, memberId })),
});
}
for (const f of remote.files ?? []) {
let uploaderId = userMap.get(f.uploadedBy);
if (!uploaderId) {
uploaderId = creatorId;
userMap.set(f.uploadedBy, uploaderId);
}
await prisma.file.create({
data: {
taskId: task.id,
milestoneId: f.milestoneId ? milestoneIdMap.get(f.milestoneId) ?? null : null,
filename: f.filename,
originalName: f.originalName,
displayName: f.displayName ?? null,
sortOrder: f.sortOrder ?? 0,
mimetype: f.mimetype,
size: f.size,
path: f.path,
uploadedBy: uploaderId,
createdAt: new Date(f.createdAt),
},
});
}
console.log(`${remote.title}`);
}
console.log('📐 Syncing column order...');
await syncColumnConfigs();
const count = await prisma.task.count();
console.log(`\n✅ Done! Local DB now has ${count} tasks (from ${SOURCE})`);
console.log(' Refresh http://localhost:3000');
}
main()
.catch((err) => {
console.error('❌ Sync failed:', err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,300 @@
/**
* 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드
* 사용: npm run db:push-remote
* 환경변수 TARGET_API_URL 로 대상 변경 가능
*/
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
const SECTIONS = ['인사관리', '학습성장', '운영관리'];
const PHOTOS_ONLY = process.argv.includes('--photos-only');
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads'));
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
function resolveLocalPhotoPath(photoUrl: string | null | undefined): string | null {
if (!photoUrl || /^https?:\/\//i.test(photoUrl) || photoUrl.startsWith('data:')) return null;
const filename = photoUrl.replace(/^\/uploads\/team\//, '').replace(/^uploads\/team\//, '');
if (!filename || filename.includes('..')) return null;
const filePath = path.join(TEAM_DIR, filename);
return fs.existsSync(filePath) ? filePath : null;
}
function mimeForExt(ext: string): string {
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
return 'image/jpeg';
}
async function uploadPhotoToRemote(filePath: string): Promise<string> {
const buf = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const form = new FormData();
form.append('photo', new Blob([buf], { type: mimeForExt(ext) }), path.basename(filePath));
const res = await fetch(`${TARGET}/api/team-members/photo`, { method: 'POST', body: form });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`photo upload failed: ${res.status} ${text}`);
}
const data = (await res.json()) as { url: string };
return data.url;
}
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${TARGET}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path}${res.status} ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
function memberKey(name: string, cell: string | null) {
return `${name}|${cell ?? ''}`;
}
async function ensureRemoteApiReady() {
const res = await fetch(`${TARGET}/api/team-members`);
if (res.status === 404) {
throw new Error(
'배포 서버에 team-members API가 없습니다. 코드 배포(Render) 완료 후 다시 실행하세요.',
);
}
if (!res.ok) throw new Error(`team-members check failed: ${res.status}`);
}
async function syncTeamMembers(): Promise<Map<string, string>> {
const locals = await prisma.teamMember.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
type RemoteMember = {
id: string;
name: string;
cell: string | null;
rank?: string | null;
role?: string | null;
contact?: string | null;
photoUrl?: string | null;
sortOrder?: number;
};
let remotes: RemoteMember[] = [];
try {
remotes = await api<RemoteMember[]>('GET', '/api/team-members?all=1');
} catch {
remotes = await api<RemoteMember[]>('GET', '/api/team-members');
}
const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m]));
const idMap = new Map<string, string>();
for (const local of locals) {
const key = memberKey(local.name, local.cell);
let photoUrl = local.photoUrl;
const localPhotoPath = resolveLocalPhotoPath(local.photoUrl);
if (localPhotoPath) {
try {
photoUrl = await uploadPhotoToRemote(localPhotoPath);
console.log(` 📷 ${local.name}${path.basename(localPhotoPath)}`);
} catch (err) {
console.warn(`${local.name} photo skip:`, (err as Error).message);
}
} else if (local.photoUrl) {
console.warn(`${local.name} — local file not found: ${local.photoUrl}`);
}
const payload = {
name: local.name,
rank: local.rank,
role: local.role,
cell: local.cell,
contact: local.contact,
photoUrl,
sortOrder: local.sortOrder,
isActive: true,
};
const existing = remoteByKey.get(key);
if (existing) {
await api('PATCH', `/api/team-members/${existing.id}`, payload);
idMap.set(local.id, existing.id);
console.log(` ✓ team ${local.name} (updated)`);
} else {
const created = await api<RemoteMember>('POST', '/api/team-members', payload);
idMap.set(local.id, created.id);
remoteByKey.set(key, created);
console.log(` ✓ team ${local.name} (created)`);
}
}
return idMap;
}
async function clearRemoteTasks() {
const remoteTasks = await api<Array<{ id: string }>>('GET', '/api/tasks');
for (const t of remoteTasks) {
await api('DELETE', `/api/tasks/${t.id}`);
}
console.log(` removed ${remoteTasks.length} remote tasks`);
}
async function syncTasks(memberIdMap: Map<string, string>) {
const tasks = await prisma.task.findMany({
include: {
milestones: { orderBy: { order: 'asc' } },
details: { orderBy: { createdAt: 'asc' } },
kpiMetrics: true,
taskAssignees: true,
pmMember: true,
},
orderBy: { createdAt: 'asc' },
});
for (const task of tasks) {
const assigneeMemberIds = task.taskAssignees
.map((ta) => memberIdMap.get(ta.memberId))
.filter((id): id is string => Boolean(id));
const pmMemberId = task.pmMemberId ? memberIdMap.get(task.pmMemberId) ?? null : null;
const created = await api<{ id: string }>('POST', '/api/tasks', {
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
quarter: task.quarter,
category: task.category,
section: task.section,
tag: task.tag,
taskType: task.taskType,
progress: task.progress,
issueNote: task.issueNote,
startDate: task.startDate?.toISOString() ?? null,
dueDate: task.dueDate?.toISOString() ?? null,
showDate: task.showDate,
showDescription: task.showDescription,
showStatus: task.showStatus,
showIssue: task.showIssue,
showProgress: task.showProgress,
pmMemberId,
assigneeMemberIds,
});
const milestoneIdMap = new Map<string, string>();
for (const ms of task.milestones) {
const remoteMs = await api<{ id: string }>('POST', `/api/milestones/${created.id}`, {
title: ms.title,
description: ms.description,
startDate: ms.startDate?.toISOString() ?? null,
dueDate: ms.dueDate?.toISOString() ?? null,
progress: ms.progress,
links: ms.links,
});
milestoneIdMap.set(ms.id, remoteMs.id);
}
for (const d of task.details) {
await api('POST', `/api/details/${created.id}`, {
content: d.content,
authorName: d.authorName,
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
});
}
for (const k of task.kpiMetrics) {
await api('POST', '/api/kpi', {
taskId: created.id,
quarter: k.quarter,
target: k.target,
actual: k.actual,
unit: k.unit,
});
}
console.log(` ✓ task ${task.title}`);
}
return tasks.length;
}
async function syncColumnConfigs() {
const configs = await prisma.columnConfig.findMany();
for (const config of configs) {
await api('PATCH', `/api/columns/${encodeURIComponent(config.key)}`, {
title: config.title,
titleEn: config.titleEn,
subtitle: config.subtitle,
cardOrder: config.cardOrder,
});
console.log(` ✓ column ${config.key}`);
}
for (const key of SECTIONS) {
if (!configs.some((c) => c.key === key)) {
const local = await prisma.columnConfig.findUnique({ where: { key } });
if (local) continue;
try {
await api('GET', `/api/columns/${encodeURIComponent(key)}`);
} catch {
/* ensure exists on remote */
}
}
}
}
async function main() {
console.log(`📤 Target: ${TARGET}`);
console.log('🔍 Checking remote API...');
await ensureRemoteApiReady();
const localTasks = await prisma.task.count();
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
if (!PHOTOS_ONLY && localTasks === 0) {
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
}
console.log(`👥 Uploading team members${PHOTOS_ONLY ? ' + photos' : ''}...`);
console.log(` Local photos dir: ${TEAM_DIR}`);
const memberIdMap = await syncTeamMembers();
if (PHOTOS_ONLY) {
console.log('\n✅ Photos sync complete!');
console.log(' Site: https://eene-dashboard.vercel.app/');
return;
}
console.log('🗑️ Clearing remote tasks...');
await clearRemoteTasks();
console.log('📋 Uploading tasks...');
await syncTasks(memberIdMap);
console.log('📐 Uploading column order...');
await syncColumnConfigs();
const remoteTasks = await api<unknown[]>('GET', '/api/tasks');
console.log(`\n✅ Done! Remote now has ${remoteTasks.length} tasks`);
console.log(` Site: https://eene-dashboard.vercel.app/`);
}
main()
.catch((err) => {
console.error('❌ Push failed:', err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,75 @@
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';
import { getProjectRoot } from '../src/lib/projectPaths';
const prisma = new PrismaClient();
/** hr-data legacy 상시업무 — 허브 대분류 셸로 대체, 시드·DB에서 제거 */
const LEGACY_ROUTINE_TITLES = [
'H/W, S/W',
'시설관리',
'배움터',
'인재채용',
'학습 지원',
'채용 운영',
];
async function deleteLegacyRoutineTasks() {
for (const title of LEGACY_ROUTINE_TITLES) {
const task = await prisma.task.findFirst({
where: { title, taskType: { in: ['기반업무', '상시업무'] } },
});
if (!task) {
console.log(` skip (not found): ${title}`);
continue;
}
await prisma.task.delete({ where: { id: task.id } });
console.log(` 🗑 deleted: ${title}`);
}
const remaining = await prisma.task.count({
where: { taskType: { in: ['기반업무', '상시업무'] } },
});
if (remaining > 0) {
const extras = await prisma.task.findMany({
where: { taskType: { in: ['기반업무', '상시업무'] } },
select: { title: true },
});
for (const t of extras) {
await prisma.task.deleteMany({ where: { title: t.title, taskType: { in: ['기반업무', '상시업무'] } } });
console.log(` 🗑 deleted (extra routine): ${t.title}`);
}
}
}
function patchHrData() {
const seedPath = path.join(getProjectRoot(), 'data', 'seed', 'hr-data.json');
const data = JSON.parse(fs.readFileSync(seedPath, 'utf-8')) as {
PROJECTS: { name: string; priority?: string; [key: string]: unknown }[];
};
const before = data.PROJECTS.length;
data.PROJECTS = data.PROJECTS.filter((p) => p.priority !== '상시');
fs.writeFileSync(seedPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(` ✓ hr-data.json: removed ${before - data.PROJECTS.length} legacy 상시 entries (${data.PROJECTS.length} projects left)`);
}
async function main() {
console.log('🧹 Delete all legacy routine tasks from DB ...');
await deleteLegacyRoutineTasks();
console.log('📝 Remove priority:상시 from hr-data.json ...');
patchHrData();
console.log('Done. 상시업무는 허브에서 대분류 클릭 시 새 셸로 생성됩니다.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -102,7 +102,7 @@ async function importViaApi(adminId: string, memberId: string) {
async function main() {
const tasks = mapAllHrProjects();
console.log(`📦 HR_Dashboard${tasks.length} tasks mapped`);
console.log(`📦 data/seed/hr-data.json${tasks.length} tasks mapped`);
let adminId: string;
let memberId: string;

View File

@@ -0,0 +1,46 @@
/**
* 4분면 보드 section 정렬 — 조직문화(EX) 프로젝트 재배치
* npx tsx scripts/migrate-board-sections.ts
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const EX_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
async function main() {
const tasks = await prisma.task.findMany({
select: { id: true, title: true, section: true },
});
let moved = 0;
for (const task of tasks) {
if (task.section === '조직문화') continue;
if (!EX_TITLE.test(task.title.trim())) continue;
await prisma.task.update({
where: { id: task.id },
data: { section: '조직문화', category: '조직문화' },
});
moved += 1;
console.log(` → 조직문화: ${task.title}`);
}
const col = await prisma.columnConfig.findUnique({ where: { key: '운영관리' } });
if (col && (col.title === '운영관리' || col.title === '운영관리 부문' || col.titleEn === 'Operations')) {
await prisma.columnConfig.update({
where: { key: '운영관리' },
data: { title: '총무관리', titleEn: 'GA' },
});
console.log(' → column 운영관리 title → 총무관리');
}
console.log(`✅ migrate-board-sections complete (${moved} tasks moved)`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,59 @@
import 'dotenv/config';
import { Prisma, PrismaClient } from '@prisma/client';
import { migrateDescriptionToPeriodEntries } from '../src/lib/milestonePeriods';
const prisma = new PrismaClient();
async function main() {
const milestones = await prisma.milestone.findMany({
where: {
description: { not: null },
NOT: { description: '' },
},
select: {
id: true,
title: true,
description: true,
startDate: true,
dueDate: true,
periodEntries: true,
},
});
let migrated = 0;
let skipped = 0;
for (const m of milestones) {
const { periodEntries, migrated: didMigrate } = migrateDescriptionToPeriodEntries({
id: m.id,
periodEntries: m.periodEntries,
startDate: m.startDate,
dueDate: m.dueDate,
description: m.description,
});
if (!didMigrate || !periodEntries) {
skipped += 1;
continue;
}
await prisma.milestone.update({
where: { id: m.id },
data: {
periodEntries: periodEntries as Prisma.InputJsonValue,
description: null,
},
});
migrated += 1;
console.log(`${m.title} → 기간1 note (${periodEntries.length}건)`);
}
console.log(`\nDone. migrated=${migrated}, skipped=${skipped}, scanned=${milestones.length}`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,66 @@
/**
* 전산관리·운영지원 → 운영관리 부문 통합 (1회 실행)
* 사용: npm run db:migrate-sections
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const MERGE_INTO = '운영관리';
const FROM = ['전산관리', '운영지원'] as const;
async function mergeCardOrder(intoKey: string, fromKeys: readonly string[]) {
const target = await prisma.columnConfig.findUnique({ where: { key: intoKey } });
const orders: string[] = [];
if (target?.cardOrder) {
try {
orders.push(...JSON.parse(target.cardOrder));
} catch {
/* ignore */
}
}
for (const from of fromKeys) {
const src = await prisma.columnConfig.findUnique({ where: { key: from } });
if (!src?.cardOrder) continue;
try {
const ids = JSON.parse(src.cardOrder) as string[];
for (const id of ids) {
if (!orders.includes(id)) orders.push(id);
}
} catch {
/* ignore */
}
}
if (orders.length === 0 && !target) return;
await prisma.columnConfig.upsert({
where: { key: intoKey },
update: { cardOrder: JSON.stringify(orders) },
create: {
key: intoKey,
title: '운영관리',
titleEn: 'GA',
subtitle: '',
cardOrder: JSON.stringify(orders),
},
});
}
async function main() {
for (const from of FROM) {
const { count } = await prisma.task.updateMany({
where: { section: from },
data: { section: MERGE_INTO },
});
if (count > 0) console.log(`${from}${MERGE_INTO}: ${count} tasks`);
}
await mergeCardOrder(MERGE_INTO, FROM);
console.log('✅ Section migration done.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,28 @@
/**
* 로컬 DB가 비어 있을 때만 seed 실행 (최초 1회용)
*/
import 'dotenv/config';
import { execSync } from 'child_process';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const users = await prisma.user.count();
if (users > 0) {
console.log('✓ Local DB has data — skip initial seed');
return;
}
console.log('📦 Empty local DB — loading initial sample data...');
const backendRoot = path.resolve(__dirname, '..');
execSync('tsx prisma/seed.ts', { stdio: 'inherit', cwd: backendRoot });
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,45 @@
/**
* 팀원이 0명일 때만 hr-data.json TEAM → team_members (업무는 건드리지 않음)
*/
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import { mapHrTeamMembers } from '../prisma/mapHrProjects';
const prisma = new PrismaClient();
async function main() {
const count = await prisma.teamMember.count();
if (count > 0) {
console.log('✓ Team members exist — skip TEAM seed');
return;
}
const teamParsed = mapHrTeamMembers();
if (teamParsed.length === 0) {
console.log('✓ hr-data.json TEAM empty — skip');
return;
}
for (const tm of teamParsed) {
await prisma.teamMember.create({
data: {
name: tm.name,
rank: tm.rank,
role: tm.role,
cell: tm.cell,
photoUrl: tm.photoUrl,
sortOrder: tm.sortOrder,
isActive: true,
},
});
}
console.log(`✅ Team members seeded: ${teamParsed.length}명 (hr-data.json TEAM)`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,198 @@
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import { getProjectRoot, getUploadDir, getTeamUploadDir } from '../src/lib/projectPaths';
const prisma = new PrismaClient();
const TEAM_ORDER = ['조태희', '최근혜', '류원준', '주완기', '정성호'];
function fileHash(filePath: string): string {
const buf = fs.readFileSync(filePath);
return crypto.createHash('md5').update(buf).digest('hex');
}
function resolveUploadPath(relative: string): string {
const clean = relative.replace(/^\//, '').replace(/^uploads\//, '');
return path.join(getUploadDir(), clean);
}
async function syncTeamPhotos() {
const teamDir = getTeamUploadDir();
if (!fs.existsSync(teamDir)) return;
const pngs = fs
.readdirSync(teamDir)
.filter((f) => /\.(png|jpe?g|webp|gif)$/i.test(f))
.map((f) => path.join(teamDir, f));
const byHash = new Map<string, string>();
const duplicates: string[] = [];
for (const filePath of pngs) {
const hash = fileHash(filePath);
const existing = byHash.get(hash);
if (existing) {
const keep =
fs.statSync(existing).birthtimeMs <= fs.statSync(filePath).birthtimeMs
? existing
: filePath;
duplicates.push(keep === existing ? filePath : existing);
byHash.set(hash, keep);
} else {
byHash.set(hash, filePath);
}
}
for (const dup of duplicates) {
fs.unlinkSync(dup);
console.log(` 🗑 duplicate removed: ${path.basename(dup)}`);
}
const unique = [...byHash.values()].sort(
(a, b) => fs.statSync(a).birthtimeMs - fs.statSync(b).birthtimeMs,
);
const members = await prisma.teamMember.findMany();
const byName = new Map(members.map((m) => [m.name, m]));
for (let i = 0; i < TEAM_ORDER.length; i++) {
const name = TEAM_ORDER[i];
const member = byName.get(name);
if (!member) continue;
const photoPath = unique[i];
const photoUrl = photoPath
? `/uploads/team/${path.basename(photoPath)}`
: null;
await prisma.teamMember.update({
where: { id: member.id },
data: { photoUrl, sortOrder: i },
});
console.log(`${name}${photoUrl ?? '(none)'}`);
}
for (const member of members) {
if (TEAM_ORDER.includes(member.name)) continue;
const url = member.photoUrl;
if (!url) continue;
const abs = resolveUploadPath(url);
if (!fs.existsSync(abs)) {
await prisma.teamMember.update({
where: { id: member.id },
data: { photoUrl: null },
});
console.log(` ✓ cleared broken photo: ${member.name}`);
}
}
}
async function clearBrokenTeamPhotoUrls() {
const members = await prisma.teamMember.findMany();
for (const member of members) {
if (!member.photoUrl) continue;
if (member.photoUrl.startsWith('http') || member.photoUrl.startsWith('data:')) continue;
const abs = resolveUploadPath(member.photoUrl);
if (!fs.existsSync(abs)) {
await prisma.teamMember.update({
where: { id: member.id },
data: { photoUrl: null },
});
console.log(` ✓ cleared missing file: ${member.name} (${member.photoUrl})`);
}
}
}
async function pruneOrphanUploads() {
const referenced = new Set<string>();
const dbFiles = await prisma.file.findMany({ select: { path: true, filename: true } });
for (const f of dbFiles) {
if (f.path) referenced.add(path.normalize(f.path));
referenced.add(path.join(getUploadDir(), f.filename));
}
const members = await prisma.teamMember.findMany({ select: { photoUrl: true } });
for (const m of members) {
if (!m.photoUrl || m.photoUrl.startsWith('http')) continue;
referenced.add(resolveUploadPath(m.photoUrl));
}
const uploadRoot = getUploadDir();
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.name === '.gitkeep') continue;
if (entry.isDirectory()) {
walk(full);
continue;
}
const norm = path.normalize(full);
if (!referenced.has(norm)) {
fs.unlinkSync(full);
console.log(` 🗑 orphan: ${path.relative(getProjectRoot(), full)}`);
}
}
};
walk(uploadRoot);
}
async function patchHrDataTeamPhotos() {
const seedPath = path.join(getProjectRoot(), 'data', 'seed', 'hr-data.json');
if (!fs.existsSync(seedPath)) return;
const data = JSON.parse(fs.readFileSync(seedPath, 'utf-8')) as {
TEAM?: { name: string; photo?: string }[];
};
if (!Array.isArray(data.TEAM)) return;
const members = await prisma.teamMember.findMany();
const byName = new Map(members.map((m) => [m.name, m]));
let changed = false;
for (const entry of data.TEAM) {
const parsed = entry.name.match(/^(\S+)/);
const name = parsed?.[1];
const member = name ? byName.get(name) : null;
const nextPhoto = member?.photoUrl ?? undefined;
if (entry.photo !== nextPhoto) {
if (nextPhoto) entry.photo = nextPhoto;
else delete entry.photo;
changed = true;
}
}
if (changed) {
fs.writeFileSync(seedPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(' ✓ hr-data.json TEAM photos synced to uploads/team paths');
}
}
async function main() {
console.log('📷 Sync team photos from uploads/team/ ...');
await syncTeamPhotos();
console.log('🔍 Clear broken photo URLs ...');
await clearBrokenTeamPhotoUrls();
console.log('🧹 Remove orphan uploads (not in DB) ...');
await pruneOrphanUploads();
console.log('📝 Update hr-data.json TEAM photo paths ...');
await patchHrDataTeamPhotos();
console.log('Done.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -8,10 +8,20 @@ import routes from './routes';
const app = express();
app.use(helmet());
// Vercel 프론트에서 Render /uploads 이미지를 img로 불러올 수 있도록 cross-origin 허용
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}),
);
const allowedOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'https://localhost:3000',
'http://127.0.0.1:3000',
'https://127.0.0.1:3000',
'http://172.16.8.248:3000',
'https://172.16.8.248:3000',
'https://eene-dashboard.vercel.app',
process.env.FRONTEND_URL,
].filter(Boolean) as string[];
@@ -19,6 +29,11 @@ const allowedOrigins = [
function isAllowedOrigin(origin: string): boolean {
if (allowedOrigins.includes(origin)) return true;
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
// 로컬·사설망 프론트 (http/https, LAN IP)
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) return true;
if (/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
return false;
}

View File

@@ -4,6 +4,8 @@ import { Server } from 'socket.io';
import app from './app';
import { setupSocketHandlers } from './socket';
import { prisma } from './lib/prisma';
import { ensureLocalDirs } from './lib/ensureLocalDirs';
import { getProjectRoot, getHrSeedPath, getUploadDir } from './lib/projectPaths';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -12,16 +14,22 @@ const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: FRONTEND_URL,
origin: (_origin, callback) => callback(null, true),
credentials: true,
},
});
setupSocketHandlers(io);
app.set('io', io);
async function main() {
ensureLocalDirs();
await prisma.$connect();
console.log('✅ Database connected');
console.log('✅ Database connected — local PostgreSQL (data/postgres)');
console.log(`📂 Project root: ${getProjectRoot()}`);
console.log(`📂 HR seed: ${getHrSeedPath()}`);
console.log(`📂 Uploads: ${getUploadDir()}`);
httpServer.listen(PORT, '0.0.0.0', () => {
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);

View File

@@ -0,0 +1,6 @@
import { ensureProjectDataDirs } from './projectPaths';
/** 로컬 data·uploads 폴더 생성 (DB·파일 영구 저장) */
export function ensureLocalDirs() {
ensureProjectDataDirs();
}

View File

@@ -0,0 +1,60 @@
const EXT_MIME: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml',
mp4: 'video/mp4',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
webm: 'video/webm',
mkv: 'video/x-matroska',
m4v: 'video/x-m4v',
wmv: 'video/x-ms-wmv',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
xls: 'application/vnd.ms-excel',
csv: 'text/csv',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
doc: 'application/msword',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
ppt: 'application/vnd.ms-powerpoint',
hwp: 'application/x-hwp',
hwpx: 'application/hwp+zip',
txt: 'text/plain',
};
/** multer가 application/octet-stream 으로 저장할 때 확장자로 보정 */
export function resolveFileMime(originalName: string, stored?: string | null): string {
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
const fromExt = ext ? EXT_MIME[ext] : undefined;
if (!stored || stored === 'application/octet-stream' || stored === 'application/x-msdownload') {
return fromExt ?? stored ?? 'application/octet-stream';
}
if (fromExt && stored.startsWith('application/') && stored.includes('octet')) {
return fromExt;
}
return stored;
}
export function isInlinePreviewMime(mime: string): boolean {
return (
mime.startsWith('image/') ||
mime.startsWith('video/') ||
mime.startsWith('text/') ||
mime === 'application/pdf' ||
mime.includes('spreadsheet') ||
mime.includes('excel') ||
mime.includes('wordprocessingml') ||
mime.includes('presentationml') ||
mime === 'application/msword' ||
mime === 'application/vnd.ms-powerpoint' ||
mime === 'application/x-hwp' ||
mime === 'application/hwp+zip'
);
}

View File

@@ -0,0 +1,140 @@
export interface MilestonePeriodEntry {
id: string;
startDate?: string | null;
dueDate?: string | null;
note?: string | null;
}
function parseDay(iso: string): Date {
const d = new Date(iso);
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function normalizePeriodEntries(raw: unknown): MilestonePeriodEntry[] {
if (!Array.isArray(raw)) return [];
const entries: MilestonePeriodEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const startDate = typeof row.startDate === 'string' ? row.startDate.trim() || null : null;
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() || null : null;
const note = typeof row.note === 'string' ? row.note.trim() || null : null;
if (!startDate && !dueDate && !note) continue;
entries.push({
id: typeof row.id === 'string' && row.id ? row.id : `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
startDate,
dueDate,
note,
});
}
return entries;
}
export function deriveMilestoneDatesFromPeriods(entries: MilestonePeriodEntry[]): {
startDate: Date | null;
dueDate: Date | null;
} {
const times: number[] = [];
for (const entry of entries) {
if (entry.startDate) times.push(parseDay(entry.startDate).getTime());
if (entry.dueDate) times.push(parseDay(entry.dueDate).getTime());
}
if (times.length === 0) return { startDate: null, dueDate: null };
return {
startDate: new Date(Math.min(...times)),
dueDate: new Date(Math.max(...times)),
};
}
export function resolveMilestonePeriodPayload(body: Record<string, unknown>): {
periodEntries: MilestonePeriodEntry[] | undefined;
startDate: Date | null | undefined;
dueDate: Date | null | undefined;
} {
if (body.periodEntries !== undefined) {
const periodEntries = normalizePeriodEntries(body.periodEntries);
const { startDate, dueDate } = deriveMilestoneDatesFromPeriods(periodEntries);
return {
periodEntries,
startDate,
dueDate,
};
}
const hasLegacyStart = body.startDate !== undefined;
const hasLegacyDue = body.dueDate !== undefined;
if (!hasLegacyStart && !hasLegacyDue) {
return { periodEntries: undefined, startDate: undefined, dueDate: undefined };
}
const startDate = body.startDate ? new Date(String(body.startDate)) : null;
const dueDate = body.dueDate ? new Date(String(body.dueDate)) : null;
const periodEntries =
startDate || dueDate
? normalizePeriodEntries([
{
id: `period-legacy-${Date.now()}`,
startDate: startDate ? startDate.toISOString().slice(0, 10) : null,
dueDate: dueDate ? dueDate.toISOString().slice(0, 10) : null,
note: null,
},
])
: [];
return { periodEntries, startDate, dueDate };
}
function toDateInput(d: Date | null | undefined): string | null {
return d ? d.toISOString().slice(0, 10) : null;
}
/** 레거시 description(@overview: 포함) → 기간1 note 본문 */
export function legacyDescriptionNote(description: string | null | undefined): string {
if (!description?.trim()) return '';
if (description.startsWith('@overview:')) {
const rest = description.slice('@overview:'.length);
const nl = rest.indexOf('\n');
if (nl === -1) return rest.trim();
const body = rest.slice(nl + 1).trim();
if (body) return body;
return rest.slice(0, nl).trim();
}
return description.trim();
}
/** 공통업무내용(description)을 periodEntries[0].note로 이관 */
export function migrateDescriptionToPeriodEntries(input: {
id?: string;
periodEntries: unknown;
startDate: Date | null;
dueDate: Date | null;
description: string | null;
}): { periodEntries: MilestonePeriodEntry[] | null; migrated: boolean } {
const legacyNote = legacyDescriptionNote(input.description);
if (!legacyNote) {
return { periodEntries: normalizePeriodEntries(input.periodEntries), migrated: false };
}
const existing = normalizePeriodEntries(input.periodEntries);
if (existing.length > 0) {
if (existing.some((e) => e.note?.trim())) {
return { periodEntries: existing, migrated: false };
}
const updated = [...existing];
updated[0] = { ...updated[0], note: legacyNote };
return { periodEntries: updated, migrated: true };
}
const id = input.id ? `period-legacy-${input.id}` : `period-legacy-${Date.now()}`;
return {
periodEntries: [
{
id,
startDate: toDateInput(input.startDate),
dueDate: toDateInput(input.dueDate),
note: legacyNote,
},
],
migrated: true,
};
}

View File

@@ -0,0 +1,65 @@
import fs from 'fs';
import path from 'path';
/** backend/ 디렉터리 (package.json 기준) */
export function getBackendRoot(): string {
return path.resolve(__dirname, '../..');
}
/** EENE_Dashboard_0608 루트 */
export function getProjectRoot(): string {
return path.resolve(getBackendRoot(), '..');
}
export function getDataDir(): string {
return path.join(getProjectRoot(), 'data');
}
export function getPostgresDataDir(): string {
return path.join(getDataDir(), 'postgres');
}
export function getSeedDir(): string {
return path.join(getDataDir(), 'seed');
}
/** HR 원본 JSON — seed·import 공통 (프로젝트 내부 data/seed/) */
export function getHrSeedPath(): string {
const configured = process.env.HR_DATA_PATH?.trim();
if (configured) {
return path.isAbsolute(configured)
? configured
: path.resolve(getBackendRoot(), configured);
}
return path.join(getSeedDir(), 'hr-data.json');
}
export function getUploadDir(): string {
const configured = process.env.UPLOAD_DIR?.trim();
if (configured) {
return path.isAbsolute(configured)
? configured
: path.resolve(getBackendRoot(), configured);
}
return path.join(getProjectRoot(), 'uploads');
}
export function getTeamUploadDir(): string {
return path.join(getUploadDir(), 'team');
}
/** 서버 기동 시 data·uploads 등 로컬 영구 저장 폴더 생성 */
export function ensureProjectDataDirs(): void {
for (const dir of [
getDataDir(),
getPostgresDataDir(),
getSeedDir(),
getUploadDir(),
getTeamUploadDir(),
]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`📁 Created: ${dir}`);
}
}
}

View File

@@ -0,0 +1,47 @@
export interface TaskIssueEntry {
id: string;
text: string;
showOnCard: boolean;
}
function newIssueId() {
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
if (!Array.isArray(raw)) return [];
const entries: TaskIssueEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const text = typeof row.text === 'string' ? row.text.trim() : '';
if (!text) continue;
entries.push({
id: typeof row.id === 'string' && row.id ? row.id : newIssueId(),
text,
showOnCard: row.showOnCard !== false,
});
}
return entries;
}
export function parseIssueEntriesFromTask(task: {
issueEntries?: unknown;
issueNote?: string | null;
showIssue?: boolean;
}): TaskIssueEntry[] {
const fromJson = normalizeIssueEntries(task.issueEntries);
if (fromJson.length > 0) return fromJson;
const legacy = task.issueNote?.trim();
if (!legacy) return [];
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
}
export function deriveIssueFields(entries: TaskIssueEntry[]) {
const visible = entries.filter((entry) => entry.showOnCard && entry.text.trim());
return {
issueEntries: entries,
issueNote: visible.length > 0 ? visible[visible.length - 1].text : null,
showIssue: visible.length > 0,
};
}

View File

@@ -0,0 +1,100 @@
import { prisma } from './prisma';
export const teamMemberSelect = {
id: true,
name: true,
rank: true,
role: true,
cell: true,
contact: true,
photoUrl: true,
sortOrder: true,
} as const;
export const milestoneInclude = {
pmMember: { select: teamMemberSelect },
milestoneAssignees: {
include: { member: { select: teamMemberSelect } },
},
} as const;
export const taskInclude = {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
pmMember: { select: teamMemberSelect },
taskAssignees: {
include: { member: { select: teamMemberSelect } },
},
_count: { select: { files: true, details: true } },
} as const;
export const taskDetailInclude = {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
pmMember: { select: teamMemberSelect },
taskAssignees: {
include: { member: { select: teamMemberSelect } },
},
details: {
orderBy: { createdAt: 'desc' as const },
include: { author: { select: { id: true, name: true } } },
},
kpiMetrics: true,
files: true,
milestones: {
orderBy: { order: 'asc' as const },
include: milestoneInclude,
},
};
export function formatMilestone<T extends Record<string, unknown>>(milestone: T) {
const { milestoneAssignees, ...rest } = milestone as T & {
milestoneAssignees?: Array<{ member: unknown }>;
};
const assigneeMembers = (milestoneAssignees ?? []).map((ma) => ma.member);
return { ...rest, assigneeMembers };
}
export function formatTask<T extends Record<string, unknown>>(task: T) {
const { taskAssignees, milestones, ...rest } = task as T & {
taskAssignees?: Array<{ member: unknown }>;
milestones?: Array<Record<string, unknown>>;
};
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
const formattedMilestones = milestones?.map((m) => formatMilestone(m));
return {
...rest,
assigneeMembers,
...(formattedMilestones !== undefined ? { milestones: formattedMilestones } : {}),
};
}
export async function syncTaskMembers(
taskId: string,
pmMemberId: string | null | undefined,
assigneeMemberIds: string[] | undefined,
) {
if (pmMemberId !== undefined) {
await prisma.task.update({
where: { id: taskId },
data: { pmMemberId: pmMemberId || null },
});
}
if (assigneeMemberIds !== undefined) {
await prisma.taskAssignee.deleteMany({ where: { taskId } });
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
if (ids.length > 0) {
await prisma.taskAssignee.createMany({
data: ids.map((memberId) => ({ taskId, memberId })),
});
}
}
}
export function parseMemberIds(body: Record<string, unknown>): string[] | undefined {
if (body.assigneeMemberIds === undefined) return undefined;
const raw = body.assigneeMemberIds;
if (!Array.isArray(raw)) return [];
return raw.map(String).filter(Boolean);
}

View File

@@ -0,0 +1,23 @@
import fs from 'fs';
import type { Express } from 'express';
export const DISK_FULL_MESSAGE =
'저장 공간이 부족하여 파일을 업로드하지 못했습니다. 불필요한 파일을 삭제한 후 다시 시도해 주세요.';
export function isDiskFullError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as NodeJS.ErrnoException & { cause?: unknown };
if (e.code === 'ENOSPC' || e.code === 'EDQUOT') return true;
if (e.cause !== undefined) return isDiskFullError(e.cause);
return false;
}
/** multer 업로드 실패 시 디스크에 남은 임시 파일 제거 */
export function cleanupUploadedFile(file?: Express.Multer.File | null) {
if (!file?.path) return;
try {
if (fs.existsSync(file.path)) fs.unlinkSync(file.path);
} catch {
/* ignore */
}
}

View File

@@ -1,33 +1,49 @@
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
console.error('[Error]', err);
const prismaCode = (err as { code?: string }).code;
if (prismaCode === 'P2022') {
res.status(500).json({ message: 'DB 스키마가 최신이 아닙니다. 배포 후 다시 시도해 주세요.' });
return;
}
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
import type { Request, Response, NextFunction } from 'express';
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({ message: err.message });
return;
}
if (isDiskFullError(err)) {

View File

@@ -3,7 +3,6 @@ import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const MAX_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 20;
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
if (!fs.existsSync(UPLOAD_DIR)) {
@@ -20,7 +19,5 @@ const storage = multer.diskStorage({
},
});
export const upload = multer({
storage,
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
});
/** 파일 크기 상한 없음 — 디스크 여유만큼 저장 (부족 시 uploadErrors) */
export const upload = multer({ storage });

View File

@@ -0,0 +1,32 @@
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
if (!fs.existsSync(TEAM_DIR)) {
fs.mkdirSync(TEAM_DIR, { recursive: true });
}
const storage = multer.diskStorage({
destination(_req, _file, cb) {
cb(null, TEAM_DIR);
},
filename(_req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${uuidv4()}${ext}`);
},
});
export const uploadTeamPhoto = multer({
storage,
fileFilter(_req, file, cb) {
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('JPEG, PNG, GIF, WebP 이미지만 업로드할 수 있습니다.'));
}
},
});

View File

@@ -10,8 +10,8 @@ const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: strin
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
},
'운영관리': {
title: '운영관리 부문',
titleEn: 'Operations',
title: '총무관리',
titleEn: 'GA',
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
},
};
@@ -27,6 +27,18 @@ router.get('/:key', async (req, res, next) => {
config = await prisma.columnConfig.create({
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
});
} else if (key === '운영관리') {
const legacyTitles = ['운영관리', '운영관리 부문'];
const legacyTitleEn = ['Operations'];
if (legacyTitles.includes(config.title) || legacyTitleEn.includes(config.titleEn)) {
config = await prisma.columnConfig.update({
where: { key },
data: {
title: DEFAULTS[key]?.title ?? config.title,
titleEn: DEFAULTS[key]?.titleEn ?? config.titleEn,
},
});
}
}
res.json(config);

View File

@@ -1,20 +1,43 @@
import { Router, type Response } from 'express';
import { Router, type Request, type Response } from 'express';
import path from 'path';
import fs from 'fs';
import { prisma } from '../lib/prisma';
import { resolveTaskActorId } from '../lib/resolveUser';
import { upload } from '../middleware/upload';
import { AppError } from '../middleware/errorHandler';
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
import { toHtml } from '@ohah/hwpjs';
import { resolveFileMime } from '../lib/fileMime';
const router = Router();
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
function allowCrossOriginPreview(res: Response) {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://localhost:3000",
const PREVIEW_FRAME_ANCESTORS = [
"'self'",
'https://eene-dashboard.vercel.app',
'http://localhost:3000',
'https://localhost:3000',
'http://127.0.0.1:3000',
'https://127.0.0.1:3000',
];
function isPrivateDevOrigin(origin: string): boolean {
return (
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin) ||
/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin) ||
/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin) ||
/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)
);
}
/** PDF 등 iframe 미리보기 — localhost·LAN https 포함 */
function allowCrossOriginPreview(req: Request, res: Response) {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
const ancestors = [...PREVIEW_FRAME_ANCESTORS];
const origin = req.headers.origin;
if (origin && isPrivateDevOrigin(origin) && !ancestors.includes(origin)) {
ancestors.push(origin);
}
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors.join(' ')}`);
res.removeHeader('X-Frame-Options');
}
@@ -59,7 +82,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
originalName: fixOriginalName(req.file.originalname),
displayName,
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
mimetype: req.file.mimetype,
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
size: req.file.size,
path: req.file.path,
uploadedBy,
@@ -68,10 +91,47 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
res.status(201).json(fileRecord);
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
next(err);
}
});
function isHwpOriginalName(originalName: string): boolean {
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
return ext === 'hwp' || ext === 'hwpx';
}
// GET /api/files/:id/hwp-preview — 한글(.hwp/.hwpx) HTML 미리보기
router.get('/:id/hwp-preview', async (req, res, next) => {
try {
const fileId = String(req.params.id);
const file = await prisma.file.findUnique({ where: { id: fileId } });
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
if (!isHwpOriginalName(file.originalName)) {
throw new AppError(400, '한글 파일만 미리보기할 수 있습니다.');
}
const data = fs.readFileSync(file.path);
const html = toHtml(data, {
includeVersion: false,
includePageInfo: false,
});
res.json({ html });
} catch (err) {
if (err instanceof AppError) {
next(err);
return;
}
next(new AppError(422, '한글 파일 미리보기 변환에 실패했습니다.'));
}
});
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
router.get('/:id/view', async (req, res, next) => {
try {
@@ -80,8 +140,9 @@ router.get('/:id/view', async (req, res, next) => {
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
allowCrossOriginPreview(res);
res.setHeader('Content-Type', file.mimetype);
allowCrossOriginPreview(req, res);
const mime = resolveFileMime(file.originalName, file.mimetype);
res.setHeader('Content-Type', mime);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
fs.createReadStream(file.path).pipe(res);
} catch (err) {
@@ -122,7 +183,7 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
data: {
filename: req.file.filename,
originalName: fixOriginalName(req.file.originalname),
mimetype: req.file.mimetype,
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
size: req.file.size,
path: req.file.path,
},
@@ -130,6 +191,11 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
res.json(file);
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
next(err);
}
});

View File

@@ -0,0 +1,74 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
const router = Router();
const HUB_ID = 'default';
export const DEFAULT_HUB_CONFIG = {
sloganTitle: '분기 중점 과제',
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
scheduleTitle: '분기 주요 일정',
scheduleItems: [
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
],
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
};
function normalizeConfig(raw: Record<string, unknown>) {
const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
return {
sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle,
sloganLines: Array.isArray(raw.sloganLines)
? (raw.sloganLines as string[])
: DEFAULT_HUB_CONFIG.sloganLines,
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
scheduleItems: Array.isArray(raw.scheduleItems)
? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems)
: DEFAULT_HUB_CONFIG.scheduleItems,
routineLabels: Array.isArray(raw.routineLabels)
? (raw.routineLabels as string[])
: DEFAULT_HUB_CONFIG.routineLabels,
};
}
async function getOrCreateHubConfig() {
let row = await prisma.hubConfig.findUnique({ where: { id: HUB_ID } });
if (!row) {
row = await prisma.hubConfig.create({
data: { id: HUB_ID, config: DEFAULT_HUB_CONFIG },
});
}
return row;
}
// GET /api/hub-config
router.get('/', async (_req, res, next) => {
try {
const row = await getOrCreateHubConfig();
res.json(normalizeConfig(row.config as Record<string, unknown>));
} catch (err) {
next(err);
}
});
// PATCH /api/hub-config
router.patch('/', async (req, res, next) => {
try {
const row = await getOrCreateHubConfig();
const merged = normalizeConfig({
...(row.config as Record<string, unknown>),
...(req.body as Record<string, unknown>),
});
const updated = await prisma.hubConfig.update({
where: { id: HUB_ID },
data: { config: merged },
});
res.json(normalizeConfig(updated.config as Record<string, unknown>));
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -7,15 +7,19 @@ import kpiRoutes from './kpi';
import columnRoutes from './columns';
import milestoneRoutes from './milestones';
import detailRoutes from './details';
import teamMemberRoutes from './teamMembers';
import hubConfigRoutes from './hubConfig';
const router = Router();
router.use('/auth', authRoutes);
router.use('/tasks', taskRoutes);
router.use('/team-members', teamMemberRoutes);
router.use('/users', userRoutes);
router.use('/files', fileRoutes);
router.use('/kpi', kpiRoutes);
router.use('/columns', columnRoutes);
router.use('/hub-config', hubConfigRoutes);
router.use('/milestones', milestoneRoutes);
router.use('/details', detailRoutes);

View File

@@ -1,6 +1,9 @@
import { Router } from 'express';
import { Prisma } from '@prisma/client';
import { prisma } from '../lib/prisma';
import { resolveTaskActorId } from '../lib/resolveUser';
import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery';
import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods';
import { AppError } from '../middleware/errorHandler';
const router = Router();
@@ -28,14 +31,47 @@ function clampProgress(value: unknown): number {
return Math.min(100, Math.max(0, Math.round(n)));
}
async function syncMilestoneMembers(
milestoneId: string,
pmMemberId: string | null | undefined,
assigneeMemberIds: string[] | undefined,
) {
if (pmMemberId !== undefined) {
await prisma.milestone.update({
where: { id: milestoneId },
data: { pmMemberId: pmMemberId || null },
});
}
if (assigneeMemberIds !== undefined) {
await prisma.milestoneAssignee.deleteMany({ where: { milestoneId } });
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
if (ids.length > 0) {
await prisma.milestoneAssignee.createMany({
data: ids.map((memberId) => ({ milestoneId, memberId })),
});
}
}
}
async function loadMilestone(id: string) {
const milestone = await prisma.milestone.findUnique({
where: { id },
include: milestoneInclude,
});
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
return formatMilestone(milestone);
}
// GET /api/milestones/:taskId
router.get('/:taskId', async (req, res, next) => {
try {
const milestones = await prisma.milestone.findMany({
where: { taskId: req.params.taskId },
orderBy: { order: 'asc' },
include: milestoneInclude,
});
res.json(milestones);
res.json(milestones.map((m) => formatMilestone(m)));
} catch (err) {
next(err);
}
@@ -45,26 +81,48 @@ router.get('/:taskId', async (req, res, next) => {
router.post('/:taskId', async (req, res, next) => {
try {
const taskId = req.params.taskId;
const { title, description, startDate, dueDate, feedback, links, progress } =
req.body as Record<string, string | number>;
const body = req.body as Record<string, unknown>;
const { title, subtitle, description, startDate, dueDate, feedback, links, progress } = body;
const assigneeMemberIds = parseMemberIds(body);
const pmMemberId =
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.');
const count = await prisma.milestone.count({ where: { taskId } });
const periodPayload = resolveMilestonePeriodPayload(body);
const milestone = await prisma.milestone.create({
data: {
taskId,
title: String(title).trim(),
subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null,
description: description?.toString().trim() || null,
startDate: startDate ? new Date(String(startDate)) : null,
dueDate: dueDate ? new Date(String(dueDate)) : null,
startDate:
periodPayload.startDate !== undefined
? periodPayload.startDate
: startDate
? new Date(String(startDate))
: null,
dueDate:
periodPayload.dueDate !== undefined
? periodPayload.dueDate
: dueDate
? new Date(String(dueDate))
: null,
periodEntries:
periodPayload.periodEntries !== undefined
? (periodPayload.periodEntries as Prisma.InputJsonValue)
: undefined,
progress: progress !== undefined ? clampProgress(progress) : 0,
links: normalizeLinks(links),
order: count,
...(pmMemberId !== undefined ? { pmMemberId } : {}),
},
});
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
if (feedback?.toString().trim()) {
const updatedBy = await resolveTaskActorId(taskId);
await prisma.taskDetail.create({
@@ -77,7 +135,7 @@ router.post('/:taskId', async (req, res, next) => {
});
}
res.status(201).json(milestone);
res.status(201).json(await loadMilestone(milestone.id));
} catch (err) {
next(err);
}
@@ -86,19 +144,33 @@ router.post('/:taskId', async (req, res, next) => {
// PATCH /api/milestones/item/:id
router.patch('/item/:id', async (req, res, next) => {
try {
const { title, description, startDate, dueDate, feedback, links, progress, completed, order } =
req.body as Record<string, string | boolean | number>;
const body = req.body as Record<string, unknown>;
const { title, subtitle, description, startDate, dueDate, feedback, links, progress, completed, order } =
body;
const assigneeMemberIds = parseMemberIds(body);
const pmMemberId =
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
const periodPayload = resolveMilestonePeriodPayload(body);
const milestone = await prisma.milestone.update({
where: { id: req.params.id },
data: {
...(title !== undefined && { title: String(title).trim() }),
...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }),
...(description !== undefined && { description: description ? String(description).trim() : null }),
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }),
...(periodPayload.dueDate !== undefined && { dueDate: periodPayload.dueDate }),
...(periodPayload.periodEntries !== undefined && {
periodEntries: periodPayload.periodEntries as Prisma.InputJsonValue,
}),
...(periodPayload.startDate === undefined &&
startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
...(periodPayload.dueDate === undefined &&
dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
...(progress !== undefined && { progress: clampProgress(progress) }),
...(links !== undefined && { links: normalizeLinks(links) }),
...(order !== undefined && { order: Number(order) }),
@@ -106,9 +178,12 @@ router.patch('/item/:id', async (req, res, next) => {
completedAt: completed ? new Date() : null,
...(completed && { progress: 100 }),
}),
...(pmMemberId !== undefined ? { pmMemberId } : {}),
},
});
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
if (typeof feedback === 'string' && feedback.trim()) {
const updatedBy = await resolveTaskActorId(existing.taskId);
await prisma.taskDetail.create({
@@ -121,7 +196,7 @@ router.patch('/item/:id', async (req, res, next) => {
});
}
res.json(milestone);
res.json(await loadMilestone(milestone.id));
} catch (err) {
next(err);
}

View File

@@ -1,10 +1,35 @@
import { Router } from 'express';
import type { Server } from 'socket.io';
import { prisma } from '../lib/prisma';
import { resolveCreatorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler';
import { emitTaskListRefresh, emitTaskUpdated } from '../socket';
import {
formatTask,
parseMemberIds,
syncTaskMembers,
taskDetailInclude,
taskInclude,
} from '../lib/taskQuery';
import { deriveIssueFields, normalizeIssueEntries } from '../lib/taskIssues';
const router = Router();
function resolveIssuePayload(body: Record<string, any>) {
if (body.issueEntries !== undefined) {
const entries = normalizeIssueEntries(body.issueEntries);
return deriveIssueFields(entries);
}
if (body.issueNote !== undefined) {
const text = typeof body.issueNote === 'string' ? body.issueNote.trim() : '';
if (!text) {
return { issueEntries: [], issueNote: null, showIssue: false };
}
return deriveIssueFields([{ id: 'legacy', text, showOnCard: body.showIssue !== false }]);
}
return null;
}
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
router.get('/', async (req, res, next) => {
try {
@@ -17,15 +42,11 @@ router.get('/', async (req, res, next) => {
...(assigneeId && { assigneeId }),
...(category && { category }),
},
include: {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
_count: { select: { files: true, details: true } },
},
include: taskInclude,
orderBy: { updatedAt: 'desc' },
});
res.json(tasks);
res.json(tasks.map((t) => formatTask(t)));
} catch (err) {
next(err);
}
@@ -37,21 +58,11 @@ router.get('/:id', async (req, res, next) => {
const taskId = String(req.params.id);
const task = await prisma.task.findUnique({
where: { id: taskId },
include: {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
details: {
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
},
kpiMetrics: true,
files: true,
milestones: { orderBy: { order: 'asc' } },
},
include: taskDetailInclude,
});
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
res.json(task);
res.json(formatTask(task));
} catch (err) {
next(err);
}
@@ -60,16 +71,18 @@ router.get('/:id', async (req, res, next) => {
// POST /api/tasks — 업무 등록
router.post('/', async (req, res, next) => {
try {
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords } =
req.body as Record<string, any>;
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
if (!title || !quarter) {
throw new AppError(400, '제목과 분기는 필수입니다.');
}
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
const creatorId = await resolveCreatorId(body.creatorId);
const assigneeMemberIds = parseMemberIds(body);
const issuePayload = resolveIssuePayload(body);
const task = await prisma.task.create({
data: {
@@ -83,21 +96,33 @@ router.post('/', async (req, res, next) => {
tag,
taskType,
progress: progress ? Number(progress) : 0,
issueNote: issueNote || null,
issueNote: issuePayload?.issueNote ?? (issueNote || null),
issueEntries: issuePayload?.issueEntries as any ?? undefined,
startDate: startDate ? new Date(startDate) : undefined,
dueDate: dueDate ? new Date(dueDate) : undefined,
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
showIssue: issuePayload?.showIssue ?? (showIssue !== undefined ? showIssue === 'true' || showIssue === true : true),
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
keywords: keywords || null,
assigneeId: assigneeId || null,
pmMemberId: pmMemberId || null,
creatorId,
},
include: taskInclude,
});
res.status(201).json(task);
if (assigneeMemberIds !== undefined) {
await syncTaskMembers(task.id, undefined, assigneeMemberIds);
const refreshed = await prisma.task.findUnique({
where: { id: task.id },
include: taskInclude,
});
res.status(201).json(formatTask(refreshed!));
return;
}
res.status(201).json(formatTask(task));
} catch (err) {
next(err);
}
@@ -109,12 +134,15 @@ router.patch('/:id', async (req, res, next) => {
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords } =
req.body as Record<string, any>;
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
const task = await prisma.task.update({
const assigneeMemberIds = parseMemberIds(body);
const issuePayload = resolveIssuePayload(body);
await prisma.task.update({
where: { id: req.params.id },
data: {
...(title && { title }),
@@ -127,20 +155,53 @@ router.patch('/:id', async (req, res, next) => {
...(tag !== undefined && { tag }),
...(taskType !== undefined && { taskType }),
...(progress !== undefined && { progress: Number(progress) }),
...(issueNote !== undefined && { issueNote: issueNote || null }),
...(issuePayload
? {
issueEntries: issuePayload.issueEntries as any,
issueNote: issuePayload.issueNote,
showIssue: issuePayload.showIssue,
}
: issueNote !== undefined
? {
issueNote: issueNote || null,
...(issueNote
? {}
: { issueEntries: [] as any, showIssue: false }),
}
: {}),
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
...(pmMemberId !== undefined && { pmMemberId: pmMemberId || null }),
...(showDate !== undefined && { showDate: showDate === true || showDate === 'true' }),
...(showDescription !== undefined && { showDescription: showDescription === true || showDescription === 'true' }),
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
...(showIssue !== undefined && { showIssue: showIssue === true || showIssue === 'true' }),
...(showProgress !== undefined && { showProgress: showProgress === true || showProgress === 'true' }),
...(keywords !== undefined && { keywords: keywords || null }),
},
});
res.json(task);
if (pmMemberId !== undefined || assigneeMemberIds !== undefined) {
await syncTaskMembers(
req.params.id,
pmMemberId !== undefined ? (pmMemberId || null) : undefined,
assigneeMemberIds,
);
}
const task = await prisma.task.findUnique({
where: { id: req.params.id },
include: taskInclude,
});
const formatted = formatTask(task!);
const io = req.app.get('io') as Server | undefined;
if (io) {
emitTaskUpdated(io, req.params.id, formatted);
emitTaskListRefresh(io);
}
res.json(formatted);
} catch (err) {
next(err);
}

View File

@@ -0,0 +1,132 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/errorHandler';
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
const router = Router();
const memberSelect = {
id: true,
name: true,
rank: true,
role: true,
cell: true,
contact: true,
photoUrl: true,
sortOrder: true,
isActive: true,
};
// GET /api/team-members (?all=1 이면 비활성 포함 — 관리 화면용)
router.get('/', async (req, res, next) => {
try {
const includeAll = req.query.all === '1' || req.query.all === 'true';
const members = await prisma.teamMember.findMany({
where: includeAll ? undefined : { isActive: true },
select: memberSelect,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
res.json(members);
} catch (err) {
next(err);
}
});
// POST /api/team-members/photo — 팀원 사진 (로컬 uploads/team/ 저장)
router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) => {
try {
if (!req.file) {
throw new AppError(400, '이미지 파일을 선택해 주세요.');
}
res.status(201).json({
url: `/uploads/team/${req.file.filename}`,
filename: req.file.filename,
});
} catch (err) {
if (isDiskFullError(err)) {
cleanupUploadedFile(req.file);
next(new AppError(507, DISK_FULL_MESSAGE));
return;
}
next(err);
}
});
// POST /api/team-members — 인원 등록
router.post('/', async (req, res, next) => {
try {
const { name, rank, role, cell, contact, photoUrl, sortOrder } =
req.body as Record<string, unknown>;
if (!name || typeof name !== 'string' || !name.trim()) {
throw new AppError(400, '이름은 필수입니다.');
}
const member = await prisma.teamMember.create({
data: {
name: name.trim(),
rank: typeof rank === 'string' ? rank : null,
role: typeof role === 'string' ? role : null,
cell: typeof cell === 'string' ? cell : null,
contact: typeof contact === 'string' ? contact : null,
photoUrl: typeof photoUrl === 'string' ? photoUrl : null,
sortOrder: typeof sortOrder === 'number' ? sortOrder : Number(sortOrder) || 0,
},
select: memberSelect,
});
res.status(201).json(member);
} catch (err) {
next(err);
}
});
// PATCH /api/team-members/:id
router.patch('/:id', async (req, res, next) => {
try {
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
const { name, rank, role, cell, contact, photoUrl, sortOrder, isActive } =
req.body as Record<string, unknown>;
const member = await prisma.teamMember.update({
where: { id: req.params.id },
data: {
...(name !== undefined && { name: String(name).trim() }),
...(rank !== undefined && { rank: rank ? String(rank) : null }),
...(role !== undefined && { role: role ? String(role) : null }),
...(cell !== undefined && { cell: cell ? String(cell) : null }),
...(contact !== undefined && { contact: contact ? String(contact) : null }),
...(photoUrl !== undefined && { photoUrl: photoUrl ? String(photoUrl) : null }),
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) || 0 }),
...(isActive !== undefined && { isActive: isActive === true || isActive === 'true' }),
},
select: memberSelect,
});
res.json(member);
} catch (err) {
next(err);
}
});
// DELETE /api/team-members/:id — soft delete
router.delete('/:id', async (req, res, next) => {
try {
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
await prisma.teamMember.update({
where: { id: req.params.id },
data: { isActive: false },
});
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

0
data/.gitkeep Normal file
View File

7
data/seed/README.md Normal file
View File

@@ -0,0 +1,7 @@
# HR seed 데이터
- **`hr-data.json`** — 대시보드 최초 seed·`npm run db:import-hr` 공통 원본
- 외부 `HR_Dashboard/data.json` 은 더 이상 사용하지 않습니다.
- PC·팀 공유 시 **`data/postgres/`** + **`uploads/`** + 이 파일을 함께 복사하세요.
경로 변경: `backend/.env``HR_DATA_PATH`

1083
data/seed/hr-data.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:-eee_password}
POSTGRES_DB: ${DB_NAME:-eee_dashboard}
ports:
- "${DB_PORT:-5432}:5432"
- "${DB_PORT:-54320}:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:

View File

@@ -0,0 +1,7 @@
# 로컬 백엔드 강제 지정 (선택 — 미설정 시 자동 감지)
# 개발(npm run dev): Vite 프록시 /api → localhost:4000
# 사설망 IP 접속: 자동으로 http://<이PC IP>:4000 연결
# Vercel 배포: Render API 사용
# VITE_API_URL=http://localhost:4000
# VITE_SOCKET_URL=http://localhost:4000

View File

@@ -13,6 +13,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0",
"docx-preview": "^0.3.7",
"pptx-react-renderer": "^0.1.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
@@ -24,6 +26,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
@@ -365,6 +368,40 @@
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -857,6 +894,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1578,6 +1634,17 @@
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1658,6 +1725,19 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1840,6 +1920,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1895,6 +1981,15 @@
"node": ">=8"
}
},
"node_modules/docx-preview": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2254,6 +2349,24 @@
"node": ">= 6"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2296,6 +2409,27 @@
"node": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2666,6 +2800,12 @@
"node": ">=18"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2715,6 +2855,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pptx-react-renderer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pptx-react-renderer/-/pptx-react-renderer-0.1.1.tgz",
"integrity": "sha512-nUNMyMJu7xIWbgO6m7szuS+EDPTvHoNYFgCCOjtVJ37lYtbtNoI8zFk1ZnkthmY27U3StzHXcU5oy23FrBhvLg==",
"license": "MIT",
"dependencies": {
"jszip": "^3.10.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2791,6 +2949,21 @@
"react-dom": ">=16.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -2836,6 +3009,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -2855,6 +3034,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
@@ -2905,6 +3090,15 @@
"node": ">=0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -2994,6 +3188,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",

View File

@@ -14,6 +14,8 @@
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.56.0",
"axios": "^1.7.0",
"docx-preview": "^0.3.7",
"pptx-react-renderer": "^0.1.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
@@ -25,6 +27,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import type { TeamMemberForm } from '../../hooks/useTeamMembersAdmin';
import { EMPTY_MEMBER_FORM } from '../../hooks/useTeamMembersAdmin';
import { TeamMemberAvatar } from '../dashboard/TeamMemberAvatar';
import { getCellLabel } from '../../lib/teamStatus';
import type { TeamMemberBrief } from '../../types';
const CELL_OPTIONS = [
{ value: '리더', label: '리더 (팀장)' },
{ value: 'HR', label: '인사' },
{ value: '총무', label: '총무' },
];
const RANK_OPTIONS = ['수석연구원', '책임연구원', '선임연구원', '연구원', '주임', '사원'];
const ROLE_OPTIONS = ['팀장', '셀장', '팀원'];
interface TeamMemberFormModalProps {
mode: 'add' | 'edit';
initial?: TeamMemberForm;
onSave: (form: TeamMemberForm) => void | Promise<void>;
onClose: () => void;
saving?: boolean;
}
export function TeamMemberFormModal({
mode,
initial = EMPTY_MEMBER_FORM,
onSave,
onClose,
saving = false,
}: TeamMemberFormModalProps) {
const [form, setForm] = useState<TeamMemberForm>(initial);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const set = <K extends keyof TeamMemberForm>(key: K, value: TeamMemberForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handlePhotoFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
try {
const fd = new FormData();
fd.append('photo', file);
const { data } = await apiClient.post<{ url: string }>('/team-members/photo', fd);
set('photoUrl', data.url);
} catch (err) {
alert(getApiErrorMessage(err, '사진 업로드에 실패했습니다.'));
} finally {
setUploadingPhoto(false);
e.target.value = '';
}
};
const preview: TeamMemberBrief = {
id: 'preview',
name: form.name || '이름',
rank: form.rank || null,
role: form.role || null,
cell: form.cell || null,
contact: form.contact || null,
photoUrl: form.photoUrl || null,
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) return;
await onSave(form);
};
return createPortal(
<div className="admin-modal-backdrop" onClick={onClose}>
<div className="admin-modal" onClick={(e) => e.stopPropagation()}>
<div className="admin-modal-header">
<h2>{mode === 'add' ? '팀원 추가' : '팀원 수정'}</h2>
<button type="button" className="admin-modal-close" onClick={onClose} aria-label="닫기">
</button>
</div>
<form onSubmit={handleSubmit} className="admin-modal-body">
<div className="admin-form-preview">
<TeamMemberAvatar member={preview} size="leader" />
<div>
<div className="admin-preview-name">{form.name || '이름'}</div>
<div className="admin-preview-sub">
{[form.rank, form.role].filter(Boolean).join(' · ') || '직급 · 직책'}
</div>
<div className="admin-preview-cell">{getCellLabel(form.cell) || '셀'}</div>
</div>
</div>
<div className="admin-form-grid">
<label className="admin-field admin-field-full">
<span> *</span>
<input
required
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="홍길동"
/>
</label>
<label className="admin-field">
<span></span>
<input
list="rank-options"
value={form.rank}
onChange={(e) => set('rank', e.target.value)}
placeholder="선임연구원"
/>
</label>
<label className="admin-field">
<span></span>
<input
list="role-options"
value={form.role}
onChange={(e) => set('role', e.target.value)}
placeholder="팀원"
/>
</label>
<label className="admin-field">
<span> </span>
<select value={form.cell} onChange={(e) => set('cell', e.target.value)}>
{CELL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="admin-field">
<span> </span>
<input
type="number"
min={0}
value={form.sortOrder}
onChange={(e) => set('sortOrder', Number(e.target.value))}
/>
</label>
<label className="admin-field admin-field-full">
<span></span>
<input
value={form.contact}
onChange={(e) => set('contact', e.target.value)}
placeholder="010-0000-0000"
/>
</label>
<div className="admin-field admin-field-full admin-photo-field">
<span> </span>
<div className="admin-photo-upload-row">
<label className="admin-photo-file-btn">
{uploadingPhoto ? '업로드 중…' : '📷 사진 파일 선택'}
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="sr-only"
disabled={uploadingPhoto}
onChange={handlePhotoFile}
/>
</label>
<span className="admin-photo-hint"> uploads/team/ </span>
</div>
<input
value={form.photoUrl}
onChange={(e) => set('photoUrl', e.target.value)}
placeholder="또는 URL 직접 입력 (/uploads/team/...)"
/>
</div>
</div>
<datalist id="rank-options">
{RANK_OPTIONS.map((r) => <option key={r} value={r} />)}
</datalist>
<datalist id="role-options">
{ROLE_OPTIONS.map((r) => <option key={r} value={r} />)}
</datalist>
<div className="admin-modal-footer">
<button type="button" className="admin-btn-ghost" onClick={onClose}></button>
<button type="submit" className="admin-btn-primary" disabled={saving || !form.name.trim()}>
{saving ? '저장 중…' : mode === 'add' ? '추가하기' : '저장하기'}
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,21 @@
type HubNavChevronProps = {
direction: 'prev' | 'next';
className?: string;
};
export function HubNavChevron({ direction, className = '' }: HubNavChevronProps) {
return (
<svg
className={`hub-nav-chevron${className ? ` ${className}` : ''}`}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
{direction === 'prev' ? (
<path stroke="currentColor" d="M15.5 1 8.5 12l7 11" />
) : (
<path stroke="currentColor" d="M8.5 1 15.5 12 8.5 23" />
)}
</svg>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import type { Task } from '../../types';
import { normalizeTaskType, displayFlagsForTaskType } from '../../lib/taskType';
import type { Task, TeamMember, TaskIssueEntry } from '../../types';
import { normalizeTaskType } from '../../lib/taskType';
import { newIssueEntry, parseIssueEntries } from '../../lib/taskIssues';
import { getRoutineCategory, routineCategoryOptions } from '../../lib/routineCategories';
const STATUS_OPTIONS = [
{ value: 'TODO', label: '대기' },
@@ -13,66 +15,116 @@ const STATUS_OPTIONS = [
export interface TaskFormData {
title: string;
section: string;
category: string;
tag: string;
taskType: string;
status: string;
progress: number;
description: string;
issueNote: string;
issueEntries: TaskIssueEntry[];
quarter: string;
startDate: string;
dueDate: string;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
keywords: string;
pmMemberId: string;
assigneeMemberIds: string[];
}
interface TaskModalProps {
mode: 'add' | 'edit';
/** project: 실행과제 전용 / routine: 기반업무(상시) 전용 */
variant?: 'project' | 'routine';
task?: Task;
defaultSection?: string;
defaultCategory?: string;
defaultQuarter?: string;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
onSave: (data: TaskFormData) => void;
onClose: () => void;
}
export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter = '2026-Q2', sectionOptions, onSave, onClose }: TaskModalProps) {
export function TaskModal({
mode,
variant = 'project',
task,
defaultSection = 'HR',
defaultCategory = '채용 운영',
defaultQuarter = '2026-Q2',
sectionOptions,
teamMembers = [],
onSave,
onClose,
}: TaskModalProps) {
const isRoutine = variant === 'routine';
const toDateInput = (iso: string | null | undefined) => {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
};
// DB값(프로젝트/상시업무) → 새 값(실행과제/기반업무) 정규화
const [form, setForm] = useState<TaskFormData>({
title: task?.title ?? '',
section: task?.section ?? defaultSection,
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
tag: task?.tag ?? '',
taskType: task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
taskType: isRoutine
? '기반업무'
: (task?.taskType ? normalizeTaskType(task.taskType) : '실행과제'),
status: task?.status ?? 'TODO',
progress: task?.progress ?? 0,
description: task?.description ?? '',
issueNote: task?.issueNote ?? '',
issueEntries: task ? parseIssueEntries(task) : [],
quarter: task?.quarter ?? defaultQuarter,
startDate: toDateInput(task?.startDate),
dueDate: toDateInput(task?.dueDate),
showDate: task?.showDate ?? true,
showDescription: task?.showDescription ?? true,
showStatus: task?.showStatus ?? true,
showIssue: task?.showIssue ?? true,
showProgress: task?.showProgress ?? true,
keywords: task?.keywords ?? '',
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
});
const toggleAssignee = (memberId: string) => {
setForm((prev) => {
const has = prev.assigneeMemberIds.includes(memberId);
return {
...prev,
assigneeMemberIds: has
? prev.assigneeMemberIds.filter((id) => id !== memberId)
: [...prev.assigneeMemberIds, memberId],
};
});
};
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
setForm(prev => ({ ...prev, [field]: value }));
const updateIssueEntry = (id: string, patch: Partial<TaskIssueEntry>) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.map((entry) =>
entry.id === id ? { ...entry, ...patch } : entry,
),
}));
};
const addIssueEntry = () => {
setForm((prev) => ({
...prev,
issueEntries: [...prev.issueEntries, newIssueEntry()],
}));
};
const removeIssueEntry = (id: string) => {
setForm((prev) => ({
...prev,
issueEntries: prev.issueEntries.filter((entry) => entry.id !== id),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(form);
const payload: TaskFormData = {
...form,
taskType: isRoutine ? '기반업무' : '실행과제',
};
onSave(payload);
};
return createPortal(
@@ -87,7 +139,9 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 className="text-2xl font-black text-gray-800">
{mode === 'add' ? '✚ 업무 추가' : '✏ 업무 수정'}
{isRoutine
? (mode === 'add' ? '✚ 상시업무 추가' : '✏ 상시업무 수정')
: (mode === 'add' ? '✚ 프로젝트 추가' : '✏ 프로젝트 수정')}
</h2>
<button
type="button"
@@ -111,14 +165,26 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
/>
</div>
{/* 섹션 + 업무유형 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
{/* 대분류 (상시업무) / 소속 부문 (프로젝트) */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">
{isRoutine ? '대분류' : '소속 부문'}
</label>
{isRoutine ? (
<select
value={form.category}
onChange={(e) => set('category', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-emerald-400 focus:ring-emerald-100"
>
{routineCategoryOptions().map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : (
<select
value={form.section}
onChange={(e) => set('section', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-blue-400 focus:ring-blue-100"
>
{(sectionOptions ?? [
{ value: 'HR', label: 'HR' },
@@ -127,42 +193,14 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> </label>
<select
value={form.taskType}
onChange={(e) => {
const newType = e.target.value;
setForm((prev) => ({
...prev,
taskType: newType,
...displayFlagsForTaskType(newType),
}));
}}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
>
<option value="기반업무"></option>
<option value="실행과제"></option>
</select>
</div>
)}
</div>
{/* 상태 + 진행률 */}
{/* 상태 + 진행률 (프로젝트만) */}
{!isRoutine && (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showStatus}
onChange={(e) => set('showStatus', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
@@ -174,22 +212,11 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showProgress}
onChange={(e) => set('showProgress', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<div className="flex items-center gap-3">
<label className="block text-sm font-bold text-gray-500 mb-1.5">
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
</label>
<div className="flex items-center gap-3 pt-2">
<input
type="range"
min={0}
@@ -202,21 +229,27 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
</div>
</div>
</div>
)}
{/* 상태 (상시업무) */}
{isRoutine && (
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<select
value={form.status}
onChange={(e) => set('status', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* 내용 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showDescription}
onChange={(e) => set('showDescription', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
@@ -226,31 +259,57 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
/>
</div>
{/* 키워드 */}
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( )</label>
<input
value={form.keywords}
onChange={(e) => set('keywords', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="예: 인사정보, ERP, 입퇴사 분석"
/>
</div>
{/* PM + 담당자 */}
{teamMembers.length > 0 && (
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5">PM</label>
<select
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"> ( )</label>
<div className="flex flex-wrap gap-2">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none text-sm font-semibold transition ${
checked
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white text-gray-600 border-gray-200 hover:border-emerald-300'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{/* 프로젝트 기간 */}
{!isRoutine && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-bold text-gray-500"></label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showDate}
onChange={(e) => set('showDate', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
</div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<div className="grid grid-cols-2 gap-3">
<input
type="date"
@@ -266,27 +325,60 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
/>
</div>
</div>
)}
{/* 이슈 메모 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-bold text-gray-500"> </label>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={form.showIssue}
onChange={(e) => set('showIssue', e.target.checked)}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-400"> </span>
</label>
<button
type="button"
onClick={addIssueEntry}
className="text-xs font-bold text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition"
>
+
</button>
</div>
<input
value={form.issueNote}
onChange={(e) => set('issueNote', e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-red-400 focus:ring-2 focus:ring-red-100 transition text-red-600 placeholder:text-gray-300"
placeholder="[날짜] 이슈 내용 (비우면 표시 안 함)"
/>
{form.issueEntries.length === 0 ? (
<p className="text-sm text-gray-400 rounded-xl border border-dashed border-gray-200 px-4 py-3 text-center">
. .
</p>
) : (
<div className="space-y-2">
{form.issueEntries.map((entry, index) => (
<div
key={entry.id}
className="rounded-xl border border-gray-200 bg-gray-50/60 p-3 space-y-2"
>
<textarea
value={entry.text}
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition bg-white"
placeholder={`[날짜] 이슈 내용 (${index + 1})`}
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={entry.showOnCard}
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
className="w-4 h-4 accent-blue-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-gray-500"> </span>
</label>
<button
type="button"
onClick={() => removeIssueEntry(entry.id)}
className="text-xs font-bold text-red-500 hover:text-red-600 px-2 py-1 rounded-lg hover:bg-red-50 transition"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* 버튼 */}
@@ -300,7 +392,9 @@ export function TaskModal({ mode, task, defaultSection = 'HR', defaultQuarter =
</button>
<button
type="submit"
className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"
className={`px-6 py-2.5 rounded-xl text-white font-bold transition ${
isRoutine ? 'bg-emerald-700 hover:bg-emerald-800' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{mode === 'add' ? '추가하기' : '저장하기'}
</button>

View File

@@ -0,0 +1,172 @@
import { useEffect, useMemo, useState, type CSSProperties, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import {
QUARTER_RANGE_LABELS,
buildMonthWeekRows,
dateToQuarter,
isSameDay,
isSameWeek,
quarterEndDate,
startOfDay,
startOfWeekMonday,
toIsoDate,
} from '../../lib/boardCalendar';
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
interface BoardCalendarPopoverProps {
referenceDate: Date;
onChange: (d: Date) => void;
onClose: () => void;
anchorRef: RefObject<HTMLElement | null>;
}
export function BoardCalendarPopover({
referenceDate,
onChange,
onClose,
anchorRef,
}: BoardCalendarPopoverProps) {
const [viewYear, setViewYear] = useState(referenceDate.getFullYear());
const [viewMonth, setViewMonth] = useState(referenceDate.getMonth());
const weekRows = useMemo(
() => buildMonthWeekRows(viewYear, viewMonth),
[viewYear, viewMonth],
);
const activeQuarterKey = dateToQuarter(referenceDate);
useEffect(() => {
setViewYear(referenceDate.getFullYear());
setViewMonth(referenceDate.getMonth());
}, [referenceDate]);
useEffect(() => {
const onPointerDown = (e: MouseEvent) => {
const anchor = anchorRef.current;
const target = e.target as Node;
if (anchor?.contains(target)) return;
const popover = document.getElementById('board-calendar-popover');
if (popover?.contains(target)) return;
onClose();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [anchorRef, onClose]);
const rect = anchorRef.current?.getBoundingClientRect();
const style: CSSProperties = rect
? { top: rect.bottom + 8, right: Math.max(12, window.innerWidth - rect.right) }
: { top: 56, right: 24 };
const shiftMonth = (delta: number) => {
const d = new Date(viewYear, viewMonth + delta, 1);
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
};
const pickDate = (d: Date) => {
onChange(startOfDay(d));
};
const pickQuarter = (q: 1 | 2 | 3 | 4) => {
onChange(quarterEndDate(`${viewYear}-Q${q}`));
};
const today = startOfDay(new Date());
return createPortal(
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="기준일 선택">
<div className="board-calendar-popover-head">
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(-1)} aria-label="이전 달"></button>
<span className="board-calendar-month">{viewYear} {viewMonth + 1}</span>
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(1)} aria-label="다음 달"></button>
</div>
<div className="board-calendar-quarter-row">
{([1, 2, 3, 4] as const).map((q) => {
const key = `${viewYear}-Q${q}`;
const selected = activeQuarterKey === key;
return (
<button
key={key}
type="button"
className={`board-calendar-quarter-chip${selected ? ' is-selected' : ''}`}
onClick={() => pickQuarter(q)}
>
<span className="board-calendar-quarter-chip-title">{q}</span>
<span className="board-calendar-quarter-chip-range">{QUARTER_RANGE_LABELS[q - 1]}</span>
</button>
);
})}
</div>
<div className="board-calendar-grid-wrap">
<table className="board-calendar-grid">
<thead>
<tr>
<th className="board-calendar-grid-week-col"></th>
{WEEKDAY_LABELS.map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{weekRows.map((row) => {
const weekSelected = isSameWeek(referenceDate, row.monday);
return (
<tr key={toIsoDate(row.monday)} className={weekSelected ? 'is-selected-week' : undefined}>
<td className="board-calendar-grid-week-col">
<button
type="button"
className="board-calendar-week-label-btn"
onClick={() => pickDate(startOfWeekMonday(row.monday))}
>
{row.label}
</button>
</td>
{row.days.map((day) => {
const inMonth = day.getMonth() === viewMonth;
const isRef = isSameDay(day, referenceDate);
const isToday = isSameDay(day, today);
return (
<td key={toIsoDate(day)}>
<button
type="button"
className={[
'board-calendar-day-btn',
!inMonth ? 'is-outside' : '',
isRef ? 'is-ref' : '',
isToday ? 'is-today' : '',
].filter(Boolean).join(' ')}
onClick={() => pickDate(day)}
>
{day.getDate()}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="board-calendar-foot">
<button type="button" onClick={() => pickDate(startOfWeekMonday(today))}> </button>
<button type="button" onClick={() => pickDate(today)}> </button>
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,24 @@
import { useBoardConnectors, type ConnectorStyle } from '../../hooks/useBoardConnectors';
export function BoardConnectors({
enabled = true,
style = 'default',
}: {
enabled?: boolean;
style?: ConnectorStyle;
}) {
const { svgRef, lineGroupRef, dotSvgRef, dotGroupRef } = useBoardConnectors(enabled, style);
return (
<>
<svg ref={svgRef} className="connectors" aria-hidden="true">
<g ref={lineGroupRef} id="connector-lines" />
</svg>
{style === 'reference' && (
<svg ref={dotSvgRef} className="connectors connectors--dots" aria-hidden="true">
<g ref={dotGroupRef} id="connector-dots" />
</svg>
)}
</>
);
}

View File

@@ -1,131 +1,406 @@
import { useRef, useState } from 'react';
import { formatReferenceSummary } from '../../lib/boardCalendar';
import { isDetailWindowOpen } from '../../lib/dualMonitor';
import {
FILTER_ALL,
isStatusChipActive,
type CoreStatusFilter,
} from '../../lib/statusFilters';
import { BoardCalendarPopover } from './BoardCalendarPopover';
import { CalendarIcon, DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
interface Stats {
total: number;
inProgress: number;
review: number;
done: number;
issues: number;
}
interface DashboardHeaderProps {
quarter: string;
referenceDate: Date;
onReferenceDateChange: (d: Date) => void;
stats: Stats;
activeStatus: string;
onStatusChange: (status: string) => void;
activeFilters: string[];
issueFilterActive: boolean;
onToggleAll: () => void;
onToggleStatus: (key: CoreStatusFilter) => void;
onToggleIssue: () => void;
onOpenDetailWindow: () => void | Promise<void>;
onOpenTaskManager: () => void;
teamPanelOpen: boolean;
onToggleTeamPanel: () => void;
}
const ICON_BTN =
'team-status-btn-new grid h-8 w-8 shrink-0 place-items-center rounded-full border-[1.5px] border-[#1a4d42] bg-[linear-gradient(180deg,#0d3f34_0%,#051f19_100%)] text-[#cef1eb] shadow-[0_0_0_1px_#000,0_2px_2px_rgba(0,0,0,0.60)] transition hover:brightness-125';
const STAT_ACCENT = {
: 'text-[#ffdb3a]',
IN_PROGRESS: 'text-[#10b981]',
REVIEW: 'text-[#ff9f0a]',
DONE: 'text-[#b0b0b0]',
ISSUES: 'text-[#ff5252]',
} as const;
type StatKey = keyof typeof STAT_ACCENT;
export function DashboardHeader({
quarter,
referenceDate,
onReferenceDateChange,
stats,
activeStatus,
onStatusChange,
activeFilters,
issueFilterActive,
onToggleAll,
onToggleStatus,
onToggleIssue,
onOpenDetailWindow,
onOpenTaskManager,
teamPanelOpen,
onToggleTeamPanel,
}: DashboardHeaderProps) {
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
const [calendarOpen, setCalendarOpen] = useState(false);
const calendarBtnRef = useRef<HTMLButtonElement>(null);
const quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
const statItems = [
{ label: '전체', value: stats.total, statusKey: '전체' as const },
{ label: '진행', value: stats.inProgress, statusKey: 'IN_PROGRESS' as const },
{ label: '보류', value: stats.review, statusKey: 'REVIEW' as const },
{ label: '완료', value: stats.done, statusKey: 'DONE' as const },
{ label: '이슈', value: stats.issues, statusKey: 'ISSUES' as const },
const handleOpenDetailWindow = () => {
void Promise.resolve(onOpenDetailWindow()).then(() => {
setDetailViewActive(isDetailWindowOpen());
});
};
const statItems: Array<{
label: string;
value: number;
statusKey: StatKey;
onClick: () => void;
isActive: boolean;
}> = [
{
label: '전체',
value: stats.total,
statusKey: '전체',
onClick: onToggleAll,
isActive: isStatusChipActive(FILTER_ALL, activeFilters, issueFilterActive),
},
{
label: '진행',
value: stats.inProgress,
statusKey: 'IN_PROGRESS',
onClick: () => onToggleStatus('IN_PROGRESS'),
isActive: isStatusChipActive('IN_PROGRESS', activeFilters, issueFilterActive),
},
{
label: '보류',
value: stats.review,
statusKey: 'REVIEW',
onClick: () => onToggleStatus('REVIEW'),
isActive: isStatusChipActive('REVIEW', activeFilters, issueFilterActive),
},
{
label: '완료',
value: stats.done,
statusKey: 'DONE',
onClick: () => onToggleStatus('DONE'),
isActive: isStatusChipActive('DONE', activeFilters, issueFilterActive),
},
{
label: '이슈',
value: stats.issues,
statusKey: 'ISSUES',
onClick: onToggleIssue,
isActive: issueFilterActive,
},
];
return (
<header className="dashboard-header-bar shrink-0 overflow-hidden">
<header className="dashboard-header-bar shrink-0">
<div className="side-left-group min-w-0 shrink-0">
<span className="side-title-main main_tit flex shrink-0 items-center gap-[10px] text-[20px] font-bold tracking-[-0.5px] text-[#bad8ca]">
<span></span>
<span>|</span>
<span>People Growth Hub</span>
</span>
<button type="button" title="팀 현황" className={`${ICON_BTN} text-[15px]`}>
👥
<button
type="button"
title="팀 현황"
className={`team-status-btn-new ${teamPanelOpen ? 'active' : ''}`}
onClick={onToggleTeamPanel}
>
<UsersIcon size={16} />
</button>
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
<PlusIcon size={16} />
</button>
<button
type="button"
onClick={handleOpenDetailWindow}
title="듀얼뷰"
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
>
<DualMonitorIcon size={16} />
</button>
</div>
<div className="header-stats-bar side-polygon-stats absolute left-1/2 top-0 z-10 flex h-10 -translate-x-1/2 items-center justify-center rounded-b-2xl border-b border-[#135643] bg-[linear-gradient(90deg,#093023_20%,#074833_50%,#093023_80%)] px-12 [filter:drop-shadow(0_2px_4px_rgba(0,0,0,0.2))]">
<div className="poly-stat-item">
<div className="header-stats-bar side-polygon-stats">
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
<span className="poly-stat-quarter header-stat-text">{quarterLabel}</span>
<span className="poly-stat-bullet header-stat-text">·</span>
{statItems.map((item, index) => (
<span key={item.statusKey} className="contents">
{(index === 1 || index === 4) && <StatDivider />}
<StatClick
label={item.label}
value={item.value}
statusKey={item.statusKey}
activeStatus={activeStatus}
accent={STAT_ACCENT[item.statusKey]}
onClick={onStatusChange}
isActive={item.isActive}
onClick={item.onClick}
/>
</span>
))}
</div>
</div>
<div className="side-right-actions shrink-0">
<div className="header-calendar-slot">
<span className="board-calendar-ref-text">{formatReferenceSummary(referenceDate)}</span>
<button
ref={calendarBtnRef}
type="button"
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
title="기준일 · 분기 선택"
aria-expanded={calendarOpen}
aria-label="캘린더 열기"
onClick={() => setCalendarOpen((open) => !open)}
>
<CalendarIcon size={16} />
</button>
{calendarOpen && (
<BoardCalendarPopover
referenceDate={referenceDate}
onChange={(d) => {
onReferenceDateChange(d);
}}
onClose={() => setCalendarOpen(false)}
anchorRef={calendarBtnRef}
/>
)}
</div>
</div>
<div className="side-right-actions shrink-0">
<button
type="button"
onClick={onOpenTaskManager}
title="업무관리"
className={`header-action-btn-new ${ICON_BTN} text-[18px] font-bold leading-none`}
>
+
</button>
<button
type="button"
onClick={onOpenDetailWindow}
title="우측 모니터에 상세 창 열기"
className={`header-view-btn-new ${ICON_BTN} text-[16px] leading-none`}
>
🖥
</button>
</div>
</header>
);
}
function StatDivider() {
return <div className="poly-stat-divider" aria-hidden />;
}
interface StatClickProps {
label: string;
value: number;
statusKey: keyof typeof STAT_ACCENT;
activeStatus: string;
accent: string;
onClick: (key: string) => void;
isActive: boolean;
onClick: () => void;
}
function StatClick({ label, value, statusKey, activeStatus, accent, onClick }: StatClickProps) {
const isActive = activeStatus === statusKey;
function StatClick({ label, value, accent, isActive, onClick }: StatClickProps) {
return (
<button
type="button"
onClick={() => onClick(isActive ? '전체' : statusKey)}
className={`poly-click-stat header-stat-text cursor-pointer whitespace-nowrap leading-none ${
isActive ? 'active' : ''
}`}
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
className={`poly-click-stat header-stat-text ${isActive ? 'active' : ''}`}
style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: '4px' }}
>
{label}{' '}
<span className={`poly-stat-val font-extrabold ${accent}`}>{value}</span>
<span className="poly-stat-unit text-[14px] font-medium opacity-50"></span>
</button>
<span className={`poly-stat-val ${accent}`}>{value}</span>
<span className="poly-stat-unit"> </span>
</span>
);
}

View File

@@ -1,34 +1,50 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { isProjectTask } from '../../lib/taskType';
import { type BoardSlotConfig, slotSectionLabel, columnDisplayTitle, columnDisplayTitleEn } from '../../lib/boardLayout';
import { SortableTaskCard } from './TaskCard';
import { DeptIcon } from './DeptIcon';
import { DeptProjectList } from './DeptProjectList';
import { ContextMenu } from '../common/ContextMenu';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
import type { Task, TeamMember } from '../../types';
interface DepartmentColumnProps {
title: string;
titleEn?: string;
subtitle?: string;
tasks: Task[];
orderedIds: string[]; // DashboardPage에서 관리
headerBg: string;
headerStyle?: React.CSSProperties;
storageKey: string;
section: string;
quarter: string;
noHeader?: boolean;
headerAlign?: 'left' | 'right';
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
/** 참고 레이아웃: 부서당 표시 슬롯 수 (2행 + 스크롤) */
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
function loadDummyHeaders(): DummyHeaders {
try {
const raw = localStorage.getItem(DUMMY_HEADER_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveDummyHeader(slotId: string, data: { title: string; titleEn: string; subtitle: string }) {
const all = loadDummyHeaders();
all[slotId] = data;
localStorage.setItem(DUMMY_HEADER_KEY, JSON.stringify(all));
}
interface DepartmentColumnProps {
slot: BoardSlotConfig;
tasks: Task[];
orderedIds: string[];
quarter: string;
onSelectTask?: (task: Task) => void;
sectionOptions?: { value: string; label: string }[];
teamMembers?: TeamMember[];
}
// ── 헤더 편집 팝업 ──────────────────────────────────────────
interface HeaderModalProps {
title: string;
titleEn: string;
@@ -62,7 +78,7 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
autoFocus
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg font-bold outline-none focus:border-emerald-400"
/>
</div>
<div>
@@ -70,8 +86,8 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
<input
value={draftTitleEn}
onChange={(e) => setDraftTitleEn(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="Human Resources"
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400"
placeholder="HRM"
/>
</div>
<div>
@@ -79,40 +95,55 @@ function HeaderModal({ title, titleEn, subtitle, onSave, onClose }: HeaderModalP
<input
value={draftSubtitle}
onChange={(e) => setDraftSubtitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
placeholder="부제목 입력"
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-emerald-400"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition"></button>
<button type="submit" className="px-6 py-2.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition"></button>
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50"></button>
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800"></button>
</div>
</form>
</div>
</div>,
document.body
document.body,
);
}
export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initialSubtitle = '', tasks, orderedIds, headerStyle, section, quarter, noHeader = false, onSelectTask, sectionOptions: externalSectionOptions }: DepartmentColumnProps) {
export function DepartmentColumn({
slot,
tasks,
orderedIds,
quarter,
onSelectTask,
sectionOptions: externalSectionOptions,
teamMembers = [],
}: DepartmentColumnProps) {
const queryClient = useQueryClient();
const section = slotSectionLabel(slot);
const isDummySlot = !slot.sectionKey;
// ── 컬럼 설정 API ─────────────────────────────────────────
const { data: colConfig } = useQuery({
queryKey: ['columns', section],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(section)}`).then((r) => r.data),
queryKey: ['columns', slot.sectionKey],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data),
enabled: !!slot.sectionKey,
staleTime: 0,
});
const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]);
useEffect(() => {
setDummyHeader(loadDummyHeaders()[slot.id]);
}, [slot.id]);
const patchColumn = useMutation({
mutationFn: (data: Record<string, string>) =>
apiClient.patch(`/columns/${encodeURIComponent(section)}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', section] }),
apiClient.patch(`/columns/${encodeURIComponent(slot.sectionKey!)}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
});
const title = colConfig?.title ?? initialTitle;
const titleEnState = colConfig?.titleEn ?? (titleEn ?? '');
const subtitle = colConfig?.subtitle ?? initialSubtitle;
const title = columnDisplayTitle(slot, colConfig, dummyHeader);
const titleEnState = columnDisplayTitleEn(slot, colConfig, dummyHeader);
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
const [cardMenu, setCardMenu] = useState<{ x: number; y: number; task: Task } | null>(null);
@@ -121,11 +152,10 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const [showHeaderModal, setShowHeaderModal] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
// ── useDroppable: 컬럼 드롭존 등록 ──────────────────────
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({ id: `drop::project::${section}` });
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({ id: `drop::routine::${section}` });
const { setNodeRef: setProjectDropRef, isOver: isProjectOver } = useDroppable({
id: `drop::project::${section}`,
});
// ── 순서 적용 ─────────────────────────────────────────────
const orderedTasks = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
@@ -135,9 +165,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
return ai - bi;
});
const saveTitle = (v: string) => patchColumn.mutate({ title: v });
const saveTitleEn = (v: string) => patchColumn.mutate({ titleEn: v });
const saveSubtitle = (v: string) => patchColumn.mutate({ subtitle: v });
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
const create = useMutation({
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
@@ -147,7 +175,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const patch = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
const remove = useMutation({
@@ -155,7 +183,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});
// capture 단계에서 카드 우클릭 처리 — dnd-kit보다 먼저 실행됨
const handleColumnContextMenuCapture = (e: React.MouseEvent) => {
const card = (e.target as HTMLElement).closest('[data-task-id]');
if (!card) return;
@@ -179,28 +206,12 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const sectionOptions = externalSectionOptions ?? [{ value: section, label: title }];
const displayTitle = title.replace(/\s*부문$/, '');
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
title: data.title,
section: data.section || null,
taskType: data.taskType || null,
status: data.status as Task['status'],
progress: data.progress,
description: data.description || null,
issueNote: data.issueNote || null,
startDate: data.startDate || null,
dueDate: data.dueDate || null,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
priority: 'MEDIUM',
...projectFormToApiPayload(data),
section,
priority: 'MEDIUM',
} as Partial<Task>);
setShowAddModal(false);
} catch (err: unknown) {
@@ -210,19 +221,7 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
const handleEdit = (data: TaskFormData) => {
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: {
title: data.title, section: data.section || null,
taskType: data.taskType || null, status: data.status, progress: data.progress,
description: data.description || null, issueNote: data.issueNote || null,
startDate: data.startDate || null, dueDate: data.dueDate || null,
showDate: data.showDate, showDescription: data.showDescription,
showStatus: data.showStatus, showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
},
});
patch.mutate({ id: editingTask.id, data: projectFormToApiPayload(data) });
setShowEditModal(false);
setEditingTask(null);
};
@@ -233,82 +232,66 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
}
};
const saveHeader = (t: string, te: string, s: string) => {
if (isDummySlot) {
saveDummyHeader(slot.id, { title: t, titleEn: te, subtitle: s });
setDummyHeader({ title: t, titleEn: te, subtitle: s });
} else {
patchColumn.mutate({ title: t, titleEn: te, subtitle: s });
}
setShowHeaderModal(false);
};
return (
<>
<div
className="flex min-h-0 flex-col overflow-hidden rounded-[1.6rem] border border-white/80 bg-white/70 shadow-[0_18px_45px_rgba(15,23,42,0.10)] ring-1 ring-slate-200/60 backdrop-blur"
<section
className={`dept-card ${slot.cssClass}`}
onContextMenuCapture={handleColumnContextMenuCapture}
>
{/* 컬럼 헤더 (noHeader 시 숨김) */}
{!noHeader && (
<div
className="relative flex h-10 shrink-0 select-none items-center justify-center gap-2 px-4 shadow-sm"
style={headerStyle}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' }); }}
>
<span className="truncate text-base font-black tracking-tight text-white drop-shadow-sm">{displayTitle}</span>
{titleEnState && (
<span className="text-white/60 text-xs font-medium truncate hidden xl:block">{titleEnState}</span>
)}
<span className="absolute right-3 shrink-0 rounded-full bg-white/25 px-2 py-0.5 text-xs font-black text-white ring-1 ring-white/20">
{tasks.length}
</span>
</div>
)}
{/* 실행과제 카드 목록 (스크롤 영역) */}
{(() => {
const projectTasks = orderedTasks.filter((t) => isProjectTask(t.taskType));
return (
<div
ref={setProjectDropRef}
className={`min-h-0 flex-1 overflow-y-auto bg-gradient-to-b from-slate-50/80 to-white/70 p-4 transition-colors ${isProjectOver ? 'bg-blue-50/60' : ''}`}
onContextMenu={handleListContextMenu}
>
{projectTasks.length === 0 ? (
<div className="flex h-40 items-center justify-center text-2xl text-slate-300">
<div
className="dept-head"
onContextMenu={(e) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY, type: 'header' });
}}
>
<div className="dept-head-main">
<DeptIcon slotId={slot.id} />
<div className="board-dept-header-main">
<div className="board-dept-title-wrap">
<span className="board-dept-title" role="heading" aria-level={2}>
{title.replace(/\s*부문$/, '')}
</span>
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
<div className="dept-head-count" aria-label={`${projectTasks.length}`}>
<span className="poly-stat-val">{projectTasks.length}</span>
<span className="poly-stat-unit"></span>
</div>
) : (
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{projectTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
))}
</SortableContext>
)}
</div>
);
})()}
{/* 기반업무 고정 영역 */}
{(() => {
const routineTasks = orderedTasks.filter((t) => isRoutineTask(t.taskType));
return (
<div
ref={setRoutineDropRef}
className={`shrink-0 border-t border-slate-200/80 bg-white/75 transition-colors ${isRoutineOver ? 'bg-amber-50/60' : ''}`}
style={{ height: 300 }}
onContextMenu={handleListContextMenu}
>
<div className="h-full overflow-y-auto p-4">
{routineTasks.length === 0 ? (
<div className="flex h-full items-center justify-center text-base text-slate-300">
</div>
) : (
<SortableContext items={routineTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
{routineTasks.map((task) => (
<SortableTaskCard key={task.id} task={task} sectionOptions={sectionOptions} onSelect={onSelectTask} />
))}
</SortableContext>
)}
</div>
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
</div>
);
})()}
</div>
</div>
</div>
<DeptProjectList
items={projectTasks}
getKey={(task) => task.id}
sortableItemIds={projectTasks.map((t) => t.id)}
dropRef={setProjectDropRef}
className={isProjectOver ? 'is-over' : ''}
onContextMenu={handleListContextMenu}
empty={<div className="board-empty"> </div>}
renderItem={(task) => (
<SortableTaskCard
task={task}
variant="project"
sectionOptions={sectionOptions}
onSelect={onSelectTask}
/>
)}
/>
</section>
{/* 카드 우클릭 메뉴 (추가/수정/삭제) */}
{cardMenu && (
<ContextMenu
x={cardMenu.x}
@@ -322,7 +305,6 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
/>
)}
{/* 빈 영역/헤더 우클릭 메뉴 */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
@@ -336,13 +318,14 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
/>
)}
{/* 추가 모달 */}
{showAddModal && (
<TaskModal
variant="project"
mode="add"
defaultSection={section}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleAdd}
onClose={() => setShowAddModal(false)}
/>
@@ -350,21 +333,22 @@ export function DepartmentColumn({ title: initialTitle, titleEn, subtitle: initi
{showEditModal && editingTask && (
<TaskModal
variant="project"
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleEdit}
onClose={() => { setShowEditModal(false); setEditingTask(null); }}
/>
)}
{/* 헤더 편집 모달 */}
{showHeaderModal && (
<HeaderModal
title={title}
titleEn={titleEnState}
subtitle={subtitle}
onSave={(t, te, s) => { saveTitle(t); saveTitleEn(te); saveSubtitle(s); setShowHeaderModal(false); }}
onSave={saveHeader}
onClose={() => setShowHeaderModal(false)}
/>
)}

View File

@@ -0,0 +1,44 @@
import type { BoardSlotId } from '../../lib/boardLayout';
function DeptIconSvg({ slotId }: { slotId: BoardSlotId }) {
switch (slotId) {
case 'hrm':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
case 'hrd':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 1.7 3.3 3 7 3s7-1.3 7-3v-5" />
</svg>
);
case 'ex':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
</svg>
);
case 'ga':
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<path d="M9 22v-4h6v4" />
<path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01" />
</svg>
);
}
}
export function DeptIcon({ slotId }: { slotId: BoardSlotId }) {
return (
<div className={`dept-icon dept-icon--${slotId}`}>
<DeptIconSvg slotId={slotId} />
</div>
);
}

View File

@@ -0,0 +1,150 @@
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEventHandler,
type ReactNode,
} from 'react';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
const PAGE_SIZE = 2;
type DeptProjectListProps<T> = {
items: T[];
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
sortableItemIds?: string[];
dropRef?: (node: HTMLDivElement | null) => void;
className?: string;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
empty?: ReactNode;
};
function mergeRefs(
...refs: Array<((node: HTMLDivElement | null) => void) | undefined>
) {
return (node: HTMLDivElement | null) => {
refs.forEach((ref) => ref?.(node));
};
}
function chunkItems<T>(items: T[], size: number): T[][] {
const pages: T[][] = [];
for (let i = 0; i < items.length; i += size) {
pages.push(items.slice(i, i + size));
}
return pages;
}
export function DeptProjectList<T>({
items,
getKey,
renderItem,
sortableItemIds,
dropRef,
className = '',
onContextMenu,
empty,
}: DeptProjectListProps<T>) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pages = useMemo(() => chunkItems(items, PAGE_SIZE), [items]);
const pageCount = pages.length;
const paged = pageCount > 1;
const [pageIndex, setPageIndex] = useState(0);
useEffect(() => {
setPageIndex((prev) => Math.min(prev, Math.max(0, pageCount - 1)));
}, [pageCount]);
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
const direction = event.deltaY > 0 ? 1 : -1;
setPageIndex((prev) => Math.min(pageCount - 1, Math.max(0, prev + direction)));
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, pageCount]);
const setRefs = mergeRefs((node) => {
rootRef.current = node;
}, dropRef);
const listBody = items.length === 0 ? (
empty
) : paged ? (
<div className="board-project-list-viewport">
<div className="board-project-list-page">
{pages[pageIndex].map((item) => (
<div key={getKey(item)} className="board-project-list-slot">
{renderItem(item)}
</div>
))}
{pages[pageIndex].length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pages[pageIndex].length }, (_, slot) => (
<div
key={`empty-${pageIndex}-${slot}`}
className="board-project-list-slot board-project-list-slot--empty"
aria-hidden="true"
/>
))}
</div>
</div>
) : (
<div className="board-project-list-viewport">
<div className="board-project-list-track">
<div className="board-project-list-page">
{pages[0].map((item) => (
<div key={getKey(item)} className="board-project-list-slot">
{renderItem(item)}
</div>
))}
{pages[0].length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pages[0].length }, (_, slot) => (
<div
key={`empty-0-${slot}`}
className="board-project-list-slot board-project-list-slot--empty"
aria-hidden="true"
/>
))}
</div>
</div>
</div>
);
const sortableIds = sortableItemIds ?? items.map(getKey);
const wrappedBody =
sortableItemIds && items.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{listBody}
</SortableContext>
) : (
listBody
);
return (
<div
ref={setRefs}
className={`board-project-list board-project-list--2slots${paged ? ' is-paged' : ''}${className ? ` ${className}` : ''}`}
onContextMenu={onContextMenu}
>
{wrappedBody}
{paged && (
<div className="board-project-list-steps" aria-hidden="true">
{Array.from({ length: pageCount }, (_, index) => (
<span
key={index}
className={`board-project-list-step${index === pageIndex ? ' is-active' : ''}`}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { Task } from '../../types';
import { getDonutDisplay } from '../../lib/taskStatusVisual';
export function DonutGauge({ task }: { task: Pick<Task, 'status' | 'progress'> }) {
const display = getDonutDisplay(task);
return (
<div
className="donut"
style={{
['--pct' as string]: display.pct,
['--color' as string]: display.ringColor,
}}
aria-label={display.ariaLabel}
>
<span style={{ color: display.labelColor }}>{display.label}</span>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import type { ReactNode } from 'react';
interface IconProps {
size?: number;
className?: string;
}
function LucideSvg({ size = 16, className, children }: IconProps & { children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden
>
{children}
</svg>
);
}
/** 참고 사이트 lucide `users` */
export function UsersIcon({ size = 16, className }: IconProps) {
return (
<LucideSvg size={size} className={className}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<circle cx="9" cy="7" r="4" />
</LucideSvg>
);
}
/** lucide `calendar` */
export function CalendarIcon({ size = 16, className }: IconProps) {
return (
<LucideSvg size={size} className={className}>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</LucideSvg>
);
}
/** 참고 사이트 lucide `plus` */
export function PlusIcon({ size = 16, className }: IconProps) {
return (
<LucideSvg size={size} className={className}>
<path d="M5 12h14" />
<path d="M12 5v14" />
</LucideSvg>
);
}
function MonitorIcon({ size = 16, className }: IconProps) {
return (
<LucideSvg size={size} className={className}>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</LucideSvg>
);
}
/** 참고 사이트 `dual-monitor-icon-wrap` — monitor 2개 겹침 */
export function DualMonitorIcon({ size = 16, className }: IconProps) {
const monitorSize = size * 0.75;
return (
<div
className={`dual-monitor-icon-wrap ${className ?? ''}`}
style={{ width: size, height: size }}
aria-hidden
>
<MonitorIcon size={monitorSize} className="m-icon m-back" />
<MonitorIcon size={monitorSize} className="m-icon m-front" />
</div>
);
}

View File

@@ -0,0 +1,326 @@
import { createPortal } from 'react-dom';
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { Task } from '../../types';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
import { useRoutineCategoryMilestones } from '../../hooks/useRoutineCategoryMilestones';
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
import { HubScheduleCarousel } from './HubScheduleCarousel';
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
import { ContextMenu } from '../common/ContextMenu';
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
import {
ROUTINE_CATEGORIES,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../../lib/routineCategories';
type HubEditSection = 'schedule';
function ModalShell({
title,
onClose,
children,
onSubmit,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
onSubmit: (e: React.FormEvent) => void;
}) {
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-2xl w-[min(480px,92vw)] max-h-[90vh] overflow-y-auto p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-black text-gray-800">{title}</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none"></button>
</div>
<form onSubmit={onSubmit} className="space-y-4">
{children}
<div className="flex justify-end gap-2 pt-1">
<button type="button" onClick={onClose} className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50">
</button>
<button type="submit" className="px-6 py-2.5 rounded-xl bg-emerald-700 text-white font-bold hover:bg-emerald-800">
</button>
</div>
</form>
</div>
</div>,
document.body,
);
}
function ScheduleEditModal({
config,
quarter,
onSave,
onClose,
}: {
config: HubConfig;
quarter: string;
onSave: (patch: Pick<HubConfig, 'scheduleTitle' | 'scheduleItems'>) => void;
onClose: () => void;
}) {
const { min, max } = quarterDateBounds(quarter);
const [title, setTitle] = useState(config.scheduleTitle);
const [items, setItems] = useState<HubScheduleItem[]>(() => structuredClone(config.scheduleItems));
const updateItem = (id: string, patch: Partial<HubScheduleItem>) => {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
};
const addItem = () => {
setItems((prev) => [...prev, { id: String(Date.now()), date: todayIso(), text: '' }]);
};
const removeItem = (id: string) => {
setItems((prev) => (prev.length <= 1 ? prev : prev.filter((item) => item.id !== id)));
};
return (
<ModalShell
title="분기 주요 일정 수정"
onClose={onClose}
onSubmit={(e) => {
e.preventDefault();
const valid = items.filter((item) => item.date && item.text.trim());
onSave({
scheduleTitle: title.trim() || '분기 주요 일정',
scheduleItems: sortScheduleItems(valid),
});
onClose();
}}
>
<div>
<label className="block text-sm font-bold text-gray-500 mb-1.5"></label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-bold text-gray-500"> ( · )</label>
{items.map((item) => (
<div key={item.id} className="flex gap-2 items-center">
<input
type="date"
value={item.date}
min={min}
max={max}
onChange={(e) => updateItem(item.id, { date: e.target.value })}
className="shrink-0 w-[140px] border border-gray-200 rounded-lg px-2 py-2 text-sm"
required
/>
<input
value={item.text}
onChange={(e) => updateItem(item.id, { text: e.target.value })}
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-3 py-2 text-sm"
placeholder="일정 내용"
/>
<button
type="button"
onClick={() => removeItem(item.id)}
className="shrink-0 px-2 text-gray-400 hover:text-red-500"
title="항목 삭제"
>
</button>
</div>
))}
<button
type="button"
onClick={addItem}
className="text-sm font-semibold text-emerald-700 hover:text-emerald-800"
>
+
</button>
</div>
</ModalShell>
);
}
function openSectionContextMenu(
e: React.MouseEvent,
section: HubEditSection,
setCtxMenu: (menu: { x: number; y: number; section: HubEditSection } | null) => void,
) {
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY, section });
}
interface HubColumnProps {
routineTasks: Task[];
quarter?: string;
referenceDate?: Date;
onSelectRoutine?: (task: Task) => void;
onSelectRoutineMilestone?: (taskId: string, milestoneId: string) => void;
}
export function HubColumn({
routineTasks,
quarter = '2026-Q2',
referenceDate,
onSelectRoutine,
onSelectRoutineMilestone,
}: HubColumnProps) {
const { config, setConfig } = useHubConfig();
const queryClient = useQueryClient();
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
const [routineOpening, setRoutineOpening] = useState(false);
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
const categoryFocus = useRoutineCategoryMilestones(routineTasks);
const hubSlots = useMemo((): { task: Task | null; label: RoutineCategory }[] => {
return ROUTINE_CATEGORIES.map((label) => ({
label,
task: pickRoutineCategoryTask(routineTasks, label),
}));
}, [routineTasks]);
const openRoutineCategory = async (label: RoutineCategory, task: Task | null) => {
if (routineOpening) return;
if (task) {
onSelectRoutine?.(task);
return;
}
setRoutineOpening(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
onSelectRoutine?.(created);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setRoutineOpening(false);
}
};
const handleFocusMilestoneClick = async (milestoneId: string) => {
const entry = categoryFocus[activeCategoryIndex];
const label = ROUTINE_CATEGORIES[activeCategoryIndex];
if (entry?.task) {
onSelectRoutineMilestone?.(entry.task.id, milestoneId);
return;
}
if (routineOpening) return;
setRoutineOpening(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, quarter),
);
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
onSelectRoutineMilestone?.(created.id, milestoneId);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setRoutineOpening(false);
}
};
return (
<>
<div className="hub-column" id="hub-column">
<div
className="hub-box hub-box--focus"
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
>
<div className="hub-schedule-planner">
<div className="hub-schedule-head">
<span className="hub-schedule-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4" />
<path d="M8 2v4" />
<path d="M3 10h18" />
</svg>
</span>
<div className="board-project-title">{config.scheduleTitle}</div>
</div>
<HubScheduleCarousel items={config.scheduleItems} focusDate={referenceDate} />
</div>
</div>
<div className="hub-diamond-wrap" id="hub-diamond-wrap">
<div className="hub-diamond" id="hub-diamond">
<div className="hub-diamond-inner">
<div className="hub-diamond-head">
<span className="hub-diamond-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21 v-5 h-5" />
</svg>
</span>
<div className="board-project-title"></div>
</div>
<div className="hub-diamond-divider" aria-hidden="true" />
<div className="hub-routine-grid">
{hubSlots.map(({ label, task }, i) => (
<button
key={task?.id ?? `cat-${i}`}
type="button"
className={`hub-routine-item${activeCategoryIndex === i ? ' is-active' : ''}`}
disabled={routineOpening}
onMouseEnter={() => setActiveCategoryIndex(i)}
onFocus={() => setActiveCategoryIndex(i)}
onClick={() => openRoutineCategory(label, task)}
onContextMenu={(e) => e.stopPropagation()}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
<div className="hub-box hub-box--message hub-box--routine-focus">
<HubRoutineFocusPanel
activeIndex={activeCategoryIndex}
onActiveIndexChange={setActiveCategoryIndex}
categories={categoryFocus}
onSelectMilestone={handleFocusMilestoneClick}
/>
</div>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[{
icon: '✏',
label: '수정',
onClick: () => setEditSection(ctxMenu.section),
}]}
/>
)}
{editSection === 'schedule' && (
<ScheduleEditModal
config={config}
quarter={quarter}
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
onClose={() => setEditSection(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { RoutineFocusMilestone } from '../../hooks/useRoutineCategoryMilestones';
const PAGE_SIZE = 3;
function chunkItems<T>(items: T[], size: number): T[][] {
const pages: T[][] = [];
for (let i = 0; i < items.length; i += size) {
pages.push(items.slice(i, i + size));
}
return pages;
}
type HubFocusTaskListProps = {
milestones: RoutineFocusMilestone[];
isLoading: boolean;
categoryKey: string;
onSelectMilestone?: (milestoneId: string) => void;
};
export function HubFocusTaskList({
milestones,
isLoading,
categoryKey,
onSelectMilestone,
}: HubFocusTaskListProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pages = useMemo(() => chunkItems(milestones, PAGE_SIZE), [milestones]);
const pageCount = pages.length;
const paged = pageCount > 1;
const [pageIndex, setPageIndex] = useState(0);
useEffect(() => {
setPageIndex(0);
}, [categoryKey]);
useEffect(() => {
setPageIndex((prev) => Math.min(prev, Math.max(0, pageCount - 1)));
}, [pageCount]);
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
const direction = event.deltaY > 0 ? 1 : -1;
setPageIndex((prev) => Math.min(pageCount - 1, Math.max(0, prev + direction)));
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, pageCount]);
if (isLoading) {
return (
<div className="hub-focus-task-list">
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page">
<li className="hub-focus-task-slot hub-focus-task-slot--message">
<span className="hub-focus-task-empty board-project-desc"> </span>
</li>
</ul>
</div>
</div>
);
}
if (milestones.length === 0) {
return (
<div className="hub-focus-task-list">
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page">
<li className="hub-focus-task-slot hub-focus-task-slot--message">
<span className="hub-focus-task-empty board-project-desc"> </span>
</li>
</ul>
</div>
</div>
);
}
const pageItems = pages[pageIndex] ?? [];
return (
<div
ref={rootRef}
className={`hub-focus-task-list${paged ? ' is-paged' : ''}`}
>
<div className="hub-focus-task-viewport">
<ul className="hub-focus-task-page" aria-live="polite">
{pageItems.map((milestone) => (
<li key={milestone.id} className="hub-focus-task-slot">
<button
type="button"
className="hub-focus-task-item board-project-desc"
onClick={() => onSelectMilestone?.(milestone.id)}
>
{milestone.title}
</button>
</li>
))}
{pageItems.length < PAGE_SIZE &&
Array.from({ length: PAGE_SIZE - pageItems.length }, (_, slot) => (
<li
key={`empty-${pageIndex}-${slot}`}
className="hub-focus-task-slot hub-focus-task-slot--empty"
aria-hidden="true"
/>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { HubNavChevron } from '../common/HubNavChevron';
import { HubFocusTaskList } from './HubFocusTaskList';
import { ROUTINE_CATEGORIES } from '../../lib/routineCategories';
import type { RoutineCategoryFocus } from '../../hooks/useRoutineCategoryMilestones';
interface HubRoutineFocusPanelProps {
activeIndex: number;
onActiveIndexChange: (index: number) => void;
categories: RoutineCategoryFocus[];
onSelectMilestone?: (milestoneId: string) => void;
}
export function HubRoutineFocusPanel({
activeIndex,
onActiveIndexChange,
categories,
onSelectMilestone,
}: HubRoutineFocusPanelProps) {
const safeIndex = Math.max(0, Math.min(activeIndex, ROUTINE_CATEGORIES.length - 1));
const active = categories[safeIndex];
const canPrev = safeIndex > 0;
const canNext = safeIndex < ROUTINE_CATEGORIES.length - 1;
const stepPrev = () => {
if (canPrev) onActiveIndexChange(safeIndex - 1);
};
const stepNext = () => {
if (canNext) onActiveIndexChange(safeIndex + 1);
};
return (
<div className="hub-routine-focus">
<div className="hub-focus-dots" role="tablist" aria-label="상시업무 대분류">
{ROUTINE_CATEGORIES.map((label, index) => (
<button
key={label}
type="button"
role="tab"
aria-selected={index === safeIndex}
aria-label={label}
title={label}
className={`hub-focus-dot${index === safeIndex ? ' is-active' : ''}`}
onClick={() => onActiveIndexChange(index)}
/>
))}
</div>
<div className="hub-routine-focus-body">
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--prev"
disabled={!canPrev}
onClick={stepPrev}
aria-label="이전 대분류"
>
<HubNavChevron direction="prev" />
</button>
<HubFocusTaskList
milestones={active?.milestones ?? []}
isLoading={!!active?.isLoading}
categoryKey={ROUTINE_CATEGORIES[safeIndex]}
onSelectMilestone={onSelectMilestone}
/>
<button
type="button"
className="hub-routine-focus-nav hub-routine-focus-nav--next"
disabled={!canNext}
onClick={stepNext}
aria-label="다음 대분류"
>
<HubNavChevron direction="next" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HubScheduleItem } from '../../lib/hubConfig';
import { HubNavChevron } from '../common/HubNavChevron';
import {
findScheduleIndexForToday,
formatScheduleDateParts,
isSchedulePast,
isScheduleToday,
scheduleWindowStart,
sortScheduleItems,
} from '../../lib/hubSchedule';
const VISIBLE_COUNT = 3;
interface HubScheduleCarouselProps {
items: HubScheduleItem[];
/** 기준일 — 없으면 오늘 */
focusDate?: Date;
}
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const focus = focusDate ?? new Date();
const sorted = useMemo(
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
[items],
);
const focusIndex = useMemo(() => findScheduleIndexForToday(sorted, focus), [sorted, focus]);
const initialStart = useMemo(
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
[sorted.length, focusIndex],
);
const [startIndex, setStartIndex] = useState(initialStart);
useEffect(() => {
setStartIndex(initialStart);
}, [initialStart, sorted.length]);
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
const paged = sorted.length > VISIBLE_COUNT;
const canPrev = startIndex > 0;
const canNext = startIndex < maxStart;
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
useEffect(() => {
const root = rootRef.current;
if (!root || !paged) return;
const onWheel = (event: WheelEvent) => {
if (Math.abs(event.deltaY) < 1) return;
event.preventDefault();
if (event.deltaY > 0) {
setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
} else {
setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
}
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [paged, maxStart]);
if (sorted.length === 0) {
return (
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
<p className="board-project-desc hub-schedule-empty"> </p>
</div>
);
}
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
return (
<div
ref={rootRef}
className={`hub-schedule-carousel${paged ? ' is-paged' : ''}`}
>
<div className="hub-schedule-viewport">
<ul className="hub-schedule-list hub-list">
{visible.map((item) => {
const past = isSchedulePast(item.date, focus);
const today = isScheduleToday(item.date, focus);
const dateParts = formatScheduleDateParts(item.date);
return (
<li
key={item.id}
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
>
<span className="hub-schedule-date board-project-desc">
{dateParts ? (
<>
<span className="hub-schedule-date-month">{dateParts.month}</span>
<span className="hub-schedule-date-day">{dateParts.day}</span>
</>
) : (
item.date
)}
</span>
<span className="board-project-desc">{item.text}</span>
</li>
);
})}
</ul>
</div>
{canPrev && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--prev"
onClick={stepPrev}
onContextMenu={(e) => e.stopPropagation()}
aria-label="이전 일정"
>
<HubNavChevron direction="prev" />
</button>
)}
{canNext && (
<button
type="button"
className="hub-schedule-nav hub-schedule-nav--next"
onClick={stepNext}
onContextMenu={(e) => e.stopPropagation()}
aria-label="다음 일정"
>
<HubNavChevron direction="next" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import type { Task } from '../../types';
import { getMemberTasks, taskStatusBadge, taskSubtitle } from '../../lib/teamStatus';
interface MemberTaskTooltipProps {
memberId: string;
tasks: Task[];
isStatic: boolean;
activeProjectId: string | null;
onProjectClick: (taskId: string | null) => void;
}
export function MemberTaskTooltip({
memberId,
tasks,
isStatic,
activeProjectId,
onProjectClick,
}: MemberTaskTooltipProps) {
const memberTasks = getMemberTasks(memberId, tasks, 'project');
if (memberTasks.length === 0) return null;
return (
<div className={`member-tooltip ${isStatic ? 'is-static' : ''}`}>
<div className="tooltip-header">
({memberTasks.length})
</div>
<div className="tooltip-list">
{memberTasks.map((task) => {
const badge = taskStatusBadge(task);
const isActive = activeProjectId === task.id;
const subtitle = taskSubtitle(task);
const pmName = task.pmMember?.name ?? '미정';
const assigneeNames = task.assigneeMembers?.length
? task.assigneeMembers.map((m) => m.name).join(', ')
: '미정';
return (
<div
key={task.id}
className={`tooltip-item ${isActive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onProjectClick(isActive ? null : task.id);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onProjectClick(isActive ? null : task.id);
}
}}
>
<div className="tooltip-item-row">
<span className="tooltip-dot" aria-hidden />
<div className="tooltip-item-body">
<div className="tooltip-title">
{task.title}
{subtitle && <span className="tooltip-sub">{subtitle}</span>}
</div>
{isActive && (
<div className="tooltip-project-detail">
<span>
PM: <strong>{pmName}</strong>
</span>
<span className="tooltip-detail-sep" aria-hidden>·</span>
<span>
: <strong>{assigneeNames}</strong>
</span>
</div>
)}
</div>
<span className={`tooltip-badge tooltip-badge-${badge.variant}`}>
{badge.label}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
type DeptKey = 'hrm' | 'hrd' | 'ex' | 'ga';
interface ProjectItem {
name: string;
period: string;
summary: string;
progress: number;
}
interface DeptBlock {
key: DeptKey;
icon: string;
name: string;
code: string;
projects: ProjectItem[];
}
const DUMMY_DEPARTMENTS: DeptBlock[] = [
{
key: 'hrm',
icon: '👥',
name: '인사관리',
code: 'HRM',
projects: [
{
name: '상반기 채용 운영',
period: '2026.04 ~ 2026.06',
summary: '채용공고, 서류검토, 면접, 사후협의 진행',
progress: 75,
},
{
name: '평가제도 개선',
period: '2026.03 ~ 2026.07',
summary: '평가항목 정비, 부서 의견수렴, 피드백 방식 개선',
progress: 60,
},
],
},
{
key: 'hrd',
icon: '📚',
name: '인재육성',
code: 'HRD',
projects: [
{
name: '신규입사자 온보딩 프로그램',
period: '2026.04 ~ 2026.06',
summary: '신규입사자 교육, 조직 적응 프로그램 운영',
progress: 85,
},
{
name: '팀장 리더십 교육',
period: '2026.05 ~ 2026.06',
summary: '팀장 대상 리더십·코칭·피드백 교육 실시',
progress: 100,
},
],
},
{
key: 'ex',
icon: '🤝',
name: '조직문화',
code: 'EX',
projects: [
{
name: '조직문화 진단',
period: '2026.04 ~ 2026.05',
summary: '만족도 조사, 직원 의견수렴, 개선과제 도출',
progress: 100,
},
{
name: '복리후생제도 개선',
period: '2026.05 ~ 2026.08',
summary: '복지제도 이용현황 분석, 개선안 검토',
progress: 50,
},
],
},
{
key: 'ga',
icon: '🏢',
name: '총무관리',
code: 'GA',
projects: [
{
name: '사무공간 재배치',
period: '2026.04 ~ 2026.07',
summary: '좌석 재배치, 회의실 운영 개선',
progress: 70,
},
{
name: '안전·보안 점검 강화',
period: '2026.04 ~ 2026.06',
summary: '출입통제, 보안카드, 시설안전, 소방점검 관리',
progress: 85,
},
],
},
];
const HUB_MESSAGE = '인사·육성·문화·총무 개선과제 정상 추진';
const ROUTINE_ITEMS = ['채용 운영', '교육 운영', '직원 소통', '자산·시설 관리', '문서·행정 지원'];
const FOCUS_ITEMS = ['핵심직무 채용 완료', '복지제도 개선안 확정', '안전보안 점검 강화'];
function deptByKey(key: DeptKey): DeptBlock {
return DUMMY_DEPARTMENTS.find((d) => d.key === key)!;
}
function DeptCard({ dept }: { dept: DeptBlock }) {
return (
<section className={`qboard-dept-card qboard-dept-card--${dept.key}`}>
<div className="qboard-dept-head">
<div className={`qboard-dept-icon qboard-dept-icon--${dept.key}`}>{dept.icon}</div>
<h2 className="qboard-dept-name">
{dept.name}
<span>{dept.code}</span>
</h2>
</div>
{dept.projects.map((project) => (
<article key={project.name} className="qboard-project-block">
<div className="qboard-project-fields">
<p className="qboard-field-row">
<span className="qboard-field-label"></span>
<span className="qboard-field-value">{project.name}</span>
</p>
<p className="qboard-field-row">
<span className="qboard-field-label"></span>
<span className="qboard-field-value">{project.period}</span>
</p>
<p className="qboard-field-row">
<span className="qboard-field-label"> </span>
<span className="qboard-field-value">{project.summary}</span>
</p>
</div>
<div className={`qboard-progress-ring qboard-progress-ring--${dept.key}`}>{project.progress}%</div>
</article>
))}
</section>
);
}
function CenterHub() {
return (
<aside className="qboard-hub">
<div className="qboard-hub-box qboard-hub-box--message">
<div className="qboard-hub-box-title">📢 </div>
<p className="qboard-hub-box-text">{HUB_MESSAGE}</p>
</div>
<div className="qboard-hub-diamond">
<div className="qboard-hub-diamond-title"></div>
<ul>
{ROUTINE_ITEMS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
<div className="qboard-hub-box qboard-hub-box--focus">
<div className="qboard-hub-box-title">🎯 </div>
<ul>
{FOCUS_ITEMS.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</aside>
);
}
export function QuarterStatusBoard() {
return (
<div className="qboard-wrap">
<h1 className="qboard-page-title">2026 2 Dashboard</h1>
<p className="qboard-page-subtitle">(HRM) · (HRD) · (EX) · (GA)</p>
<div className="qboard-layout">
<DeptCard dept={deptByKey('hrm')} />
<DeptCard dept={deptByKey('hrd')} />
<CenterHub />
<DeptCard dept={deptByKey('ex')} />
<DeptCard dept={deptByKey('ga')} />
</div>
</div>
);
}

View File

@@ -1,24 +1,12 @@
import { useRef } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DraggableAttributes } from '@dnd-kit/core';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { Task } from '../../types';
const STATUS_STYLE: Record<string, string> = {
IN_PROGRESS: 'bg-blue-500 text-white shadow-blue-500/20',
REVIEW: 'bg-amber-400 text-white shadow-amber-400/20',
TODO: 'bg-slate-200 text-slate-600 shadow-slate-300/20',
DONE: 'bg-emerald-500 text-white shadow-emerald-500/20',
CANCELLED: 'bg-slate-200 text-slate-400 shadow-slate-300/20',
};
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행',
REVIEW: '보류',
TODO: '대기',
DONE: '완료',
CANCELLED: '취소',
};
import { DonutGauge } from './DonutGauge';
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
import { getVisibleIssueEntries } from '../../lib/taskIssues';
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '';
@@ -26,21 +14,55 @@ function fmtDate(iso: string | null | undefined): string {
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
}
function fmtDateRange(task: Task): string {
if (!task.showDate || (!task.startDate && !task.dueDate)) return '';
const start = task.startDate ? fmtDate(task.startDate) : '?';
const end = task.dueDate ? fmtDate(task.dueDate) : '?';
return `${start} ~ ${end}`;
}
function firstDescriptionLine(text: string | null | undefined): string {
if (!text) return '';
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
return line ?? '';
}
type SectionOption = { value: string; label: string };
export function SortableTaskCard({
task,
variant = 'project',
onSelect,
}: {
task: Task;
variant?: 'project' | 'routine';
sectionOptions?: SectionOption[];
accent?: string;
onSelect?: (task: Task) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const pointerStart = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
pointerStart.current = { x: e.clientX, y: e.clientY };
listeners?.onPointerDown?.(e);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (e.button !== 0 || !pointerStart.current) return;
const dx = e.clientX - pointerStart.current.x;
const dy = e.clientY - pointerStart.current.y;
pointerStart.current = null;
if (!isDragging && Math.hypot(dx, dy) < 8) {
onSelect?.(task);
}
};
return (
<TaskCard
task={task}
variant={variant}
dragRef={setNodeRef}
dragStyle={{
transform: CSS.Transform.toString(transform),
@@ -49,88 +71,118 @@ export function SortableTaskCard({
}}
dragAttributes={attributes}
dragListeners={listeners}
onCardClick={() => { if (!isDragging) onSelect?.(task); }}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
);
}
export function TaskCard({
task,
variant = 'project',
dragRef,
dragStyle,
dragAttributes,
dragListeners,
onCardClick,
onPointerDown,
onPointerUp,
}: {
task: Task;
variant?: 'project' | 'routine';
dragRef?: (node: HTMLElement | null) => void;
dragStyle?: React.CSSProperties;
dragAttributes?: DraggableAttributes;
dragListeners?: SyntheticListenerMap;
onCardClick?: () => void;
onPointerDown?: (e: React.PointerEvent) => void;
onPointerUp?: (e: React.PointerEvent) => void;
}) {
const dragHandlers = {
onPointerDown: (e: React.PointerEvent) => {
onPointerDown?.(e);
},
onPointerUp: (e: React.PointerEvent) => {
onPointerUp?.(e);
},
onKeyDown: dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined,
};
if (variant === 'routine') {
const descLine = firstDescriptionLine(task.description);
return (
<div
ref={dragRef}
style={dragStyle}
{...dragAttributes}
data-task-card="true"
data-task-id={task.id}
className="board-routine-item"
{...dragHandlers}
>
<span className="board-project-title">{task.title}</span>
{descLine && <p className="board-project-desc"> {descLine}</p>}
</div>
);
}
const dateRange = fmtDateRange(task);
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
const showProgress = task.showProgress !== false;
const visibleIssues = getVisibleIssueEntries(task);
return (
<div
<article
ref={dragRef}
style={dragStyle}
{...dragAttributes}
data-task-card="true"
data-task-id={task.id}
className="mb-3 cursor-grab select-none overflow-hidden rounded-[1.35rem] border border-white/80 bg-white px-5 py-4 shadow-[0_10px_28px_rgba(15,23,42,0.08)] ring-1 ring-slate-200/60 transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(15,23,42,0.14)] active:cursor-grabbing"
onPointerDown={(e) => {
if (e.button !== 0) return;
dragListeners?.onPointerDown?.(e);
}}
onKeyDown={dragListeners?.onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | undefined}
onClick={onCardClick}
className="project-sub-card"
{...dragHandlers}
>
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-2xl font-black leading-snug text-slate-900">
{task.title}
</span>
{task.showProgress !== false && (
<span className={`mt-0.5 min-w-[4rem] shrink-0 text-right text-2xl font-black ${
task.progress >= 70 ? 'text-emerald-500' :
task.progress >= 40 ? 'text-blue-400' : 'text-orange-400'
}`}>
{task.progress}%
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2">
<span className="flex-1 truncate text-sm font-semibold text-slate-400">
{task.showDate && (task.startDate || task.dueDate)
? `${task.startDate ? fmtDate(task.startDate) : '?'} ~ ${task.dueDate ? fmtDate(task.dueDate) : '?'}`
: ''}
</span>
{task.showStatus && (
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-sm font-black shadow-sm ${STATUS_STYLE[task.status]}`}>
{STATUS_LABEL[task.status]}
</span>
)}
</div>
{task.keywords && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{task.keywords.split(',').map((kw, i) => (
<span key={i} className="rounded-md border border-slate-200/60 bg-slate-100 px-2 py-0.5 text-sm font-bold text-slate-600">
{kw.trim()}
</span>
<div className="project-sub-body">
<div className="project-fields">
<div className={`project-sub-title ${getProjectTitleStatusClass(task)}`}>
<span className="project-sub-title-text">{task.title}</span>
</div>
{dateRange && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-value">{dateRange}</span>
</div>
)}
{descLine && (
<div className="project-field">
<span className="project-field-label"> </span>
<span className="project-field-value">{descLine}</span>
</div>
)}
{visibleIssues.map((entry) => (
<div key={entry.id} className="project-field project-field--issue">
<span className="project-issue-icon" aria-label="이슈" title="이슈">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 7v6" />
<path d="M12 17h.01" />
</svg>
</span>
<span className="project-field-value project-field-value--issue">{entry.text}</span>
</div>
))}
{visibleIssues.length === 0 && (
<div className="project-field project-field--issue project-field--issue-reserved" aria-hidden="true">
<span className="project-issue-icon" />
<span className="project-field-value project-field-value--issue">&nbsp;</span>
</div>
)}
</div>
)}
{task.showDescription && task.description && (
<div className="mt-2 truncate text-2xl text-slate-700">{task.description}</div>
)}
{task.showIssue && task.issueNote && (
<div className="mt-1.5 flex min-w-0 gap-2 rounded-xl bg-red-50/80 px-2 py-1 text-2xl text-red-500">
<span className="shrink-0"></span>
<span className="truncate">{task.issueNote}</span>
</div>
)}
</div>
{showProgress && (
<>
<div className="project-sub-divider" aria-hidden="true" />
<div className="progress-col">
<DonutGauge task={task} />
</div>
</>
)}
</div>
</article>
);
}

View File

@@ -4,8 +4,10 @@ import { createPortal } from 'react-dom';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { TaskModal } from '../common/TaskModal';
import type { TaskFormData } from '../common/TaskModal';
import type { Task } from '../../types';
import type { Task, TeamMember } from '../../types';
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
const STATUS_LABEL: Record<string, string> = {
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
@@ -18,16 +20,17 @@ const STATUS_STYLE: Record<string, string> = {
CANCELLED: 'bg-gray-100 text-gray-400',
};
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'] as const;
import { SECTIONS, formatSectionDisplay } from '../../lib/sections';
interface TaskManagerProps {
tasks: Task[];
sectionOptions: { value: string; label: string }[];
quarter: string;
teamMembers?: TeamMember[];
onClose: () => void;
}
export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskManagerProps) {
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
const queryClient = useQueryClient();
const [filterSection, setFilterSection] = useState<string>('전체');
const [filterType, setFilterType] = useState<string>('전체');
@@ -41,7 +44,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
const patch = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
apiClient.patch(`/tasks/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
@@ -64,18 +67,8 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
const handleAdd = async (data: TaskFormData) => {
try {
await create.mutateAsync({
title: data.title, section: data.section || null, tag: data.tag || null,
taskType: data.taskType || null, status: data.status, progress: data.progress,
description: data.description || null, issueNote: data.issueNote || null,
startDate: data.startDate || null, dueDate: data.dueDate || null,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
quarter: data.quarter,
priority: 'MEDIUM',
...taskFormToApiPayload(data),
priority: 'MEDIUM',
});
setModalMode(null);
} catch (err: unknown) {
@@ -87,18 +80,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
if (!editingTask) return;
patch.mutate({
id: editingTask.id,
data: {
title: data.title, section: data.section || null, tag: data.tag || null,
taskType: data.taskType || null, status: data.status, progress: data.progress,
description: data.description || null, issueNote: data.issueNote || null,
startDate: data.startDate || null, dueDate: data.dueDate || null,
showDate: data.showDate,
showDescription: data.showDescription,
showStatus: data.showStatus,
showIssue: data.showIssue,
showProgress: data.showProgress,
keywords: data.keywords || null,
},
data: taskFormToApiPayload(data),
});
setModalMode(null);
setEditingTask(null);
@@ -172,7 +154,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
<tr><td colSpan={9} className="text-center py-16 text-gray-300 text-lg"> </td></tr>
) : filtered.map((task) => (
<tr key={task.id} className="hover:bg-blue-50/40 transition-colors group">
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{task.section ?? '-'}</td>
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{formatSectionDisplay(task.section)}</td>
<td className="px-4 py-3">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
@@ -232,6 +214,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
defaultQuarter={quarter}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleAdd}
onClose={() => setModalMode(null)}
/>
@@ -241,6 +224,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, onClose }: TaskMan
mode="edit"
task={editingTask}
sectionOptions={sectionOptions}
teamMembers={teamMembers}
onSave={handleEdit}
onClose={() => { setModalMode(null); setEditingTask(null); }}
/>

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { staticAssetUrl } from '../../lib/apiBase';
import type { TeamMemberBrief } from '../../types';
interface TeamMemberAvatarProps {
member: TeamMemberBrief;
className?: string;
size?: 'leader' | 'member';
}
export function TeamMemberAvatar({ member, className = '', size = 'member' }: TeamMemberAvatarProps) {
const [imgError, setImgError] = useState(false);
const initial = member.name?.charAt(0) ?? '?';
const isLeader = size === 'leader';
const photoSrc = staticAssetUrl(member.photoUrl);
if (photoSrc && !imgError) {
return (
<img
src={photoSrc}
alt={member.name}
className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}
onError={() => setImgError(true)}
/>
);
}
return (
<div className={`${isLeader ? 'leader-avatar' : 'member-avatar'} ${className}`}>
{initial}
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import type { Task, TeamMember } from '../../types';
import {
getCellLabel,
getHighlightMemberIds,
groupTeamMembers,
} from '../../lib/teamStatus';
import { TeamMemberAvatar } from './TeamMemberAvatar';
import { MemberTaskTooltip } from './MemberTaskTooltip';
import { UsersIcon } from './HeaderIcons';
function LayersIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>
);
}
function CloseIcon({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
interface TeamStatusPanelProps {
members: TeamMember[];
tasks: Task[];
showAllTasks: boolean;
activeProjectId: string | null;
onToggleShowAll: () => void;
onProjectClick: (taskId: string | null) => void;
onClose: () => void;
}
function MemberInfo({
member,
showAllTasks,
isLeader,
}: {
member: TeamMember;
showAllTasks: boolean;
isLeader: boolean;
}) {
if (showAllTasks) {
return (
<>
<span className="member-name">{member.name}</span>
{member.rank && <span className="member-rank">{member.rank}</span>}
{member.role && <span className="member-role">{member.role}</span>}
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
if (isLeader) {
return (
<>
<div className="leader-name-row">
<span className="leader-name">{member.name}</span>
<span className="leader-sub">
{[member.rank, member.role].filter(Boolean).join(' · ')}
</span>
</div>
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
return (
<>
<div className="member-name-row">
<span className="member-name">{member.name}</span>
<span className="member-role">
{[member.rank, member.role].filter(Boolean).join(' · ')}
</span>
</div>
{member.contact && <span className="member-contact">{member.contact}</span>}
</>
);
}
export function TeamStatusPanel({
members,
tasks,
showAllTasks,
activeProjectId,
onToggleShowAll,
onProjectClick,
onClose,
}: TeamStatusPanelProps) {
const activeTask = activeProjectId ? tasks.find((t) => t.id === activeProjectId) : null;
const highlightIds = useMemo(() => getHighlightMemberIds(activeTask), [activeTask]);
const { groups, cellKeys } = useMemo(() => groupTeamMembers(members), [members]);
const visibleCells = cellKeys.filter((key) => (groups[key]?.length ?? 0) > 0);
const leaders = groups. ?? [];
if (members.length === 0) {
return (
<div className="team-overlay">
<div className="team-panel-header">
<div className="team-panel-title">
<UsersIcon size={20} />
<span> </span>
<span className="team-total-badge">0</span>
</div>
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
<CloseIcon />
</button>
</div>
<div className="team-tree-scroll team-empty-state">
<p> .</p>
<p className="team-empty-hint">
<Link to="/admin" className="admin-team-manage-link"> </Link>
.
</p>
</div>
</div>
);
}
const renderMemberCard = (member: TeamMember, isLeader: boolean) => {
const highlighted = highlightIds.includes(member.id);
const cardClass = isLeader ? 'tree-leader-card' : 'tree-member-card';
return (
<div
key={member.id}
className={`${cardClass} ${highlighted ? 'highlighted-member' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<TeamMemberAvatar member={member} size={isLeader ? 'leader' : 'member'} />
<div className={isLeader ? 'leader-info' : 'member-info-wrap'}>
<MemberInfo member={member} showAllTasks={showAllTasks} isLeader={isLeader} />
</div>
<MemberTaskTooltip
memberId={member.id}
tasks={tasks}
isStatic={showAllTasks}
activeProjectId={activeProjectId}
onProjectClick={onProjectClick}
/>
</div>
);
};
return (
<div className="team-overlay">
<div className="team-panel-header">
<div className="team-panel-title">
<UsersIcon size={20} />
<span> </span>
<span className="team-total-badge">{members.length}</span>
</div>
<div className="team-panel-actions">
<button
type="button"
className={`team-view-toggle ${showAllTasks ? 'active' : ''}`}
onClick={onToggleShowAll}
>
<LayersIcon size={14} />
<span> </span>
</button>
<button type="button" className="team-close-btn" onClick={onClose} aria-label="닫기">
<CloseIcon />
</button>
</div>
</div>
<div
className={`team-tree-scroll ${showAllTasks ? 'show-all-tooltips' : ''}`}
onClick={() => onProjectClick(null)}
>
{leaders.length > 0 && (
<>
<div className="tree-leaders-row">
{leaders.map((m) => renderMemberCard(m, true))}
</div>
{visibleCells.length > 0 && <div className="tree-root-vline" />}
</>
)}
{visibleCells.length > 0 && (
<div className="tree-cells-row">
{visibleCells.map((cellKey, index, arr) => {
const cellMembers = groups[cellKey] ?? [];
return (
<div
key={cellKey}
className={`tree-cell-col ${index === arr.length - 1 ? 'last' : ''}`}
>
<div className="tree-cell-hline-wrap" aria-hidden />
<div className="tree-cell-card static">
<span className="tree-cell-name">{getCellLabel(cellKey)}</span>
<span className="tree-badge">{cellMembers.length}</span>
</div>
<div className="tree-members-list">
{cellMembers.map((m) => renderMemberCard(m, false))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import { renderAsync } from 'docx-preview';
import { useFileArrayBuffer } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface DocxPreviewProps {
fileId: string;
fileName: string;
}
export function DocxPreview({ fileId, fileName }: DocxPreviewProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const styleRef = useRef<HTMLDivElement>(null);
const { buffer, loading, error } = useFileArrayBuffer(fileId);
useEffect(() => {
const body = bodyRef.current;
const style = styleRef.current;
if (!buffer || !body || !style) return;
body.innerHTML = '';
style.innerHTML = '';
let cancelled = false;
renderAsync(buffer, body, style, {
className: 'docx-preview',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
}).catch(() => {
if (!cancelled) {
body.innerHTML = '';
}
});
return () => {
cancelled = true;
};
}, [buffer, fileId]);
if (error || (!loading && !buffer)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="h-full w-full min-h-0 overflow-auto bg-white">
<div ref={styleRef} className="docx-preview-styles" />
<div
ref={bodyRef}
className="docx-preview-body p-4 [&_.docx-wrapper]:mx-auto [&_.docx-wrapper]:bg-white [&_.docx-wrapper]:shadow-sm"
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { fileDownloadUrl } from '../../lib/apiClient';
interface FilePreviewFallbackProps {
fileId: string;
fileName: string;
loading?: boolean;
error?: string | null;
message?: string;
}
export function FilePreviewFallback({
fileId,
fileName,
loading,
error,
message,
}: FilePreviewFallbackProps) {
if (loading) {
return <p className="text-lg text-white/50"> </p>;
}
return (
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
<p className="text-xl font-bold text-white/70">{fileName}</p>
<p className="text-base leading-relaxed text-white/45">
{error ?? message ?? '미리보기를 표시할 수 없습니다.'}
</p>
{error && (
<p className="text-sm text-white/35">
. .
</p>
)}
<a
href={fileDownloadUrl(fileId)}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-bold text-white/80 hover:bg-white/20"
>
</a>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { fileHwpPreviewUrl } from '../../lib/apiClient';
import { FilePreviewFallback } from './FilePreviewFallback';
interface HwpPreviewProps {
fileId: string;
fileName: string;
}
export function HwpPreview({ fileId, fileName }: HwpPreviewProps) {
const [html, setHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setHtml(null);
fetch(fileHwpPreviewUrl(fileId))
.then(async (res) => {
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { message?: string } | null;
throw new Error(body?.message ?? '한글 미리보기를 불러올 수 없습니다.');
}
return res.json() as Promise<{ html: string }>;
})
.then((data) => {
if (!cancelled) {
setHtml(data.html);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '한글 미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [fileId]);
if (error) {
return (
<FilePreviewFallback
fileId={fileId}
fileName={fileName}
error={error}
message="한글 미리보기 변환에 실패했습니다. 다운로드 후 한/글에서 확인해 주세요."
/>
);
}
if (loading || !html) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="h-full w-full min-h-0 overflow-auto bg-white">
<div
className="hwp-preview mx-auto max-w-4xl p-6 text-[15px] leading-relaxed text-slate-800 [&_img]:max-w-full [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import {
fmtPeriodPickerLabel,
parseMilestonePeriods,
parsePeriodNoteLines,
pickLatestPeriodId,
sortPeriodsByRecent,
} from '../../lib/milestonePeriods';
import type { Milestone } from '../../types';
interface MilestoneContentListProps {
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined;
emptyMessage: string;
onContextMenu?: (event: MouseEvent) => void;
}
function PeriodPicker({
periods,
selectedId,
onSelect,
}: {
periods: ReturnType<typeof parseMilestonePeriods>;
selectedId: string;
onSelect: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = periods.find((p) => p.id === selectedId) ?? periods[0];
const selectedIndex = periods.findIndex((p) => p.id === selected?.id);
const label = selected ? fmtPeriodPickerLabel(selected, selectedIndex >= 0 ? selectedIndex : 0) : '';
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
if (!selected || !label) return null;
const canPick = periods.length > 1;
return (
<div className="milestone-content-period" ref={rootRef}>
<button
type="button"
className={`milestone-content-period__btn${open ? ' is-open' : ''}`}
aria-haspopup="listbox"
aria-expanded={open}
disabled={!canPick}
onClick={() => canPick && setOpen((v) => !v)}
title={canPick ? '다른 기간 업무내용 보기' : undefined}
>
<span className="milestone-content-period__label">{label}</span>
{canPick && (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden className="milestone-content-period__chev">
<path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
)}
</button>
{open && canPick && (
<ul className="milestone-content-period__menu" role="listbox">
{periods.map((period, index) => {
const isActive = period.id === selectedId;
return (
<li key={period.id} role="option" aria-selected={isActive}>
<button
type="button"
className={`milestone-content-period__option${isActive ? ' is-active' : ''}`}
onClick={() => {
onSelect(period.id);
setOpen(false);
}}
>
{fmtPeriodPickerLabel(period, index)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
export function MilestoneContentList({
milestone,
emptyMessage,
onContextMenu,
}: MilestoneContentListProps) {
const periods = useMemo(
() => sortPeriodsByRecent(parseMilestonePeriods(milestone)),
[milestone],
);
const latestId = useMemo(() => pickLatestPeriodId(periods), [periods]);
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(null);
useEffect(() => {
setSelectedPeriodId(latestId);
}, [milestone?.id, latestId]);
const activePeriod =
periods.find((p) => p.id === selectedPeriodId) ?? periods[0] ?? null;
const lines = parsePeriodNoteLines(activePeriod?.note);
return (
<>
<div className="detail-section-head">
<h3 className="detail-section-label"></h3>
{milestone && periods.length > 0 && activePeriod && (
<PeriodPicker
periods={periods}
selectedId={activePeriod.id}
onSelect={setSelectedPeriodId}
/>
)}
</div>
<ul
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
onContextMenu={onContextMenu}
>
{!milestone ? (
<li className="detail-body-muted">{emptyMessage}</li>
) : periods.length === 0 ? (
<li className="detail-body-muted">{emptyMessage}</li>
) : lines.length === 0 ? (
<li className="detail-body-muted"> .</li>
) : (
lines.map((line, index) => (
<li key={`${activePeriod!.id}-${index}`} className="flex gap-2" onContextMenu={onContextMenu}>
<span className="detail-body-text shrink-0 text-[#4a90d9]"></span>
<p className="detail-body-content min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</p>
</li>
))
)}
</ul>
</>
);
}

View File

@@ -0,0 +1,237 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildMilestoneTimeline,
type TimelineMilestoneInput,
type TimelineRangeFallback,
} from '../../lib/milestoneTimeline';
interface MilestoneTimelineProps {
milestones: TimelineMilestoneInput[];
fallback?: TimelineRangeFallback;
selectedId?: string | null;
onSelect?: (id: string) => void;
title?: string;
subtitle?: string;
emptyMessage?: string;
className?: string;
preserveRowOrder?: boolean;
/** 상시업무 — 시작·종료일 없는 단계는 게이지 미표시 */
hideUndatedBars?: boolean;
}
export function MilestoneTimeline({
milestones,
fallback = {},
selectedId,
onSelect,
title = '업무별 타임라인',
subtitle,
emptyMessage = '기간을 설정한 단계만 표시됩니다.',
preserveRowOrder = false,
hideUndatedBars = true,
className = '',
}: MilestoneTimelineProps) {
const model = useMemo(
() => buildMilestoneTimeline(milestones, fallback, { preserveOrder: preserveRowOrder, hideUndatedBars }),
[milestones, fallback, preserveRowOrder, hideUndatedBars],
);
const chartRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const measureRef = useRef<HTMLSpanElement>(null);
const [expandedBar, setExpandedBar] = useState<{
id: string;
widthPx: number;
leftPx: number;
} | null>(null);
const measureTitleWidth = (title: string) => {
const el = measureRef.current;
if (!el) return 0;
el.textContent = title;
return el.offsetWidth + 16;
};
const handleBarEnter = (
row: { id: string; title: string; leftPct: number; widthPct: number },
event: React.MouseEvent<HTMLButtonElement>,
) => {
const chart = chartRef.current;
if (!chart) return;
const btn = event.currentTarget;
const chartWidth = chart.clientWidth;
const leftPx = (row.leftPct / 100) * chartWidth;
const origWidthPx = btn.offsetWidth;
const neededWidthPx = measureTitleWidth(row.title);
if (neededWidthPx <= origWidthPx + 1) return;
const widthPx = Math.min(chartWidth, neededWidthPx);
const extra = widthPx - origWidthPx;
let expandedLeftPx = leftPx - extra / 2;
if (expandedLeftPx < 0) expandedLeftPx = 0;
if (expandedLeftPx + widthPx > chartWidth) expandedLeftPx = chartWidth - widthPx;
setExpandedBar({
id: row.id,
widthPx,
leftPx: expandedLeftPx,
});
};
useEffect(() => {
if (!selectedId) return;
const row = rowRefs.current.get(selectedId);
const chart = chartRef.current;
if (!row || !chart) return;
const rowRect = row.getBoundingClientRect();
const chartRect = chart.getBoundingClientRect();
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
const delta =
rowRect.top < chartRect.top
? rowRect.top - chartRect.top
: rowRect.bottom - chartRect.bottom;
chart.scrollBy({ top: delta, behavior: 'smooth' });
}, [selectedId, model?.rows.length]);
const rangeSubtitle =
subtitle ?? (model ? `${model.rangeStartLabel} ~ ${model.rangeEndLabel}` : undefined);
const rowGroups = useMemo(() => {
if (!model) return [];
const groups: Array<{
milestoneId: string;
title: string;
progress: number;
segments: (typeof model.rows)[number][];
}> = [];
const indexByMilestone = new Map<string, number>();
for (const row of model.rows) {
const idx = indexByMilestone.get(row.milestoneId);
if (idx === undefined) {
indexByMilestone.set(row.milestoneId, groups.length);
groups.push({
milestoneId: row.milestoneId,
title: row.title,
progress: row.progress,
segments: [row],
});
} else {
groups[idx].segments.push(row);
}
}
return groups;
}, [model]);
return (
<footer className={`milestone-timeline ${className}`.trim()}>
<div className="milestone-timeline__head">
<span className="milestone-timeline__title">{title}</span>
{rangeSubtitle && (
<span className="milestone-timeline__subtitle truncate">{rangeSubtitle}</span>
)}
</div>
{!model ? (
<p className="milestone-timeline__empty">{emptyMessage}</p>
) : (
<div className="milestone-timeline__body">
<div className="milestone-timeline__ticks" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`${tick.label}-${tick.leftPct}`}
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
style={{ left: `${tick.leftPct}%` }}
title={tick.isToday ? `오늘 (${tick.label})` : undefined}
>
<span className="milestone-timeline__tick-label">{tick.label}</span>
</span>
))}
</div>
<div className="milestone-timeline__chart" ref={chartRef}>
<span ref={measureRef} className="milestone-timeline__measure" aria-hidden="true" />
<div className="milestone-timeline__grid" aria-hidden="true">
{model.ticks.map((tick) => (
<span
key={`grid-${tick.label}-${tick.leftPct}`}
className="milestone-timeline__grid-line"
style={{ left: `${tick.leftPct}%` }}
/>
))}
</div>
<div className="milestone-timeline__rows">
{rowGroups.map((group) => {
const isSelected = group.milestoneId === selectedId;
return (
<div
key={group.milestoneId}
className="milestone-timeline__row"
ref={(el) => {
if (el) rowRefs.current.set(group.milestoneId, el);
else rowRefs.current.delete(group.milestoneId);
}}
>
{group.segments.map((row) => {
const isExpanded = expandedBar?.id === row.id;
return (
<button
key={row.id}
type="button"
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${isExpanded ? 'is-expanded' : ''}`}
style={
isExpanded
? {
left: `${expandedBar.leftPx}px`,
width: `${expandedBar.widthPx}px`,
}
: {
left: `${row.leftPct}%`,
width: `${row.widthPct}%`,
}
}
aria-label={group.title}
title={group.title}
onMouseEnter={(e) => handleBarEnter({ ...row, title: group.title }, e)}
onMouseLeave={() => setExpandedBar(null)}
onClick={() => onSelect?.(group.milestoneId)}
>
<span className="milestone-timeline__bar-track" />
<span
className="milestone-timeline__bar-fill"
style={{ width: `${row.progress}%` }}
/>
<span className="milestone-timeline__bar-label-wrap" aria-hidden="true">
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--fill"
style={{ clipPath: `inset(0 ${100 - row.progress}% 0 0)` }}
>
{group.title}
</span>
<span
className="milestone-timeline__bar-label milestone-timeline__bar-label--track"
style={{ clipPath: `inset(0 0 0 ${row.progress}%)` }}
>
{group.title}
</span>
</span>
</button>
);
})}
</div>
);
})}
</div>
</div>
</div>
)}
</footer>
);
}

View File

@@ -0,0 +1,27 @@
import { useFileBlobUrl } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface PdfPreviewProps {
fileId: string;
fileName: string;
}
export function PdfPreview({ fileId, fileName }: PdfPreviewProps) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, 'application/pdf');
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<iframe
src={blobUrl}
title={fileName}
className="h-full w-full border-0 bg-white"
/>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react';
import { renderPptx } from 'pptx-react-renderer';
import { useFileArrayBuffer } from '../../hooks/useFileBuffer';
import { FilePreviewFallback } from './FilePreviewFallback';
interface PptxPreviewProps {
fileId: string;
fileName: string;
}
export function PptxPreview({ fileId, fileName }: PptxPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { buffer, loading, error } = useFileArrayBuffer(fileId);
useEffect(() => {
const container = containerRef.current;
if (!buffer || !container) return;
container.innerHTML = '';
let cancelled = false;
renderPptx(buffer, {
container,
scale: 0.55,
showSlideNumbers: true,
theme: 'light',
}).catch(() => {
if (!cancelled) container.innerHTML = '';
});
return () => {
cancelled = true;
};
}, [buffer, fileId]);
if (error || (!loading && !buffer)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div
ref={containerRef}
className="h-full w-full min-h-0 overflow-auto bg-slate-100 p-4 [&_.pptx-slide]:mx-auto [&_.pptx-slide]:mb-6"
/>
);
}

View File

@@ -1,9 +1,18 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { ExcelPreview } from './ExcelPreview';
import { fileDownloadUrl, fileViewUrl } from '../../lib/apiClient';
import { DocxPreview } from './DocxPreview';
import { PptxPreview } from './PptxPreview';
import { PdfPreview } from './PdfPreview';
import { HwpPreview } from './HwpPreview';
import { FilePreviewFallback } from './FilePreviewFallback';
import { openLinkOnRightMonitor } from '../../lib/dualMonitor';
import { fileDisplayName, isExcelFile, isVideoFile } from '../../lib/fileDisplay';
import {
fileDisplayName,
getFilePreviewKind,
resolvePreviewMime,
} from '../../lib/fileDisplay';
import { useFileBlobUrl } from '../../hooks/useFileBuffer';
import type { FileRecord, MilestoneLink } from '../../types';
interface ResultPreviewProps {
@@ -12,32 +21,67 @@ interface ResultPreviewProps {
hasSelectedStage: boolean;
}
function FileMissingNotice({ label, fileId }: { label: string; fileId: string }) {
function ImagePreview({
fileId,
fileName,
mime,
zoom,
}: {
fileId: string;
fileName: string;
mime: string;
zoom: number;
}) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, mime);
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return (
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
<p className="text-xl font-bold text-white/70">{label}</p>
<p className="text-base leading-relaxed text-white/45">
.
<br />
· .
<br />
.
</p>
<a
href={fileDownloadUrl(fileId)}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-bold text-white/80 hover:bg-white/20"
>
</a>
</div>
<img
src={blobUrl}
alt={fileName}
className="max-h-full max-w-full object-contain transition-transform duration-150"
style={{ transform: `scale(${zoom})` }}
draggable={false}
/>
);
}
function VideoPreview({ fileId, fileName, mime }: { fileId: string; fileName: string; mime: string }) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, mime);
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return <video src={blobUrl} controls className="max-h-full max-w-full" title={fileName} />;
}
function TextPreview({ fileId, fileName }: { fileId: string; fileName: string }) {
const { blobUrl, loading, error } = useFileBlobUrl(fileId, 'text/plain;charset=utf-8');
if (error || (!loading && !blobUrl)) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} error={error} />;
}
if (loading || !blobUrl) {
return <FilePreviewFallback fileId={fileId} fileName={fileName} loading />;
}
return <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />;
}
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
const [fileId, setFileId] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [fullscreen, setFullscreen] = useState(false);
const [fileMissing, setFileMissing] = useState(false);
useEffect(() => {
if (files.length > 0) {
@@ -49,29 +93,11 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
}, [files]);
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
const previewKind = activeFile ? getFilePreviewKind(activeFile) : null;
const previewMime = activeFile ? resolvePreviewMime(activeFile) : '';
useEffect(() => {
if (!activeFile || isExcelFile(activeFile)) {
setFileMissing(false);
return;
}
let cancelled = false;
setFileMissing(false);
fetch(fileViewUrl(activeFile.id), { method: 'HEAD' })
.then((res) => {
if (!cancelled) setFileMissing(!res.ok);
})
.catch(() => {
if (!cancelled) setFileMissing(true);
});
return () => {
cancelled = true;
};
}, [activeFile?.id, activeFile?.mimetype]);
const fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
const isImage = activeFile?.mimetype.includes('image') ?? false;
const isVideo = activeFile ? isVideoFile(activeFile) : false;
const isExcel = activeFile ? isExcelFile(activeFile) : false;
const isImage = previewKind === 'image';
const goFile = useCallback(
(delta: number) => {
@@ -89,43 +115,42 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
const headerTitle = activeFile ? fileDisplayName(activeFile) : '결과물 프리뷰';
const renderContent = () => {
if (activeFile) {
const label = fileDisplayName(activeFile);
if (isExcel) {
const previewContent = useMemo(() => {
if (!activeFile || !previewKind) return null;
const label = fileDisplayName(activeFile);
switch (previewKind) {
case 'excel':
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
}
if (fileMissing) {
return <FileMissingNotice label={label} fileId={activeFile.id} />;
}
const src = fileViewUrl(activeFile.id);
if (isImage) {
case 'image':
return (
<img
src={src}
alt={label}
className="max-h-full max-w-full object-contain transition-transform duration-150"
style={{ transform: `scale(${zoom})` }}
draggable={false}
onError={() => setFileMissing(true)}
<ImagePreview fileId={activeFile.id} fileName={label} mime={previewMime} zoom={zoom} />
);
case 'video':
return <VideoPreview fileId={activeFile.id} fileName={label} mime={previewMime} />;
case 'pdf':
return <PdfPreview fileId={activeFile.id} fileName={label} />;
case 'docx':
return <DocxPreview fileId={activeFile.id} fileName={label} />;
case 'pptx':
return <PptxPreview fileId={activeFile.id} fileName={label} />;
case 'text':
return <TextPreview fileId={activeFile.id} fileName={label} />;
case 'hwp':
return <HwpPreview fileId={activeFile.id} fileName={label} />;
default:
return (
<FilePreviewFallback
fileId={activeFile.id}
fileName={label}
message="이 형식은 미리보기를 지원하지 않습니다. 다운로드 후 확인해 주세요."
/>
);
}
if (isVideo) {
return (
<video
src={src}
controls
className="max-h-full max-w-full"
title={label}
onError={() => setFileMissing(true)}
/>
);
}
return (
<iframe src={src} title={label} className="h-full w-full border-0 bg-white" />
);
}
}, [activeFile, previewKind, previewMime, zoom]);
const renderContent = () => {
if (activeFile && previewContent) return previewContent;
if (links.length > 0) {
return (
<p className="px-6 text-center text-xl text-white/40">

View File

@@ -0,0 +1,740 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
import { ContextMenu } from '../common/ContextMenu';
import {
StageModal,
parseMilestoneLinks,
type StageFileSavePayload,
type StageFormData,
} from './StageModal';
import {
ROUTINE_CATEGORIES,
getRoutineCategory,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../../lib/routineCategories';
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
import { isRoutineTask } from '../../lib/taskType';
import { getMilestoneSubtitle } from '../../lib/milestoneSubtitle';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import { useTeamMembers } from '../../hooks/useTeamMembers';
import { ResultPreview } from './ResultPreview';
import { MilestoneTimeline } from './MilestoneTimeline';
import { MilestoneContentList } from './MilestoneContentList';
import { taskTimelineFallback } from '../../lib/milestoneTimeline';
import { serializePeriodEntries } from '../../lib/milestonePeriods';
import type { Task, Milestone, FileRecord } from '../../types';
type TaskWithRelations = Task & {
files: FileRecord[];
milestones: Milestone[];
};
function LeftSection({ children }: { children: React.ReactNode }) {
return (
<section className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0">
{children}
</section>
);
}
export function RoutineDetailView({
task: initialTask,
initialStageId,
}: {
task: TaskWithRelations;
initialStageId?: string | null;
}) {
const qc = useQueryClient();
const { data: teamMembers = [] } = useTeamMembers();
const [activeTaskId, setActiveTaskId] = useState(initialTask.id);
const [selectedStageId, setSelectedStageId] = useState<string | null>(initialStageId ?? null);
const [stageModal, setStageModal] = useState<{ mode: 'add' | 'edit'; milestone?: Milestone } | null>(null);
const [stageSaving, setStageSaving] = useState(false);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
const [tabSwitching, setTabSwitching] = useState(false);
useEffect(() => {
setActiveTaskId(initialTask.id);
}, [initialTask.id]);
const { data: activeTask = initialTask } = useQuery({
queryKey: ['task', activeTaskId],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${activeTaskId}`);
return data;
},
initialData: activeTaskId === initialTask.id ? initialTask : undefined,
staleTime: 10_000,
retry: 2,
});
const { data: quarterTasks = [] } = useQuery({
queryKey: ['tasks', { quarter: activeTask.quarter }],
queryFn: async () => {
const { data } = await apiClient.get<Task[]>('/tasks', {
params: { quarter: activeTask.quarter },
});
return data;
},
staleTime: 30_000,
retry: 2,
});
const tabTasks = useMemo(() => {
const routines = quarterTasks.filter((t) => isRoutineTask(t.taskType));
return ROUTINE_CATEGORIES.map((label) => ({
label,
task: pickRoutineCategoryTask(routines, label),
}));
}, [quarterTasks]);
const activeCategory = getRoutineCategory(activeTask) ?? '채용 운영';
const handleTabClick = async (label: RoutineCategory, tabTask: Task | null) => {
if (tabSwitching) return;
if (tabTask) {
setActiveTaskId(tabTask.id);
return;
}
setTabSwitching(true);
try {
const { data: created } = await apiClient.post<Task>(
'/tasks',
routineCategoryShellPayload(label, activeTask.quarter),
);
await qc.invalidateQueries({ queryKey: ['tasks'] });
setActiveTaskId(created.id);
} catch (err: unknown) {
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
} finally {
setTabSwitching(false);
}
};
const milestones = useMemo(
() => [...(activeTask.milestones ?? [])].sort((a, b) => a.order - b.order),
[activeTask.milestones],
);
useEffect(() => {
if (initialStageId && milestones.some((m) => m.id === initialStageId)) {
setSelectedStageId(initialStageId);
}
}, [initialTask.id, initialStageId, milestones]);
useEffect(() => {
if (!selectedStageId || !milestones.some((m) => m.id === selectedStageId)) {
setSelectedStageId(milestones[0]?.id ?? null);
}
}, [activeTaskId, milestones, selectedStageId]);
const selectedStage = milestones.find((m) => m.id === selectedStageId) ?? null;
const stageFiles = useMemo(
() =>
sortFilesByOrder(
selectedStageId ? (activeTask.files ?? []).filter((f) => f.milestoneId === selectedStageId) : [],
),
[activeTask.files, selectedStageId],
);
const stageLinks = useMemo(
() => (selectedStage ? parseMilestoneLinks(selectedStage.links) : []),
[selectedStage],
);
const deleteStage = useMutation({
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', activeTaskId] }),
});
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
for (const item of filePayload) {
const form = new FormData();
form.append('file', item.file);
form.append('milestoneId', milestoneId);
form.append('sortOrder', String(item.sortOrder));
if (item.displayName.trim()) {
form.append('displayName', item.displayName.trim());
}
await apiClient.post(`/files/upload/${activeTaskId}`, form);
}
};
const handleStageSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
setStageSaving(true);
try {
const payload = {
title: data.title.trim(),
subtitle: data.subtitle.trim() || null,
periodEntries: serializePeriodEntries(data.periodEntries),
progress: data.progress,
links: data.links,
pmMemberId: data.pmMemberId || null,
assigneeMemberIds: data.assigneeMemberIds,
};
let milestoneId: string;
if (stageModal?.mode === 'add') {
const { data: created } = await apiClient.post<Milestone>(
`/milestones/${activeTaskId}`,
payload,
);
milestoneId = created.id;
setSelectedStageId(created.id);
} else if (stageModal?.milestone) {
const { data: updated } = await apiClient.patch<Milestone>(
`/milestones/item/${stageModal.milestone.id}`,
payload,
);
milestoneId = updated.id;
setSelectedStageId(updated.id);
} else {
return;
}
try {
for (const id of filePayload.deletedFileIds) {
await apiClient.delete(`/files/${id}`);
}
for (const rep of filePayload.replacements) {
const form = new FormData();
form.append('file', rep.file);
await apiClient.post(`/files/${rep.id}/replace`, form);
}
for (const edit of filePayload.existingEdits) {
const original = activeTask.files?.find((f) => f.id === edit.id);
if (!original) continue;
const prevName = (original.displayName ?? '').trim();
const nextName = edit.displayName.trim();
const prevOrder = original.sortOrder ?? 0;
if (nextName !== prevName || edit.sortOrder !== prevOrder) {
await apiClient.patch(`/files/${edit.id}`, {
displayName: nextName || null,
sortOrder: edit.sortOrder,
});
}
}
if (filePayload.uploads.length > 0) {
await uploadFiles(milestoneId, filePayload.uploads);
}
} catch (err: unknown) {
alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
}
await qc.invalidateQueries({ queryKey: ['task', activeTaskId] });
setStageModal(null);
} catch (err: unknown) {
alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
} finally {
setStageSaving(false);
}
};
return (
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
<header className="detail-page-header detail-page-header--routine shrink-0">
<div className="detail-page-header__tabs">
{tabTasks.map(({ label, task: tabTask }) => {
const isActive = activeCategory === label;
return (
<button
key={label}
type="button"
disabled={tabSwitching}
className={`detail-page-header__tab ${isActive ? 'is-active' : ''}`}
onClick={() => handleTabClick(label, tabTask)}
>
{label}
</button>
);
})}
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[1fr_3fr] grid-rows-1">
<aside className="detail-aside grid h-full min-h-0 grid-rows-[2fr_1fr] overflow-hidden">
<LeftSection>
<div className="detail-section-head">
<h3 className="detail-section-label"></h3>
<button
type="button"
title="단계 추가"
onClick={() => setStageModal({ mode: 'add' })}
className="detail-add-btn"
>
+
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
{milestones.length === 0 ? (
<p className="detail-body-muted">+ .</p>
) : (
milestones.map((stage) => {
const isSelected = stage.id === selectedStageId;
const subtitle = getMilestoneSubtitle(stage);
return (
<button
key={stage.id}
type="button"
className={`detail-stage-card shrink-0 rounded-lg border px-3 py-2 text-left transition-colors ${
isSelected
? 'is-selected ring-1 ring-[#7eb3e8]'
: 'hover:border-slate-300 hover:bg-white/80'
}`}
onClick={() => setSelectedStageId(stage.id)}
onContextMenu={(e) => {
e.preventDefault();
setSelectedStageId(stage.id);
setCtxMenu({ x: e.clientX, y: e.clientY, stageId: stage.id });
}}
>
<p className="detail-body-title truncate">{stage.title}</p>
{subtitle ? (
<p className="detail-body-caption mt-0.5 truncate">{subtitle}</p>
) : null}
</button>
);
})
)}
</div>
</LeftSection>
<LeftSection>
<MilestoneContentList
milestone={selectedStage}
emptyMessage={
selectedStage
? '수행 기간에 업무내용을 입력하세요.'
: '단계를 선택하세요.'
}
/>
</LeftSection>
</aside>
<div className="flex h-full min-h-0 min-w-0 flex-col">
<ResultPreview
files={stageFiles}
links={stageLinks}
hasSelectedStage={!!selectedStage}
/>
<MilestoneTimeline
milestones={milestones}
fallback={taskTimelineFallback(activeTask)}
selectedId={selectedStageId}
onSelect={setSelectedStageId}
preserveRowOrder
emptyMessage="기간을 설정한 업무명만 타임라인에 표시됩니다."
/>
</div>
</div>
{stageModal && (
<StageModal
variant="routine"
mode={stageModal.mode}
milestone={stageModal.milestone}
existingFiles={
stageModal.milestone
? sortFilesByOrder(
(activeTask.files ?? []).filter((f) => f.milestoneId === stageModal.milestone!.id),
)
: []
}
teamMembers={teamMembers}
saving={stageSaving}
onClose={() => setStageModal(null)}
onSave={handleStageSave}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={[
{
icon: '✏',
label: '수정',
onClick: () => {
const m = milestones.find((s) => s.id === ctxMenu.stageId);
if (m) setStageModal({ mode: 'edit', milestone: m });
},
},
{
icon: '🗑',
label: '삭제',
danger: true,
onClick: () => {
if (window.confirm('이 단계를 정말 삭제하시겠습니까?')) {
deleteStage.mutate(ctxMenu.stageId);
}
},
},
]}
/>
)}
</div>
);
}
export function RoutineDetailShell({
task,
initialStageId,
}: {
task: TaskWithRelations;
initialStageId?: string | null;
}) {
return <RoutineDetailView task={task} initialStageId={initialStageId} />;
}

View File

@@ -1,15 +1,22 @@
import { useState, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { sortFilesByOrder } from '../../lib/fileDisplay';
import type { FileRecord, Milestone, MilestoneLink } from '../../types';
import { decodeRoutineStageDescription } from '../../lib/routineMilestone';
import {
newPeriodEntry,
parseMilestonePeriods,
type MilestonePeriodEntry,
} from '../../lib/milestonePeriods';
import type { FileRecord, Milestone, MilestoneLink, TeamMember } from '../../types';
export interface StageFormData {
title: string;
startDate: string;
dueDate: string;
subtitle: string;
periodEntries: MilestonePeriodEntry[];
progress: number;
description: string;
links: MilestoneLink[];
pmMemberId: string;
assigneeMemberIds: string[];
}
export interface PendingFileUpload {
@@ -39,8 +46,10 @@ export interface StageFileSavePayload {
interface StageModalProps {
mode: 'add' | 'edit';
variant?: 'project' | 'routine';
milestone?: Milestone;
existingFiles?: FileRecord[];
teamMembers?: TeamMember[];
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
onClose: () => void;
saving?: boolean;
@@ -55,11 +64,6 @@ type EditTarget =
| { type: 'link'; index: number }
| null;
function toDateInput(iso: string | null | undefined) {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
function parseLinks(raw: string | null | undefined): MilestoneLink[] {
if (!raw) return [];
try {
@@ -107,21 +111,30 @@ let pendingKeySeq = 0;
export function StageModal({
mode,
variant = 'project',
milestone,
existingFiles = [],
teamMembers = [],
onSave,
onClose,
saving,
}: StageModalProps) {
const isRoutine = variant === 'routine';
const sortedExisting = useMemo(() => sortFilesByOrder(existingFiles), [existingFiles]);
const [form, setForm] = useState<StageFormData>({
title: milestone?.title ?? '',
startDate: toDateInput(milestone?.startDate),
dueDate: toDateInput(milestone?.dueDate),
progress: milestone?.progress ?? 0,
description: milestone?.description ?? '',
links: parseLinks(milestone?.links),
const [form, setForm] = useState<StageFormData>(() => {
const legacyOverview = isRoutine
? decodeRoutineStageDescription(milestone?.description).overview
: '';
return {
title: milestone?.title ?? '',
subtitle: milestone?.subtitle?.trim() ?? legacyOverview,
periodEntries: parseMilestonePeriods(milestone),
progress: milestone?.progress ?? 0,
links: parseLinks(milestone?.links),
pmMemberId: milestone?.pmMemberId ?? milestone?.pmMember?.id ?? '',
assigneeMemberIds: milestone?.assigneeMembers?.map((m) => m.id) ?? [],
};
});
const [fileRows, setFileRows] = useState<FileRow[]>([]);
@@ -163,6 +176,49 @@ export function StageModal({
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const toggleAssignee = (memberId: string) => {
setForm((prev) => {
const has = prev.assigneeMemberIds.includes(memberId);
return {
...prev,
assigneeMemberIds: has
? prev.assigneeMemberIds.filter((id) => id !== memberId)
: [...prev.assigneeMemberIds, memberId],
};
});
};
const addPeriodEntry = () => {
setForm((prev) => ({
...prev,
periodEntries: [...prev.periodEntries, newPeriodEntry()],
}));
};
const updatePeriodEntry = (id: string, patch: Partial<MilestonePeriodEntry>) => {
setForm((prev) => ({
...prev,
periodEntries: prev.periodEntries.map((entry) =>
entry.id === id ? { ...entry, ...patch } : entry,
),
}));
};
const removePeriodEntry = (id: string) => {
setForm((prev) => ({
...prev,
periodEntries: prev.periodEntries.filter((entry) => entry.id !== id),
}));
};
const modalTitle = isRoutine
? mode === 'add'
? '업무 단계 추가'
: '업무 단계 수정'
: mode === 'add'
? '업무 일정 추가'
: '업무 일정 수정';
const clearEdit = () => {
setEditTarget(null);
setEditDisplayName('');
@@ -345,9 +401,7 @@ export function StageModal({
onSubmit={handleSubmit}
>
<div className="shrink-0 border-b border-slate-100 px-6 py-4">
<h2 className="text-xl font-black text-slate-800">
{mode === 'add' ? '업무 단계 추가' : '업무 단계 수정'}
</h2>
<h2 className="text-xl font-black text-slate-800">{modalTitle}</h2>
</div>
<div className="space-y-4 overflow-y-auto px-6 py-4">
@@ -362,6 +416,18 @@ export function StageModal({
/>
</label>
{isRoutine && (
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
value={form.subtitle}
onChange={(e) => set('subtitle', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="업무명 아래 표시 (선택)"
/>
</label>
)}
<label className="block">
<span className="mb-1 flex items-center justify-between text-sm font-bold text-slate-500">
<span></span>
@@ -378,37 +444,120 @@ export function StageModal({
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
type="date"
value={form.startDate}
onChange={(e) => set('startDate', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<input
type="date"
value={form.dueDate}
onChange={(e) => set('dueDate', e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-bold text-slate-500"> </span>
<button
type="button"
onClick={addPeriodEntry}
className="rounded-lg px-2 py-1 text-xs font-bold text-emerald-600 transition hover:bg-emerald-50 hover:text-emerald-700"
>
+
</button>
</div>
{form.periodEntries.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 px-4 py-3 text-center text-sm text-slate-400">
. · .
</p>
) : (
<div className="space-y-2">
{form.periodEntries.map((entry, index) => (
<div
key={entry.id}
className="space-y-2 rounded-xl border border-slate-200 bg-slate-50/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold text-slate-500"> {index + 1}</span>
<button
type="button"
onClick={() => removePeriodEntry(entry.id)}
className="rounded-lg px-2 py-1 text-xs font-bold text-red-500 transition hover:bg-red-50 hover:text-red-600"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<input
type="date"
value={entry.startDate}
onChange={(e) => updatePeriodEntry(entry.id, { startDate: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<input
type="date"
value={entry.dueDate}
onChange={(e) => updatePeriodEntry(entry.id, { dueDate: e.target.value })}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-slate-500"></span>
<textarea
value={entry.note}
onChange={(e) => updatePeriodEntry(entry.id, { note: e.target.value })}
rows={2}
className="w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-emerald-400 focus:outline-none"
placeholder="이 기간에 수행한 내용"
/>
</label>
</div>
))}
</div>
)}
</div>
<label className="block">
<span className="mb-1 block text-sm font-bold text-slate-500"></span>
<textarea
value={form.description}
onChange={(e) => set('description', e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
placeholder="단계별 업무 내용 (줄바꿈 가능)"
/>
</label>
{isRoutine && teamMembers.length > 0 && (
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<div>
<label className="mb-1 block text-sm font-bold text-slate-500">PM</label>
<select
value={form.pmMemberId}
onChange={(e) => set('pmMemberId', e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-emerald-400"
>
<option value=""> </option>
{teamMembers.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
{m.rank ? ` · ${m.rank}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-bold text-slate-500"> ( )</label>
<div className="flex flex-wrap gap-2">
{teamMembers.map((m) => {
const checked = form.assigneeMemberIds.includes(m.id);
return (
<label
key={m.id}
className={`inline-flex cursor-pointer select-none items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-semibold transition ${
checked
? 'border-emerald-600 bg-emerald-600 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-emerald-300'
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleAssignee(m.id)}
/>
{m.name}
</label>
);
})}
</div>
</div>
</div>
)}
{/* 첨부 자료 */}
<div>

View File

@@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../../lib/apiClient';
import {
columnDisplayTitle,
columnDisplayTitleEn,
type BoardSlotConfig,
} from '../../lib/boardLayout';
import { isProjectTask } from '../../lib/taskType';
import { DeptIcon } from '../dashboard/DeptIcon';
import { TaskCard } from '../dashboard/TaskCard';
import { DeptProjectList } from '../dashboard/DeptProjectList';
import type { Task } from '../../types';
const SLOT_COUNT = 3;
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
function loadDummyHeaders(): DummyHeaders {
try {
const raw = localStorage.getItem(DUMMY_HEADER_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
interface DummyDepartmentColumnProps {
slot: BoardSlotConfig;
tasks: Task[];
orderedIds: string[];
}
export function DummyDepartmentColumn({ slot, tasks, orderedIds }: DummyDepartmentColumnProps) {
const isDummySlot = !slot.sectionKey;
const { data: colConfig } = useQuery({
queryKey: ['columns', slot.sectionKey],
queryFn: () => apiClient.get(`/columns/${encodeURIComponent(slot.sectionKey!)}`).then((r) => r.data),
enabled: !!slot.sectionKey,
staleTime: 0,
});
const [dummyHeader, setDummyHeader] = useState(() => loadDummyHeaders()[slot.id]);
useEffect(() => {
setDummyHeader(loadDummyHeaders()[slot.id]);
}, [slot.id]);
const title = columnDisplayTitle(slot, colConfig, dummyHeader);
const titleEnState = columnDisplayTitleEn(slot, colConfig, dummyHeader);
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
const projectTasks = useMemo(() => {
const ordered = [...tasks].sort((a, b) => {
const ai = orderedIds.indexOf(a.id);
const bi = orderedIds.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return ordered.filter((t) => isProjectTask(t.taskType)).slice(0, SLOT_COUNT);
}, [tasks, orderedIds]);
return (
<section className={`dept-card ${slot.cssClass}`}>
<div className="dept-head">
<div className="dept-head-main">
<DeptIcon slotId={slot.id} />
<div className="board-dept-header-main">
<div className="board-dept-title-wrap">
<span className="board-dept-title" role="heading" aria-level={2}>
{title.replace(/\s*부문$/, '')}
</span>
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
<div className="dept-head-count" aria-label={`${projectTasks.length}`}>
<span className="poly-stat-val">{projectTasks.length}</span>
<span className="poly-stat-unit"></span>
</div>
</div>
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
</div>
</div>
</div>
<DeptProjectList
items={projectTasks}
getKey={(task) => task.id}
renderItem={(task) => <TaskCard key={task.id} task={task} variant="project" />}
/>
</section>
);
}

View File

@@ -1,24 +1,17 @@
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';
import { getSocketUrl } from '../lib/apiBase';
const SocketContext = createContext<Socket | null>(null);
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
const SOCKET_URL =
import.meta.env.VITE_SOCKET_URL ||
(import.meta.env.PROD
? RENDER_API
: `${window.location.protocol}//${window.location.hostname}:4000`);
export function SocketProvider({ children }: { children: ReactNode }) {
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io(SOCKET_URL, { transports: ['websocket'] });
const socket = io(getSocketUrl(), { transports: ['websocket'] });
socketRef.current = socket;
socket.on('connect', () => console.log('[Socket] Connected'));
socket.on('connect', () => console.log('[Socket] Connected', getSocketUrl()));
socket.on('disconnect', () => console.log('[Socket] Disconnected'));
return () => {

View File

@@ -0,0 +1,554 @@
import { useEffect, useRef } from 'react';
const SVG_NS = 'http://www.w3.org/2000/svg';
interface Point {
x: number;
y: number;
}
interface Box {
left: number;
top: number;
right: number;
bottom: number;
cx: number;
cy: number;
width: number;
height: number;
}
function relBox(el: Element, parent: Element): Box {
const r = el.getBoundingClientRect();
const p = parent.getBoundingClientRect();
return {
left: r.left - p.left,
top: r.top - p.top,
right: r.right - p.left,
bottom: r.bottom - p.top,
cx: r.left - p.left + r.width / 2,
cy: r.top - p.top + r.height / 2,
width: r.width,
height: r.height,
};
}
function snap(n: number) {
return Math.round(n * 100) / 100;
}
/** 참고 레이아웃: 카드 변 부메랑(둔각 다이아) 끝점 */
function appendBoomerangMarker(
group: SVGGElement,
at: Point,
side: 'left' | 'right',
fill: string,
) {
const dir = side === 'right' ? 1 : -1;
const tipLen = 7;
const wing = 4.5;
const pts = [
[at.x + dir * tipLen, at.y],
[at.x + dir * 1.5, at.y - wing],
[at.x - dir * 2.5, at.y],
[at.x + dir * 1.5, at.y + wing],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
/** 참고 레이아웃: 허브 박스 연결부 V형 부메랑 (박스 안쪽 방향) */
function appendHubBoomerangMarker(
group: SVGGElement,
at: Point,
dir: 'up' | 'down',
fill: string,
) {
const sign = dir === 'down' ? 1 : -1;
const tipLen = 5.5;
const wing = 5;
const base = 3;
const pts = [
[at.x, at.y + sign * tipLen],
[at.x - wing, at.y - sign * base],
[at.x + wing, at.y - sign * base],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
function pathLength(a: Point, b: Point) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/** snap·배율 100%에서도 0px 세그먼트가 되지 않도록 보정 */
function normalizePathPoints(points: Point[], minSeg = 1.25): Point[] {
if (points.length < 2) return points;
const out: Point[] = [{ ...points[0] }];
for (let i = 1; i < points.length; i++) {
const prev = out[out.length - 1];
const cur = { ...points[i] };
const len = pathLength(prev, cur);
if (len < minSeg) {
const ref =
i + 1 < points.length
? points[i + 1]
: i > 0
? points[i - 1]
: cur;
let dx = cur.x - prev.x;
let dy = cur.y - prev.y;
if (len < 0.01) {
dx = ref.x - prev.x;
dy = ref.y - prev.y;
}
const d = Math.hypot(dx, dy) || 1;
out.push({ x: prev.x + (dx / d) * minSeg, y: prev.y + (dy / d) * minSeg });
} else {
out.push(cur);
}
}
return out;
}
/** reference 상·하: 허브 테두리 ↔ 다이아 꼭짓점 (wrap clamp·stub 보정 없음) */
function referenceVerticalLine(cx: number, yHub: number, yDiamond: number): Point[] {
if (Math.abs(yDiamond - yHub) < 0.5) return [];
return [
{ x: cx, y: yHub },
{ x: cx, y: yDiamond },
];
}
function pointsToPath(points: Point[]) {
return points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${snap(p.x).toFixed(1)} ${snap(p.y).toFixed(1)}`)
.join(' ');
}
function diamondVertex(cx: number, cy: number, size: number, vertex: 'top' | 'right' | 'bottom' | 'left'): Point {
const r = size / Math.SQRT2;
if (vertex === 'top') return { x: cx, y: cy - r };
if (vertex === 'right') return { x: cx + r, y: cy };
if (vertex === 'bottom') return { x: cx, y: cy + r };
return { x: cx - r, y: cy };
}
function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string): Point {
const d = size / (2 * Math.SQRT2);
if (edge === 'top-left') return { x: cx - d, y: cy - d };
if (edge === 'top-right') return { x: cx + d, y: cy - d };
if (edge === 'bottom-left') return { x: cx - d, y: cy + d };
return { x: cx + d, y: cy + d };
}
/** 다이아몬드 변 중점에서 바깥(테두리)쪽으로 살짝 밀어 연결 */
function diamondBorderEdgeMidpoint(
cx: number,
cy: number,
size: number,
edge: string,
outward = 2,
): Point {
const mid = diamondEdgeMidpoint(cx, cy, size, edge);
const dx = mid.x - cx;
const dy = mid.y - cy;
const len = Math.hypot(dx, dy) || 1;
return { x: mid.x + (dx / len) * outward, y: mid.y + (dy / len) * outward };
}
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
const cardBox = relBox(cardEl, layout);
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
}
/** 참고 레이아웃: 카드 안쪽 변 — 위 행은 중앙보다 살짝 아래, 아래 행은 살짝 위 */
function cardReferenceInnerEdgeAnchor(
cardEl: Element,
layout: Element,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point {
const cardBox = relBox(cardEl, layout);
const offset = Math.min(36, Math.max(22, cardBox.height * 0.09));
const yShift = vert === 'top' ? offset : -offset;
const pad = 14;
const y = Math.max(cardBox.top + pad, Math.min(cardBox.bottom - pad, cardBox.cy + yShift));
return { x: side === 'right' ? cardBox.right : cardBox.left, y };
}
const FACE_LINKS = [
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
{ edge: 'bottom-left', card: '.dept-card--ex', side: 'right' as const, vert: 'bottom' as const, knee: 'left' as const },
{ edge: 'bottom-right', card: '.dept-card--ga', side: 'left' as const, vert: 'bottom' as const, knee: 'right' as const },
];
function buildBentPath(
cardAnchor: Point,
edgeMid: Point,
side: 'left' | 'right',
kneeX: number,
approachX: number,
): Point[] {
const minLeg = 18;
let x1 = kneeX;
if (side === 'right') {
if (x1 < cardAnchor.x + minLeg) x1 = cardAnchor.x + minLeg;
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
}
if (x1 > cardAnchor.x - minLeg) x1 = cardAnchor.x - minLeg;
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
}
/** 참고 레이아웃: 카드 → 짧은 수평 → ~120° 둔각 꺾임 → 대각 → 다이아몬드 */
function buildReferenceElbowPath(
cardAnchor: Point,
edgeMid: Point,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point[] {
const towardCenter = side === 'right' ? 1 : -1;
const turnSign = vert === 'top' ? -1 : 1;
const turn = Math.PI / 3; // 60° 꺾임 → 내각 120°
const uOutX = towardCenter * Math.cos(turn);
const uOutY = turnSign * Math.sin(turn);
const minStub = 18;
const maxStub = 64;
let knee: Point;
if (Math.abs(uOutY) > 1e-4) {
const t = (cardAnchor.y - edgeMid.y) / uOutY;
knee = { x: edgeMid.x - t * uOutX, y: cardAnchor.y };
} else {
knee = { x: cardAnchor.x + towardCenter * minStub, y: cardAnchor.y };
}
const stub = (knee.x - cardAnchor.x) * towardCenter;
if (!Number.isFinite(stub) || stub < minStub || stub > maxStub) {
knee = { x: cardAnchor.x + towardCenter * Math.max(minStub, Math.min(maxStub, stub || minStub)), y: cardAnchor.y };
}
return [cardAnchor, knee, edgeMid];
}
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
return vert === 'top' ? faceY + runX : faceY - runX;
}
const REF_HUB_LINE_COLOR = '#c5d2de';
const REF_HUB_DOT_COLOR = '#b8c6d4';
/** 둥근 꼭짓점 — 선 끝은 테두리 밖(다이아 레이어에 가려짐) */
const REF_DIAMOND_LINE_OVERLAP = 0;
/** reference 상·하: 실제 렌더 박스 + border-radius 보정 (기하 topV는 둥근 꼭짓점보다 바깥) */
function referenceDiamondVerticalY(
diamondEl: HTMLElement,
diamondBox: Box,
end: 'top' | 'bottom',
): number {
const r = parseFloat(getComputedStyle(diamondEl).borderRadius) || 16;
const tipOffset = Math.min(r * 0.7, diamondBox.height * 0.07);
return end === 'top'
? diamondBox.top + tipOffset + REF_DIAMOND_LINE_OVERLAP
: diamondBox.bottom - tipOffset - REF_DIAMOND_LINE_OVERLAP;
}
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
const wrap = diamond.parentElement;
if (!wrap) return;
const rowW = wrap.clientWidth;
const rowH = wrap.clientHeight;
const fitInRow = 1.04 / Math.SQRT2;
let size = Math.min(rowW * fitInRow, rowH * fitInRow);
let scale = 0.9;
const v = getComputedStyle(hubColumn).getPropertyValue('--hub-diamond-scale').trim();
if (v) scale = parseFloat(v) || scale;
size = Math.max(Math.floor(size * scale), Math.floor(150 * scale));
diamond.style.width = `${size}px`;
diamond.style.height = `${size}px`;
}
export type ConnectorStyle = 'default' | 'reference';
export function useBoardConnectors(enabled = true, style: ConnectorStyle = 'default') {
const lineGroupRef = useRef<SVGGElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const dotGroupRef = useRef<SVGGElement>(null);
const dotSvgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!enabled) return;
let resizeTimer: ReturnType<typeof setTimeout>;
const drawConnectors = () => {
const layout = document.querySelector('.board-layout');
const diamond = document.getElementById('hub-diamond');
const hubColumn = document.getElementById('hub-column');
const lineGroup = lineGroupRef.current;
const svg = svgRef.current;
const dotGroup = dotGroupRef.current;
const dotSvg = dotSvgRef.current;
if (!layout || !diamond || !lineGroup || !svg) return;
fitDiamond(hubColumn, diamond);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!layout.isConnected || !diamond.isConnected) return;
drawConnectorsNow(
layout,
diamond,
hubColumn,
lineGroup,
svg,
dotGroup,
dotSvg,
);
});
});
};
const drawConnectorsNow = (
layout: Element,
diamond: HTMLElement,
hubColumn: HTMLElement | null,
lineGroup: SVGGElement,
svg: SVGSVGElement,
dotGroup: SVGGElement | null,
dotSvg: SVGSVGElement | null,
) => {
if (window.innerWidth <= 1200) {
lineGroup.innerHTML = '';
dotGroup && (dotGroup.innerHTML = '');
svg.removeAttribute('viewBox');
dotSvg?.removeAttribute('viewBox');
return;
}
const layoutBox = layout.getBoundingClientRect();
const diamondBox = relBox(diamond, layout);
const diamondSize = diamond.offsetWidth;
const diamondCx = diamondBox.cx;
const diamondCy = diamondBox.cy;
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
lineGroup.innerHTML = '';
if (dotGroup) dotGroup.innerHTML = '';
if (dotSvg) dotSvg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
const topV = diamondVertex(diamondCx, diamondCy, diamondSize, 'top');
const bottomV = diamondVertex(diamondCx, diamondCy, diamondSize, 'bottom');
const hubColumnEl = layout.querySelector('.hub-column, #hub-column');
const hubBoxEls = hubColumnEl
? Array.from(hubColumnEl.querySelectorAll(':scope > .hub-box'))
: [];
const topHubBox =
hubBoxEls[0] ??
(style === 'reference'
? layout.querySelector('.hub-box--message')
: layout.querySelector('.hub-postit-sheet--front'));
const bottomHubBox =
(hubBoxEls.length > 1 ? hubBoxEls[hubBoxEls.length - 1] : hubBoxEls[0]) ??
(style === 'reference'
? layout.querySelector('.hub-box--focus')
: layout.querySelector('.hub-schedule-planner'));
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
const hrmBox = document.querySelector('.dept-card--hrm');
const exBox = document.querySelector('.dept-card--ex');
const hrdBox = document.querySelector('.dept-card--hrd');
const gaBox = document.querySelector('.dept-card--ga');
const kneeXs = { left: 0, right: 0 };
if (hubBox && hrmBox && exBox) {
const innerLeft = Math.max(relBox(hrmBox, layout).right, relBox(exBox, layout).right);
kneeXs.left = (innerLeft + hubBox.left) / 2;
}
if (hubBox && hrdBox && gaBox) {
const innerRight = Math.min(relBox(hrdBox, layout).left, relBox(gaBox, layout).left);
kneeXs.right = (innerRight + hubBox.right) / 2;
}
const d = diamondSize / (2 * Math.SQRT2);
const approachGap = 32;
const leftApproachX = diamondCx - d - approachGap;
const rightApproachX = diamondCx + d + approachGap;
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
const REF_CARD_LINE_COLORS: Record<string, string> = {
'.dept-card--hrm': '#4a90d9',
'.dept-card--hrd': '#37a184',
'.dept-card--ex': '#9168b8',
'.dept-card--ga': '#2563ab',
};
const appendPath = (
points: Point[],
opts?: {
cardSelector?: string;
dotAt?: Point;
vertical?: boolean;
markerSide?: 'left' | 'right';
markerDir?: 'up' | 'down';
},
) => {
if (points.length < 2) return;
const cardSelector = opts?.cardSelector;
const stroke =
opts?.vertical
? REF_HUB_LINE_COLOR
: style === 'reference' && cardSelector
? REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_LINE_COLOR
: style === 'reference'
? REF_HUB_LINE_COLOR
: '#b0bcc8';
if (opts?.vertical && points.length === 2) {
const [a, b] = points;
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('x1', String(snap(a.x)));
line.setAttribute('y1', String(snap(a.y)));
line.setAttribute('x2', String(snap(b.x)));
line.setAttribute('y2', String(snap(b.y)));
line.setAttribute('stroke', stroke);
line.setAttribute('stroke-width', '2');
line.setAttribute('fill', 'none');
line.setAttribute('stroke-linecap', 'butt');
lineGroup.appendChild(line);
} else {
const path = document.createElementNS(SVG_NS, 'path');
const normalized = normalizePathPoints(points);
if (normalized.length < 2) return;
path.setAttribute('d', pointsToPath(normalized));
path.setAttribute('stroke', stroke);
path.setAttribute('stroke-width', style === 'reference' ? '2' : '2.5');
path.setAttribute('opacity', style === 'reference' ? '1' : '0.85');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', style === 'reference' ? 'butt' : 'round');
path.setAttribute('stroke-linejoin', style === 'reference' ? 'miter' : 'round');
lineGroup.appendChild(path);
}
if (style === 'reference' && opts?.dotAt && dotGroup) {
const fill =
cardSelector ? (REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_DOT_COLOR) : REF_HUB_DOT_COLOR;
if (cardSelector && opts.markerSide) {
appendBoomerangMarker(dotGroup, opts.dotAt, opts.markerSide, fill);
} else if (opts.markerDir) {
appendHubBoomerangMarker(dotGroup, opts.dotAt, opts.markerDir, fill);
} else {
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', String(snap(opts.dotAt.x)));
dot.setAttribute('cy', String(snap(opts.dotAt.y)));
dot.setAttribute('r', '4');
dot.setAttribute('fill', fill);
dotGroup.appendChild(dot);
}
}
};
FACE_LINKS.forEach((link) => {
const cardEl = document.querySelector(link.card);
if (!cardEl) return;
const edgeMid =
style === 'reference'
? diamondBorderEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge)
: diamondEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge);
const runX = link.knee === 'left' ? leftRunX : rightRunX;
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
const cardAnchor =
style === 'reference'
? cardReferenceInnerEdgeAnchor(cardEl, layout, link.side, link.vert)
: cardEdgeAnchor(
cardEl,
layout,
link.side,
anchorYForSymmetricFace(edgeMid.y, runX, link.vert),
);
const pathPoints =
style === 'reference'
? buildReferenceElbowPath(cardAnchor, edgeMid, link.side, link.vert)
: buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX);
if (style === 'reference') {
appendPath(pathPoints, {
cardSelector: link.card,
dotAt: cardAnchor,
markerSide: link.side,
});
} else {
appendPath(pathPoints);
}
});
if (style === 'reference' && topHubBox) {
const topHub = relBox(topHubBox, layout);
const hubAnchor = { x: diamondCx, y: topHub.bottom };
const diamondTopY = referenceDiamondVerticalY(diamond, diamondBox, 'top');
const topPoints = referenceVerticalLine(diamondCx, hubAnchor.y, diamondTopY);
appendPath(topPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'down' });
} else if (topHubBox) {
const topHub = relBox(topHubBox, layout);
appendPath([topV, { x: diamondCx, y: topHub.bottom }]);
}
if (style === 'reference' && bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
const hubAnchor = { x: diamondCx, y: bottomHub.top };
const diamondBottomY = referenceDiamondVerticalY(diamond, diamondBox, 'bottom');
const bottomPoints = referenceVerticalLine(diamondCx, diamondBottomY, hubAnchor.y);
appendPath(bottomPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'up' });
} else if (bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
appendPath([bottomV, { x: diamondCx, y: bottomHub.top }]);
}
};
const scheduleDraw = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(drawConnectors, 50);
};
window.addEventListener('resize', scheduleDraw);
drawConnectors();
if (document.fonts?.ready) {
document.fonts.ready.then(() => {
requestAnimationFrame(() => requestAnimationFrame(drawConnectors));
});
}
const layoutEl = document.querySelector('.board-layout');
const hubColEl = document.getElementById('hub-column');
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleDraw);
if (layoutEl) ro.observe(layoutEl);
if (hubColEl) ro.observe(hubColEl);
}
return () => {
window.removeEventListener('resize', scheduleDraw);
clearTimeout(resizeTimer);
ro?.disconnect();
};
}, [enabled, style]);
return { svgRef, lineGroupRef, dotSvgRef, dotGroupRef };
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo, useState } from 'react';
import {
BOARD_REF_DATE_KEY,
dateToQuarter,
parseIsoDate,
startOfDay,
toIsoDate,
} from '../lib/boardCalendar';
export function useBoardReferenceDate() {
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
try {
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
if (stored) {
const parsed = parseIsoDate(stored);
if (parsed) return startOfDay(parsed);
}
} catch {
/* ignore */
}
return startOfDay(new Date());
});
const setReferenceDate = useCallback((d: Date) => {
const normalized = startOfDay(d);
setReferenceDateState(normalized);
try {
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
} catch {
/* ignore */
}
}, []);
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
const resetToToday = useCallback(() => {
setReferenceDate(startOfDay(new Date()));
}, [setReferenceDate]);
return { referenceDate, setReferenceDate, quarter, resetToToday };
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { fileViewUrl } from '../lib/apiClient';
export function useFileArrayBuffer(fileId: string | null) {
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBuffer(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setBuffer(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (!cancelled) {
setBuffer(data);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [fileId]);
return { buffer, loading, error };
}
export function useFileBlobUrl(fileId: string | null, mime: string) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBlobUrl(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setError(null);
setBlobUrl(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(new Blob([data], { type: mime }));
setBlobUrl(objectUrl);
setLoading(false);
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, mime]);
return { blobUrl, loading, error };
}

View File

@@ -0,0 +1,58 @@
import { useQueries } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import {
ROUTINE_CATEGORIES,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../lib/routineCategories';
import type { Milestone, Task } from '../types';
type TaskWithMilestones = Task & { milestones?: Milestone[] };
export interface RoutineFocusMilestone {
id: string;
title: string;
}
export interface RoutineCategoryFocus {
category: RoutineCategory;
task: Task | null;
milestones: RoutineFocusMilestone[];
isLoading: boolean;
}
export function useRoutineCategoryMilestones(routineTasks: Task[]): RoutineCategoryFocus[] {
const shells = ROUTINE_CATEGORIES.map((category) => ({
category,
task: pickRoutineCategoryTask(routineTasks, category),
}));
const queries = useQueries({
queries: shells.map(({ task }) => ({
queryKey: ['task', task?.id, 'hub-routine-focus'],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithMilestones>(`/tasks/${task!.id}`);
return data;
},
enabled: !!task?.id,
staleTime: 30_000,
})),
});
return shells.map(({ category, task }, index) => {
const data = queries[index].data;
const milestones = (data?.milestones ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((m) => ({ id: m.id, title: m.title.trim() }))
.filter((m) => m.title);
return {
category,
task,
milestones,
isLoading: queries[index].isLoading && !!task?.id,
};
});
}

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import type { TeamMember } from '../types';
export function useTeamMembers() {
return useQuery({
queryKey: ['team-members'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members');
return data;
},
staleTime: 60_000,
});
}

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
import type { TeamMember } from '../types';
export interface TeamMemberForm {
name: string;
rank: string;
role: string;
cell: string;
contact: string;
photoUrl: string;
sortOrder: number;
}
export const EMPTY_MEMBER_FORM: TeamMemberForm = {
name: '',
rank: '',
role: '',
cell: 'HR',
contact: '',
photoUrl: '',
sortOrder: 0,
};
export function memberToForm(member: TeamMember): TeamMemberForm {
return {
name: member.name,
rank: member.rank ?? '',
role: member.role ?? '',
cell: member.cell ?? '리더',
contact: member.contact ?? '',
photoUrl: member.photoUrl ?? '',
sortOrder: member.sortOrder ?? 0,
};
}
export function formToPayload(form: TeamMemberForm) {
return {
name: form.name.trim(),
rank: form.rank.trim() || null,
role: form.role.trim() || null,
cell: form.cell.trim() || null,
contact: form.contact.trim() || null,
photoUrl: form.photoUrl.trim() || null,
sortOrder: Number(form.sortOrder) || 0,
};
}
export function useTeamMembersAdmin() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['team-members', 'admin'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members', {
params: { all: '1' },
});
return data.filter((m) => m.isActive !== false);
},
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['team-members'] });
queryClient.invalidateQueries({ queryKey: ['team-members', 'admin'] });
};
const create = useMutation({
mutationFn: (form: TeamMemberForm) =>
apiClient.post('/team-members', formToPayload(form)),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, form }: { id: string; form: TeamMemberForm }) =>
apiClient.patch(`/team-members/${id}`, formToPayload(form)),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/team-members/${id}`),
onSuccess: invalidate,
});
return { query, create, update, remove, getApiErrorMessage };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
/** 배포(Render) 백엔드 — Vercel 등 외부 호스팅용 */
export const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
/** 사설망·로컬 IP 여부 (로컬 서버 우선 연결) */
export function isLocalNetworkHost(hostname: string): boolean {
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^10\./.test(hostname)
);
}
/** API·소켓·정적 파일이 붙는 백엔드 origin (프로토콜+호스트+포트) */
export function getBackendOrigin(): string {
const envUrl = import.meta.env.VITE_API_URL || import.meta.env.VITE_SOCKET_URL;
if (envUrl) {
return String(envUrl).replace(/\/$/, '');
}
if (import.meta.env.DEV && typeof window !== 'undefined') {
return `${window.location.protocol}//${window.location.hostname}:4000`;
}
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
return `${window.location.protocol}//${window.location.hostname}:4000`;
}
return RENDER_API;
}
/** REST API base (/api 포함) */
export function getApiBaseUrl(): string {
if (import.meta.env.VITE_API_URL) {
return `${import.meta.env.VITE_API_URL.replace(/\/$/, '')}/api`;
}
if (import.meta.env.DEV) {
return '/api';
}
if (typeof window !== 'undefined' && isLocalNetworkHost(window.location.hostname)) {
return `${getBackendOrigin()}/api`;
}
return `${RENDER_API}/api`;
}
export function getSocketUrl(): string {
if (import.meta.env.VITE_SOCKET_URL) {
return import.meta.env.VITE_SOCKET_URL;
}
// dev: Vite proxy /socket.io → API (localhost·LAN IP 동일 origin)
if (import.meta.env.DEV && typeof window !== 'undefined') {
return window.location.origin;
}
return getBackendOrigin();
}
/** /uploads/... 경로를 브라우저에서 열 수 있는 URL로 변환 */
export function staticAssetUrl(path: string | null | undefined): string {
if (!path) return '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:')) return path;
const normalized = path.startsWith('/') ? path : `/${path}`;
if (import.meta.env.DEV) {
return normalized;
}
return `${getBackendOrigin()}${normalized}`;
}

View File

@@ -1,13 +1,10 @@
import axios from 'axios';
import { getApiBaseUrl } from './apiBase';
// 개발: Vite 프록시 /api (localhost:4000)
// 배포: VITE_API_URL 미설정 시 Render 백엔드 기본값 사용
const RENDER_API = 'https://eene-dashboard-backend.onrender.com';
const baseURL = import.meta.env.VITE_API_URL
? `${import.meta.env.VITE_API_URL}/api`
: import.meta.env.PROD
? `${RENDER_API}/api`
: '/api';
// 개발: Vite 프록시 /api localhost:4000
// 사설망 IP 접속: 자동으로 같은 IP:4000 백엔드
// Vercel 배포: Render API (VITE_API_URL로 오버라이드 가능)
const baseURL = getApiBaseUrl();
export const apiClient = axios.create({
baseURL,
@@ -24,6 +21,10 @@ export function fileDownloadUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/download`;
}
export function fileHwpPreviewUrl(fileId: string): string {
return `${baseURL}/files/${fileId}/hwp-preview`;
}
apiClient.interceptors.request.use((config) => {
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
@@ -37,6 +38,14 @@ apiClient.interceptors.response.use(
);
export function getApiErrorMessage(err: unknown, fallback: string): string {
const ax = err as { response?: { data?: { message?: string }; status?: number }; message?: string };
return ax.response?.data?.message || ax.message || fallback;
const ax = err as {
response?: { data?: { message?: string }; status?: number };
message?: string;
code?: string;
};
if (ax.response?.data?.message) return ax.response.data.message;
if (!ax.response && (ax.code === 'ERR_NETWORK' || ax.message?.includes('Network Error'))) {
return '서버에 연결할 수 없습니다. 서버 PC에서 서버시작.bat 이 실행 중인지 확인해 주세요.';
}
return ax.message || fallback;
}

View File

@@ -0,0 +1,108 @@
export const BOARD_REF_DATE_KEY = 'eene-board-reference-date';
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function parseIsoDate(iso: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
const [y, m, day] = iso.split('-').map(Number);
const dt = new Date(y, m - 1, day);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== day) return null;
return dt;
}
export function dateToQuarter(d: Date): string {
const q = Math.floor(d.getMonth() / 3) + 1;
return `${d.getFullYear()}-Q${q}`;
}
export function quarterToLabel(quarter: string): string {
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
}
export function weekOfMonthLabel(d: Date): string {
return `${d.getMonth() + 1}${Math.ceil(d.getDate() / 7)}주차`;
}
export function quarterNumber(d: Date): number {
return Math.floor(d.getMonth() / 3) + 1;
}
export function formatReferenceSummary(d: Date): string {
return `기준일 ${toIsoDate(d)} · ${quarterNumber(d)}분기`;
}
/** @deprecated use formatReferenceSummary */
export function formatReferencePill(d: Date): string {
return formatReferenceSummary(d);
}
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
export function startOfWeekMonday(d: Date): Date {
const x = startOfDay(d);
const dow = x.getDay();
const offset = dow === 0 ? -6 : 1 - dow;
x.setDate(x.getDate() + offset);
return x;
}
export function quarterStartDate(quarter: string): Date {
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
if (!m) return startOfDay(new Date());
return new Date(Number(m[1]), (Number(m[2]) - 1) * 3, 1);
}
export function quarterEndDate(quarter: string): Date {
const m = quarter.match(/^(\d{4})-Q([1-4])$/);
if (!m) return startOfDay(new Date());
const year = Number(m[1]);
const q = Number(m[2]);
const endMonth = q * 3;
return new Date(year, endMonth, 0);
}
export function isSameDay(a: Date, b: Date): boolean {
return startOfDay(a).getTime() === startOfDay(b).getTime();
}
export function isSameWeek(a: Date, b: Date): boolean {
return startOfWeekMonday(a).getTime() === startOfWeekMonday(b).getTime();
}
export interface CalendarWeekRow {
label: string;
monday: Date;
days: Date[];
}
export function buildMonthWeekRows(year: number, monthIndex: number): CalendarWeekRow[] {
const rows: CalendarWeekRow[] = [];
let monday = startOfWeekMonday(new Date(year, monthIndex, 1));
const monthEnd = new Date(year, monthIndex + 1, 0);
for (let w = 0; w < 6; w++) {
const days: Date[] = Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d;
});
if (days.some((d) => d.getMonth() === monthIndex)) {
const anchor = days.find((d) => d.getMonth() === monthIndex)!;
rows.push({
label: weekOfMonthLabel(anchor),
monday: startOfDay(new Date(monday)),
days,
});
}
monday = new Date(monday);
monday.setDate(monday.getDate() + 7);
if (monday > monthEnd && monday.getMonth() !== monthIndex) break;
}
return rows;
}

View File

@@ -0,0 +1,140 @@
import type { SectionKey } from './sections';
import { taskBelongsToSection } from './sections';
/** 4분면 보드 슬롯 (preview 레이아웃) */
export type BoardSlotId = 'hrm' | 'hrd' | 'ex' | 'ga';
export interface BoardSlotConfig {
id: BoardSlotId;
cssClass: `dept-card--${BoardSlotId}`;
/** API 컬럼 키 — null이면 더미 슬롯(로컬 헤더·section 문자열) */
sectionKey: SectionKey | null;
/** 더미 슬롯·API 미연동 시 task.section 값 */
dummySection?: string;
defaultTitle: string;
defaultTitleEn: string;
accent: string;
}
export const BOARD_SLOTS: BoardSlotConfig[] = [
{
id: 'hrm',
cssClass: 'dept-card--hrm',
sectionKey: '인사관리',
defaultTitle: '인사관리',
defaultTitleEn: 'HRM',
accent: '#29724f',
},
{
id: 'hrd',
cssClass: 'dept-card--hrd',
sectionKey: '학습성장',
defaultTitle: '인재육성',
defaultTitleEn: 'HRD',
accent: '#37a184',
},
{
id: 'ex',
cssClass: 'dept-card--ex',
sectionKey: null,
dummySection: '조직문화',
defaultTitle: '조직문화',
defaultTitleEn: 'EX',
accent: '#4a9480',
},
{
id: 'ga',
cssClass: 'dept-card--ga',
sectionKey: '운영관리',
defaultTitle: '총무관리',
defaultTitleEn: 'GA',
accent: '#0d4a38',
},
];
export function slotSectionLabel(slot: BoardSlotConfig): string {
return slot.sectionKey ?? slot.dummySection ?? slot.defaultTitle;
}
/** 조직문화(EX) 슬롯 — HR 원본 category와 무관하게 배치할 프로젝트 */
const EX_CULTURE_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
export function isExCultureTask(task: {
section?: string | null;
title?: string | null;
tag?: string | null;
}): boolean {
if (task.section?.trim() === '조직문화') return true;
if (task.tag === 'Culture') return true;
const title = task.title?.trim() ?? '';
return title.length > 0 && EX_CULTURE_TITLE.test(title);
}
export function resolveTaskBoardSlot(task: {
section?: string | null;
title?: string | null;
tag?: string | null;
}): BoardSlotId | null {
if (isExCultureTask(task)) return 'ex';
for (const slot of BOARD_SLOTS) {
if (slot.id === 'ex') continue;
if (taskBelongsToSlot(task.section, slot)) return slot.id;
}
return null;
}
export function taskBelongsToBoardSlot(
task: { section?: string | null; title?: string | null; tag?: string | null },
slot: BoardSlotConfig,
): boolean {
return resolveTaskBoardSlot(task) === slot.id;
}
/** 컬럼 헤더 — API 기본값(운영관리 등)은 슬롯 표시명(총무관리 등)으로 */
export function columnDisplayTitle(
slot: BoardSlotConfig,
colConfig?: { title?: string | null } | null,
dummyHeader?: { title?: string } | null,
): string {
if (!slot.sectionKey) {
return dummyHeader?.title?.trim() || slot.defaultTitle;
}
const custom = colConfig?.title?.replace(/\s*부문$/, '').trim();
if (!custom) return slot.defaultTitle;
if (custom === slot.sectionKey || custom === '운영관리' || custom === 'HR' || custom === '학습성장') {
return slot.defaultTitle;
}
return custom;
}
export function columnDisplayTitleEn(
slot: BoardSlotConfig,
colConfig?: { titleEn?: string | null } | null,
dummyHeader?: { titleEn?: string } | null,
): string {
if (!slot.sectionKey) {
return dummyHeader?.titleEn?.trim() || slot.defaultTitleEn;
}
const custom = colConfig?.titleEn?.trim();
if (!custom || custom === 'Operations' || custom === 'Human Resources') {
return slot.defaultTitleEn;
}
return custom;
}
export function taskBelongsToSlot(
taskSection: string | null | undefined,
slot: BoardSlotConfig,
): boolean {
if (!taskSection) return false;
const label = slotSectionLabel(slot);
if (taskSection.trim() === label) return true;
if (slot.sectionKey && taskBelongsToSection(taskSection, slot.sectionKey)) return true;
return false;
}
export const BOARD_SLOT_ORDER: BoardSlotId[] = ['hrm', 'hrd', 'ex', 'ga'];
export function getBoardSlot(id: BoardSlotId): BoardSlotConfig {
return BOARD_SLOTS.find((s) => s.id === id)!;
}

Some files were not shown because too many files have changed in this diff Show More