EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
.env.example
14
.env.example
@@ -1,19 +1,17 @@
|
|||||||
# ─── Database ───────────────────────────────────────────────
|
# ─── Database (Docker → data/postgres 영구 저장, 포트 54320) ───
|
||||||
DB_USER=eee_admin
|
DB_USER=eee_admin
|
||||||
DB_PASSWORD=eee_password
|
DB_PASSWORD=eee_password
|
||||||
DB_NAME=eee_dashboard
|
DB_NAME=eee_dashboard
|
||||||
DB_PORT=5432
|
DB_PORT=54320
|
||||||
# Docker compose 기본값과 동일 (data/postgres 에 영구 저장)
|
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:54320/eee_dashboard"
|
||||||
DATABASE_URL="postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard"
|
|
||||||
|
|
||||||
# ─── Backend ─────────────────────────────────────────────────
|
# ─── Backend ─────────────────────────────────────────────────
|
||||||
PORT=4000
|
PORT=4000
|
||||||
FRONTEND_URL=http://172.16.8.248:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
# JWT 시크릿 (운영 시 반드시 강력한 랜덤 문자열로 교체)
|
|
||||||
JWT_SECRET=change_this_secret_in_production
|
JWT_SECRET=change_this_secret_in_production
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
# ─── File Upload ─────────────────────────────────────────────
|
# ─── Paths (프로젝트 루트 기준, backend/.env 와 동일) ────────
|
||||||
UPLOAD_DIR=../uploads
|
UPLOAD_DIR=../uploads
|
||||||
MAX_FILE_SIZE_MB=20
|
HR_DATA_PATH=../data/seed/hr-data.json
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,9 +2,14 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.env
|
.env
|
||||||
data/
|
backend/.env
|
||||||
|
data/postgres/
|
||||||
|
!data/.gitkeep
|
||||||
|
!data/seed/
|
||||||
|
!data/seed/**
|
||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
frontend/.lan-ip
|
||||||
|
|||||||
5
Gitea업로드.bat
Normal file
5
Gitea업로드.bat
Normal 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
33
PC이전.txt
Normal 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
|
||||||
272
README.md
272
README.md
@@ -1,204 +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)
|
- [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
|
```bash
|
||||||
# 루트에 .env 파일이 없을 경우 복사
|
npm run local:db # Docker DB (data/postgres)
|
||||||
copy .env.example .env
|
npm run local:db:stop # Docker DB 중지
|
||||||
|
npm run local:setup # 스키마 + 빈 DB seed
|
||||||
# 백엔드 .env 확인
|
npm run local:api # :4000
|
||||||
# backend\.env 파일은 이미 기본값으로 생성되어 있음
|
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에 영구 저장)
|
|
||||||
|
|
||||||
Render 용량 제한 시 **이 PC만으로** 운영할 수 있습니다. 모든 데이터는 아래 폴더에 저장됩니다.
|
|
||||||
|
|
||||||
| 데이터 | 저장 위치 |
|
|
||||||
|--------|-----------|
|
|
||||||
| 업무·팀원·KPI 등 | `data/postgres/` (Docker PostgreSQL 볼륨) |
|
|
||||||
| 업무 첨부 파일 | `uploads/` |
|
|
||||||
| 팀원 프로필 사진 | `uploads/team/` |
|
|
||||||
|
|
||||||
### 빠른 시작
|
|
||||||
|
|
||||||
**Windows:** `서버시작.bat` 더블클릭 (DB 시작 → 스키마 동기화 → API+WEB 실행)
|
|
||||||
**종료:** `서버종료.bat` (API/WEB만) · `서버종료.bat docker` (Docker DB까지 중지, 데이터는 유지)
|
|
||||||
|
|
||||||
수동 실행:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run local:db # PostgreSQL 컨테이너 시작
|
|
||||||
npm run local:setup # DB 스키마 동기화
|
|
||||||
npm run local:api # 백엔드 :4000 (터미널 1)
|
|
||||||
npm run local:web # 프론트 :3000 (터미널 2)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 접속 주소
|
|
||||||
|
|
||||||
| 화면 | 주소 |
|
|
||||||
|------|------|
|
|
||||||
| 대시보드 | `http://localhost:3000` |
|
|
||||||
| 팀원 관리 | `http://localhost:3000/admin` |
|
|
||||||
| API | `http://localhost:4000/api` |
|
|
||||||
|
|
||||||
사설망 IP(`172.x`, `192.168.x`)로 접속하면 **자동으로 같은 IP의 :4000 백엔드**에 연결됩니다. (Render 서버 불필요)
|
|
||||||
|
|
||||||
> `backend/.env` 의 `DATABASE_URL` 이 Docker 계정과 맞아야 합니다.
|
|
||||||
> 기본: `postgresql://eee_admin:eee_password@localhost:5432/eee_dashboard`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 서버(이 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 등록 |
|
|
||||||
| GET | /api/team-members | 팀원 목록 |
|
|
||||||
| POST | /api/team-members | 팀원 등록 |
|
|
||||||
| POST | /api/team-members/photo | 팀원 사진 업로드 (로컬 저장) |
|
|
||||||
|
|||||||
17
backend/.env.example
Normal file
17
backend/.env.example
Normal 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
|
||||||
253
backend/package-lock.json
generated
253
backend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "eene-dashboard-backend",
|
"name": "eene-dashboard-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ohah/hwpjs": "^0.1.0-rc.10",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"prisma": "^6.0.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
@@ -29,11 +31,44 @@
|
|||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"prisma": "^6.0.0",
|
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"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-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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -522,7 +714,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||||
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "3.1.0",
|
"c12": "3.1.0",
|
||||||
@@ -535,14 +726,12 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||||
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||||
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -556,14 +745,12 @@
|
|||||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||||
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.3",
|
"@prisma/debug": "6.19.3",
|
||||||
@@ -575,7 +762,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||||
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.19.3"
|
"@prisma/debug": "6.19.3"
|
||||||
@@ -591,9 +777,18 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/bcrypt": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||||
@@ -990,7 +1185,6 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -1048,7 +1242,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
@@ -1073,7 +1266,6 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"consola": "^3.2.3"
|
"consola": "^3.2.3"
|
||||||
@@ -1088,6 +1280,15 @@
|
|||||||
"color-support": "bin.js"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -1113,14 +1314,12 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/consola": {
|
"node_modules/consola": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.18.0 || >=16.10.0"
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
@@ -1204,7 +1403,6 @@
|
|||||||
"version": "7.1.5",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -1214,7 +1412,6 @@
|
|||||||
"version": "6.1.7",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
@@ -1236,7 +1433,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/destroy": {
|
"node_modules/destroy": {
|
||||||
@@ -1303,7 +1499,6 @@
|
|||||||
"version": "3.21.0",
|
"version": "3.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
@@ -1320,7 +1515,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@@ -1525,14 +1719,12 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -1703,7 +1895,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.1.6",
|
"citty": "^0.1.6",
|
||||||
@@ -1905,7 +2096,6 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -2254,7 +2444,6 @@
|
|||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
@@ -2289,7 +2478,6 @@
|
|||||||
"version": "0.6.6",
|
"version": "0.6.6",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
|
||||||
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
|
"integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
@@ -2307,7 +2495,6 @@
|
|||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
@@ -2335,7 +2522,6 @@
|
|||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
@@ -2396,21 +2582,18 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
@@ -2422,7 +2605,6 @@
|
|||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2467,7 +2649,6 @@
|
|||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||||
"devOptional": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2523,7 +2704,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -2555,7 +2735,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
@@ -2956,7 +3135,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -2977,6 +3155,13 @@
|
|||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.3",
|
"version": "4.22.3",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run db:sync && 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",
|
"db:sync": "prisma migrate deploy || prisma db push",
|
||||||
"build": "prisma generate && tsc",
|
"build": "prisma generate && tsc",
|
||||||
"start": "npm run db:sync && node dist/index.js",
|
"start": "npm run db:sync && node dist/index.js",
|
||||||
@@ -12,13 +13,17 @@
|
|||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.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:import-hr": "tsx scripts/import-hr-data.ts",
|
||||||
"db:sync-remote": "tsx scripts/sync-from-remote.ts",
|
"db:migrate-sections": "tsx scripts/migrate-sections.ts",
|
||||||
"db:push-remote": "tsx scripts/sync-to-remote.ts",
|
"db:migrate-milestone-period-notes": "tsx scripts/migrate-milestone-period-notes.ts"
|
||||||
"db:push-photos": "tsx scripts/sync-to-remote.ts --photos-only",
|
|
||||||
"db:migrate-sections": "tsx scripts/migrate-sections.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ohah/hwpjs": "^0.1.0-rc.10",
|
||||||
"@prisma/client": "^6.0.0",
|
"@prisma/client": "^6.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { Priority, TaskStatus } from '@prisma/client';
|
import type { Priority, TaskStatus } from '@prisma/client';
|
||||||
|
import { getHrSeedPath, getUploadDir } from '../src/lib/projectPaths';
|
||||||
|
|
||||||
export interface HrProject {
|
export interface HrProject {
|
||||||
idx?: number;
|
idx?: number;
|
||||||
@@ -26,6 +27,21 @@ export interface HrProject {
|
|||||||
subPhases?: { name: string; status?: string; text?: string }[];
|
subPhases?: { name: string; status?: string; text?: string }[];
|
||||||
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
|
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
|
||||||
showOnDashboard?: boolean;
|
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 {
|
export interface MappedTask {
|
||||||
@@ -59,6 +75,8 @@ const SECTION_MAP: Record<string, string> = {
|
|||||||
인사관리: '인사관리',
|
인사관리: '인사관리',
|
||||||
성장지원: '학습성장',
|
성장지원: '학습성장',
|
||||||
운영관리: '운영관리',
|
운영관리: '운영관리',
|
||||||
|
운영지원: '운영관리',
|
||||||
|
전산관리: '운영관리',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, TaskStatus> = {
|
const STATUS_MAP: Record<string, TaskStatus> = {
|
||||||
@@ -75,7 +93,7 @@ const PHASE_PROGRESS: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function defaultHrDataPath(): string {
|
export function defaultHrDataPath(): string {
|
||||||
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
|
return getHrSeedPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
||||||
@@ -84,6 +102,72 @@ export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
|
|||||||
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
|
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 {
|
function parseDate(value?: string): Date | null {
|
||||||
if (!value?.trim()) return null;
|
if (!value?.trim()) return null;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
@@ -94,6 +178,14 @@ function mapSection(category: string): string {
|
|||||||
return SECTION_MAP[category] ?? category;
|
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 {
|
function mapStatus(status?: string, isRoutine = false): TaskStatus {
|
||||||
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
|
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
|
||||||
return STATUS_MAP[status] ?? 'IN_PROGRESS';
|
return STATUS_MAP[status] ?? 'IN_PROGRESS';
|
||||||
@@ -186,8 +278,8 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
|
|||||||
status: mapStatus(p.status, isRoutine),
|
status: mapStatus(p.status, isRoutine),
|
||||||
priority: mapPriority(p.priority),
|
priority: mapPriority(p.priority),
|
||||||
quarter,
|
quarter,
|
||||||
category: mapSection(p.category),
|
category: mapBoardSection(p),
|
||||||
section: mapSection(p.category),
|
section: mapBoardSection(p),
|
||||||
taskType,
|
taskType,
|
||||||
progress: mapProgress(p.progress),
|
progress: mapProgress(p.progress),
|
||||||
issueNote: pickIssueNote(p),
|
issueNote: pickIssueNote(p),
|
||||||
@@ -204,5 +296,7 @@ export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "issueEntries" JSONB;
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "milestones" ADD COLUMN "periodEntries" JSONB;
|
||||||
@@ -46,6 +46,8 @@ model TeamMember {
|
|||||||
|
|
||||||
pmTasks Task[] @relation("PmTasks")
|
pmTasks Task[] @relation("PmTasks")
|
||||||
taskAssignees TaskAssignee[]
|
taskAssignees TaskAssignee[]
|
||||||
|
milestonePmTasks Milestone[] @relation("MilestonePm")
|
||||||
|
milestoneAssignees MilestoneAssignee[]
|
||||||
|
|
||||||
@@index([cell])
|
@@index([cell])
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@ -73,6 +75,7 @@ model Task {
|
|||||||
taskType String? // 상시업무 | 프로젝트
|
taskType String? // 상시업무 | 프로젝트
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
issueNote String?
|
issueNote String?
|
||||||
|
issueEntries Json?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
showDate Boolean @default(true)
|
showDate Boolean @default(true)
|
||||||
@@ -200,24 +203,42 @@ model Milestone {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
title String
|
title String
|
||||||
|
subtitle String?
|
||||||
description String?
|
description String?
|
||||||
startDate DateTime?
|
startDate DateTime?
|
||||||
dueDate DateTime?
|
dueDate DateTime?
|
||||||
|
periodEntries Json?
|
||||||
progress Int @default(0)
|
progress Int @default(0)
|
||||||
links String? // JSON: [{ "label": string, "url": string }]
|
links String? // JSON: [{ "label": string, "url": string }]
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
|
pmMemberId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
pmMember TeamMember? @relation("MilestonePm", fields: [pmMemberId], references: [id])
|
||||||
|
milestoneAssignees MilestoneAssignee[]
|
||||||
details TaskDetail[]
|
details TaskDetail[]
|
||||||
files File[]
|
files File[]
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
|
@@index([pmMemberId])
|
||||||
@@map("milestones")
|
@@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 {
|
model ColumnConfig {
|
||||||
@@ -231,6 +252,16 @@ model ColumnConfig {
|
|||||||
@@map("column_configs")
|
@@map("column_configs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 허브 설정 (분기 중점 과제·일정·상시 라벨) ─────────────────
|
||||||
|
|
||||||
|
model HubConfig {
|
||||||
|
id String @id @default("default")
|
||||||
|
config Json
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("hub_configs")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 감사 로그 ───────────────────────────────────────────────
|
// ─── 감사 로그 ───────────────────────────────────────────────
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@@ -1,67 +1,273 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { mapAllHrProjects } from './mapHrProjects';
|
|
||||||
|
import {
|
||||||
|
|
||||||
|
loadHrProjects,
|
||||||
|
|
||||||
|
mapAllHrProjects,
|
||||||
|
|
||||||
|
mapHrProjectToTask,
|
||||||
|
|
||||||
|
mapHrTeamMembers,
|
||||||
|
|
||||||
|
normalizePersonName,
|
||||||
|
|
||||||
|
} from './mapHrProjects';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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() {
|
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 adminPw = await bcrypt.hash('admin1234!', 12);
|
||||||
|
|
||||||
const memberPw = await bcrypt.hash('member1234!', 12);
|
const memberPw = await bcrypt.hash('member1234!', 12);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
|
|
||||||
where: { email: 'admin@eene.com' },
|
where: { email: 'admin@eene.com' },
|
||||||
|
|
||||||
update: {},
|
update: {},
|
||||||
|
|
||||||
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
|
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const member = await prisma.user.upsert({
|
const member = await prisma.user.upsert({
|
||||||
|
|
||||||
where: { email: 'member@eene.com' },
|
where: { email: 'member@eene.com' },
|
||||||
|
|
||||||
update: { name: '정성호' },
|
update: { name: '정성호' },
|
||||||
|
|
||||||
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
|
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
console.log('✅ Users ready');
|
console.log('✅ Users ready');
|
||||||
|
|
||||||
const mapped = mapAllHrProjects();
|
|
||||||
|
|
||||||
|
await prisma.milestoneAssignee.deleteMany({});
|
||||||
|
|
||||||
|
await prisma.taskAssignee.deleteMany({});
|
||||||
|
|
||||||
await prisma.file.deleteMany({});
|
await prisma.file.deleteMany({});
|
||||||
|
|
||||||
await prisma.taskDetail.deleteMany({});
|
await prisma.taskDetail.deleteMany({});
|
||||||
|
|
||||||
await prisma.milestone.deleteMany({});
|
await prisma.milestone.deleteMany({});
|
||||||
|
|
||||||
await prisma.kpiMetric.deleteMany({});
|
await prisma.kpiMetric.deleteMany({});
|
||||||
|
|
||||||
await prisma.task.deleteMany({});
|
await prisma.task.deleteMany({});
|
||||||
|
|
||||||
for (const t of mapped) {
|
await prisma.teamMember.deleteMany({});
|
||||||
const { milestones, detailContent, ...taskData } = t;
|
|
||||||
const task = await prisma.task.create({
|
|
||||||
|
|
||||||
|
const teamParsed = mapHrTeamMembers();
|
||||||
|
|
||||||
|
const memberIdByName = new Map<string, string>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for (const tm of teamParsed) {
|
||||||
|
|
||||||
|
const created = await prisma.teamMember.create({
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
...taskData,
|
|
||||||
creatorId: admin.id,
|
name: tm.name,
|
||||||
assigneeId: member.id,
|
|
||||||
|
rank: tm.rank,
|
||||||
|
|
||||||
|
role: tm.role,
|
||||||
|
|
||||||
|
cell: tm.cell,
|
||||||
|
|
||||||
|
photoUrl: tm.photoUrl,
|
||||||
|
|
||||||
|
sortOrder: tm.sortOrder,
|
||||||
|
|
||||||
|
isActive: true,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
memberIdByName.set(normalizePersonName(tm.name), created.id);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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()) {
|
for (const [order, ms] of milestones.entries()) {
|
||||||
|
|
||||||
await prisma.milestone.create({
|
await prisma.milestone.create({
|
||||||
|
|
||||||
data: { ...ms, taskId: task.id, order },
|
data: { ...ms, taskId: task.id, order },
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (detailContent) {
|
if (detailContent) {
|
||||||
|
|
||||||
await prisma.taskDetail.create({
|
await prisma.taskDetail.create({
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
|
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
|
|
||||||
content: detailContent,
|
content: detailContent,
|
||||||
|
|
||||||
updatedBy: admin.id,
|
updatedBy: admin.id,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Tasks created: ${mapped.length}개 (HR_Dashboard 데이터)`);
|
|
||||||
console.log('🎉 Seeding complete!');
|
|
||||||
|
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());
|
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
|
|
||||||
|
|||||||
75
backend/scripts/cleanup-legacy-routine.ts
Normal file
75
backend/scripts/cleanup-legacy-routine.ts
Normal 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());
|
||||||
@@ -102,7 +102,7 @@ async function importViaApi(adminId: string, memberId: string) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const tasks = mapAllHrProjects();
|
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 adminId: string;
|
||||||
let memberId: string;
|
let memberId: string;
|
||||||
|
|||||||
46
backend/scripts/migrate-board-sections.ts
Normal file
46
backend/scripts/migrate-board-sections.ts
Normal 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());
|
||||||
59
backend/scripts/migrate-milestone-period-notes.ts
Normal file
59
backend/scripts/migrate-milestone-period-notes.ts
Normal 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());
|
||||||
28
backend/scripts/seed-if-empty.ts
Normal file
28
backend/scripts/seed-if-empty.ts
Normal 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());
|
||||||
45
backend/scripts/seed-team-if-empty.ts
Normal file
45
backend/scripts/seed-team-if-empty.ts
Normal 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());
|
||||||
198
backend/scripts/sync-team-photos.ts
Normal file
198
backend/scripts/sync-team-photos.ts
Normal 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());
|
||||||
@@ -17,8 +17,11 @@ app.use(
|
|||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
|
'https://localhost:3000',
|
||||||
'http://127.0.0.1:3000',
|
'http://127.0.0.1:3000',
|
||||||
|
'https://127.0.0.1:3000',
|
||||||
'http://172.16.8.248:3000',
|
'http://172.16.8.248:3000',
|
||||||
|
'https://172.16.8.248:3000',
|
||||||
'https://eene-dashboard.vercel.app',
|
'https://eene-dashboard.vercel.app',
|
||||||
process.env.FRONTEND_URL,
|
process.env.FRONTEND_URL,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
@@ -26,11 +29,11 @@ const allowedOrigins = [
|
|||||||
function isAllowedOrigin(origin: string): boolean {
|
function isAllowedOrigin(origin: string): boolean {
|
||||||
if (allowedOrigins.includes(origin)) return true;
|
if (allowedOrigins.includes(origin)) return true;
|
||||||
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
|
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
|
||||||
// 로컬·사설망 프론트 (용량 절약용 로컬 서버)
|
// 로컬·사설망 프론트 (http/https, LAN IP)
|
||||||
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
|
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) return true;
|
||||||
if (/^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
if (/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
if (/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
if (/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
if (/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
if (/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import app from './app';
|
|||||||
import { setupSocketHandlers } from './socket';
|
import { setupSocketHandlers } from './socket';
|
||||||
import { prisma } from './lib/prisma';
|
import { prisma } from './lib/prisma';
|
||||||
import { ensureLocalDirs } from './lib/ensureLocalDirs';
|
import { ensureLocalDirs } from './lib/ensureLocalDirs';
|
||||||
|
import { getProjectRoot, getHrSeedPath, getUploadDir } from './lib/projectPaths';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 4000;
|
const PORT = Number(process.env.PORT) || 4000;
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
@@ -19,11 +20,16 @@ const io = new Server(httpServer, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setupSocketHandlers(io);
|
setupSocketHandlers(io);
|
||||||
|
app.set('io', io);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
ensureLocalDirs();
|
ensureLocalDirs();
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
console.log('✅ Database connected (PostgreSQL — 로컬 data/postgres 또는 DATABASE_URL)');
|
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', () => {
|
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import fs from 'fs';
|
import { ensureProjectDataDirs } from './projectPaths';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
/** 로컬 uploads·팀 사진 폴더 생성 (데이터 영구 저장) */
|
/** 로컬 data·uploads 폴더 생성 (DB·파일 영구 저장) */
|
||||||
export function ensureLocalDirs() {
|
export function ensureLocalDirs() {
|
||||||
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
ensureProjectDataDirs();
|
||||||
const teamDir = path.join(uploadDir, 'team');
|
|
||||||
const dataPostgresHint = path.resolve('../data/postgres');
|
|
||||||
|
|
||||||
for (const dir of [uploadDir, teamDir]) {
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
console.log(`📁 Created: ${dir}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(dataPostgresHint)) {
|
|
||||||
console.log(
|
|
||||||
'💡 PostgreSQL 로컬 저장: 프로젝트 루트에서 docker compose up -d 실행 시 data/postgres 에 DB가 보존됩니다.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
backend/src/lib/fileMime.ts
Normal file
60
backend/src/lib/fileMime.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
140
backend/src/lib/milestonePeriods.ts
Normal file
140
backend/src/lib/milestonePeriods.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
backend/src/lib/projectPaths.ts
Normal file
65
backend/src/lib/projectPaths.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/src/lib/taskIssues.ts
Normal file
47
backend/src/lib/taskIssues.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ export const teamMemberSelect = {
|
|||||||
sortOrder: true,
|
sortOrder: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const milestoneInclude = {
|
||||||
|
pmMember: { select: teamMemberSelect },
|
||||||
|
milestoneAssignees: {
|
||||||
|
include: { member: { select: teamMemberSelect } },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const taskInclude = {
|
export const taskInclude = {
|
||||||
assignee: { select: { id: true, name: true, department: true } },
|
assignee: { select: { id: true, name: true, department: true } },
|
||||||
creator: { select: { id: true, name: true } },
|
creator: { select: { id: true, name: true } },
|
||||||
@@ -34,15 +41,32 @@ export const taskDetailInclude = {
|
|||||||
},
|
},
|
||||||
kpiMetrics: true,
|
kpiMetrics: true,
|
||||||
files: true,
|
files: true,
|
||||||
milestones: { orderBy: { order: 'asc' as const } },
|
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) {
|
export function formatTask<T extends Record<string, unknown>>(task: T) {
|
||||||
const { taskAssignees, ...rest } = task as T & {
|
const { taskAssignees, milestones, ...rest } = task as T & {
|
||||||
taskAssignees?: Array<{ member: unknown }>;
|
taskAssignees?: Array<{ member: unknown }>;
|
||||||
|
milestones?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
|
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
|
||||||
return { ...rest, assigneeMembers };
|
const formattedMilestones = milestones?.map((m) => formatMilestone(m));
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
assigneeMembers,
|
||||||
|
...(formattedMilestones !== undefined ? { milestones: formattedMilestones } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncTaskMembers(
|
export async function syncTaskMembers(
|
||||||
|
|||||||
23
backend/src/lib/uploadErrors.ts
Normal file
23
backend/src/lib/uploadErrors.ts
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||||
|
|
||||||
|
|
||||||
@@ -21,11 +22,26 @@ export function errorHandler(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function errorHandler(
|
||||||
|
|
||||||
|
err: Error,
|
||||||
|
|
||||||
|
_req: Request,
|
||||||
|
|
||||||
|
res: Response,
|
||||||
|
|
||||||
|
_next: NextFunction,
|
||||||
|
|
||||||
): void {
|
): void {
|
||||||
|
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
res.status(500).json({ message: 'DB 스키마가 최신이 아닙니다. 배포 후 다시 시도해 주세요.' });
|
|
||||||
|
res.status(err.statusCode).json({ message: err.message });
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from 'path';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import fs from 'fs';
|
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');
|
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||||
|
|
||||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
@@ -20,7 +19,5 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const upload = multer({
|
/** 파일 크기 상한 없음 — 디스크 여유만큼 저장 (부족 시 uploadErrors) */
|
||||||
storage,
|
export const upload = multer({ storage });
|
||||||
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
export const uploadTeamPhoto = multer({
|
export const uploadTeamPhoto = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
|
||||||
fileFilter(_req, file, cb) {
|
fileFilter(_req, file, cb) {
|
||||||
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
|
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: strin
|
|||||||
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
||||||
},
|
},
|
||||||
'운영관리': {
|
'운영관리': {
|
||||||
title: '운영관리 부문',
|
title: '총무관리',
|
||||||
titleEn: 'Operations',
|
titleEn: 'GA',
|
||||||
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -27,6 +27,18 @@ router.get('/:key', async (req, res, next) => {
|
|||||||
config = await prisma.columnConfig.create({
|
config = await prisma.columnConfig.create({
|
||||||
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
|
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);
|
res.json(config);
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
import { Router, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
import { upload } from '../middleware/upload';
|
import { upload } from '../middleware/upload';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
|
const PREVIEW_FRAME_ANCESTORS = [
|
||||||
function allowCrossOriginPreview(res: Response) {
|
"'self'",
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
'https://eene-dashboard.vercel.app',
|
||||||
res.setHeader(
|
'http://localhost:3000',
|
||||||
'Content-Security-Policy',
|
'https://localhost:3000',
|
||||||
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://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');
|
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),
|
originalName: fixOriginalName(req.file.originalname),
|
||||||
displayName,
|
displayName,
|
||||||
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
||||||
mimetype: req.file.mimetype,
|
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
uploadedBy,
|
uploadedBy,
|
||||||
@@ -68,10 +91,47 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
|||||||
|
|
||||||
res.status(201).json(fileRecord);
|
res.status(201).json(fileRecord);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
next(err);
|
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 — 파일 미리보기 (브라우저에서 바로 열기)
|
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
||||||
router.get('/:id/view', async (req, res, next) => {
|
router.get('/:id/view', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -80,8 +140,9 @@ router.get('/:id/view', async (req, res, next) => {
|
|||||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||||
|
|
||||||
allowCrossOriginPreview(res);
|
allowCrossOriginPreview(req, res);
|
||||||
res.setHeader('Content-Type', file.mimetype);
|
const mime = resolveFileMime(file.originalName, file.mimetype);
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
||||||
fs.createReadStream(file.path).pipe(res);
|
fs.createReadStream(file.path).pipe(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -122,7 +183,7 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
|||||||
data: {
|
data: {
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
originalName: fixOriginalName(req.file.originalname),
|
originalName: fixOriginalName(req.file.originalname),
|
||||||
mimetype: req.file.mimetype,
|
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
path: req.file.path,
|
path: req.file.path,
|
||||||
},
|
},
|
||||||
@@ -130,6 +191,11 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
|||||||
|
|
||||||
res.json(file);
|
res.json(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
74
backend/src/routes/hubConfig.ts
Normal file
74
backend/src/routes/hubConfig.ts
Normal 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;
|
||||||
@@ -8,6 +8,7 @@ import columnRoutes from './columns';
|
|||||||
import milestoneRoutes from './milestones';
|
import milestoneRoutes from './milestones';
|
||||||
import detailRoutes from './details';
|
import detailRoutes from './details';
|
||||||
import teamMemberRoutes from './teamMembers';
|
import teamMemberRoutes from './teamMembers';
|
||||||
|
import hubConfigRoutes from './hubConfig';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ router.use('/users', userRoutes);
|
|||||||
router.use('/files', fileRoutes);
|
router.use('/files', fileRoutes);
|
||||||
router.use('/kpi', kpiRoutes);
|
router.use('/kpi', kpiRoutes);
|
||||||
router.use('/columns', columnRoutes);
|
router.use('/columns', columnRoutes);
|
||||||
|
router.use('/hub-config', hubConfigRoutes);
|
||||||
router.use('/milestones', milestoneRoutes);
|
router.use('/milestones', milestoneRoutes);
|
||||||
router.use('/details', detailRoutes);
|
router.use('/details', detailRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||||
|
import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery';
|
||||||
|
import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -28,14 +31,47 @@ function clampProgress(value: unknown): number {
|
|||||||
return Math.min(100, Math.max(0, Math.round(n)));
|
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
|
// GET /api/milestones/:taskId
|
||||||
router.get('/:taskId', async (req, res, next) => {
|
router.get('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const milestones = await prisma.milestone.findMany({
|
const milestones = await prisma.milestone.findMany({
|
||||||
where: { taskId: req.params.taskId },
|
where: { taskId: req.params.taskId },
|
||||||
orderBy: { order: 'asc' },
|
orderBy: { order: 'asc' },
|
||||||
|
include: milestoneInclude,
|
||||||
});
|
});
|
||||||
res.json(milestones);
|
res.json(milestones.map((m) => formatMilestone(m)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -45,26 +81,48 @@ router.get('/:taskId', async (req, res, next) => {
|
|||||||
router.post('/:taskId', async (req, res, next) => {
|
router.post('/:taskId', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const taskId = req.params.taskId;
|
const taskId = req.params.taskId;
|
||||||
const { title, description, startDate, dueDate, feedback, links, progress } =
|
const body = req.body as Record<string, unknown>;
|
||||||
req.body as Record<string, string | number>;
|
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, '단계 제목은 필수입니다.');
|
if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.');
|
||||||
|
|
||||||
const count = await prisma.milestone.count({ where: { taskId } });
|
const count = await prisma.milestone.count({ where: { taskId } });
|
||||||
|
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||||
|
|
||||||
const milestone = await prisma.milestone.create({
|
const milestone = await prisma.milestone.create({
|
||||||
data: {
|
data: {
|
||||||
taskId,
|
taskId,
|
||||||
title: String(title).trim(),
|
title: String(title).trim(),
|
||||||
|
subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null,
|
||||||
description: description?.toString().trim() || null,
|
description: description?.toString().trim() || null,
|
||||||
startDate: startDate ? new Date(String(startDate)) : null,
|
startDate:
|
||||||
dueDate: dueDate ? new Date(String(dueDate)) : null,
|
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,
|
progress: progress !== undefined ? clampProgress(progress) : 0,
|
||||||
links: normalizeLinks(links),
|
links: normalizeLinks(links),
|
||||||
order: count,
|
order: count,
|
||||||
|
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||||
|
|
||||||
if (feedback?.toString().trim()) {
|
if (feedback?.toString().trim()) {
|
||||||
const updatedBy = await resolveTaskActorId(taskId);
|
const updatedBy = await resolveTaskActorId(taskId);
|
||||||
await prisma.taskDetail.create({
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -86,19 +144,33 @@ router.post('/:taskId', async (req, res, next) => {
|
|||||||
// PATCH /api/milestones/item/:id
|
// PATCH /api/milestones/item/:id
|
||||||
router.patch('/item/:id', async (req, res, next) => {
|
router.patch('/item/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, startDate, dueDate, feedback, links, progress, completed, order } =
|
const body = req.body as Record<string, unknown>;
|
||||||
req.body as Record<string, string | boolean | number>;
|
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 } });
|
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
||||||
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||||
|
|
||||||
|
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||||
|
|
||||||
const milestone = await prisma.milestone.update({
|
const milestone = await prisma.milestone.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...(title !== undefined && { title: String(title).trim() }),
|
...(title !== undefined && { title: String(title).trim() }),
|
||||||
|
...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }),
|
||||||
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
||||||
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
...(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) }),
|
...(progress !== undefined && { progress: clampProgress(progress) }),
|
||||||
...(links !== undefined && { links: normalizeLinks(links) }),
|
...(links !== undefined && { links: normalizeLinks(links) }),
|
||||||
...(order !== undefined && { order: Number(order) }),
|
...(order !== undefined && { order: Number(order) }),
|
||||||
@@ -106,9 +178,12 @@ router.patch('/item/:id', async (req, res, next) => {
|
|||||||
completedAt: completed ? new Date() : null,
|
completedAt: completed ? new Date() : null,
|
||||||
...(completed && { progress: 100 }),
|
...(completed && { progress: 100 }),
|
||||||
}),
|
}),
|
||||||
|
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||||
|
|
||||||
if (typeof feedback === 'string' && feedback.trim()) {
|
if (typeof feedback === 'string' && feedback.trim()) {
|
||||||
const updatedBy = await resolveTaskActorId(existing.taskId);
|
const updatedBy = await resolveTaskActorId(existing.taskId);
|
||||||
await prisma.taskDetail.create({
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import type { Server } from 'socket.io';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { resolveCreatorId } from '../lib/resolveUser';
|
import { resolveCreatorId } from '../lib/resolveUser';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
|
import { emitTaskListRefresh, emitTaskUpdated } from '../socket';
|
||||||
import {
|
import {
|
||||||
formatTask,
|
formatTask,
|
||||||
parseMemberIds,
|
parseMemberIds,
|
||||||
@@ -9,9 +11,25 @@ import {
|
|||||||
taskDetailInclude,
|
taskDetailInclude,
|
||||||
taskInclude,
|
taskInclude,
|
||||||
} from '../lib/taskQuery';
|
} from '../lib/taskQuery';
|
||||||
|
import { deriveIssueFields, normalizeIssueEntries } from '../lib/taskIssues';
|
||||||
|
|
||||||
const router = Router();
|
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)
|
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +82,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
|
|
||||||
const creatorId = await resolveCreatorId(body.creatorId);
|
const creatorId = await resolveCreatorId(body.creatorId);
|
||||||
const assigneeMemberIds = parseMemberIds(body);
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const issuePayload = resolveIssuePayload(body);
|
||||||
|
|
||||||
const task = await prisma.task.create({
|
const task = await prisma.task.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -77,13 +96,14 @@ router.post('/', async (req, res, next) => {
|
|||||||
tag,
|
tag,
|
||||||
taskType,
|
taskType,
|
||||||
progress: progress ? Number(progress) : 0,
|
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,
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
dueDate: dueDate ? new Date(dueDate) : undefined,
|
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||||
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
|
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
|
||||||
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
|
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
|
||||||
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === 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,
|
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
||||||
assigneeId: assigneeId || null,
|
assigneeId: assigneeId || null,
|
||||||
pmMemberId: pmMemberId || null,
|
pmMemberId: pmMemberId || null,
|
||||||
@@ -120,6 +140,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
|
|
||||||
const assigneeMemberIds = parseMemberIds(body);
|
const assigneeMemberIds = parseMemberIds(body);
|
||||||
|
const issuePayload = resolveIssuePayload(body);
|
||||||
|
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
@@ -134,7 +155,20 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
...(tag !== undefined && { tag }),
|
...(tag !== undefined && { tag }),
|
||||||
...(taskType !== undefined && { taskType }),
|
...(taskType !== undefined && { taskType }),
|
||||||
...(progress !== undefined && { progress: Number(progress) }),
|
...(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 }),
|
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
||||||
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
||||||
@@ -160,7 +194,14 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
include: taskInclude,
|
include: taskInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(formatTask(task!));
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
|
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
|
||||||
|
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) =>
|
|||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isDiskFullError(err)) {
|
||||||
|
cleanupUploadedFile(req.file);
|
||||||
|
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
7
data/seed/README.md
Normal file
7
data/seed/README.md
Normal 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
1083
data/seed/hr-data.json
Normal file
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-eee_password}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-eee_password}
|
||||||
POSTGRES_DB: ${DB_NAME:-eee_dashboard}
|
POSTGRES_DB: ${DB_NAME:-eee_dashboard}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-54320}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
200
frontend/package-lock.json
generated
200
frontend/package-lock.json
generated
@@ -13,6 +13,8 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.56.0",
|
"@tanstack/react-query": "^5.56.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
|
"pptx-react-renderer": "^0.1.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
@@ -365,6 +368,40 @@
|
|||||||
"react": ">=16.8.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -857,6 +894,25 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.2",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
@@ -1578,6 +1634,17 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1658,6 +1725,19 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1840,6 +1920,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/crc-32": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
@@ -1895,6 +1981,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2254,6 +2349,24 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2296,6 +2409,27 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -2666,6 +2800,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2715,6 +2855,24 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -2791,6 +2949,21 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.4",
|
"version": "4.60.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||||
@@ -2836,6 +3009,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -2855,6 +3034,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/socket.io-client": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
@@ -2905,6 +3090,15 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
@@ -2994,6 +3188,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.56.0",
|
"@tanstack/react-query": "^5.56.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
|
"pptx-react-renderer": "^0.1.1",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
|
|||||||
@@ -32,6 +32,17 @@
|
|||||||
--conn-line-width: 4;
|
--conn-line-width: 4;
|
||||||
--text-primary: #1a2b3c;
|
--text-primary: #1a2b3c;
|
||||||
--text-muted: #5a6b7d;
|
--text-muted: #5a6b7d;
|
||||||
|
/* 헤더 상태 통계(stat-*)와 동일 — 프로젝트 제목 불릿 */
|
||||||
|
--status-in-progress: #10b981;
|
||||||
|
--status-hold: #ff9f0a;
|
||||||
|
--status-done: #b0b0b0;
|
||||||
|
--status-issue: #ff5252;
|
||||||
|
/* 부서 카드 헤드 — 샴페인 골드 */
|
||||||
|
--dept-head-bg-top: #faf6ef;
|
||||||
|
--dept-head-bg-bottom: #f0e6d4;
|
||||||
|
--dept-head-text: #4a3d2e;
|
||||||
|
--dept-head-accent: #9a7b4f;
|
||||||
|
--dept-head-line: rgba(180, 152, 108, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -188,7 +199,7 @@
|
|||||||
.poly-click-stat {
|
.poly-click-stat {
|
||||||
display: inline;
|
display: inline;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
line-height: 21px;
|
line-height: 21px;
|
||||||
color: #fffc;
|
color: #fffc;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -255,7 +266,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px) minmax(0, 1fr);
|
/* 중앙 허브 확대(다이아몬드 1.1×) — 좌·우 부문 카드 열은 1fr로 자동 축소 */
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 396px) minmax(0, 1fr);
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
gap: 12px 24px;
|
gap: 12px 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -360,9 +372,30 @@
|
|||||||
.dept-card--ex { grid-column: 1; grid-row: 2; }
|
.dept-card--ex { grid-column: 1; grid-row: 2; }
|
||||||
.dept-card--ga { grid-column: 3; grid-row: 2; }
|
.dept-card--ga { grid-column: 3; grid-row: 2; }
|
||||||
|
|
||||||
/* 아이콘 숨김 (HTML은 BACKUP용으로 유지) */
|
|
||||||
.dept-icon {
|
.dept-icon {
|
||||||
display: none;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.48);
|
||||||
|
border: 1px solid rgba(180, 152, 108, 0.24);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.62),
|
||||||
|
0 1px 2px rgba(92, 74, 50, 0.06);
|
||||||
|
color: var(--dept-head-accent);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-icon svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dept-head {
|
.dept-head {
|
||||||
@@ -373,15 +406,32 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding: 16px 16px 10px;
|
padding: 9px 16px 9px;
|
||||||
border-bottom: 1px solid #d8e8e0;
|
border-bottom: none;
|
||||||
background: linear-gradient(180deg, #fcfefd 0%, #f7fbf9 100%);
|
background: linear-gradient(180deg, var(--dept-head-bg-top) 0%, var(--dept-head-bg-bottom) 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dept-head::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--dept-head-line) 18%,
|
||||||
|
var(--dept-head-line) 82%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.dept-head-main {
|
.dept-head-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -411,13 +461,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dept-card .board-dept-title {
|
.dept-card .board-dept-title {
|
||||||
color: #0a2e24;
|
color: var(--dept-head-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dept-card .dept-head-count .poly-stat-val,
|
.dept-card .dept-head-count .poly-stat-val,
|
||||||
.dept-card .dept-head-count .poly-stat-unit,
|
.dept-card .dept-head-count .poly-stat-unit,
|
||||||
.dept-card .board-dept-title-en {
|
.dept-card .board-dept-title-en {
|
||||||
color: var(--dept-accent);
|
color: var(--dept-head-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-dept-header-main {
|
.board-dept-header-main {
|
||||||
@@ -452,14 +502,30 @@
|
|||||||
|
|
||||||
/* ─── 프로젝트 목록 — 종이 메모 리스트 ─── */
|
/* ─── 프로젝트 목록 — 종이 메모 리스트 ─── */
|
||||||
.board-project-list {
|
.board-project-list {
|
||||||
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 14px 10px;
|
padding: 10px 14px 10px;
|
||||||
background: transparent;
|
background: linear-gradient(180deg, #f8fbfa 0%, rgba(248, 251, 250, 0) 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-project-list::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(92, 74, 50, 0.04) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-sub-card {
|
.project-sub-card {
|
||||||
@@ -482,16 +548,41 @@
|
|||||||
|
|
||||||
.project-sub-body {
|
.project-sub-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
gap: 14px;
|
column-gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-sub-divider {
|
||||||
|
display: block;
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 6px 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
#c8d9d0 14%,
|
||||||
|
#c8d9d0 86%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 93px;
|
||||||
|
}
|
||||||
|
|
||||||
.project-fields {
|
.project-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* 불릿(10px) + gap(10px) — 하위 필드는 제목 글자 시작선과 정렬 */
|
||||||
|
--project-title-indent: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-field {
|
.project-field {
|
||||||
@@ -499,19 +590,43 @@
|
|||||||
grid-template-columns: 96px 1fr;
|
grid-template-columns: 96px 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
padding-left: var(--project-title-indent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-sub-title {
|
.project-sub-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0a2e24;
|
color: #0a2e24;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-sub-title::before {
|
||||||
|
content: "";
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-top: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-bullet, var(--status-in-progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-sub-title-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-sub-title--in-progress { --status-bullet: var(--status-in-progress); }
|
||||||
|
.project-sub-title--hold { --status-bullet: var(--status-hold); }
|
||||||
|
.project-sub-title--done { --status-bullet: var(--status-done); }
|
||||||
|
.project-sub-title--issue { --status-bullet: var(--status-issue); }
|
||||||
|
|
||||||
.project-field-label {
|
.project-field-label {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -532,19 +647,6 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-sub-divider {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-col {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 93px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dept-card .donut {
|
.dept-card .donut {
|
||||||
--color: var(--dept-accent);
|
--color: var(--dept-accent);
|
||||||
background: conic-gradient(var(--color) calc(var(--pct) * 1%), #d4ddd8 0);
|
background: conic-gradient(var(--color) calc(var(--pct) * 1%), #d4ddd8 0);
|
||||||
@@ -598,8 +700,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
--hub-row-h: calc((100% - 12px) / 2);
|
--hub-row-h: calc((100% - 12px) / 2);
|
||||||
--hub-band-h: min(calc(var(--hub-row-h) * 0.52), 188px);
|
--hub-band-h: min(calc(var(--hub-row-h) * 0.473), 171px);
|
||||||
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.624), 226px);
|
/* 슬로건·일정 밴드 — 다이아몬드 1.1×에 맞춰 높이 축소 (0.624→0.567, 226→205) */
|
||||||
|
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.567), 205px);
|
||||||
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
|
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
|
||||||
--hub-title-top: 20px;
|
--hub-title-top: 20px;
|
||||||
/* 슬로건: 바깥(그리드→포스트잇) + 안(핀 아래→제목). 일정 플래너 14px과 같은 호흡 */
|
/* 슬로건: 바깥(그리드→포스트잇) + 안(핀 아래→제목). 일정 플래너 14px과 같은 호흡 */
|
||||||
@@ -611,7 +714,7 @@
|
|||||||
--hub-head-pad-bottom: 7px;
|
--hub-head-pad-bottom: 7px;
|
||||||
--hub-card-pad-x: 12px;
|
--hub-card-pad-x: 12px;
|
||||||
--hub-card-pad-bottom: 10px;
|
--hub-card-pad-bottom: 10px;
|
||||||
--hub-diamond-scale: 0.9;
|
--hub-diamond-scale: 0.99;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-box--message {
|
.hub-box--message {
|
||||||
@@ -677,7 +780,7 @@
|
|||||||
border: 1px solid #d8e2ea;
|
border: 1px solid #d8e2ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 분기 슬로건 — 2장 겹친 sage 포스트잇 */
|
/* 분기 중점 과제 — 2장 겹친 sage 포스트잇 */
|
||||||
.hub-box--message {
|
.hub-box--message {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -985,14 +1088,20 @@
|
|||||||
color: #0a2e24;
|
color: #0a2e24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-column .board-project-desc,
|
.hub-column .board-project-desc {
|
||||||
.hub-column .hub-routine-item {
|
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hub-column .hub-routine-item {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.hub-column .hub-schedule-month {
|
.hub-column .hub-schedule-month {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1180,30 +1289,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hub-diamond-icon {
|
.hub-diamond-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid var(--hrm);
|
||||||
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--hrm);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-diamond-icon svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
stroke: #6eecc8;
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 2.4;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-diamond {
|
.hub-diamond {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
background: var(--hub-diamond-bg);
|
background: #fff;
|
||||||
border: 2.5px solid var(--hub-diamond-border);
|
border: 2.5px solid var(--hrm);
|
||||||
border-radius: 10px;
|
box-shadow: 0 4px 18px rgba(7, 65, 46, 0.1);
|
||||||
box-shadow: 0 6px 22px rgba(7, 65, 46, 0.28);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1211,77 +1315,52 @@
|
|||||||
|
|
||||||
.hub-diamond-inner {
|
.hub-diamond-inner {
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
width: 92%;
|
|
||||||
max-height: 92%;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-diamond-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-diamond-head .board-project-title {
|
|
||||||
text-align: left;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-diamond-divider {
|
|
||||||
width: 88%;
|
width: 88%;
|
||||||
height: 1px;
|
text-align: center;
|
||||||
background: rgba(255, 255, 255, 0.22);
|
padding: 0 4px;
|
||||||
margin: 2px 0 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-routine-grid {
|
.hub-diamond-icon {
|
||||||
display: grid;
|
width: 38px;
|
||||||
grid-template-columns: 1fr 1fr;
|
height: 38px;
|
||||||
column-gap: 10px;
|
margin: 0 auto 6px;
|
||||||
row-gap: 2px;
|
border: 2px solid var(--hrm);
|
||||||
width: 100%;
|
border-radius: 50%;
|
||||||
max-width: 100%;
|
display: flex;
|
||||||
justify-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--hrm);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-routine-item {
|
.hub-diamond-title {
|
||||||
appearance: none;
|
font-size: 18px;
|
||||||
background: none;
|
font-weight: 800;
|
||||||
border: none;
|
color: #1e3a5f;
|
||||||
padding: 2px 4px;
|
margin-bottom: 6px;
|
||||||
margin: 0;
|
line-height: 1.3;
|
||||||
color: #8fd4bc;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: color 0.18s ease, opacity 0.18s ease, text-shadow 0.18s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-routine-item:hover {
|
.hub-diamond ul {
|
||||||
color: #b4ecd6;
|
list-style: none;
|
||||||
text-shadow: 0 0 10px rgba(143, 212, 188, 0.28);
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-routine-item:active {
|
.hub-diamond li {
|
||||||
opacity: 0.82;
|
padding: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-routine-item:focus-visible {
|
.hub-diamond li::before {
|
||||||
outline: 1px solid rgba(143, 212, 188, 0.45);
|
content: "• ";
|
||||||
outline-offset: 2px;
|
color: var(--hrm);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
@@ -1405,7 +1484,10 @@
|
|||||||
<article class="project-sub-card project-sub-card--hrm">
|
<article class="project-sub-card project-sub-card--hrm">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">상반기 채용 운영</div>
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">상반기 채용 운영</span>
|
||||||
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">작업 기간</span>
|
||||||
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
||||||
@@ -1424,9 +1506,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--hrm">
|
<article class="project-sub-card project-sub-card--hrm">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">평가제도 개선</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">평가제도 개선</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.03 ~ 2026.07</span>
|
<span class="project-field-value">2026.03 ~ 2026.07</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1436,7 +1521,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
<div class="donut" style="--pct:60"><span>60%</span></div>
|
<span class="progress-label">진행/달성률</span>
|
||||||
|
<div class="donut" style="--pct:60; --color:#ff9f0a"><span style="color:#ff9f0a">보류</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -1465,9 +1551,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--hrd">
|
<article class="project-sub-card project-sub-card--hrd">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">신규입사자 온보딩 프로그램</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">신규입사자 온보딩 프로그램</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1477,6 +1566,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
|
<span class="progress-label">진행/달성률</span>
|
||||||
<div class="donut" style="--pct:85"><span>85%</span></div>
|
<div class="donut" style="--pct:85"><span>85%</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1484,9 +1574,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--hrd">
|
<article class="project-sub-card project-sub-card--hrd">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">팀장 리더십 교육</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">팀장 리더십 교육</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.05 ~ 2026.06</span>
|
<span class="project-field-value">2026.05 ~ 2026.06</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1496,7 +1589,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
<div class="donut" style="--pct:100"><span>100%</span></div>
|
<span class="progress-label">진행/달성률</span>
|
||||||
|
<div class="donut" style="--pct:100; --color:#b0b0b0"><span style="color:#b0b0b0">완료</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -1505,7 +1599,7 @@
|
|||||||
|
|
||||||
<!-- 중앙 허브 -->
|
<!-- 중앙 허브 -->
|
||||||
<div class="hub-column" id="hub-column">
|
<div class="hub-column" id="hub-column">
|
||||||
<!-- 분기 슬로건 -->
|
<!-- 분기 중점 과제 -->
|
||||||
<div class="hub-box hub-box--message">
|
<div class="hub-box hub-box--message">
|
||||||
<div class="hub-postit-stack">
|
<div class="hub-postit-stack">
|
||||||
<div class="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true"></div>
|
<div class="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true"></div>
|
||||||
@@ -1517,17 +1611,12 @@
|
|||||||
<div class="hub-box-title-row">
|
<div class="hub-box-title-row">
|
||||||
<span class="hub-message-icon" aria-hidden="true">
|
<span class="hub-message-icon" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M12 4v5"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<path d="M12 15v5"/>
|
<circle cx="12" cy="12" r="6"/>
|
||||||
<path d="M4 12h5"/>
|
<circle cx="12" cy="12" r="2"/>
|
||||||
<path d="M15 12h5"/>
|
|
||||||
<path d="M18 5v2"/>
|
|
||||||
<path d="M17 6h2"/>
|
|
||||||
<path d="M5 17v2"/>
|
|
||||||
<path d="M4 18h2"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div class="board-project-title">분기 슬로건</div>
|
<div class="board-project-title">분기 중점 과제</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hub-postit-message">
|
<div class="hub-postit-message">
|
||||||
@@ -1554,12 +1643,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hub-diamond-divider" aria-hidden="true"></div>
|
<div class="hub-diamond-divider" aria-hidden="true"></div>
|
||||||
<div class="hub-routine-grid">
|
<div class="hub-routine-grid">
|
||||||
<button type="button" class="hub-routine-item">채용</button>
|
<button type="button" class="hub-routine-item">채용 운영</button>
|
||||||
<button type="button" class="hub-routine-item">교육</button>
|
<button type="button" class="hub-routine-item">교육 운영</button>
|
||||||
<button type="button" class="hub-routine-item">소통</button>
|
<button type="button" class="hub-routine-item">직원 소통</button>
|
||||||
<button type="button" class="hub-routine-item">시설</button>
|
<button type="button" class="hub-routine-item">자산·시설</button>
|
||||||
<button type="button" class="hub-routine-item">자산</button>
|
<button type="button" class="hub-routine-item">문서·행정</button>
|
||||||
<button type="button" class="hub-routine-item">행정</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1623,9 +1711,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--ex">
|
<article class="project-sub-card project-sub-card--ex">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">조직문화 진단</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">조직문화 진단</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.04 ~ 2026.05</span>
|
<span class="project-field-value">2026.04 ~ 2026.05</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1635,6 +1726,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
|
<span class="progress-label">진행/달성률</span>
|
||||||
<div class="donut" style="--pct:100"><span>100%</span></div>
|
<div class="donut" style="--pct:100"><span>100%</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1642,9 +1734,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--ex">
|
<article class="project-sub-card project-sub-card--ex">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">복리후생제도 개선</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">복리후생제도 개선</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.05 ~ 2026.08</span>
|
<span class="project-field-value">2026.05 ~ 2026.08</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1654,7 +1749,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
<div class="donut" style="--pct:50"><span>50%</span></div>
|
<span class="progress-label">진행/달성률</span>
|
||||||
|
<div class="donut" style="--pct:50; --color:#ff9f0a"><span style="color:#ff9f0a">보류</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -1683,9 +1779,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--ga">
|
<article class="project-sub-card project-sub-card--ga">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">사무공간 재배치</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">사무공간 재배치</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.04 ~ 2026.07</span>
|
<span class="project-field-value">2026.04 ~ 2026.07</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1695,6 +1794,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
|
<span class="progress-label">진행/달성률</span>
|
||||||
<div class="donut" style="--pct:70"><span>70%</span></div>
|
<div class="donut" style="--pct:70"><span>70%</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1702,9 +1802,12 @@
|
|||||||
<article class="project-sub-card project-sub-card--ga">
|
<article class="project-sub-card project-sub-card--ga">
|
||||||
<div class="project-sub-body">
|
<div class="project-sub-body">
|
||||||
<div class="project-fields">
|
<div class="project-fields">
|
||||||
<div class="project-sub-title">안전·보안 점검 강화</div>
|
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
<span class="project-field-label">작업 기간</span>
|
<span class="project-field-label">프로젝트명</span>
|
||||||
|
<span class="project-field-value">안전·보안 점검 강화</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-field">
|
||||||
|
<span class="project-field-label">기간</span>
|
||||||
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
<span class="project-field-value">2026.04 ~ 2026.06</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-field">
|
<div class="project-field">
|
||||||
@@ -1714,6 +1817,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="project-sub-divider" aria-hidden="true"></div>
|
<div class="project-sub-divider" aria-hidden="true"></div>
|
||||||
<div class="progress-col">
|
<div class="progress-col">
|
||||||
|
<span class="progress-label">진행/달성률</span>
|
||||||
<div class="donut" style="--pct:85"><span>85%</span></div>
|
<div class="donut" style="--pct:85"><span>85%</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
frontend/src/components/common/HubNavChevron.tsx
Normal file
21
frontend/src/components/common/HubNavChevron.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember, TaskIssueEntry } from '../../types';
|
||||||
import { normalizeTaskType, displayFlagsForTaskType, TASK_TYPE_OPTIONS } from '../../lib/taskType';
|
import { normalizeTaskType } from '../../lib/taskType';
|
||||||
|
import { newIssueEntry, parseIssueEntries } from '../../lib/taskIssues';
|
||||||
|
import { getRoutineCategory, routineCategoryOptions } from '../../lib/routineCategories';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'TODO', label: '대기' },
|
{ value: 'TODO', label: '대기' },
|
||||||
@@ -13,28 +15,27 @@ const STATUS_OPTIONS = [
|
|||||||
export interface TaskFormData {
|
export interface TaskFormData {
|
||||||
title: string;
|
title: string;
|
||||||
section: string;
|
section: string;
|
||||||
|
category: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
taskType: string;
|
taskType: string;
|
||||||
status: string;
|
status: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
description: string;
|
description: string;
|
||||||
issueNote: string;
|
issueEntries: TaskIssueEntry[];
|
||||||
quarter: string;
|
quarter: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
showDate: boolean;
|
|
||||||
showDescription: boolean;
|
|
||||||
showStatus: boolean;
|
|
||||||
showIssue: boolean;
|
|
||||||
showProgress: boolean;
|
|
||||||
pmMemberId: string;
|
pmMemberId: string;
|
||||||
assigneeMemberIds: string[];
|
assigneeMemberIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskModalProps {
|
interface TaskModalProps {
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
|
/** project: 실행과제 전용 / routine: 기반업무(상시) 전용 */
|
||||||
|
variant?: 'project' | 'routine';
|
||||||
task?: Task;
|
task?: Task;
|
||||||
defaultSection?: string;
|
defaultSection?: string;
|
||||||
|
defaultCategory?: string;
|
||||||
defaultQuarter?: string;
|
defaultQuarter?: string;
|
||||||
sectionOptions?: { value: string; label: string }[];
|
sectionOptions?: { value: string; label: string }[];
|
||||||
teamMembers?: TeamMember[];
|
teamMembers?: TeamMember[];
|
||||||
@@ -44,37 +45,37 @@ interface TaskModalProps {
|
|||||||
|
|
||||||
export function TaskModal({
|
export function TaskModal({
|
||||||
mode,
|
mode,
|
||||||
|
variant = 'project',
|
||||||
task,
|
task,
|
||||||
defaultSection = 'HR',
|
defaultSection = 'HR',
|
||||||
|
defaultCategory = '채용 운영',
|
||||||
defaultQuarter = '2026-Q2',
|
defaultQuarter = '2026-Q2',
|
||||||
sectionOptions,
|
sectionOptions,
|
||||||
teamMembers = [],
|
teamMembers = [],
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
}: TaskModalProps) {
|
}: TaskModalProps) {
|
||||||
|
const isRoutine = variant === 'routine';
|
||||||
const toDateInput = (iso: string | null | undefined) => {
|
const toDateInput = (iso: string | null | undefined) => {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
// DB값(프로젝트/상시업무) → 새 값(실행과제/기반업무) 정규화
|
|
||||||
const [form, setForm] = useState<TaskFormData>({
|
const [form, setForm] = useState<TaskFormData>({
|
||||||
title: task?.title ?? '',
|
title: task?.title ?? '',
|
||||||
section: task?.section ?? defaultSection,
|
section: task?.section ?? defaultSection,
|
||||||
|
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
|
||||||
tag: task?.tag ?? '',
|
tag: task?.tag ?? '',
|
||||||
taskType: task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
|
taskType: isRoutine
|
||||||
|
? '기반업무'
|
||||||
|
: (task?.taskType ? normalizeTaskType(task.taskType) : '실행과제'),
|
||||||
status: task?.status ?? 'TODO',
|
status: task?.status ?? 'TODO',
|
||||||
progress: task?.progress ?? 0,
|
progress: task?.progress ?? 0,
|
||||||
description: task?.description ?? '',
|
description: task?.description ?? '',
|
||||||
issueNote: task?.issueNote ?? '',
|
issueEntries: task ? parseIssueEntries(task) : [],
|
||||||
quarter: task?.quarter ?? defaultQuarter,
|
quarter: task?.quarter ?? defaultQuarter,
|
||||||
startDate: toDateInput(task?.startDate),
|
startDate: toDateInput(task?.startDate),
|
||||||
dueDate: toDateInput(task?.dueDate),
|
dueDate: toDateInput(task?.dueDate),
|
||||||
showDate: task?.showDate ?? true,
|
|
||||||
showDescription: task?.showDescription ?? true,
|
|
||||||
showStatus: task?.showStatus ?? true,
|
|
||||||
showIssue: task?.showIssue ?? true,
|
|
||||||
showProgress: task?.showProgress ?? true,
|
|
||||||
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
||||||
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||||
});
|
});
|
||||||
@@ -94,9 +95,36 @@ export function TaskModal({
|
|||||||
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
|
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSave(form);
|
const payload: TaskFormData = {
|
||||||
|
...form,
|
||||||
|
taskType: isRoutine ? '기반업무' : '실행과제',
|
||||||
|
};
|
||||||
|
onSave(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -111,7 +139,9 @@ export function TaskModal({
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
<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">
|
<h2 className="text-2xl font-black text-gray-800">
|
||||||
{mode === 'add' ? '✚ 업무 추가' : '✏ 업무 수정'}
|
{isRoutine
|
||||||
|
? (mode === 'add' ? '✚ 상시업무 추가' : '✏ 상시업무 수정')
|
||||||
|
: (mode === 'add' ? '✚ 프로젝트 추가' : '✏ 프로젝트 수정')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,14 +165,26 @@ export function TaskModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 섹션 + 업무유형 */}
|
{/* 대분류 (상시업무) / 소속 부문 (프로젝트) */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">섹션</label>
|
<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
|
<select
|
||||||
value={form.section}
|
value={form.section}
|
||||||
onChange={(e) => set('section', e.target.value)}
|
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 ?? [
|
{(sectionOptions ?? [
|
||||||
{ value: 'HR', label: 'HR' },
|
{ value: 'HR', label: 'HR' },
|
||||||
@@ -151,43 +193,14 @@ export function TaskModal({
|
|||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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"
|
|
||||||
>
|
|
||||||
{TASK_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 + 진행률 */}
|
{/* 상태 + 진행률 (프로젝트만) */}
|
||||||
|
{!isRoutine && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">상태</label>
|
||||||
<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>
|
|
||||||
<select
|
<select
|
||||||
value={form.status}
|
value={form.status}
|
||||||
onChange={(e) => set('status', e.target.value)}
|
onChange={(e) => set('status', e.target.value)}
|
||||||
@@ -199,22 +212,11 @@ export function TaskModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">
|
||||||
<label className="text-sm font-bold text-gray-500">
|
|
||||||
진행률
|
진행률
|
||||||
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
|
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<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">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -227,21 +229,27 @@ export function TaskModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">내용</label>
|
||||||
<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>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) => set('description', e.target.value)}
|
onChange={(e) => set('description', e.target.value)}
|
||||||
@@ -299,19 +307,9 @@ export function TaskModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 프로젝트 기간 */}
|
{/* 프로젝트 기간 */}
|
||||||
|
{!isRoutine && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<label className="block text-sm font-bold text-gray-500 mb-1.5">기간</label>
|
||||||
<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>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -327,27 +325,60 @@ export function TaskModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="text-sm font-bold text-gray-500">이슈 메모</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>
|
||||||
|
{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">
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.showIssue}
|
checked={entry.showOnCard}
|
||||||
onChange={(e) => set('showIssue', e.target.checked)}
|
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
|
||||||
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-gray-400">카드 표시</span>
|
<span className="text-xs font-semibold text-gray-500">카드 표시</span>
|
||||||
</label>
|
</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>
|
||||||
<input
|
</div>
|
||||||
value={form.issueNote}
|
))}
|
||||||
onChange={(e) => set('issueNote', e.target.value)}
|
</div>
|
||||||
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="[날짜] 이슈 내용 (비우면 표시 안 함)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 버튼 */}
|
{/* 버튼 */}
|
||||||
@@ -361,7 +392,9 @@ export function TaskModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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' ? '추가하기' : '저장하기'}
|
{mode === 'add' ? '추가하기' : '저장하기'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
172
frontend/src/components/dashboard/BoardCalendarPopover.tsx
Normal file
172
frontend/src/components/dashboard/BoardCalendarPopover.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
import { useBoardConnectors } from '../../hooks/useBoardConnectors';
|
import { useBoardConnectors, type ConnectorStyle } from '../../hooks/useBoardConnectors';
|
||||||
|
|
||||||
export function BoardConnectors({ enabled = true }: { enabled?: boolean }) {
|
export function BoardConnectors({
|
||||||
const { svgRef, lineGroupRef } = useBoardConnectors(enabled);
|
enabled = true,
|
||||||
|
style = 'default',
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
style?: ConnectorStyle;
|
||||||
|
}) {
|
||||||
|
const { svgRef, lineGroupRef, dotSvgRef, dotGroupRef } = useBoardConnectors(enabled, style);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<svg ref={svgRef} className="connectors" aria-hidden="true">
|
<svg ref={svgRef} className="connectors" aria-hidden="true">
|
||||||
<g ref={lineGroupRef} id="connector-lines" />
|
<g ref={lineGroupRef} id="connector-lines" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{style === 'reference' && (
|
||||||
|
<svg ref={dotSvgRef} className="connectors connectors--dots" aria-hidden="true">
|
||||||
|
<g ref={dotGroupRef} id="connector-dots" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { formatReferenceSummary } from '../../lib/boardCalendar';
|
||||||
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,8 @@ import {
|
|||||||
|
|
||||||
} from '../../lib/statusFilters';
|
} from '../../lib/statusFilters';
|
||||||
|
|
||||||
import { DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
import { BoardCalendarPopover } from './BoardCalendarPopover';
|
||||||
|
import { CalendarIcon, DualMonitorIcon, PlusIcon, UsersIcon } from './HeaderIcons';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ interface DashboardHeaderProps {
|
|||||||
|
|
||||||
quarter: string;
|
quarter: string;
|
||||||
|
|
||||||
|
referenceDate: Date;
|
||||||
|
|
||||||
|
onReferenceDateChange: (d: Date) => void;
|
||||||
|
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
|
|
||||||
activeFilters: string[];
|
activeFilters: string[];
|
||||||
@@ -84,6 +90,10 @@ export function DashboardHeader({
|
|||||||
|
|
||||||
quarter,
|
quarter,
|
||||||
|
|
||||||
|
referenceDate,
|
||||||
|
|
||||||
|
onReferenceDateChange,
|
||||||
|
|
||||||
stats,
|
stats,
|
||||||
|
|
||||||
activeFilters,
|
activeFilters,
|
||||||
@@ -107,6 +117,8 @@ export function DashboardHeader({
|
|||||||
}: DashboardHeaderProps) {
|
}: DashboardHeaderProps) {
|
||||||
|
|
||||||
const [detailViewActive, setDetailViewActive] = useState(isDetailWindowOpen);
|
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 quarterLabel = quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
||||||
|
|
||||||
@@ -237,10 +249,21 @@ export function DashboardHeader({
|
|||||||
<UsersIcon size={16} />
|
<UsersIcon size={16} />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="header-stats-bar side-polygon-stats">
|
<div className="header-stats-bar side-polygon-stats">
|
||||||
|
|
||||||
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
|
<div className="poly-stat-item" style={{ fontSize: '18px', gap: '8px' }}>
|
||||||
@@ -280,29 +303,30 @@ export function DashboardHeader({
|
|||||||
|
|
||||||
|
|
||||||
<div className="side-right-actions shrink-0">
|
<div className="side-right-actions shrink-0">
|
||||||
|
<div className="header-calendar-slot">
|
||||||
<button type="button" onClick={onOpenTaskManager} title="신규 프로젝트 추가" className="header-action-btn-new">
|
<span className="board-calendar-ref-text">{formatReferenceSummary(referenceDate)}</span>
|
||||||
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
ref={calendarBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
|
||||||
onClick={handleOpenDetailWindow}
|
title="기준일 · 분기 선택"
|
||||||
|
aria-expanded={calendarOpen}
|
||||||
title="듀얼뷰"
|
aria-label="캘린더 열기"
|
||||||
|
onClick={() => setCalendarOpen((open) => !open)}
|
||||||
className={`header-view-btn-new ${detailViewActive ? 'active' : ''}`}
|
|
||||||
|
|
||||||
>
|
>
|
||||||
|
<CalendarIcon size={16} />
|
||||||
<DualMonitorIcon size={16} />
|
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
{calendarOpen && (
|
||||||
|
<BoardCalendarPopover
|
||||||
|
referenceDate={referenceDate}
|
||||||
|
onChange={(d) => {
|
||||||
|
onReferenceDateChange(d);
|
||||||
|
}}
|
||||||
|
onClose={() => setCalendarOpen(false)}
|
||||||
|
anchorRef={calendarBtnRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import { useState, useEffect } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
||||||
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||||
import { isProjectTask } from '../../lib/taskType';
|
import { isProjectTask } from '../../lib/taskType';
|
||||||
import { type BoardSlotConfig, slotSectionLabel } from '../../lib/boardLayout';
|
import { type BoardSlotConfig, slotSectionLabel, columnDisplayTitle, columnDisplayTitleEn } from '../../lib/boardLayout';
|
||||||
import { SortableTaskCard } from './TaskCard';
|
import { SortableTaskCard } from './TaskCard';
|
||||||
|
import { DeptIcon } from './DeptIcon';
|
||||||
|
import { DeptProjectList } from './DeptProjectList';
|
||||||
import { ContextMenu } from '../common/ContextMenu';
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
import { TaskModal } from '../common/TaskModal';
|
import { TaskModal } from '../common/TaskModal';
|
||||||
import type { TaskFormData } from '../common/TaskModal';
|
import type { TaskFormData } from '../common/TaskModal';
|
||||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
|
||||||
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
|
|
||||||
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
|
const DUMMY_HEADER_KEY = 'eene-board-slot-headers-v1';
|
||||||
|
/** 참고 레이아웃: 부서당 표시 슬롯 수 (2행 + 스크롤) */
|
||||||
|
|
||||||
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
|
type DummyHeaders = Record<string, { title: string; titleEn: string; subtitle: string }>;
|
||||||
|
|
||||||
@@ -138,12 +141,8 @@ export function DepartmentColumn({
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['columns', slot.sectionKey] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = isDummySlot
|
const title = columnDisplayTitle(slot, colConfig, dummyHeader);
|
||||||
? dummyHeader?.title ?? slot.defaultTitle
|
const titleEnState = columnDisplayTitleEn(slot, colConfig, dummyHeader);
|
||||||
: colConfig?.title ?? slot.defaultTitle;
|
|
||||||
const titleEnState = isDummySlot
|
|
||||||
? dummyHeader?.titleEn ?? slot.defaultTitleEn
|
|
||||||
: colConfig?.titleEn ?? slot.defaultTitleEn;
|
|
||||||
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
|
const subtitle = isDummySlot ? dummyHeader?.subtitle ?? '' : colConfig?.subtitle ?? '';
|
||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; type: 'header' | 'list' } | null>(null);
|
||||||
@@ -176,7 +175,7 @@ export function DepartmentColumn({
|
|||||||
const patch = useMutation({
|
const patch = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
apiClient.patch(`/tasks/${id}`, data),
|
apiClient.patch(`/tasks/${id}`, data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
@@ -210,7 +209,7 @@ export function DepartmentColumn({
|
|||||||
const handleAdd = async (data: TaskFormData) => {
|
const handleAdd = async (data: TaskFormData) => {
|
||||||
try {
|
try {
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
...taskFormToApiPayload(data),
|
...projectFormToApiPayload(data),
|
||||||
section,
|
section,
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
} as Partial<Task>);
|
} as Partial<Task>);
|
||||||
@@ -222,7 +221,7 @@ export function DepartmentColumn({
|
|||||||
|
|
||||||
const handleEdit = (data: TaskFormData) => {
|
const handleEdit = (data: TaskFormData) => {
|
||||||
if (!editingTask) return;
|
if (!editingTask) return;
|
||||||
patch.mutate({ id: editingTask.id, data: taskFormToApiPayload(data) });
|
patch.mutate({ id: editingTask.id, data: projectFormToApiPayload(data) });
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingTask(null);
|
setEditingTask(null);
|
||||||
};
|
};
|
||||||
@@ -257,41 +256,40 @@ export function DepartmentColumn({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="dept-head-main">
|
<div className="dept-head-main">
|
||||||
|
<DeptIcon slotId={slot.id} />
|
||||||
<div className="board-dept-header-main">
|
<div className="board-dept-header-main">
|
||||||
<div className="board-dept-title-wrap">
|
<div className="board-dept-title-wrap">
|
||||||
<h2 className="board-dept-title">{title.replace(/\s*부문$/, '')}</h2>
|
<span className="board-dept-title" role="heading" aria-level={2}>
|
||||||
|
{title.replace(/\s*부문$/, '')}
|
||||||
|
</span>
|
||||||
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
|
{titleEnState && <span className="board-dept-title-en">{titleEnState}</span>}
|
||||||
</div>
|
|
||||||
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="dept-head-count" aria-label={`${projectTasks.length}건`}>
|
<div className="dept-head-count" aria-label={`${projectTasks.length}건`}>
|
||||||
<span className="poly-stat-val">{projectTasks.length}</span>
|
<span className="poly-stat-val">{projectTasks.length}</span>
|
||||||
<span className="poly-stat-unit">건</span>
|
<span className="poly-stat-unit">건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{subtitle && <p className="board-dept-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<DeptProjectList
|
||||||
ref={setProjectDropRef}
|
items={projectTasks}
|
||||||
className={`board-project-list ${isProjectOver ? 'is-over' : ''}`}
|
getKey={(task) => task.id}
|
||||||
|
sortableItemIds={projectTasks.map((t) => t.id)}
|
||||||
|
dropRef={setProjectDropRef}
|
||||||
|
className={isProjectOver ? 'is-over' : ''}
|
||||||
onContextMenu={handleListContextMenu}
|
onContextMenu={handleListContextMenu}
|
||||||
>
|
empty={<div className="board-empty">해당 업무 없음</div>}
|
||||||
{projectTasks.length === 0 ? (
|
renderItem={(task) => (
|
||||||
<div className="board-empty">해당 업무 없음</div>
|
|
||||||
) : (
|
|
||||||
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
{projectTasks.map((task) => (
|
|
||||||
<SortableTaskCard
|
<SortableTaskCard
|
||||||
key={task.id}
|
|
||||||
task={task}
|
task={task}
|
||||||
variant="project"
|
variant="project"
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{cardMenu && (
|
{cardMenu && (
|
||||||
@@ -322,6 +320,7 @@ export function DepartmentColumn({
|
|||||||
|
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<TaskModal
|
<TaskModal
|
||||||
|
variant="project"
|
||||||
mode="add"
|
mode="add"
|
||||||
defaultSection={section}
|
defaultSection={section}
|
||||||
defaultQuarter={quarter}
|
defaultQuarter={quarter}
|
||||||
@@ -334,6 +333,7 @@ export function DepartmentColumn({
|
|||||||
|
|
||||||
{showEditModal && editingTask && (
|
{showEditModal && editingTask && (
|
||||||
<TaskModal
|
<TaskModal
|
||||||
|
variant="project"
|
||||||
mode="edit"
|
mode="edit"
|
||||||
task={editingTask}
|
task={editingTask}
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
|
|||||||
44
frontend/src/components/dashboard/DeptIcon.tsx
Normal file
44
frontend/src/components/dashboard/DeptIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/components/dashboard/DeptProjectList.tsx
Normal file
150
frontend/src/components/dashboard/DeptProjectList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,18 @@ export function UsersIcon({ size = 16, className }: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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` */
|
/** 참고 사이트 lucide `plus` */
|
||||||
export function PlusIcon({ size = 16, className }: IconProps) {
|
export function PlusIcon({ size = 16, className }: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Task } from '../../types';
|
import type { Task } from '../../types';
|
||||||
|
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||||
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
|
import { useHubConfig, type HubConfig, type HubScheduleItem } from '../../lib/hubConfig';
|
||||||
|
import { useRoutineCategoryMilestones } from '../../hooks/useRoutineCategoryMilestones';
|
||||||
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
|
import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSchedule';
|
||||||
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
||||||
|
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
|
||||||
import { ContextMenu } from '../common/ContextMenu';
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
|
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
|
||||||
|
|
||||||
interface HubColumnProps {
|
import {
|
||||||
routineTasks: Task[];
|
ROUTINE_CATEGORIES,
|
||||||
quarter?: string;
|
pickRoutineCategoryTask,
|
||||||
onSelectRoutine?: (task: Task) => void;
|
type RoutineCategory,
|
||||||
}
|
} from '../../lib/routineCategories';
|
||||||
|
|
||||||
type HubEditSection = 'slogan' | 'routine' | 'schedule';
|
type HubEditSection = 'schedule';
|
||||||
|
|
||||||
function ModalShell({
|
function ModalShell({
|
||||||
title,
|
title,
|
||||||
@@ -53,96 +57,6 @@ function ModalShell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SloganEditModal({
|
|
||||||
config,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
config: HubConfig;
|
|
||||||
onSave: (patch: Pick<HubConfig, 'sloganTitle' | 'sloganLines'>) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [title, setTitle] = useState(config.sloganTitle);
|
|
||||||
const [lines, setLines] = useState(config.sloganLines.join('\n'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalShell
|
|
||||||
title="분기 슬로건 수정"
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave({ sloganTitle: title.trim() || '분기 슬로건', sloganLines: lines.split('\n') });
|
|
||||||
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>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">내용 (줄마다 Enter)</label>
|
|
||||||
<textarea
|
|
||||||
rows={5}
|
|
||||||
value={lines}
|
|
||||||
onChange={(e) => setLines(e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 resize-y"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoutineEditModal({
|
|
||||||
config,
|
|
||||||
hasRoutineTasks,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
config: HubConfig;
|
|
||||||
hasRoutineTasks: boolean;
|
|
||||||
onSave: (patch: Pick<HubConfig, 'routineLabels'>) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [labels, setLabels] = useState(config.routineLabels.join(', '));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalShell
|
|
||||||
title="상시업무 수정"
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave({
|
|
||||||
routineLabels: labels
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 6),
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasRoutineTasks && (
|
|
||||||
<p className="text-sm text-gray-500 leading-relaxed">
|
|
||||||
등록된 기반업무가 있으면 다이아몬드에 업무 제목이 우선 표시됩니다. 아래 라벨은 빈 칸·업무 없을 때 사용됩니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">표시 라벨 (쉼표 구분, 최대 6개)</label>
|
|
||||||
<input
|
|
||||||
value={labels}
|
|
||||||
onChange={(e) => setLabels(e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400"
|
|
||||||
placeholder="채용, 교육, 소통, 시설, 자산, 행정"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScheduleEditModal({
|
function ScheduleEditModal({
|
||||||
config,
|
config,
|
||||||
quarter,
|
quarter,
|
||||||
@@ -243,101 +157,84 @@ function openSectionContextMenu(
|
|||||||
setCtxMenu({ x: e.clientX, y: e.clientY, section });
|
setCtxMenu({ x: e.clientX, y: e.clientY, section });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }: HubColumnProps) {
|
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 { config, setConfig } = useHubConfig();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
|
const [editSection, setEditSection] = useState<HubEditSection | null>(null);
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; section: HubEditSection } | null>(null);
|
||||||
const { setNodeRef: setRoutineDropRef, isOver: isRoutineOver } = useDroppable({
|
const [routineOpening, setRoutineOpening] = useState(false);
|
||||||
id: 'drop::routine::hub',
|
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
|
||||||
});
|
|
||||||
|
|
||||||
const routineLabels =
|
const categoryFocus = useRoutineCategoryMilestones(routineTasks);
|
||||||
routineTasks.length > 0
|
|
||||||
? routineTasks.slice(0, 6).map((t) => ({ label: t.title, task: t }))
|
const hubSlots = useMemo((): { task: Task | null; label: RoutineCategory }[] => {
|
||||||
: config.routineLabels.map((label) => ({ label, task: null as Task | null }));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hub-column" id="hub-column">
|
<div className="hub-column" id="hub-column">
|
||||||
<div
|
|
||||||
className="hub-box hub-box--message"
|
|
||||||
onContextMenu={(e) => openSectionContextMenu(e, 'slogan', setCtxMenu)}
|
|
||||||
>
|
|
||||||
<div className="hub-postit-stack">
|
|
||||||
<div className="hub-postit-sheet hub-postit-sheet--back" aria-hidden="true" />
|
|
||||||
<div className="hub-postit-sheet hub-postit-sheet--front">
|
|
||||||
<span className="hub-postit-pin" aria-hidden="true" />
|
|
||||||
<span className="hub-postit-fold" aria-hidden="true" />
|
|
||||||
<div className="hub-postit-content">
|
|
||||||
<div className="hub-postit-header">
|
|
||||||
<div className="hub-box-title-row">
|
|
||||||
<span className="hub-message-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 4v5" />
|
|
||||||
<path d="M12 15v5" />
|
|
||||||
<path d="M4 12h5" />
|
|
||||||
<path d="M15 12h5" />
|
|
||||||
<path d="M18 5v2" />
|
|
||||||
<path d="M17 6h2" />
|
|
||||||
<path d="M5 17v2" />
|
|
||||||
<path d="M4 18h2" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div className="board-project-title">{config.sloganTitle}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hub-postit-message">
|
|
||||||
{config.sloganLines.filter(Boolean).map((line, i) => (
|
|
||||||
<p key={i} className="board-project-desc">
|
|
||||||
{line}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="hub-diamond-wrap"
|
|
||||||
id="hub-diamond-wrap"
|
|
||||||
onContextMenu={(e) => openSectionContextMenu(e, 'routine', setCtxMenu)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={setRoutineDropRef}
|
|
||||||
className={`hub-diamond ${isRoutineOver ? 'is-over' : ''}`}
|
|
||||||
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">
|
|
||||||
<path d="m17 2 4 4-4 4" />
|
|
||||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
|
|
||||||
<path d="m7 22-4-4 4-4" />
|
|
||||||
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div className="board-project-title">상시업무</div>
|
|
||||||
</div>
|
|
||||||
<div className="hub-diamond-divider" aria-hidden="true" />
|
|
||||||
<div className="hub-routine-grid">
|
|
||||||
{routineLabels.map(({ label, task }, i) => (
|
|
||||||
<button
|
|
||||||
key={task?.id ?? `label-${i}`}
|
|
||||||
type="button"
|
|
||||||
className="hub-routine-item"
|
|
||||||
onClick={() => task && onSelectRoutine?.(task)}
|
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="hub-box hub-box--focus"
|
className="hub-box hub-box--focus"
|
||||||
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
|
onContextMenu={(e) => openSectionContextMenu(e, 'schedule', setCtxMenu)}
|
||||||
@@ -354,9 +251,53 @@ export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }
|
|||||||
</span>
|
</span>
|
||||||
<div className="board-project-title">{config.scheduleTitle}</div>
|
<div className="board-project-title">{config.scheduleTitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<HubScheduleCarousel items={config.scheduleItems} />
|
<HubScheduleCarousel items={config.scheduleItems} focusDate={referenceDate} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
@@ -364,31 +305,14 @@ export function HubColumn({ routineTasks, quarter = '2026-Q2', onSelectRoutine }
|
|||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
y={ctxMenu.y}
|
y={ctxMenu.y}
|
||||||
onClose={() => setCtxMenu(null)}
|
onClose={() => setCtxMenu(null)}
|
||||||
items={[
|
items={[{
|
||||||
{
|
|
||||||
icon: '✏',
|
icon: '✏',
|
||||||
label: '수정',
|
label: '수정',
|
||||||
onClick: () => setEditSection(ctxMenu.section),
|
onClick: () => setEditSection(ctxMenu.section),
|
||||||
},
|
}]}
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editSection === 'slogan' && (
|
|
||||||
<SloganEditModal
|
|
||||||
config={config}
|
|
||||||
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
|
||||||
onClose={() => setEditSection(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{editSection === 'routine' && (
|
|
||||||
<RoutineEditModal
|
|
||||||
config={config}
|
|
||||||
hasRoutineTasks={routineTasks.length > 0}
|
|
||||||
onSave={(patch) => setConfig((prev) => ({ ...prev, ...patch }))}
|
|
||||||
onClose={() => setEditSection(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{editSection === 'schedule' && (
|
{editSection === 'schedule' && (
|
||||||
<ScheduleEditModal
|
<ScheduleEditModal
|
||||||
config={config}
|
config={config}
|
||||||
|
|||||||
117
frontend/src/components/dashboard/HubFocusTaskList.tsx
Normal file
117
frontend/src/components/dashboard/HubFocusTaskList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/components/dashboard/HubRoutineFocusPanel.tsx
Normal file
79
frontend/src/components/dashboard/HubRoutineFocusPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { HubScheduleItem } from '../../lib/hubConfig';
|
import type { HubScheduleItem } from '../../lib/hubConfig';
|
||||||
|
import { HubNavChevron } from '../common/HubNavChevron';
|
||||||
import {
|
import {
|
||||||
findScheduleIndexForToday,
|
findScheduleIndexForToday,
|
||||||
formatScheduleDateLabel,
|
formatScheduleDateParts,
|
||||||
isSchedulePast,
|
isSchedulePast,
|
||||||
isScheduleToday,
|
isScheduleToday,
|
||||||
scheduleWindowStart,
|
scheduleWindowStart,
|
||||||
@@ -13,18 +14,22 @@ const VISIBLE_COUNT = 3;
|
|||||||
|
|
||||||
interface HubScheduleCarouselProps {
|
interface HubScheduleCarouselProps {
|
||||||
items: HubScheduleItem[];
|
items: HubScheduleItem[];
|
||||||
|
/** 기준일 — 없으면 오늘 */
|
||||||
|
focusDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
|
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const focus = focusDate ?? new Date();
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(
|
||||||
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
||||||
[items],
|
[items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const todayIndex = useMemo(() => findScheduleIndexForToday(sorted), [sorted]);
|
const focusIndex = useMemo(() => findScheduleIndexForToday(sorted, focus), [sorted, focus]);
|
||||||
const initialStart = useMemo(
|
const initialStart = useMemo(
|
||||||
() => scheduleWindowStart(sorted.length, todayIndex, VISIBLE_COUNT),
|
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
|
||||||
[sorted.length, todayIndex],
|
[sorted.length, focusIndex],
|
||||||
);
|
);
|
||||||
const [startIndex, setStartIndex] = useState(initialStart);
|
const [startIndex, setStartIndex] = useState(initialStart);
|
||||||
|
|
||||||
@@ -32,6 +37,32 @@ export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
|
|||||||
setStartIndex(initialStart);
|
setStartIndex(initialStart);
|
||||||
}, [initialStart, sorted.length]);
|
}, [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) {
|
if (sorted.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
|
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
|
||||||
@@ -40,56 +71,64 @@ export function HubScheduleCarousel({ items }: HubScheduleCarouselProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxStart = Math.max(0, 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));
|
|
||||||
|
|
||||||
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
|
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hub-schedule-viewport">
|
<div
|
||||||
<button
|
ref={rootRef}
|
||||||
type="button"
|
className={`hub-schedule-carousel${paged ? ' is-paged' : ''}`}
|
||||||
className="hub-schedule-nav hub-schedule-nav--prev"
|
|
||||||
disabled={!canPrev}
|
|
||||||
onClick={stepPrev}
|
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
|
||||||
aria-label="이전 일정"
|
|
||||||
>
|
>
|
||||||
‹
|
<div className="hub-schedule-viewport">
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul className="hub-schedule-list hub-list">
|
<ul className="hub-schedule-list hub-list">
|
||||||
{visible.map((item) => {
|
{visible.map((item) => {
|
||||||
const past = isSchedulePast(item.date);
|
const past = isSchedulePast(item.date, focus);
|
||||||
const today = isScheduleToday(item.date);
|
const today = isScheduleToday(item.date, focus);
|
||||||
|
const dateParts = formatScheduleDateParts(item.date);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
|
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="hub-schedule-date board-project-desc">
|
<span className="hub-schedule-date board-project-desc">
|
||||||
{formatScheduleDateLabel(item.date)}
|
{dateParts ? (
|
||||||
|
<>
|
||||||
|
<span className="hub-schedule-date-month">{dateParts.month}</span>
|
||||||
|
<span className="hub-schedule-date-day">{dateParts.day}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
item.date
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="board-project-desc">{item.text}</span>
|
<span className="board-project-desc">{item.text}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hub-schedule-nav hub-schedule-nav--next"
|
className="hub-schedule-nav hub-schedule-nav--next"
|
||||||
disabled={!canNext}
|
|
||||||
onClick={stepNext}
|
onClick={stepNext}
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
aria-label="다음 일정"
|
aria-label="다음 일정"
|
||||||
>
|
>
|
||||||
›
|
<HubNavChevron direction="next" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function MemberTaskTooltip({
|
|||||||
activeProjectId,
|
activeProjectId,
|
||||||
onProjectClick,
|
onProjectClick,
|
||||||
}: MemberTaskTooltipProps) {
|
}: MemberTaskTooltipProps) {
|
||||||
const memberTasks = getMemberTasks(memberId, tasks);
|
const memberTasks = getMemberTasks(memberId, tasks, 'project');
|
||||||
if (memberTasks.length === 0) return null;
|
if (memberTasks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
188
frontend/src/components/dashboard/QuarterStatusBoard.tsx
Normal file
188
frontend/src/components/dashboard/QuarterStatusBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import type { DraggableAttributes } from '@dnd-kit/core';
|
|||||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||||
import type { Task } from '../../types';
|
import type { Task } from '../../types';
|
||||||
import { DonutGauge } from './DonutGauge';
|
import { DonutGauge } from './DonutGauge';
|
||||||
|
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
|
||||||
|
import { getVisibleIssueEntries } from '../../lib/taskIssues';
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined): string {
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -125,6 +127,7 @@ export function TaskCard({
|
|||||||
const dateRange = fmtDateRange(task);
|
const dateRange = fmtDateRange(task);
|
||||||
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
|
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
|
||||||
const showProgress = task.showProgress !== false;
|
const showProgress = task.showProgress !== false;
|
||||||
|
const visibleIssues = getVisibleIssueEntries(task);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -138,10 +141,12 @@ export function TaskCard({
|
|||||||
>
|
>
|
||||||
<div className="project-sub-body">
|
<div className="project-sub-body">
|
||||||
<div className="project-fields">
|
<div className="project-fields">
|
||||||
<div className="project-sub-title">{task.title}</div>
|
<div className={`project-sub-title ${getProjectTitleStatusClass(task)}`}>
|
||||||
|
<span className="project-sub-title-text">{task.title}</span>
|
||||||
|
</div>
|
||||||
{dateRange && (
|
{dateRange && (
|
||||||
<div className="project-field">
|
<div className="project-field">
|
||||||
<span className="project-field-label">작업 기간</span>
|
<span className="project-field-label">수행 기간</span>
|
||||||
<span className="project-field-value">{dateRange}</span>
|
<span className="project-field-value">{dateRange}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -151,19 +156,31 @@ export function TaskCard({
|
|||||||
<span className="project-field-value">{descLine}</span>
|
<span className="project-field-value">{descLine}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.showIssue && task.issueNote && (
|
{visibleIssues.map((entry) => (
|
||||||
<div className="project-field">
|
<div key={entry.id} className="project-field project-field--issue">
|
||||||
<span className="project-field-label">이슈</span>
|
<span className="project-issue-icon" aria-label="이슈" title="이슈">
|
||||||
<span className="project-field-value" style={{ color: '#c0392b' }}>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
{task.issueNote}
|
<path d="M12 7v6" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
</span>
|
</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"> </span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
|
<>
|
||||||
|
<div className="project-sub-divider" aria-hidden="true" />
|
||||||
<div className="progress-col">
|
<div className="progress-col">
|
||||||
<DonutGauge task={task} />
|
<DonutGauge task={task} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { TaskFormData } from '../common/TaskModal';
|
|||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
||||||
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
||||||
@@ -43,7 +44,7 @@ export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [],
|
|||||||
const patch = useMutation({
|
const patch = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
apiClient.patch(`/tasks/${id}`, data),
|
apiClient.patch(`/tasks/${id}`, data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
|
||||||
});
|
});
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
|
||||||
|
|||||||
59
frontend/src/components/detail/DocxPreview.tsx
Normal file
59
frontend/src/components/detail/DocxPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/detail/FilePreviewFallback.tsx
Normal file
41
frontend/src/components/detail/FilePreviewFallback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/detail/HwpPreview.tsx
Normal file
70
frontend/src/components/detail/HwpPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/components/detail/MilestoneContentList.tsx
Normal file
145
frontend/src/components/detail/MilestoneContentList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
frontend/src/components/detail/MilestoneTimeline.tsx
Normal file
237
frontend/src/components/detail/MilestoneTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/detail/PdfPreview.tsx
Normal file
27
frontend/src/components/detail/PdfPreview.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
frontend/src/components/detail/PptxPreview.tsx
Normal file
50
frontend/src/components/detail/PptxPreview.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ExcelPreview } from './ExcelPreview';
|
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 { 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';
|
import type { FileRecord, MilestoneLink } from '../../types';
|
||||||
|
|
||||||
interface ResultPreviewProps {
|
interface ResultPreviewProps {
|
||||||
@@ -12,32 +21,67 @@ interface ResultPreviewProps {
|
|||||||
hasSelectedStage: boolean;
|
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 (
|
return (
|
||||||
<div className="flex max-w-lg flex-col items-center gap-3 px-6 text-center">
|
<img
|
||||||
<p className="text-xl font-bold text-white/70">{label}</p>
|
src={blobUrl}
|
||||||
<p className="text-base leading-relaxed text-white/45">
|
alt={fileName}
|
||||||
첨부 파일을 서버에서 찾을 수 없습니다.
|
className="max-h-full max-w-full object-contain transition-transform duration-150"
|
||||||
<br />
|
style={{ transform: `scale(${zoom})` }}
|
||||||
코드 배포·서버 재시작 시 파일이 삭제될 수 있습니다.
|
draggable={false}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewProps) {
|
||||||
const [fileId, setFileId] = useState<string | null>(null);
|
const [fileId, setFileId] = useState<string | null>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [fileMissing, setFileMissing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -49,29 +93,11 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
|||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
const activeFile = fileId ? files.find((f) => f.id === fileId) ?? null : null;
|
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 fileIndex = activeFile ? files.findIndex((f) => f.id === activeFile.id) : -1;
|
||||||
const isImage = activeFile?.mimetype.includes('image') ?? false;
|
const isImage = previewKind === 'image';
|
||||||
const isVideo = activeFile ? isVideoFile(activeFile) : false;
|
|
||||||
const isExcel = activeFile ? isExcelFile(activeFile) : false;
|
|
||||||
|
|
||||||
const goFile = useCallback(
|
const goFile = useCallback(
|
||||||
(delta: number) => {
|
(delta: number) => {
|
||||||
@@ -89,43 +115,42 @@ export function ResultPreview({ files, links, hasSelectedStage }: ResultPreviewP
|
|||||||
|
|
||||||
const headerTitle = activeFile ? fileDisplayName(activeFile) : '결과물 프리뷰';
|
const headerTitle = activeFile ? fileDisplayName(activeFile) : '결과물 프리뷰';
|
||||||
|
|
||||||
const renderContent = () => {
|
const previewContent = useMemo(() => {
|
||||||
if (activeFile) {
|
if (!activeFile || !previewKind) return null;
|
||||||
const label = fileDisplayName(activeFile);
|
const label = fileDisplayName(activeFile);
|
||||||
if (isExcel) {
|
|
||||||
|
switch (previewKind) {
|
||||||
|
case 'excel':
|
||||||
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
return <ExcelPreview fileId={activeFile.id} fileName={label} />;
|
||||||
}
|
case 'image':
|
||||||
if (fileMissing) {
|
|
||||||
return <FileMissingNotice label={label} fileId={activeFile.id} />;
|
|
||||||
}
|
|
||||||
const src = fileViewUrl(activeFile.id);
|
|
||||||
if (isImage) {
|
|
||||||
return (
|
return (
|
||||||
<img
|
<ImagePreview fileId={activeFile.id} fileName={label} mime={previewMime} zoom={zoom} />
|
||||||
src={src}
|
);
|
||||||
alt={label}
|
case 'video':
|
||||||
className="max-h-full max-w-full object-contain transition-transform duration-150"
|
return <VideoPreview fileId={activeFile.id} fileName={label} mime={previewMime} />;
|
||||||
style={{ transform: `scale(${zoom})` }}
|
case 'pdf':
|
||||||
draggable={false}
|
return <PdfPreview fileId={activeFile.id} fileName={label} />;
|
||||||
onError={() => setFileMissing(true)}
|
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) {
|
}, [activeFile, previewKind, previewMime, zoom]);
|
||||||
return (
|
|
||||||
<video
|
const renderContent = () => {
|
||||||
src={src}
|
if (activeFile && previewContent) return previewContent;
|
||||||
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" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (links.length > 0) {
|
if (links.length > 0) {
|
||||||
return (
|
return (
|
||||||
<p className="px-6 text-center text-xl text-white/40">
|
<p className="px-6 text-center text-xl text-white/40">
|
||||||
|
|||||||
740
frontend/src/components/detail/RoutineDetailView.tsx
Normal file
740
frontend/src/components/detail/RoutineDetailView.tsx
Normal 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} />;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useState, useRef, useMemo, useEffect } from 'react';
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { sortFilesByOrder } from '../../lib/fileDisplay';
|
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 {
|
export interface StageFormData {
|
||||||
title: string;
|
title: string;
|
||||||
startDate: string;
|
subtitle: string;
|
||||||
dueDate: string;
|
periodEntries: MilestonePeriodEntry[];
|
||||||
progress: number;
|
progress: number;
|
||||||
description: string;
|
|
||||||
links: MilestoneLink[];
|
links: MilestoneLink[];
|
||||||
|
pmMemberId: string;
|
||||||
|
assigneeMemberIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingFileUpload {
|
export interface PendingFileUpload {
|
||||||
@@ -39,8 +46,10 @@ export interface StageFileSavePayload {
|
|||||||
|
|
||||||
interface StageModalProps {
|
interface StageModalProps {
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
|
variant?: 'project' | 'routine';
|
||||||
milestone?: Milestone;
|
milestone?: Milestone;
|
||||||
existingFiles?: FileRecord[];
|
existingFiles?: FileRecord[];
|
||||||
|
teamMembers?: TeamMember[];
|
||||||
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
|
onSave: (data: StageFormData, files: StageFileSavePayload) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
@@ -55,11 +64,6 @@ type EditTarget =
|
|||||||
| { type: 'link'; index: number }
|
| { type: 'link'; index: number }
|
||||||
| null;
|
| 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[] {
|
function parseLinks(raw: string | null | undefined): MilestoneLink[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
@@ -107,21 +111,30 @@ let pendingKeySeq = 0;
|
|||||||
|
|
||||||
export function StageModal({
|
export function StageModal({
|
||||||
mode,
|
mode,
|
||||||
|
variant = 'project',
|
||||||
milestone,
|
milestone,
|
||||||
existingFiles = [],
|
existingFiles = [],
|
||||||
|
teamMembers = [],
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
}: StageModalProps) {
|
}: StageModalProps) {
|
||||||
|
const isRoutine = variant === 'routine';
|
||||||
const sortedExisting = useMemo(() => sortFilesByOrder(existingFiles), [existingFiles]);
|
const sortedExisting = useMemo(() => sortFilesByOrder(existingFiles), [existingFiles]);
|
||||||
|
|
||||||
const [form, setForm] = useState<StageFormData>({
|
const [form, setForm] = useState<StageFormData>(() => {
|
||||||
|
const legacyOverview = isRoutine
|
||||||
|
? decodeRoutineStageDescription(milestone?.description).overview
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
title: milestone?.title ?? '',
|
title: milestone?.title ?? '',
|
||||||
startDate: toDateInput(milestone?.startDate),
|
subtitle: milestone?.subtitle?.trim() ?? legacyOverview,
|
||||||
dueDate: toDateInput(milestone?.dueDate),
|
periodEntries: parseMilestonePeriods(milestone),
|
||||||
progress: milestone?.progress ?? 0,
|
progress: milestone?.progress ?? 0,
|
||||||
description: milestone?.description ?? '',
|
|
||||||
links: parseLinks(milestone?.links),
|
links: parseLinks(milestone?.links),
|
||||||
|
pmMemberId: milestone?.pmMemberId ?? milestone?.pmMember?.id ?? '',
|
||||||
|
assigneeMemberIds: milestone?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [fileRows, setFileRows] = useState<FileRow[]>([]);
|
const [fileRows, setFileRows] = useState<FileRow[]>([]);
|
||||||
@@ -163,6 +176,49 @@ export function StageModal({
|
|||||||
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
|
const set = <K extends keyof StageFormData>(key: K, value: StageFormData[K]) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
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 = () => {
|
const clearEdit = () => {
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
setEditDisplayName('');
|
setEditDisplayName('');
|
||||||
@@ -345,9 +401,7 @@ export function StageModal({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<div className="shrink-0 border-b border-slate-100 px-6 py-4">
|
<div className="shrink-0 border-b border-slate-100 px-6 py-4">
|
||||||
<h2 className="text-xl font-black text-slate-800">
|
<h2 className="text-xl font-black text-slate-800">{modalTitle}</h2>
|
||||||
{mode === 'add' ? '업무 단계 추가' : '업무 단계 수정'}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 overflow-y-auto px-6 py-4">
|
<div className="space-y-4 overflow-y-auto px-6 py-4">
|
||||||
@@ -362,6 +416,18 @@ export function StageModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="block">
|
||||||
<span className="mb-1 flex items-center justify-between text-sm font-bold text-slate-500">
|
<span className="mb-1 flex items-center justify-between text-sm font-bold text-slate-500">
|
||||||
<span>진행률</span>
|
<span>진행률</span>
|
||||||
@@ -378,37 +444,120 @@ export function StageModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-bold text-slate-500">시작일</span>
|
<span className="mb-1 block text-xs font-semibold text-slate-500">시작일</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.startDate}
|
value={entry.startDate}
|
||||||
onChange={(e) => set('startDate', e.target.value)}
|
onChange={(e) => updatePeriodEntry(entry.id, { 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"
|
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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-bold text-slate-500">종료일</span>
|
<span className="mb-1 block text-xs font-semibold text-slate-500">종료일</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.dueDate}
|
value={entry.dueDate}
|
||||||
onChange={(e) => set('dueDate', e.target.value)}
|
onChange={(e) => updatePeriodEntry(entry.id, { 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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-bold text-slate-500">업무내용</span>
|
<span className="mb-1 block text-xs font-semibold text-slate-500">업무내용</span>
|
||||||
<textarea
|
<textarea
|
||||||
value={form.description}
|
value={entry.note}
|
||||||
onChange={(e) => set('description', e.target.value)}
|
onChange={(e) => updatePeriodEntry(entry.id, { note: e.target.value })}
|
||||||
rows={4}
|
rows={2}
|
||||||
className="w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-base focus:border-emerald-400 focus:outline-none"
|
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="단계별 업무 내용 (줄바꿈 가능)"
|
placeholder="이 기간에 수행한 내용"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
<div>
|
||||||
|
|||||||
96
frontend/src/components/dummy/DummyDepartmentColumn.tsx
Normal file
96
frontend/src/components/dummy/DummyDepartmentColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,98 @@ function relBox(el: Element, parent: Element): Box {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snap(n: number) {
|
function snap(n: number) {
|
||||||
return Math.round(n * 2) / 2;
|
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[]) {
|
function pointsToPath(points: Point[]) {
|
||||||
@@ -59,12 +150,42 @@ function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string)
|
|||||||
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 {
|
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
|
||||||
const cardBox = relBox(cardEl, layout);
|
const cardBox = relBox(cardEl, layout);
|
||||||
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
|
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
|
||||||
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
|
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 = [
|
const FACE_LINKS = [
|
||||||
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
|
{ 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: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
|
||||||
@@ -89,10 +210,60 @@ function buildBentPath(
|
|||||||
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
|
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') {
|
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
|
||||||
return vert === 'top' ? faceY + runX : faceY - runX;
|
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) {
|
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
|
||||||
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
|
||||||
const wrap = diamond.parentElement;
|
const wrap = diamond.parentElement;
|
||||||
@@ -109,9 +280,13 @@ function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null)
|
|||||||
diamond.style.height = `${size}px`;
|
diamond.style.height = `${size}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardConnectors(enabled = true) {
|
export type ConnectorStyle = 'default' | 'reference';
|
||||||
|
|
||||||
|
export function useBoardConnectors(enabled = true, style: ConnectorStyle = 'default') {
|
||||||
const lineGroupRef = useRef<SVGGElement>(null);
|
const lineGroupRef = useRef<SVGGElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const dotGroupRef = useRef<SVGGElement>(null);
|
||||||
|
const dotSvgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -124,29 +299,73 @@ export function useBoardConnectors(enabled = true) {
|
|||||||
const hubColumn = document.getElementById('hub-column');
|
const hubColumn = document.getElementById('hub-column');
|
||||||
const lineGroup = lineGroupRef.current;
|
const lineGroup = lineGroupRef.current;
|
||||||
const svg = svgRef.current;
|
const svg = svgRef.current;
|
||||||
|
const dotGroup = dotGroupRef.current;
|
||||||
|
const dotSvg = dotSvgRef.current;
|
||||||
if (!layout || !diamond || !lineGroup || !svg) return;
|
if (!layout || !diamond || !lineGroup || !svg) return;
|
||||||
|
|
||||||
fitDiamond(hubColumn, diamond);
|
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) {
|
if (window.innerWidth <= 1200) {
|
||||||
lineGroup.innerHTML = '';
|
lineGroup.innerHTML = '';
|
||||||
|
dotGroup && (dotGroup.innerHTML = '');
|
||||||
svg.removeAttribute('viewBox');
|
svg.removeAttribute('viewBox');
|
||||||
|
dotSvg?.removeAttribute('viewBox');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutBox = layout.getBoundingClientRect();
|
const layoutBox = layout.getBoundingClientRect();
|
||||||
const diamondBox = relBox(diamond, layout);
|
const diamondBox = relBox(diamond, layout);
|
||||||
const diamondSize = diamond.offsetWidth;
|
const diamondSize = diamond.offsetWidth;
|
||||||
const { cx, cy } = diamondBox;
|
const diamondCx = diamondBox.cx;
|
||||||
|
const diamondCy = diamondBox.cy;
|
||||||
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||||
lineGroup.innerHTML = '';
|
lineGroup.innerHTML = '';
|
||||||
|
if (dotGroup) dotGroup.innerHTML = '';
|
||||||
|
if (dotSvg) dotSvg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
|
||||||
|
|
||||||
const topV = diamondVertex(cx, cy, diamondSize, 'top');
|
const topV = diamondVertex(diamondCx, diamondCy, diamondSize, 'top');
|
||||||
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
|
const bottomV = diamondVertex(diamondCx, diamondCy, diamondSize, 'bottom');
|
||||||
|
|
||||||
const msgBox = document.querySelector('.hub-postit-sheet--front');
|
const hubColumnEl = layout.querySelector('.hub-column, #hub-column');
|
||||||
const focusBox = document.querySelector('.hub-schedule-planner');
|
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 hubBox = hubColumn ? relBox(hubColumn, layout) : null;
|
||||||
const hrmBox = document.querySelector('.dept-card--hrm');
|
const hrmBox = document.querySelector('.dept-card--hrm');
|
||||||
@@ -166,39 +385,138 @@ export function useBoardConnectors(enabled = true) {
|
|||||||
|
|
||||||
const d = diamondSize / (2 * Math.SQRT2);
|
const d = diamondSize / (2 * Math.SQRT2);
|
||||||
const approachGap = 32;
|
const approachGap = 32;
|
||||||
const leftApproachX = cx - d - approachGap;
|
const leftApproachX = diamondCx - d - approachGap;
|
||||||
const rightApproachX = cx + d + approachGap;
|
const rightApproachX = diamondCx + d + approachGap;
|
||||||
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
|
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
|
||||||
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
|
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
|
||||||
|
|
||||||
const appendPath = (points: Point[]) => {
|
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 path = document.createElementNS(SVG_NS, 'path');
|
||||||
path.setAttribute('d', pointsToPath(points));
|
const normalized = normalizePathPoints(points);
|
||||||
path.setAttribute('stroke', '#b0bcc8');
|
if (normalized.length < 2) return;
|
||||||
path.setAttribute('stroke-width', '2.5');
|
path.setAttribute('d', pointsToPath(normalized));
|
||||||
path.setAttribute('opacity', '0.85');
|
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('fill', 'none');
|
||||||
|
path.setAttribute('stroke-linecap', style === 'reference' ? 'butt' : 'round');
|
||||||
|
path.setAttribute('stroke-linejoin', style === 'reference' ? 'miter' : 'round');
|
||||||
lineGroup.appendChild(path);
|
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) => {
|
FACE_LINKS.forEach((link) => {
|
||||||
const cardEl = document.querySelector(link.card);
|
const cardEl = document.querySelector(link.card);
|
||||||
if (!cardEl) return;
|
if (!cardEl) return;
|
||||||
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
|
const edgeMid =
|
||||||
|
style === 'reference'
|
||||||
|
? diamondBorderEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge)
|
||||||
|
: diamondEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge);
|
||||||
const runX = link.knee === 'left' ? leftRunX : rightRunX;
|
const runX = link.knee === 'left' ? leftRunX : rightRunX;
|
||||||
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
|
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
|
||||||
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
|
const cardAnchor =
|
||||||
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
|
style === 'reference'
|
||||||
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
|
? 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 (msgBox) {
|
if (style === 'reference' && topHubBox) {
|
||||||
const msg = relBox(msgBox, layout);
|
const topHub = relBox(topHubBox, layout);
|
||||||
appendPath([topV, { x: cx, y: msg.bottom }]);
|
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 (focusBox) {
|
|
||||||
const focus = relBox(focusBox, layout);
|
if (style === 'reference' && bottomHubBox) {
|
||||||
appendPath([bottomV, { x: cx, y: focus.top }]);
|
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 }]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,10 +535,12 @@ export function useBoardConnectors(enabled = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const layoutEl = document.querySelector('.board-layout');
|
const layoutEl = document.querySelector('.board-layout');
|
||||||
|
const hubColEl = document.getElementById('hub-column');
|
||||||
let ro: ResizeObserver | undefined;
|
let ro: ResizeObserver | undefined;
|
||||||
if (layoutEl && typeof ResizeObserver !== 'undefined') {
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
ro = new ResizeObserver(scheduleDraw);
|
ro = new ResizeObserver(scheduleDraw);
|
||||||
ro.observe(layoutEl);
|
if (layoutEl) ro.observe(layoutEl);
|
||||||
|
if (hubColEl) ro.observe(hubColEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -228,7 +548,7 @@ export function useBoardConnectors(enabled = true) {
|
|||||||
clearTimeout(resizeTimer);
|
clearTimeout(resizeTimer);
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled, style]);
|
||||||
|
|
||||||
return { svgRef, lineGroupRef };
|
return { svgRef, lineGroupRef, dotSvgRef, dotGroupRef };
|
||||||
}
|
}
|
||||||
|
|||||||
42
frontend/src/hooks/useBoardReferenceDate.ts
Normal file
42
frontend/src/hooks/useBoardReferenceDate.ts
Normal 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 };
|
||||||
|
}
|
||||||
92
frontend/src/hooks/useFileBuffer.ts
Normal file
92
frontend/src/hooks/useFileBuffer.ts
Normal 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 };
|
||||||
|
}
|
||||||
58
frontend/src/hooks/useRoutineCategoryMilestones.ts
Normal file
58
frontend/src/hooks/useRoutineCategoryMilestones.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--app-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
|
||||||
|
--app-header-border: #135643;
|
||||||
|
--app-header-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
--board-cal-accent: #059669;
|
||||||
|
--board-cal-accent-dark: #047857;
|
||||||
|
--board-cal-accent-deep: #065f46;
|
||||||
|
--board-cal-border: #a7d7c5;
|
||||||
|
--board-cal-surface: #ecfdf5;
|
||||||
|
--board-cal-surface-hover: #d1fae5;
|
||||||
|
--board-cal-muted: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -97,12 +110,222 @@ body,
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 0 22px 0 20px;
|
padding: 0 22px 0 20px;
|
||||||
background: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
|
background: var(--app-header-bg);
|
||||||
border-bottom: 1px solid #135643;
|
border-bottom: 1px solid var(--app-header-border);
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--app-header-shadow);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-calendar-slot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 101;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-ref-text {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid rgba(186, 216, 202, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 65, 46, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-popover {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
width: min(400px, calc(100vw - 24px));
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border: 1px solid var(--board-cal-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 16px 40px rgba(7, 65, 46, 0.16);
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-popover-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-month {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--board-cal-accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-nav {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--board-cal-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--board-cal-surface);
|
||||||
|
color: var(--board-cal-accent);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-nav:hover {
|
||||||
|
background: var(--board-cal-surface-hover);
|
||||||
|
border-color: var(--board-cal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: 1px solid var(--board-cal-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--board-cal-accent-deep);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-chip:hover {
|
||||||
|
background: var(--board-cal-surface);
|
||||||
|
border-color: var(--board-cal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-chip.is-selected {
|
||||||
|
border-color: var(--board-cal-accent-dark);
|
||||||
|
background: linear-gradient(180deg, #37a184 0%, #047857 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-chip-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-quarter-chip-range {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid th {
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--board-cal-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid-week-col {
|
||||||
|
width: 72px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-week-label-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--board-cal-accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-week-label-btn:hover {
|
||||||
|
color: var(--board-cal-accent-dark);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-btn.is-outside {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-btn.is-ref {
|
||||||
|
background: linear-gradient(180deg, #37a184 0%, #047857 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-btn.is-today:not(.is-ref) {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--board-cal-accent);
|
||||||
|
color: var(--board-cal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-btn:hover:not(.is-ref) {
|
||||||
|
background: var(--board-cal-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid tr.is-selected-week .board-calendar-week-label-btn {
|
||||||
|
color: var(--board-cal-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-foot button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--board-cal-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--board-cal-accent-deep);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-foot button:hover {
|
||||||
|
background: var(--board-cal-surface);
|
||||||
|
border-color: var(--board-cal-accent);
|
||||||
|
color: var(--board-cal-accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
.side-left-group {
|
.side-left-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -119,6 +342,7 @@ body,
|
|||||||
/* F12 — 헤더 원형 아이콘 버튼 (team / + / 듀얼모니터) */
|
/* F12 — 헤더 원형 아이콘 버튼 (team / + / 듀얼모니터) */
|
||||||
.header-action-btn-new,
|
.header-action-btn-new,
|
||||||
.header-view-btn-new,
|
.header-view-btn-new,
|
||||||
|
.header-calendar-btn-new,
|
||||||
.team-status-btn-new {
|
.team-status-btn-new {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -137,6 +361,7 @@ body,
|
|||||||
|
|
||||||
.header-action-btn-new svg,
|
.header-action-btn-new svg,
|
||||||
.header-view-btn-new svg,
|
.header-view-btn-new svg,
|
||||||
|
.header-calendar-btn-new svg,
|
||||||
.team-status-btn-new svg {
|
.team-status-btn-new svg {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
filter: none;
|
filter: none;
|
||||||
@@ -144,6 +369,7 @@ body,
|
|||||||
|
|
||||||
.header-action-btn-new:hover,
|
.header-action-btn-new:hover,
|
||||||
.header-view-btn-new:hover,
|
.header-view-btn-new:hover,
|
||||||
|
.header-calendar-btn-new:hover,
|
||||||
.team-status-btn-new:hover {
|
.team-status-btn-new:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: #36816d;
|
border-color: #36816d;
|
||||||
@@ -158,6 +384,7 @@ body,
|
|||||||
.header-action-btn-new:active,
|
.header-action-btn-new:active,
|
||||||
.header-action-btn-new.active,
|
.header-action-btn-new.active,
|
||||||
.header-view-btn-new.active,
|
.header-view-btn-new.active,
|
||||||
|
.header-calendar-btn-new.active,
|
||||||
.team-status-btn-new.active {
|
.team-status-btn-new.active {
|
||||||
color: #cef1eb;
|
color: #cef1eb;
|
||||||
border-color: #1a4d42;
|
border-color: #1a4d42;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export function getSocketUrl(): string {
|
|||||||
if (import.meta.env.VITE_SOCKET_URL) {
|
if (import.meta.env.VITE_SOCKET_URL) {
|
||||||
return 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();
|
return getBackendOrigin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export function fileDownloadUrl(fileId: string): string {
|
|||||||
return `${baseURL}/files/${fileId}/download`;
|
return `${baseURL}/files/${fileId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fileHwpPreviewUrl(fileId: string): string {
|
||||||
|
return `${baseURL}/files/${fileId}/hwp-preview`;
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
if (config.data instanceof FormData) {
|
if (config.data instanceof FormData) {
|
||||||
delete config.headers['Content-Type'];
|
delete config.headers['Content-Type'];
|
||||||
@@ -34,6 +38,14 @@ apiClient.interceptors.response.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||||
const ax = err as { response?: { data?: { message?: string }; status?: number }; message?: string };
|
const ax = err as {
|
||||||
return ax.response?.data?.message || ax.message || fallback;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
108
frontend/src/lib/boardCalendar.ts
Normal file
108
frontend/src/lib/boardCalendar.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -56,6 +56,72 @@ export function slotSectionLabel(slot: BoardSlotConfig): string {
|
|||||||
return slot.sectionKey ?? slot.dummySection ?? slot.defaultTitle;
|
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(
|
export function taskBelongsToSlot(
|
||||||
taskSection: string | null | undefined,
|
taskSection: string | null | undefined,
|
||||||
slot: BoardSlotConfig,
|
slot: BoardSlotConfig,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 듀얼 모니터 연동 — dashboard-260504.vercel.app 와 동일한 창 배치 로직
|
* 듀얼 모니터 연동 — dashboard-260504.vercel.app 와 동일한 창 배치 로직
|
||||||
* @see https://dashboard-260504.vercel.app/
|
* @see https://dashboard-260504.vercel.app/
|
||||||
|
*
|
||||||
|
* 핵심: 클릭 핸들러 안에서 await getScreenDetails() → 권한(모든 디스플레이) → window.open
|
||||||
|
* localhost / IP(172.x) 동일 코드. IP는 origin별로 권한을 한 번 더 허용해야 함.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CHANNEL_NAME = 'eee_dashboard';
|
const CHANNEL_NAME = 'eee_dashboard';
|
||||||
const DETAIL_WINDOW_NAME = 'eene_detail';
|
const DETAIL_WINDOW_NAME = 'eene_detail';
|
||||||
const SELECTED_TASK_KEY = 'eee_selected_task';
|
const SELECTED_TASK_KEY = 'eee_selected_task';
|
||||||
|
const PLACEMENT_STORAGE_KEY = 'eee_detail_window_placement';
|
||||||
|
|
||||||
export type DualMonitorEvent =
|
export type DualMonitorEvent =
|
||||||
| { type: 'TASK_SELECTED'; taskId: string }
|
| { type: 'TASK_SELECTED'; taskId: string }
|
||||||
@@ -18,6 +22,33 @@ let detailWindow: Window | null = null;
|
|||||||
let dualModeActive = false;
|
let dualModeActive = false;
|
||||||
let closePollTimer: ReturnType<typeof setInterval> | null = null;
|
let closePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let syncProvider: (() => string | null) | null = null;
|
let syncProvider: (() => string | null) | null = null;
|
||||||
|
let cachedPlacement: WindowPlacement | null = null;
|
||||||
|
let lastPlacementIssue: string | null = null;
|
||||||
|
|
||||||
|
export function getLastPlacementIssue(): string | null {
|
||||||
|
return lastPlacementIssue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** http://IP 는 secure context가 아니어서 getScreenDetails·권한 팝업 불가 → https 필요 */
|
||||||
|
export function getWindowPlacementHint(): string | null {
|
||||||
|
const win = window as WindowWithScreenDetails;
|
||||||
|
if (win.getScreenDetails) return null;
|
||||||
|
|
||||||
|
const { protocol, hostname } = window.location;
|
||||||
|
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
if (protocol === 'http:' && !isLocalhost) {
|
||||||
|
return (
|
||||||
|
`http://${hostname} 으로는 「모든 디스플레이」 권한 창이 뜨지 않아 ` +
|
||||||
|
`상세 창이 왼쪽 모니터에 열립니다.\n\n` +
|
||||||
|
`→ https://${hostname}:3000 으로 접속해 주세요.\n` +
|
||||||
|
`(인증서 경고 → 고급 → 계속)\n\n` +
|
||||||
|
`localhost:3000 은 http 로도 동작합니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '이 브라우저/환경에서는 다중 모니터 창 배치 API를 사용할 수 없습니다.';
|
||||||
|
}
|
||||||
|
|
||||||
interface ScreenDetailed {
|
interface ScreenDetailed {
|
||||||
left: number;
|
left: number;
|
||||||
@@ -50,47 +81,93 @@ function placementToFeatures({ left, top, width, height }: WindowPlacement): str
|
|||||||
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;
|
return `left=${left},top=${top},width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPersistedPlacement(): WindowPlacement | null {
|
||||||
|
if (cachedPlacement) return cachedPlacement;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PLACEMENT_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const p = JSON.parse(raw) as WindowPlacement;
|
||||||
|
if (
|
||||||
|
typeof p.left === 'number' &&
|
||||||
|
typeof p.top === 'number' &&
|
||||||
|
typeof p.width === 'number' &&
|
||||||
|
typeof p.height === 'number'
|
||||||
|
) {
|
||||||
|
cachedPlacement = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistPlacement(placement: WindowPlacement) {
|
||||||
|
cachedPlacement = placement;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(placement));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placementFromScreenDetails(details: ScreenDetails): WindowPlacement {
|
||||||
|
const current = details.currentScreen;
|
||||||
|
const sorted = [...details.screens].sort((a, b) => a.left - b.left || a.top - b.top);
|
||||||
|
const idx = sorted.findIndex((s) => s.left === current.left && s.top === current.top);
|
||||||
|
const target =
|
||||||
|
(idx >= 0 && idx < sorted.length - 1 ? sorted[idx + 1] : null) ??
|
||||||
|
sorted.find((s) => s.left !== current.left || s.top !== current.top) ??
|
||||||
|
sorted[sorted.length - 1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: target.availLeft ?? target.left,
|
||||||
|
top: target.availTop ?? target.top,
|
||||||
|
width: target.availWidth ?? target.width,
|
||||||
|
height: target.availHeight ?? target.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackDetailPlacement(): WindowPlacement {
|
||||||
|
const stored = readPersistedPlacement();
|
||||||
|
if (stored) return stored;
|
||||||
|
|
||||||
|
const top = window.screen.availTop ?? 0;
|
||||||
|
const height = window.screen.availHeight;
|
||||||
|
const availLeft = window.screen.availLeft ?? 0;
|
||||||
|
const availWidth = window.screen.availWidth;
|
||||||
|
const monitorRight = availLeft + availWidth;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: monitorRight,
|
||||||
|
top,
|
||||||
|
width: Math.max(800, availWidth),
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 참고 사이트(fr)와 동일한 좌표 계산
|
* 참고 사이트와 동일 — getScreenDetails(await) 로 「모든 디스플레이」 권한 후 좌표 계산
|
||||||
* — getScreenDetails await 후 window.open (권한 요청 + 우측 모니터 배치)
|
|
||||||
*/
|
*/
|
||||||
async function resolveDetailWindowPlacement(): Promise<WindowPlacement> {
|
async function resolveDetailWindowPlacement(): Promise<WindowPlacement> {
|
||||||
let left = window.screenX + window.outerWidth;
|
lastPlacementIssue = getWindowPlacementHint();
|
||||||
let top = window.screenY;
|
|
||||||
let width = window.screen.availWidth;
|
|
||||||
let height = window.screen.availHeight;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const win = window as WindowWithScreenDetails;
|
const win = window as WindowWithScreenDetails;
|
||||||
if (win.getScreenDetails) {
|
if (win.getScreenDetails) {
|
||||||
const details = await win.getScreenDetails();
|
const details = await win.getScreenDetails();
|
||||||
const current = details.currentScreen;
|
lastPlacementIssue = null;
|
||||||
let target = details.screens.find((s) => s.left >= current.left + current.width);
|
return placementFromScreenDetails(details);
|
||||||
target ||= details.screens.find((s) => s !== current);
|
|
||||||
|
|
||||||
if (target) {
|
|
||||||
left = target.availLeft ?? target.left;
|
|
||||||
top = target.availTop ?? target.top;
|
|
||||||
width = target.availWidth ?? target.width;
|
|
||||||
height = target.availHeight ?? target.height;
|
|
||||||
} else {
|
|
||||||
left = window.screenX + window.outerWidth;
|
|
||||||
width = window.screen.availWidth - left;
|
|
||||||
if (width < 800) {
|
|
||||||
width = 1920;
|
|
||||||
left = window.screen.availWidth;
|
|
||||||
}
|
|
||||||
height = window.screen.availHeight;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Window Management API failed or denied, using fallback', err);
|
console.warn('[dualMonitor] getScreenDetails failed, using fallback', err);
|
||||||
|
lastPlacementIssue =
|
||||||
|
'「모든 디스플레이」 권한이 거부되었거나 사용할 수 없습니다.\n' +
|
||||||
|
'주소창 자물쇠 → 사이트 권한 → 창 배치 → 허용';
|
||||||
|
}
|
||||||
|
return fallbackDetailPlacement();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { left, top, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 우측 모니터 좌표·크기 (열린 뒤 moveTo 보정용) */
|
|
||||||
export async function getRightMonitorWindowFeatures(): Promise<string> {
|
export async function getRightMonitorWindowFeatures(): Promise<string> {
|
||||||
return placementToFeatures(await resolveDetailWindowPlacement());
|
return placementToFeatures(await resolveDetailWindowPlacement());
|
||||||
}
|
}
|
||||||
@@ -145,13 +222,23 @@ export function registerSyncProvider(fn: () => string | null): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) {
|
function applyWindowPlacement(win: Window, left: number, top: number, width: number, height: number) {
|
||||||
try {
|
try {
|
||||||
win.moveTo(left, top);
|
win.moveTo(left, top);
|
||||||
win.resizeTo(width, height);
|
win.resizeTo(width, height);
|
||||||
} catch {
|
} catch {
|
||||||
// 브라우저 정책으로 실패할 수 있음
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePlacementRetries(win: Window, placement: WindowPlacement) {
|
||||||
|
const apply = () => {
|
||||||
|
if (win.closed) return;
|
||||||
|
applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
for (const delay of [100, 400, 1000, 2000]) {
|
||||||
|
setTimeout(apply, delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +265,7 @@ function openDetailWindowWithPlacement(placement: WindowPlacement): Window | nul
|
|||||||
try {
|
try {
|
||||||
detailWindow.focus();
|
detailWindow.focus();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
|
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
|
||||||
@@ -188,10 +275,13 @@ function openDetailWindowWithPlacement(placement: WindowPlacement): Window | nul
|
|||||||
/** 참고 사이트: getScreenDetails(await) → window.open */
|
/** 참고 사이트: getScreenDetails(await) → window.open */
|
||||||
async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise<Window | null> {
|
async function openDetailWindowPlaced(onPopupClosed?: () => void): Promise<Window | null> {
|
||||||
const placement = await resolveDetailWindowPlacement();
|
const placement = await resolveDetailWindowPlacement();
|
||||||
|
persistPlacement(placement);
|
||||||
|
|
||||||
const win = openDetailWindowWithPlacement(placement);
|
const win = openDetailWindowWithPlacement(placement);
|
||||||
if (!win) return null;
|
if (!win) return null;
|
||||||
|
|
||||||
startClosePoll(() => onPopupClosed?.());
|
startClosePoll(() => onPopupClosed?.());
|
||||||
|
schedulePlacementRetries(win, placement);
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +308,11 @@ export async function openDetailWindow(onPopupClosed?: () => void): Promise<Wind
|
|||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 업무 선택 — 참고 사이트와 같이 배치 계산(await) 후 팝업 열기 */
|
/** 업무 선택 — await getScreenDetails(권한) 후 팝업 open */
|
||||||
export async function sendTaskSelected(taskId: string, onPopupClosed?: () => void): Promise<void> {
|
export async function sendTaskSelected(
|
||||||
|
taskId: string,
|
||||||
|
onPopupClosed?: () => void,
|
||||||
|
): Promise<boolean> {
|
||||||
persistSelectedTask(taskId);
|
persistSelectedTask(taskId);
|
||||||
|
|
||||||
if (!isDetailWindowOpen()) {
|
if (!isDetailWindowOpen()) {
|
||||||
@@ -227,10 +320,13 @@ export async function sendTaskSelected(taskId: string, onPopupClosed?: () => voi
|
|||||||
const win = await openDetailWindowPlaced(onPopupClosed);
|
const win = await openDetailWindowPlaced(onPopupClosed);
|
||||||
if (!win) {
|
if (!win) {
|
||||||
dualModeActive = false;
|
dualModeActive = false;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
scheduleTaskSelected(taskId);
|
scheduleTaskSelected(taskId);
|
||||||
return;
|
if (lastPlacementIssue) {
|
||||||
|
window.alert(lastPlacementIssue);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleTaskSelected(taskId);
|
scheduleTaskSelected(taskId);
|
||||||
@@ -238,14 +334,16 @@ export async function sendTaskSelected(taskId: string, onPopupClosed?: () => voi
|
|||||||
try {
|
try {
|
||||||
detailWindow!.focus();
|
detailWindow!.focus();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
void resolveDetailWindowPlacement().then((placement) => {
|
const placement = await resolveDetailWindowPlacement();
|
||||||
|
persistPlacement(placement);
|
||||||
if (detailWindow && !detailWindow.closed) {
|
if (detailWindow && !detailWindow.closed) {
|
||||||
applyWindowPlacement(detailWindow, placement.left, placement.top, placement.width, placement.height);
|
schedulePlacementRetries(detailWindow, placement);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendTaskDeselected(): void {
|
export function sendTaskDeselected(): void {
|
||||||
@@ -299,14 +397,12 @@ export function openLinkOnRightMonitor(url: string, windowName: string): Window
|
|||||||
try {
|
try {
|
||||||
win?.focus();
|
win?.focus();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
if (win) {
|
if (win) {
|
||||||
void resolveDetailWindowPlacement().then((placement) => {
|
void resolveDetailWindowPlacement().then((placement) => {
|
||||||
if (win && !win.closed) {
|
schedulePlacementRetries(win, placement);
|
||||||
applyWindowPlacement(win, placement.left, placement.top, placement.width, placement.height);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,75 @@ export function isExcelFile(file: Pick<FileRecord, 'mimetype' | 'originalName'>)
|
|||||||
name.endsWith('.csv')
|
name.endsWith('.csv')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FilePreviewKind =
|
||||||
|
| 'image'
|
||||||
|
| 'video'
|
||||||
|
| 'excel'
|
||||||
|
| 'pdf'
|
||||||
|
| 'docx'
|
||||||
|
| 'pptx'
|
||||||
|
| 'hwp'
|
||||||
|
| 'text'
|
||||||
|
| 'unsupported';
|
||||||
|
|
||||||
|
function extOf(name: string): string {
|
||||||
|
return name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 미리보기 라우팅 — iframe 대신 형식별 뷰어 사용 */
|
||||||
|
export function getFilePreviewKind(
|
||||||
|
file: Pick<FileRecord, 'mimetype' | 'originalName'>,
|
||||||
|
): FilePreviewKind {
|
||||||
|
const name = file.originalName.toLowerCase();
|
||||||
|
const ext = extOf(name);
|
||||||
|
|
||||||
|
if (isExcelFile(file)) return 'excel';
|
||||||
|
if (file.mimetype.startsWith('image/') || /^(png|jpe?g|gif|webp|bmp|svg)$/.test(ext)) return 'image';
|
||||||
|
if (isVideoFile(file)) return 'video';
|
||||||
|
if (file.mimetype === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||||
|
if (file.mimetype.includes('wordprocessingml') || ext === 'docx') return 'docx';
|
||||||
|
if (file.mimetype.includes('presentationml') || ext === 'pptx') return 'pptx';
|
||||||
|
if (ext === 'hwp' || ext === 'hwpx' || file.mimetype.includes('hwp')) return 'hwp';
|
||||||
|
if (file.mimetype.startsWith('text/') || ext === 'txt') return 'text';
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolvePreviewMime(file: Pick<FileRecord, 'mimetype' | 'originalName'>): string {
|
||||||
|
const ext = extOf(file.originalName);
|
||||||
|
const fromExt = EXT_MIME[ext];
|
||||||
|
const stored = file.mimetype?.trim() ?? '';
|
||||||
|
if (!stored || stored === 'application/octet-stream') {
|
||||||
|
return fromExt ?? stored ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
if (fromExt && stored.includes('octet')) return fromExt;
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from './apiClient';
|
||||||
import { migrateScheduleItem, quarterDateBounds } from './hubSchedule';
|
import { migrateScheduleItem, quarterDateBounds } from './hubSchedule';
|
||||||
|
import { ROUTINE_CATEGORIES } from './routineCategories';
|
||||||
|
|
||||||
const STORAGE_KEY = 'eene-quarter-hub-config-v1';
|
const STORAGE_KEY = 'eene-quarter-hub-config-v1';
|
||||||
|
const QUERY_KEY = ['hub-config'] as const;
|
||||||
|
|
||||||
export interface HubScheduleItem {
|
export interface HubScheduleItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,7 +23,7 @@ export interface HubConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HUB_CONFIG: HubConfig = {
|
export const DEFAULT_HUB_CONFIG: HubConfig = {
|
||||||
sloganTitle: '분기 슬로건',
|
sloganTitle: '분기 중점 과제',
|
||||||
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||||
scheduleTitle: '분기 주요 일정',
|
scheduleTitle: '분기 주요 일정',
|
||||||
scheduleItems: [
|
scheduleItems: [
|
||||||
@@ -27,9 +31,29 @@ export const DEFAULT_HUB_CONFIG: HubConfig = {
|
|||||||
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||||
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||||
],
|
],
|
||||||
routineLabels: ['채용', '교육', '소통', '시설', '자산', '행정'],
|
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function migrateRoutineLabels(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [...ROUTINE_CATEGORIES];
|
||||||
|
const labels = raw.map(String);
|
||||||
|
if (labels.length === ROUTINE_CATEGORIES.length && labels.every((label, i) => label === ROUTINE_CATEGORIES[i])) {
|
||||||
|
return [...ROUTINE_CATEGORIES];
|
||||||
|
}
|
||||||
|
const legacyFull = ['채용 운영', '교육 운영', '직원 소통', '자산·시설', '문서·행정'];
|
||||||
|
if (labels.length === legacyFull.length && labels.every((label, i) => label === legacyFull[i])) {
|
||||||
|
return [...ROUTINE_CATEGORIES];
|
||||||
|
}
|
||||||
|
const legacyShort = ['채용', '교육', '소통', '시설', '자산', '행정'];
|
||||||
|
if (labels.length === legacyShort.length && labels.every((label, i) => label === legacyShort[i])) {
|
||||||
|
return [...ROUTINE_CATEGORIES];
|
||||||
|
}
|
||||||
|
if (labels.some((label) => label === '교육 운영')) {
|
||||||
|
return labels.map((label) => (label === '교육 운영' ? '학습 지원' : label));
|
||||||
|
}
|
||||||
|
return labels.length > 0 ? labels : [...ROUTINE_CATEGORIES];
|
||||||
|
}
|
||||||
|
|
||||||
function migrateConfig(raw: Record<string, unknown>): HubConfig {
|
function migrateConfig(raw: Record<string, unknown>): HubConfig {
|
||||||
const { year } = quarterDateBounds('2026-Q2');
|
const { year } = quarterDateBounds('2026-Q2');
|
||||||
const scheduleItems = Array.isArray(raw.scheduleItems)
|
const scheduleItems = Array.isArray(raw.scheduleItems)
|
||||||
@@ -39,52 +63,84 @@ function migrateConfig(raw: Record<string, unknown>): HubConfig {
|
|||||||
: DEFAULT_HUB_CONFIG.scheduleItems;
|
: DEFAULT_HUB_CONFIG.scheduleItems;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sloganTitle: (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle,
|
sloganTitle: (() => {
|
||||||
|
const t = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
|
||||||
|
return t === '분기 슬로건' ? '분기 중점 과제' : t;
|
||||||
|
})(),
|
||||||
sloganLines: (raw.sloganLines as string[]) ?? DEFAULT_HUB_CONFIG.sloganLines,
|
sloganLines: (raw.sloganLines as string[]) ?? DEFAULT_HUB_CONFIG.sloganLines,
|
||||||
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
||||||
scheduleItems,
|
scheduleItems,
|
||||||
routineLabels: (raw.routineLabels as string[]) ?? DEFAULT_HUB_CONFIG.routineLabels,
|
routineLabels: migrateRoutineLabels(raw.routineLabels),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadConfig(): HubConfig {
|
async function fetchHubConfig(): Promise<HubConfig> {
|
||||||
if (typeof window === 'undefined') return DEFAULT_HUB_CONFIG;
|
const raw = await apiClient.get<Record<string, unknown>>('/hub-config').then((r) => r.data);
|
||||||
try {
|
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...raw });
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return DEFAULT_HUB_CONFIG;
|
|
||||||
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...JSON.parse(raw) });
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_HUB_CONFIG;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveConfig(config: HubConfig) {
|
async function saveHubConfig(config: HubConfig): Promise<HubConfig> {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
const raw = await apiClient.patch<Record<string, unknown>>('/hub-config', config).then((r) => r.data);
|
||||||
|
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...raw });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyLocalConfig(): HubConfig | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return migrateConfig({ ...DEFAULT_HUB_CONFIG, ...JSON.parse(raw) });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHubConfig() {
|
export function useHubConfig() {
|
||||||
const [config, setConfigState] = useState<HubConfig>(loadConfig);
|
const queryClient = useQueryClient();
|
||||||
|
const legacyMigrated = useRef(false);
|
||||||
|
|
||||||
const setConfig = useCallback((patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
|
const { data: config = DEFAULT_HUB_CONFIG, isLoading } = useQuery({
|
||||||
setConfigState((prev) => {
|
queryKey: QUERY_KEY,
|
||||||
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
|
queryFn: fetchHubConfig,
|
||||||
saveConfig(next);
|
staleTime: 30_000,
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
const saveMutation = useMutation({
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
mutationFn: saveHubConfig,
|
||||||
setConfigState(DEFAULT_HUB_CONFIG);
|
onSuccess: (saved) => {
|
||||||
}, []);
|
queryClient.setQueryData(QUERY_KEY, saved);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onStorage = (e: StorageEvent) => {
|
if (isLoading || legacyMigrated.current) return;
|
||||||
if (e.key === STORAGE_KEY) setConfigState(loadConfig());
|
const legacy = readLegacyLocalConfig();
|
||||||
};
|
if (!legacy) return;
|
||||||
window.addEventListener('storage', onStorage);
|
legacyMigrated.current = true;
|
||||||
return () => window.removeEventListener('storage', onStorage);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}, []);
|
saveHubConfig(legacy)
|
||||||
|
.then((saved) => {
|
||||||
|
queryClient.setQueryData(QUERY_KEY, saved);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* API 미준비 등 — 기본값 유지 */
|
||||||
|
});
|
||||||
|
}, [isLoading, queryClient]);
|
||||||
|
|
||||||
return { config, setConfig, resetConfig };
|
const setConfig = useCallback(
|
||||||
|
(patch: Partial<HubConfig> | ((prev: HubConfig) => HubConfig)) => {
|
||||||
|
const prev = queryClient.getQueryData<HubConfig>(QUERY_KEY) ?? DEFAULT_HUB_CONFIG;
|
||||||
|
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
|
||||||
|
queryClient.setQueryData(QUERY_KEY, next);
|
||||||
|
saveMutation.mutate(next);
|
||||||
|
},
|
||||||
|
[queryClient, saveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetConfig = useCallback(() => {
|
||||||
|
queryClient.setQueryData(QUERY_KEY, DEFAULT_HUB_CONFIG);
|
||||||
|
saveMutation.mutate(DEFAULT_HUB_CONFIG);
|
||||||
|
}, [queryClient, saveMutation]);
|
||||||
|
|
||||||
|
return { config, setConfig, resetConfig, isLoading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,19 @@ export function startOfDay(date: Date): Date {
|
|||||||
|
|
||||||
/** "6월 8일" */
|
/** "6월 8일" */
|
||||||
export function formatScheduleDateLabel(dateStr: string): string {
|
export function formatScheduleDateLabel(dateStr: string): string {
|
||||||
|
const parts = formatScheduleDateParts(dateStr);
|
||||||
|
if (!parts) return dateStr;
|
||||||
|
return `${parts.month} ${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** { month: "6월", day: "8일" } — 일정 목록 좌우 정렬용 */
|
||||||
|
export function formatScheduleDateParts(dateStr: string): { month: string; day: string } | null {
|
||||||
const dt = parseScheduleDate(dateStr);
|
const dt = parseScheduleDate(dateStr);
|
||||||
if (!dt) return dateStr;
|
if (!dt) return null;
|
||||||
return `${dt.getMonth() + 1}월 ${dt.getDate()}일`;
|
return {
|
||||||
|
month: `${dt.getMonth() + 1}월`,
|
||||||
|
day: `${dt.getDate()}일`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortScheduleItems(items: HubScheduleItem[]): HubScheduleItem[] {
|
export function sortScheduleItems(items: HubScheduleItem[]): HubScheduleItem[] {
|
||||||
|
|||||||
168
frontend/src/lib/milestonePeriods.ts
Normal file
168
frontend/src/lib/milestonePeriods.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Milestone, MilestonePeriodEntry } from '../types';
|
||||||
|
import { routineStageBody } from './routineMilestone';
|
||||||
|
|
||||||
|
export type { MilestonePeriodEntry };
|
||||||
|
|
||||||
|
export function newPeriodEntry(): MilestonePeriodEntry {
|
||||||
|
return {
|
||||||
|
id: `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
startDate: '',
|
||||||
|
dueDate: '',
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInput(iso: string | null | undefined) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() : '';
|
||||||
|
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() : '';
|
||||||
|
const note = typeof row.note === 'string' ? row.note : '';
|
||||||
|
if (!startDate && !dueDate && !note.trim()) continue;
|
||||||
|
entries.push({
|
||||||
|
id: typeof row.id === 'string' && row.id ? row.id : newPeriodEntry().id,
|
||||||
|
startDate,
|
||||||
|
dueDate,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyDescriptionNote(description: string | null | undefined): string {
|
||||||
|
return routineStageBody(description).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMilestonePeriods(
|
||||||
|
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
|
||||||
|
): MilestonePeriodEntry[] {
|
||||||
|
if (!milestone) return [];
|
||||||
|
const legacyNote = legacyDescriptionNote(milestone.description);
|
||||||
|
const fromJson = normalizePeriodEntries(milestone.periodEntries);
|
||||||
|
if (fromJson.length > 0) {
|
||||||
|
if (legacyNote && !fromJson.some((p) => p.note.trim())) {
|
||||||
|
return fromJson.map((p, i) => (i === 0 ? { ...p, note: legacyNote } : p));
|
||||||
|
}
|
||||||
|
return fromJson;
|
||||||
|
}
|
||||||
|
if (milestone.startDate || milestone.dueDate || legacyNote) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: milestone.id ? `period-legacy-${milestone.id}` : newPeriodEntry().id,
|
||||||
|
startDate: toDateInput(milestone.startDate),
|
||||||
|
dueDate: toDateInput(milestone.dueDate),
|
||||||
|
note: legacyNote,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePeriodEntries(entries: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
|
||||||
|
return entries
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
startDate: entry.startDate.trim(),
|
||||||
|
dueDate: entry.dueDate.trim(),
|
||||||
|
note: entry.note.trim(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.startDate || entry.dueDate || entry.note);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtPeriodRange(entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>) {
|
||||||
|
const fmt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)} ~ ${fmt(entry.dueDate)}`;
|
||||||
|
if (entry.dueDate) return fmt(entry.dueDate);
|
||||||
|
if (entry.startDate) return `${fmt(entry.startDate)} ~`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 업무내용 기간 선택 — MM.DD~MM.DD (연도 생략) */
|
||||||
|
export function fmtPeriodPickerLabel(
|
||||||
|
entry: Pick<MilestonePeriodEntry, 'startDate' | 'dueDate'>,
|
||||||
|
index: number,
|
||||||
|
): string {
|
||||||
|
const fmt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
if (entry.startDate && entry.dueDate) return `${fmt(entry.startDate)}~${fmt(entry.dueDate)}`;
|
||||||
|
if (entry.dueDate) return fmt(entry.dueDate);
|
||||||
|
if (entry.startDate) return `${fmt(entry.startDate)}~`;
|
||||||
|
return `기간 ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePeriodNoteLines(text: string | null | undefined): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^[•·\-]\s*/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최신 기간 우선 — 종료일·시작일 내림차순 */
|
||||||
|
export function sortPeriodsByRecent(periods: MilestonePeriodEntry[]): MilestonePeriodEntry[] {
|
||||||
|
return [...periods].sort((a, b) => {
|
||||||
|
const ta = a.dueDate || a.startDate || '';
|
||||||
|
const tb = b.dueDate || b.startDate || '';
|
||||||
|
if (ta && tb) return tb.localeCompare(ta);
|
||||||
|
if (tb) return 1;
|
||||||
|
if (ta) return -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickLatestPeriodId(periods: MilestonePeriodEntry[]): string | null {
|
||||||
|
return sortPeriodsByRecent(periods)[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목록·카드용 — 최신(마지막) 기간 또는 요약 */
|
||||||
|
export function fmtMilestonePeriodSummary(
|
||||||
|
milestone: Pick<Milestone, 'periodEntries' | 'startDate' | 'dueDate'>,
|
||||||
|
): string {
|
||||||
|
const periods = parseMilestonePeriods(milestone);
|
||||||
|
if (periods.length === 0) return '';
|
||||||
|
if (periods.length === 1) return fmtPeriodRange(periods[0]) || '';
|
||||||
|
const last = periods[periods.length - 1];
|
||||||
|
const lastLabel = fmtPeriodRange(last);
|
||||||
|
return lastLabel ? `${lastLabel} 외 ${periods.length - 1}건` : `기간 ${periods.length}건`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneContentBlock {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated MilestoneContentList에서 기간 선택 UI로 대체 */
|
||||||
|
export function buildMilestoneContentBlocks(
|
||||||
|
milestone: Pick<Milestone, 'id' | 'periodEntries' | 'startDate' | 'dueDate' | 'description'> | null | undefined,
|
||||||
|
): MilestoneContentBlock[] {
|
||||||
|
if (!milestone) return [];
|
||||||
|
|
||||||
|
const blocks: MilestoneContentBlock[] = [];
|
||||||
|
const periods = sortPeriodsByRecent(parseMilestonePeriods(milestone)).reverse();
|
||||||
|
|
||||||
|
periods.forEach((period, index) => {
|
||||||
|
const lines = parsePeriodNoteLines(period.note);
|
||||||
|
if (lines.length === 0) return;
|
||||||
|
blocks.push({
|
||||||
|
key: period.id,
|
||||||
|
label: fmtPeriodPickerLabel(period, index),
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
9
frontend/src/lib/milestoneSubtitle.ts
Normal file
9
frontend/src/lib/milestoneSubtitle.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Milestone } from '../types';
|
||||||
|
import { decodeRoutineStageDescription } from './routineMilestone';
|
||||||
|
|
||||||
|
/** 업무명(단계) 카드 — 부제목 (기간 대신 표시) */
|
||||||
|
export function getMilestoneSubtitle(m: Milestone): string {
|
||||||
|
if (m.subtitle?.trim()) return m.subtitle.trim();
|
||||||
|
const { overview } = decodeRoutineStageDescription(m.description);
|
||||||
|
return overview;
|
||||||
|
}
|
||||||
267
frontend/src/lib/milestoneTimeline.ts
Normal file
267
frontend/src/lib/milestoneTimeline.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { quarterDateBounds } from './hubSchedule';
|
||||||
|
|
||||||
|
export interface TimelineMilestoneInput {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
periodEntries?: Array<{
|
||||||
|
id: string;
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
}> | null;
|
||||||
|
progress?: number;
|
||||||
|
completedAt?: string | null;
|
||||||
|
order: number;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineRangeFallback {
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
quarter?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineTick {
|
||||||
|
label: string;
|
||||||
|
leftPct: number;
|
||||||
|
isToday?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineRow {
|
||||||
|
id: string;
|
||||||
|
milestoneId: string;
|
||||||
|
title: string;
|
||||||
|
leftPct: number;
|
||||||
|
widthPct: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MilestoneTimelineModel {
|
||||||
|
rangeStartLabel: string;
|
||||||
|
rangeEndLabel: string;
|
||||||
|
ticks: TimelineTick[];
|
||||||
|
rows: TimelineRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const RANGE_PAD_DAYS = 2;
|
||||||
|
/** 하루·단일일정 막대가 타임라인에서 묻히지 않도록 */
|
||||||
|
const MIN_BAR_WIDTH_PCT = 2;
|
||||||
|
/** 오늘 눈금과 겹치는 기존 날짜 라벨 제거 (주간 눈금 등) */
|
||||||
|
const TODAY_TICK_MIN_GAP_PCT = 3.2;
|
||||||
|
|
||||||
|
export function taskTimelineFallback(task: {
|
||||||
|
startDate?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
quarter?: string | null;
|
||||||
|
}): TimelineRangeFallback {
|
||||||
|
return {
|
||||||
|
startDate: task.startDate,
|
||||||
|
dueDate: task.dueDate,
|
||||||
|
quarter: task.quarter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDay(iso: string): Date {
|
||||||
|
return startOfDay(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTimelineDayLabel(d: Date): string {
|
||||||
|
return `${d.getMonth() + 1}.${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneProgress(m: TimelineMilestoneInput): number {
|
||||||
|
if (m.completedAt) return 100;
|
||||||
|
return Math.min(100, Math.max(0, m.progress ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMilestoneTimes(milestones: TimelineMilestoneInput[]): number[] {
|
||||||
|
const times: number[] = [];
|
||||||
|
for (const m of milestones) {
|
||||||
|
const periods = m.periodEntries?.length
|
||||||
|
? m.periodEntries
|
||||||
|
: m.startDate || m.dueDate
|
||||||
|
? [{ startDate: m.startDate, dueDate: m.dueDate }]
|
||||||
|
: [];
|
||||||
|
for (const p of periods) {
|
||||||
|
if (p.startDate) times.push(parseDay(p.startDate).getTime());
|
||||||
|
if (p.dueDate) times.push(parseDay(p.dueDate).getTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRange(
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
fallback: TimelineRangeFallback,
|
||||||
|
): { start: Date; end: Date } | null {
|
||||||
|
const times = collectMilestoneTimes(milestones);
|
||||||
|
|
||||||
|
if (times.length >= 1) {
|
||||||
|
const min = Math.min(...times);
|
||||||
|
const max = Math.max(...times);
|
||||||
|
const pad = RANGE_PAD_DAYS * DAY_MS;
|
||||||
|
return {
|
||||||
|
start: new Date(min - pad),
|
||||||
|
end: new Date(max + pad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback.startDate && fallback.dueDate) {
|
||||||
|
return {
|
||||||
|
start: parseDay(fallback.startDate),
|
||||||
|
end: parseDay(fallback.dueDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback.quarter) {
|
||||||
|
const { min, max } = quarterDateBounds(fallback.quarter);
|
||||||
|
return { start: parseDay(min), end: parseDay(max) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
|
||||||
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
|
||||||
|
const totalDays = Math.ceil(rangeMs / DAY_MS) + 1;
|
||||||
|
const stepDays = totalDays > 45 ? 7 : totalDays > 28 ? 2 : 1;
|
||||||
|
const now = startOfDay(new Date());
|
||||||
|
const todayInRange = now >= rangeStart && now <= rangeEnd;
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
|
||||||
|
const ticks: TimelineTick[] = [];
|
||||||
|
|
||||||
|
for (let t = rangeStart.getTime(); t <= rangeEnd.getTime(); t += stepDays * DAY_MS) {
|
||||||
|
const d = new Date(t);
|
||||||
|
ticks.push({
|
||||||
|
label: fmtTimelineDayLabel(d),
|
||||||
|
leftPct: ((d.getTime() - rangeStart.getTime()) / rangeMs) * 100,
|
||||||
|
isToday: todayInRange && d.getTime() === nowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
|
||||||
|
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
|
||||||
|
const todayTick: TimelineTick = {
|
||||||
|
label: fmtTimelineDayLabel(now),
|
||||||
|
leftPct: todayLeftPct,
|
||||||
|
isToday: true,
|
||||||
|
};
|
||||||
|
const spaced = ticks.filter(
|
||||||
|
(tick) => Math.abs(tick.leftPct - todayLeftPct) >= TODAY_TICK_MIN_GAP_PCT,
|
||||||
|
);
|
||||||
|
spaced.push(todayTick);
|
||||||
|
spaced.sort((a, b) => a.leftPct - b.leftPct);
|
||||||
|
return spaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestoneHasDates(m: TimelineMilestoneInput): boolean {
|
||||||
|
if (m.periodEntries?.some((p) => p.startDate || p.dueDate)) return true;
|
||||||
|
return !!(m.startDate || m.dueDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function milestonePeriodSpans(m: TimelineMilestoneInput): Array<{ startMs: number; endMs: number }> {
|
||||||
|
const raw = m.periodEntries?.length
|
||||||
|
? m.periodEntries
|
||||||
|
: m.startDate || m.dueDate
|
||||||
|
? [{ startDate: m.startDate, dueDate: m.dueDate }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const spans: Array<{ startMs: number; endMs: number }> = [];
|
||||||
|
for (const p of raw) {
|
||||||
|
if (!p.startDate && !p.dueDate) continue;
|
||||||
|
if (p.startDate && p.dueDate) {
|
||||||
|
let startMs = parseDay(p.startDate).getTime();
|
||||||
|
let endMs = parseDay(p.dueDate).getTime();
|
||||||
|
if (endMs < startMs) [startMs, endMs] = [endMs, startMs];
|
||||||
|
spans.push({ startMs, endMs });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ms = parseDay((p.dueDate ?? p.startDate)!).getTime();
|
||||||
|
spans.push({ startMs: ms, endMs: ms });
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(
|
||||||
|
ordered: TimelineMilestoneInput[],
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
hideUndatedBars?: boolean,
|
||||||
|
): TimelineRow[] {
|
||||||
|
const items =
|
||||||
|
hideUndatedBars === false
|
||||||
|
? ordered
|
||||||
|
: ordered.filter((m) => milestoneHasDates(m));
|
||||||
|
|
||||||
|
const rangeStartMs = rangeStart.getTime();
|
||||||
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStartMs, DAY_MS);
|
||||||
|
|
||||||
|
const rows: TimelineRow[] = [];
|
||||||
|
|
||||||
|
for (const m of items) {
|
||||||
|
const spans = milestonePeriodSpans(m);
|
||||||
|
if (spans.length === 0) continue;
|
||||||
|
|
||||||
|
spans.forEach((span, index) => {
|
||||||
|
const { startMs, endMs } = span;
|
||||||
|
const spanMs = endMs - startMs;
|
||||||
|
const isPoint = spanMs === 0;
|
||||||
|
|
||||||
|
let widthPct = isPoint ? MIN_BAR_WIDTH_PCT : (spanMs / rangeMs) * 100;
|
||||||
|
widthPct = Math.max(MIN_BAR_WIDTH_PCT, widthPct);
|
||||||
|
|
||||||
|
let leftPct = ((startMs - rangeStartMs) / rangeMs) * 100;
|
||||||
|
if (isPoint) leftPct -= widthPct / 2;
|
||||||
|
leftPct = Math.max(0, Math.min(100 - widthPct, leftPct));
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: spans.length > 1 ? `${m.id}:${index}` : m.id,
|
||||||
|
milestoneId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
leftPct,
|
||||||
|
widthPct,
|
||||||
|
progress: milestoneProgress(m),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMilestoneTimeline(
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
fallback: TimelineRangeFallback = {},
|
||||||
|
options?: { preserveOrder?: boolean; hideUndatedBars?: boolean },
|
||||||
|
): MilestoneTimelineModel | null {
|
||||||
|
if (milestones.length === 0) return null;
|
||||||
|
|
||||||
|
const range = computeRange(milestones, fallback);
|
||||||
|
if (!range) return null;
|
||||||
|
|
||||||
|
const ordered = options?.preserveOrder
|
||||||
|
? [...milestones]
|
||||||
|
: [...milestones].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const ticks = buildTicks(range.start, range.end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rangeStartLabel: fmtTimelineDayLabel(range.start),
|
||||||
|
rangeEndLabel: fmtTimelineDayLabel(range.end),
|
||||||
|
ticks,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
frontend/src/lib/routineCategories.ts
Normal file
79
frontend/src/lib/routineCategories.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/** 허브 다이아몬드 · 상시업무 대분류 (고정 5종) */
|
||||||
|
export const ROUTINE_CATEGORIES = [
|
||||||
|
'채용 운영',
|
||||||
|
'학습 지원',
|
||||||
|
'직원 소통',
|
||||||
|
'자산·시설',
|
||||||
|
'문서·행정',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RoutineCategory = (typeof ROUTINE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
const LEGACY_CATEGORY_ALIASES: Record<string, RoutineCategory> = {
|
||||||
|
채용: '채용 운영',
|
||||||
|
'채용 운영': '채용 운영',
|
||||||
|
교육: '학습 지원',
|
||||||
|
'교육 운영': '학습 지원',
|
||||||
|
'학습 지원': '학습 지원',
|
||||||
|
학습: '학습 지원',
|
||||||
|
소통: '직원 소통',
|
||||||
|
'직원 소통': '직원 소통',
|
||||||
|
시설: '자산·시설',
|
||||||
|
자산: '자산·시설',
|
||||||
|
'자산·시설': '자산·시설',
|
||||||
|
'자산·시설 관리': '자산·시설',
|
||||||
|
행정: '문서·행정',
|
||||||
|
'문서·행정': '문서·행정',
|
||||||
|
'문서·행정 지원': '문서·행정',
|
||||||
|
인사관리: '채용 운영',
|
||||||
|
학습성장: '학습 지원',
|
||||||
|
운영지원: '자산·시설',
|
||||||
|
전산관리: '문서·행정',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeRoutineCategory(value: string | null | undefined): RoutineCategory | null {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (LEGACY_CATEGORY_ALIASES[trimmed]) return LEGACY_CATEGORY_ALIASES[trimmed];
|
||||||
|
const matched = ROUTINE_CATEGORIES.find((cat) => cat === trimmed);
|
||||||
|
return matched ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoutineCategory(task: {
|
||||||
|
category?: string | null;
|
||||||
|
section?: string | null;
|
||||||
|
title?: string;
|
||||||
|
}): RoutineCategory | null {
|
||||||
|
const fromField = normalizeRoutineCategory(task.category) ?? normalizeRoutineCategory(task.section);
|
||||||
|
if (fromField) return fromField;
|
||||||
|
if (task.title) {
|
||||||
|
const fromTitle = normalizeRoutineCategory(task.title);
|
||||||
|
if (fromTitle) return fromTitle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routineCategoryOptions(): { value: RoutineCategory; label: RoutineCategory }[] {
|
||||||
|
return ROUTINE_CATEGORIES.map((cat) => ({ value: cat, label: cat }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupRoutineTasksByCategory<T extends { category?: string | null; section?: string | null; title?: string }>(
|
||||||
|
tasks: T[],
|
||||||
|
): Record<RoutineCategory, T[]> {
|
||||||
|
const groups = Object.fromEntries(ROUTINE_CATEGORIES.map((cat) => [cat, [] as T[]])) as Record<RoutineCategory, T[]>;
|
||||||
|
for (const task of tasks) {
|
||||||
|
const cat = getRoutineCategory(task) ?? '채용 운영';
|
||||||
|
groups[cat].push(task);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 탭·허브 슬롯용 — 제목이 대분류명과 일치하는 대표 task 우선 */
|
||||||
|
export function pickRoutineCategoryTask<
|
||||||
|
T extends { id: string; category?: string | null; section?: string | null; title?: string },
|
||||||
|
>(tasks: T[], category: RoutineCategory): T | null {
|
||||||
|
const grouped = groupRoutineTasksByCategory(tasks);
|
||||||
|
const inCategory = grouped[category];
|
||||||
|
const shell = inCategory.find((task) => task.title?.trim() === category);
|
||||||
|
return shell ?? inCategory[0] ?? null;
|
||||||
|
}
|
||||||
43
frontend/src/lib/routineMilestone.ts
Normal file
43
frontend/src/lib/routineMilestone.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const OVERVIEW_PREFIX = '@overview:';
|
||||||
|
|
||||||
|
/** 상시업무 단계 — overview + 본문(description)을 한 필드에 저장 */
|
||||||
|
export function encodeRoutineStageDescription(overview: string, body: string): string | undefined {
|
||||||
|
const o = overview.trim();
|
||||||
|
const b = body.trim();
|
||||||
|
if (!o && !b) return undefined;
|
||||||
|
if (!o) return b || undefined;
|
||||||
|
return `${OVERVIEW_PREFIX}${o}\n${b}`.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeRoutineStageDescription(raw: string | null | undefined): {
|
||||||
|
overview: string;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
if (!raw?.trim()) return { overview: '', body: '' };
|
||||||
|
if (raw.startsWith(OVERVIEW_PREFIX)) {
|
||||||
|
const rest = raw.slice(OVERVIEW_PREFIX.length);
|
||||||
|
const nl = rest.indexOf('\n');
|
||||||
|
if (nl === -1) return { overview: rest.trim(), body: '' };
|
||||||
|
return {
|
||||||
|
overview: rest.slice(0, nl).trim(),
|
||||||
|
body: rest.slice(nl + 1).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { overview: '', body: raw.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoutineContentLines(text: string | null | undefined): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/^[•·\-]\s*/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시 @overview: 인코딩 → 편집용 본문 */
|
||||||
|
export function routineStageBody(raw: string | null | undefined): string {
|
||||||
|
if (!raw?.trim()) return '';
|
||||||
|
const { overview, body } = decodeRoutineStageDescription(raw);
|
||||||
|
if (body) return body;
|
||||||
|
return overview;
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export const COLUMN_META: Record<
|
|||||||
운영관리: {
|
운영관리: {
|
||||||
titleEn: 'GA',
|
titleEn: 'GA',
|
||||||
accent: '#36816d',
|
accent: '#36816d',
|
||||||
displayTitle: '운영관리',
|
displayTitle: '총무관리',
|
||||||
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
|
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user