Compare commits
228 Commits
fa4bc40761
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01132d2ac3 | |||
| 5ff9bf0df8 | |||
| fb0669990d | |||
| de92fc01b4 | |||
| a65b5aac05 | |||
| 9a79e169c8 | |||
| 2d4f2e2c19 | |||
| 3adb596511 | |||
| bd4f3e70bf | |||
| 8d00d71072 | |||
| 26cad39857 | |||
| 5ee5ad6973 | |||
| f7b8a4c42d | |||
| e42ccf7139 | |||
| ec54ba0e8d | |||
| 2cbd19e791 | |||
| 65796a0893 | |||
| 5267b759be | |||
| 524c6f6782 | |||
| 5a92813a31 | |||
| cf1f4db6af | |||
| f023bd0ed7 | |||
| 638d1265f6 | |||
| 9ce3158450 | |||
| 83d0cda163 | |||
| 46f1875ca3 | |||
| 612c4fec4c | |||
| eebdbc34f5 | |||
| 22a02be937 | |||
| 101b1553b0 | |||
| 2d1026466f | |||
| 5ecc1d7be4 | |||
| bde4b561f3 | |||
| 5866d6ad55 | |||
| 2015b794fc | |||
| 04995ca91a | |||
| 2740205ed5 | |||
| 4ad762881c | |||
| 6bd7fac875 | |||
| 7f6baff425 | |||
| 8d78ba25a2 | |||
| 2313418e9c | |||
| 38a3ecf8a2 | |||
| 0da97ebd21 | |||
| 8f49774aff | |||
| 79194428fc | |||
| 954f4a6932 | |||
| 034afe2cf8 | |||
| 1a17540d91 | |||
| 3ec7e76f7c | |||
| dfe2310661 | |||
| 9ad10afca6 | |||
| 960ff1d420 | |||
| d8d2ff21f9 | |||
| 30b4b87368 | |||
| 999a8730dd | |||
| 251830d421 | |||
| c5a8fc8697 | |||
| b40e795664 | |||
| 88ef92628d | |||
| 423f2197ed | |||
| 54fe3d36e7 | |||
| 2416f728ba | |||
| 3c7aec86a2 | |||
| 9a4d287ffd | |||
| f519723260 | |||
| 85b6b65e24 | |||
| bf2321f2d5 | |||
| 145b4611d3 | |||
| c52630af27 | |||
| 3f2a72ab31 | |||
| e719829f0b | |||
| d964f9101a | |||
| 739a5a70f3 | |||
| 87c9019446 | |||
| 8fc9287747 | |||
| 916dd49887 | |||
| 42bf97636a | |||
| a344945e4e | |||
| 34b5b1d552 | |||
| ad950e88ad | |||
| 511efec64d | |||
| 4ff746638c | |||
| 3f134ccca3 | |||
| cc06fd9816 | |||
| f2ead261c8 | |||
| 1ad95ff7e8 | |||
| b9d1990162 | |||
| a094283e62 | |||
| b8756d34ab | |||
| 0cdd2bb98e | |||
| 7568637de2 | |||
| 61d247de08 | |||
| ddf94b188b | |||
| daff61c85e | |||
| 3380bbdcff | |||
| 21cabbe20b | |||
| d3e2861440 | |||
| e7760e9ddc | |||
| d3a916dbd7 | |||
| 7425d57134 | |||
| 0c4f0817e6 | |||
| 5f8b2597bf | |||
| 92ed6c332f | |||
| d5b54c1afb | |||
| 346ec75553 | |||
| 62df92e6eb | |||
| 2c02c4afc1 | |||
| 2b534743d6 | |||
| af8b363f23 | |||
| 4efe5a7a9a | |||
| dd7d75310e | |||
| 32c04db9fe | |||
| b8bb046733 | |||
| ea83895938 | |||
| 1f7d7dcebb | |||
| 1df0f2e4e2 | |||
| 69a25d46ec | |||
| a8d068a76c | |||
| ce50c3c4f5 | |||
| 2fd19e0bec | |||
| 1a6321ed49 | |||
| bc99911b5f | |||
| 69708b2b8b | |||
| b00785d056 | |||
| 4a31331ae1 | |||
| 8a52ad0d0a | |||
| 6577550937 | |||
| b717ecd9a5 | |||
| 814dd7bb8b | |||
| 98a2308c4e | |||
| 31704cc95b | |||
| 4ef07f65a7 | |||
| 25ca63a925 | |||
| 20664dab0f | |||
| c550d66826 | |||
| 48b8a1b613 | |||
| 9e2326f939 | |||
| 24031d93a9 | |||
| 6c0f1778c8 | |||
| 5e742e5c8b | |||
| afed0d087a | |||
| 944b8efbce | |||
| 433901312d | |||
| 6767316cdb | |||
| 71040f94c8 | |||
| 1e6acff4f1 | |||
| fd2e3245b4 | |||
| 791c74f388 | |||
| 034bcf9bc7 | |||
| 43254fc388 | |||
| 0d453fc426 | |||
| 9e29d0d0b0 | |||
| b1afe5480c | |||
| 118d12f829 | |||
| 5c3b1e5ab4 | |||
| f6b01b01a8 | |||
| d9a343e5e9 | |||
| 32815a0627 | |||
| f4a1253526 | |||
| f60f705f56 | |||
| 3b78574855 | |||
| 70d75cf88d | |||
| 8b3cebbc62 | |||
| b387b7e378 | |||
| 9d004dd64e | |||
| c32df3cf39 | |||
| 5f0b80593f | |||
| 20f593bd5a | |||
| 79e0c820ba | |||
| c86c04f99e | |||
| 93c8ae2a22 | |||
| 1f2a93b01a | |||
| 042d724b3a | |||
| ff33fb2e6c | |||
| 0d47e989ba | |||
| f8ae813c05 | |||
| 3379c54033 | |||
| bd21557e39 | |||
| 867f47ba8f | |||
| 116560b549 | |||
| 612d6c7de8 | |||
| 9f66c6d0ec | |||
| ae6af518dd | |||
| 544d49aa97 | |||
| 73eb53625c | |||
| 32a87d8f62 | |||
| be0a1bc9d1 | |||
| ef1757181d | |||
| 861c8efa6b | |||
| d0599787f7 | |||
| 647336b14d | |||
| 3667f48897 | |||
| 4d943e8d0d | |||
| 135cc0759f | |||
| 58c0b9510d | |||
| 9fdcecace8 | |||
| 72b05f1b69 | |||
| 52b70363d9 | |||
| cb6aa07ebc | |||
| 9aa22f092d | |||
| 8a2e34d617 | |||
| e028449312 | |||
| 7cd6676ef9 | |||
| 6ba148314c | |||
| 2f66cdda8a | |||
| 9bcbc1564f | |||
| e72e6aee83 | |||
| 85ae82f3ce | |||
| 57a88ff741 | |||
| df97719e0f | |||
| 406e13da1c | |||
| 0976a5057b | |||
| 51b608efeb | |||
| b8e8095170 | |||
| 2f7f28dd7b | |||
| e432832c92 | |||
| 5da3ec8818 | |||
| db6a80a6f4 | |||
| df268d3b5e | |||
| b9e2b0e67d | |||
| 8efcacb6bb | |||
| 8766a4f5b6 | |||
| 7a2c70a260 | |||
| 42db9b5d00 | |||
| 8935f6e488 | |||
| 56c89d9927 | |||
| 5351373694 |
@@ -1,5 +1,11 @@
|
||||
# ==========================================
|
||||
# 프로젝트 전역 설정
|
||||
# 루트 .env (development) — 모든 설정의 단일 진실 공급원(SSOT)
|
||||
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
|
||||
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
|
||||
# ==========================================
|
||||
|
||||
# ==========================================
|
||||
# 프로젝트 전역
|
||||
# ==========================================
|
||||
PROJECT_NAME=comrelay
|
||||
APP_ENV=development
|
||||
@@ -8,7 +14,7 @@ FRONTEND_DIR=frontend
|
||||
BACKEND_DIR=backend
|
||||
|
||||
# ==========================================
|
||||
# 포트 설정
|
||||
# 포트
|
||||
# ==========================================
|
||||
FRONTEND_PORT=5273
|
||||
BACKEND_PORT=3100
|
||||
@@ -17,7 +23,7 @@ DB_PORT=5532
|
||||
REDIS_PORT=6479
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# Database (PostgreSQL)
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
DB_USER=dev_ttipo
|
||||
@@ -26,14 +32,60 @@ DB_NAME=comrelay_db
|
||||
DB_DATA_DIR=./data/postgres
|
||||
|
||||
# ==========================================
|
||||
# Redis 설정
|
||||
# Redis
|
||||
# ==========================================
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
REDIS_PASSWORD=change-me-redis-password
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# API 경로
|
||||
# ==========================================
|
||||
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL)
|
||||
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진
|
||||
BACKEND_API_URL=http://localhost:3100/api
|
||||
CORS_ORIGIN=http://localhost:5273
|
||||
|
||||
# ==========================================
|
||||
# AI (백엔드·프론트 공통)
|
||||
# ==========================================
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit Cloud)
|
||||
# ==========================================
|
||||
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
|
||||
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
|
||||
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
|
||||
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
|
||||
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
|
||||
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
|
||||
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
|
||||
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
|
||||
LIVEKIT_API_KEY=APIxxxxxxxxxxxx
|
||||
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
|
||||
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
|
||||
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 파일 업로드/저장용 오브젝트 스토리지. NCP 콘솔에서 버킷 생성 후 값을 채운다.
|
||||
# 비시크릿(endpoint/region/bucket)만 루트에 두고, ACCESS/SECRET 키는 backend/.env 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 버킷명(환경별로 분리 권장, 예: comrelay-dev)
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-dev
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
+61
-6
@@ -1,5 +1,11 @@
|
||||
# ==========================================
|
||||
# 프로젝트 전역 설정
|
||||
# 루트 .env (production) — 모든 설정의 단일 진실 공급원(SSOT)
|
||||
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
|
||||
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
|
||||
# ==========================================
|
||||
|
||||
# ==========================================
|
||||
# 프로젝트 전역
|
||||
# ==========================================
|
||||
PROJECT_NAME=comrelay
|
||||
APP_ENV=production
|
||||
@@ -8,16 +14,16 @@ FRONTEND_DIR=frontend
|
||||
BACKEND_DIR=backend
|
||||
|
||||
# ==========================================
|
||||
# 포트 설정
|
||||
# 포트
|
||||
# ==========================================
|
||||
FRONTEND_PORT=5173
|
||||
FRONTEND_PORT=80
|
||||
BACKEND_PORT=3000
|
||||
WEBSOCKET_PORT=3001
|
||||
DB_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# Database (PostgreSQL)
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
DB_USER=prd_ttipo
|
||||
@@ -26,14 +32,63 @@ DB_NAME=comrelay_db
|
||||
DB_DATA_DIR=./data/postgres
|
||||
|
||||
# ==========================================
|
||||
# Redis 설정
|
||||
# Redis
|
||||
# ==========================================
|
||||
REDIS_IMAGE=redis:7-alpine
|
||||
REDIS_PASSWORD=change-me-redis-password
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# API 경로
|
||||
# ==========================================
|
||||
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL) — 운영 도메인으로 교체
|
||||
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진 — 운영 도메인으로 교체
|
||||
BACKEND_API_URL=http://localhost:3000/api
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# ==========================================
|
||||
# AI (백엔드·프론트 공통)
|
||||
# ==========================================
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit) — 셀프호스팅(기본) / Cloud(대안)
|
||||
# ==========================================
|
||||
# [셀프호스팅] 운영 권장. 절차: livekit/DEPLOY.md (단일노드 3장, AWS EC2+도커 nginx 4-1장).
|
||||
# - selfhost-livekit 프로파일로 컨테이너 기동:
|
||||
# docker compose --profile selfhost-livekit --env-file .env.production up -d --build
|
||||
# - 리버스 프록시(wss 종단)는 livekit/nginx.livekit.conf.example 참고.
|
||||
# - 키 생성(절대 Cloud 키 재사용 금지):
|
||||
# LIVEKIT_API_KEY=$(openssl rand -hex 8)
|
||||
# LIVEKIT_API_SECRET=$(openssl rand -hex 32) # 최소 32자
|
||||
# - LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
|
||||
# - LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
|
||||
# [Cloud] 대안. LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로
|
||||
# 채우고 selfhost-livekit 프로파일은 켜지 않는다.
|
||||
LIVEKIT_API_KEY=change-me-livekit-key
|
||||
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
|
||||
LIVEKIT_WS_URL=wss://livekit.example.com
|
||||
LIVEKIT_URL=http://livekit:7880
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 운영 버킷은 개발과 분리한다(예: comrelay-prod). 비시크릿만 루트에 두고
|
||||
# ACCESS/SECRET 키는 backend/.env.production 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 운영 버킷명
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-prod
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
+13
@@ -50,3 +50,16 @@ Thumbs.db
|
||||
.cache/
|
||||
.eslintcache
|
||||
.npm/
|
||||
|
||||
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
|
||||
frontend/*단독*.html
|
||||
frontend/*단일 파일*.html
|
||||
|
||||
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
|
||||
livekit/*.local.yaml
|
||||
|
||||
# 작업 중 공유용 임시 스크린샷(루트) 커밋 방지
|
||||
/image.png
|
||||
|
||||
# Claude Code 메모리 디렉터리(작업 메모) — 커밋 제외
|
||||
/memory/
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
|
||||
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
|
||||
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
|
||||
- **단독 실행(non-Docker)** 시에는 주입이 없으므로, 각 서비스 `.env.{APP_ENV}` 의 주석 처리된 예시 값을 활성화해 사용한다.
|
||||
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
|
||||
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker / Docker Compose (권장 구동 방식)
|
||||
- 단독 실행 시: Node.js `^20.19 || >=22.12`, Flutter SDK `^3.11`
|
||||
- Docker / Docker Compose
|
||||
- 모바일 앱(선택): Flutter SDK `^3.11`
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
|
||||
@@ -26,7 +26,6 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
|
||||
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
|
||||
cp .env.development.example .env.development # ← 여기에만 값 입력
|
||||
cp backend/.env.development.example backend/.env.development # (존재만 필요, 내용 최소)
|
||||
cp frontend/.env.development.example frontend/.env.development # (단독 실행 시에만 사용)
|
||||
|
||||
# 2) 개발 환경 기동 (코드만 수정 시)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
@@ -43,21 +42,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
|
||||
전체 실행/배포 명령어는 [`command.md`](./command.md) 참조.
|
||||
|
||||
## 단독 실행 (Docker 미사용)
|
||||
|
||||
```bash
|
||||
# 백엔드
|
||||
cd backend && npm install && npm run start:dev
|
||||
|
||||
# 프론트엔드
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# 모바일
|
||||
cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> 단독 실행 시 백엔드 `.env.development` 의 `DB_HOST`/`REDIS_HOST` 를 `localhost` 로 두고,
|
||||
> PostgreSQL·Redis 를 별도로 띄워야 합니다.
|
||||
> 모바일 앱은 docker-compose 대상이 아니므로 별도로 실행합니다:
|
||||
> `cd flutter && cp .env.example .env && flutter pub get && flutter run`
|
||||
|
||||
## 환경변수
|
||||
|
||||
@@ -70,7 +56,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
| ----------------------------- | ---------------------------------------- | --------------------- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
|
||||
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
|
||||
| `frontend/.env.{env}.example` | 프론트 — 단독 실행용 `VITE_API_BASE_URL` | 단독 실행 시에만 |
|
||||
| `frontend/.env.{env}.example` | 프론트 — 값 없음(compose 가 build arg 주입) | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 업무 · 지시한 업무 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee;
|
||||
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
|
||||
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
|
||||
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
|
||||
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
|
||||
.pagehead .new-btn { margin-left: auto; height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--accent); background: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; white-space: nowrap; }
|
||||
.pagehead .new-btn:hover { background: var(--accent-hover); }
|
||||
.pagehead .new-btn svg { width: 15px; height: 15px; }
|
||||
|
||||
/* view toggle (담당 / 지시) */
|
||||
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
|
||||
.viewtab:hover { color: var(--text); }
|
||||
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
|
||||
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
|
||||
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.search svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
|
||||
.search input::placeholder { color: var(--text-3); }
|
||||
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
|
||||
/* highlight banner for pending approvals */
|
||||
.pending-note { display: flex; align-items: center; gap: 10px; padding: 11px 15px; background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 9px; margin-bottom: 22px; font-size: 13px; color: #8a5a12; }
|
||||
.pending-note svg { width: 17px; height: 17px; color: var(--amber); flex-shrink: 0; }
|
||||
.pending-note span { white-space: nowrap; }
|
||||
.pending-note b { font-weight: 700; }
|
||||
|
||||
/* status group */
|
||||
.group { margin-bottom: 22px; }
|
||||
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
|
||||
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
|
||||
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
|
||||
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
||||
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.gh-action { margin-left: 4px; font-size: 11.5px; font-weight: 600; color: var(--amber); white-space: nowrap; }
|
||||
|
||||
/* list + rows */
|
||||
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.list.attn { border-color: var(--amber-border); box-shadow: 0 0 0 3px rgba(183,121,31,.08), var(--shadow-sm); }
|
||||
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
|
||||
.task-row:first-child { border-top: none; }
|
||||
.task-row:hover { background: #fafbfc; }
|
||||
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
|
||||
.task-row:hover .task-title .ttext { color: var(--accent); }
|
||||
.task-row.is-done .task-title { color: var(--text-2); }
|
||||
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
|
||||
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
|
||||
.mi { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.mi svg { width: 13px; height: 13px; }
|
||||
.mi.attn { color: var(--amber); font-weight: 600; }
|
||||
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
|
||||
|
||||
/* assignee avatars */
|
||||
.mini-stack { display: inline-flex; align-items: center; }
|
||||
.mini-av { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 9px; font-weight: 700; color: #fff; box-shadow: 0 0 0 1.5px #fff; flex-shrink: 0; }
|
||||
.mini-stack .mini-av + .mini-av { margin-left: -5px; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
|
||||
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
|
||||
.due svg { width: 14px; height: 14px; color: var(--text-3); }
|
||||
.due.overdue { color: var(--red); font-weight: 600; }
|
||||
.due.overdue svg { color: var(--red); }
|
||||
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
|
||||
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
|
||||
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
|
||||
.st-badge.changes { color: var(--red); background: var(--red-weak); }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); }
|
||||
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
|
||||
/* action button on pending-approval rows */
|
||||
.review-btn { font-size: 12px; font-weight: 600; color: #fff; background: var(--amber); border: 1px solid var(--amber); border-radius: var(--radius); padding: 5px 11px; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html" class="active">내 업무</a>
|
||||
<a href="저장소 목록.html">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="내 업무 · 지시한 업무">
|
||||
<div class="pagehead">
|
||||
<h1>내 업무</h1>
|
||||
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
|
||||
<a class="new-btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무 지시</a>
|
||||
</div>
|
||||
|
||||
<!-- 담당 / 지시 토글 -->
|
||||
<div class="viewtabs">
|
||||
<a class="viewtab" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
|
||||
<a class="viewtab active" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<input type="text" placeholder="업무 검색…">
|
||||
</div>
|
||||
<div class="repofilter">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
모든 저장소
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 강조 배너 -->
|
||||
<div class="pending-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"/><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span><b>1건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 (내가 결정) -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span><span class="gh-action">· 내 승인 필요</span></div>
|
||||
<div class="list attn">
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot review"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">메인 배너 이미지 2종 (가로/세로) 디자인</span> <span class="tid">#9</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-b">박</span><span class="mini-av av-d">최</span></span></span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi attn">박지훈님이 2시간 전 승인 요청</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
|
||||
<span class="review-btn">검토하기</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행 중 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot prog"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">법무 검토 요청 및 회신 반영</span> <span class="tid">#11</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-c">이</span></span> 이도윤</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>6월 12일 <span class="dday urgent">3일 지남</span></span>
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot prog"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">SNS 콘텐츠 2차 시안 제작</span> <span class="tid">#16</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/6</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-e">정</span></span> 정민호</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 19일 <span class="dday soon">D-4</span></span>
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 요청 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청함</span><span class="gh-count">1</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세 (수정 요청).html">
|
||||
<span class="st-dot changes"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">상세페이지 카피라이팅</span> <span class="tid">#8</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-c">이</span></span> 이도윤에게 수정 요청 · 1일 전</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 20일 <span class="dday soon">D-5</span></span>
|
||||
<span class="st-badge changes">수정 요청</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 할 일 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">1</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot todo"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">SNS 콘텐츠 1차 시안</span> <span class="tid">#7</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-e">정</span></span> 정민호</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 23일 <span class="dday calm">D-8</span></span>
|
||||
<span class="st-badge todo">할 일</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 완료 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">캠페인 핵심 메시지 시안 작성</span> <span class="tid">#6</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-a">김</span></span> 김서연</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due">6월 9일 승인됨</span>
|
||||
<span class="st-badge done">완료</span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">퍼포먼스 광고 소재 세팅</span> <span class="tid">#4</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><span class="mini-stack"><span class="mini-av av-b">박</span></span> 박지훈</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due">6월 5일 승인됨</span>
|
||||
<span class="st-badge done">완료</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,314 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>내 업무 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee;
|
||||
--blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
|
||||
--amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.pagehead { display: flex; align-items: flex-end; gap: 12px; padding: 24px 0 16px; }
|
||||
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
|
||||
.pagehead .sub { font-size: 13px; color: var(--text-2); padding-bottom: 2px; white-space: nowrap; }
|
||||
|
||||
/* view toggle (담당 / 지시) */
|
||||
.viewtabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.viewtab { display: flex; align-items: center; gap: 8px; padding: 10px 4px; margin-right: 18px; font-size: 14px; font-weight: 600; color: var(--text-2); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap; text-decoration: none; }
|
||||
.viewtab:hover { color: var(--text); }
|
||||
.viewtab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.viewtab .vt-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; height: 17px; min-width: 17px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.viewtab.active .vt-count { background: var(--accent-weak); color: var(--accent); }
|
||||
.viewtab .vt-flag { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); border-radius: 20px; padding: 1px 7px; line-height: 1.4; white-space: nowrap; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
|
||||
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 260px; }
|
||||
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.search svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
|
||||
.search input::placeholder { color: var(--text-3); }
|
||||
.repofilter { display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.repofilter svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
|
||||
/* status group */
|
||||
.group { margin-bottom: 22px; }
|
||||
.group-head { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; padding-left: 2px; }
|
||||
.gh-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.gh-dot.changes { background: var(--red); } .gh-dot.prog { background: var(--blue); }
|
||||
.gh-dot.todo { background: #c4c8cf; } .gh-dot.review { background: var(--amber); } .gh-dot.done { background: var(--green); }
|
||||
.gh-label { font-size: 13px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
||||
.gh-count { font-size: 11.5px; font-weight: 600; color: var(--text-2); background: #eceef1; border-radius: 20px; height: 18px; min-width: 18px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
|
||||
/* list + rows */
|
||||
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.task-row { display: flex; align-items: center; gap: 14px; padding: 13px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
|
||||
.task-row:first-child { border-top: none; }
|
||||
.task-row:hover { background: #fafbfc; }
|
||||
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; } .st-dot.changes { background: var(--red); }
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.task-title .ttext { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); flex-shrink: 0; }
|
||||
.task-row:hover .task-title .ttext { color: var(--accent); }
|
||||
.task-row.is-done .task-title { color: var(--text-2); }
|
||||
.task-meta { display: flex; align-items: center; gap: 12px; margin-top: 4px; font-size: 12px; color: var(--text-3); }
|
||||
.repo-chip { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
.repo-chip svg { width: 13px; height: 13px; color: var(--accent); flex-shrink: 0; }
|
||||
.mi { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.mi svg { width: 13px; height: 13px; }
|
||||
.meta-sep { width: 3px; height: 3px; border-radius: 50%; background: #d7dae0; flex-shrink: 0; }
|
||||
|
||||
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||
.due { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-2); font-weight: 500; white-space: nowrap; }
|
||||
.due svg { width: 14px; height: 14px; color: var(--text-3); }
|
||||
.dday { font-size: 11px; font-weight: 700; padding: 1px 7px; border-radius: 20px; white-space: nowrap; line-height: 1.5; }
|
||||
.dday.urgent { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
|
||||
.dday.soon { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.dday.calm { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 64px; text-align: center; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); }
|
||||
.st-badge.changes { color: var(--red); background: var(--red-weak); }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); }
|
||||
.st-badge.todo { color: var(--text-2); background: #f1f2f4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="#" class="active">내 업무</a>
|
||||
<a href="저장소 목록.html">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="내 업무">
|
||||
<div class="pagehead">
|
||||
<h1>내 업무</h1>
|
||||
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- 담당 / 지시 토글 -->
|
||||
<div class="viewtabs">
|
||||
<a class="viewtab active" href="내 업무.html"><span class="tb-label">담당 업무</span> <span class="vt-count">6</span></a>
|
||||
<a class="viewtab" href="내 업무 (지시한 업무).html"><span class="tb-label">지시한 업무</span> <span class="vt-count">5</span> <span class="vt-flag">승인 대기 1</span></a>
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<input type="text" placeholder="업무 검색…">
|
||||
</div>
|
||||
<div class="repofilter">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
모든 저장소
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수정 요청 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot changes"></span><span class="gh-label">수정 요청</span><span class="gh-count">1</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세 (수정 요청).html">
|
||||
<span class="st-dot changes"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">3분기 캠페인 예산안 재작성</span> <span class="tid">#14</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 16일 <span class="dday urgent">D-1</span></span>
|
||||
<span class="st-badge changes">수정 요청</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행 중 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot prog"></span><span class="gh-label">진행 중</span><span class="gh-count">2</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세 (승인 요청).html">
|
||||
<span class="st-dot prog"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">브랜드 리뉴얼 킥오프 기획서 작성</span> <span class="tid">#12</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/5</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 18일 <span class="dday soon">D-3</span></span>
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="task-row" href="업무 상세 (승인 요청).html">
|
||||
<span class="st-dot prog"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">9월 신제품 GTM 전략 수립</span> <span class="tid">#10</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/4</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 21일 <span class="dday calm">D-6</span></span>
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 할 일 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot todo"></span><span class="gh-label">할 일</span><span class="gh-count">2</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세 (승인 요청).html">
|
||||
<span class="st-dot todo"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">월간 뉴스레터 8월호 주제 선정</span> <span class="tid">#15</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 24일 <span class="dday calm">D-9</span></span>
|
||||
<span class="st-badge todo">할 일</span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="task-row" href="업무 상세 (승인 요청).html">
|
||||
<span class="st-dot todo"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">SEO 키워드 전략 분기 리뷰</span> <span class="tid">#13</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>SEO 콘텐츠 허브</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 27일 <span class="dday calm">D-12</span></span>
|
||||
<span class="st-badge todo">할 일</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot review"></span><span class="gh-label">승인 대기</span><span class="gh-count">1</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row" href="업무 상세 (승인 요청).html">
|
||||
<span class="st-dot review"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">브랜드 무드보드 방향 정리</span> <span class="tid">#2</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>브랜드 리뉴얼 2026</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">한승우님 검토 중 · 1일 전 요청</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>6월 17일 <span class="dday soon">D-2</span></span>
|
||||
<span class="st-badge review">승인 대기</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 완료 -->
|
||||
<div class="group">
|
||||
<div class="group-head"><span class="gh-dot done"></span><span class="gh-label">완료</span><span class="gh-count">2</span></div>
|
||||
<div class="list">
|
||||
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">2분기 캠페인 성과 회고 정리</span> <span class="tid">#6</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>월간 뉴스레터</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due">6월 11일 승인됨</span>
|
||||
<span class="st-badge done">완료</span>
|
||||
</span>
|
||||
</a>
|
||||
<a class="task-row is-done" href="업무 상세 (승인 완료).html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title"><span class="ttext">신제품 네이밍 후보 정리</span> <span class="tid">#5</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="repo-chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>3분기 신제품 런칭 캠페인</span>
|
||||
<span class="meta-sep"></span>
|
||||
<span class="mi">지시 · 한승우</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="due">6월 5일 승인됨</span>
|
||||
<span class="st-badge done">완료</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,187 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--kakao: #FEE500; --kakao-text: #191600;
|
||||
--radius: 8px; --radius-sm: 5px;
|
||||
}
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
|
||||
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.auth { display: flex; min-height: 100vh; }
|
||||
|
||||
/* ---- brand panel ---- */
|
||||
.brand-panel {
|
||||
flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden;
|
||||
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
|
||||
color: #fff; padding: 44px 48px; display: flex; flex-direction: column;
|
||||
}
|
||||
.brand-panel .blob { position: absolute; border-radius: 50%; filter: blur(2px); pointer-events: none; }
|
||||
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
|
||||
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
|
||||
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
|
||||
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
|
||||
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
|
||||
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
|
||||
.bp-body { margin-top: auto; position: relative; z-index: 1; }
|
||||
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
|
||||
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
|
||||
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
|
||||
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.bp-point .ic svg { width: 13px; height: 13px; }
|
||||
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
|
||||
|
||||
/* ---- form panel ---- */
|
||||
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
|
||||
.auth-card { width: 100%; max-width: 384px; }
|
||||
.ac-logo { display: none; }
|
||||
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
|
||||
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
|
||||
|
||||
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; }
|
||||
.sbtn {
|
||||
height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff;
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer;
|
||||
font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none;
|
||||
position: relative; transition: background .12s, border-color .12s;
|
||||
}
|
||||
.sbtn:hover { background: #f7f8fa; }
|
||||
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
|
||||
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
|
||||
.sbtn.kakao:hover { background: #f2d900; }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; color: var(--text-3); font-size: 12.5px; }
|
||||
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
|
||||
|
||||
.field { margin-bottom: 14px; }
|
||||
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
|
||||
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
|
||||
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.tinput::placeholder { color: var(--text-3); }
|
||||
.pw-wrap { position: relative; }
|
||||
.pw-wrap .tinput { padding-right: 44px; }
|
||||
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
|
||||
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
|
||||
.pw-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
.options { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; }
|
||||
.keep { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: var(--text-2); user-select: none; white-space: nowrap; }
|
||||
.keep .box { width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
|
||||
.keep .box.on { background: var(--accent); border-color: var(--accent); }
|
||||
.keep .box svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
|
||||
.keep .box.on svg { opacity: 1; }
|
||||
.link { color: var(--accent); font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); text-decoration: none; display: flex; align-items: center; justify-content: center; }
|
||||
.submit:hover { background: var(--accent-hover); }
|
||||
|
||||
.signup { margin-top: 24px; text-align: center; font-size: 13.5px; color: var(--text-2); }
|
||||
.signup a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.signup a:hover { text-decoration: underline; }
|
||||
.verify-note { margin-top: 6px; text-align: center; font-size: 12px; color: var(--text-3); display: flex; align-items: center; justify-content: center; gap: 5px; }
|
||||
.verify-note svg { width: 13px; height: 13px; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.brand-panel { display: none; }
|
||||
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 26px; }
|
||||
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth">
|
||||
|
||||
<aside class="brand-panel">
|
||||
<div class="blob b1"></div>
|
||||
<div class="blob b2"></div>
|
||||
<div class="ring"></div>
|
||||
<div class="bp-logo"><span class="logo">R</span> Relay</div>
|
||||
<div class="bp-body">
|
||||
<div class="bp-head">지시부터 완료까지,<br>팀의 업무 흐름을 하나로</div>
|
||||
<div class="bp-sub">저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에 추적됩니다.</div>
|
||||
<div class="bp-points">
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 저장소 · 업무 · 담당자로 이어지는 명확한 구조</div>
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 체크리스트와 마감기한으로 진행 관리</div>
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> Gitea 연동으로 안전하게</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
|
||||
</aside>
|
||||
|
||||
<main class="form-panel">
|
||||
<div class="auth-card">
|
||||
<div class="ac-logo"><span class="logo">R</span> Relay</div>
|
||||
<h1 class="ac-title">로그인</h1>
|
||||
<p class="ac-sub">업무 지시를 시작하려면 로그인하세요.</p>
|
||||
|
||||
<!-- 소셜 로그인 -->
|
||||
<div class="socials">
|
||||
<a class="sbtn" href="저장소 목록.html">
|
||||
<span class="sico"><svg width="20" height="20" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.4 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.5-11.2-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C41.4 36 44 30.5 44 24c0-1.3-.1-2.3-.4-3.5z"/></svg></span>
|
||||
Google로 계속하기
|
||||
</a>
|
||||
<a class="sbtn kakao" href="저장소 목록.html">
|
||||
<span class="sico"><svg width="19" height="19" viewBox="0 0 24 24" fill="#191600"><path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.8 1.9 5.2 4.7 6.6-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.6.1 1.3.1 2 .1 5.5 0 10-3.5 10-7.8C22 6.5 17.5 3 12 3z"/></svg></span>
|
||||
카카오로 계속하기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="divider">또는 이메일로 로그인</div>
|
||||
|
||||
<!-- 이메일 로그인 -->
|
||||
<div class="field">
|
||||
<label class="flabel">이메일</label>
|
||||
<input class="tinput" type="email" placeholder="name@acme.co" value="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="flabel">비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
|
||||
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<span class="keep"><span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 로그인 상태 유지</span>
|
||||
<a class="link" href="#">비밀번호 찾기</a>
|
||||
</div>
|
||||
|
||||
<a class="submit" href="저장소 목록.html">로그인</a>
|
||||
|
||||
<div class="signup">계정이 없으신가요? <a href="회원가입.html">회원가입</a></div>
|
||||
<div class="verify-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z" opacity="0"/><path d="M22 6 12 13 2 6"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>
|
||||
이메일 회원가입은 인증 메일 확인 후 완료됩니다
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 비밀번호 표시 토글 (정적 시안용 최소 인터랙션)
|
||||
document.querySelector('.pw-toggle').addEventListener('click', function () {
|
||||
var input = this.parentElement.querySelector('input');
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
document.querySelector('.keep').addEventListener('click', function () {
|
||||
this.querySelector('.box').classList.toggle('on');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,469 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>메인 배너 이미지 2종 디자인 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
|
||||
.ph-main { flex: 1; min-width: 0; }
|
||||
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
|
||||
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
|
||||
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
|
||||
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
|
||||
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
|
||||
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.icon { width: 34px; padding: 0; justify-content: center; }
|
||||
|
||||
/* more menu */
|
||||
.more-wrap { position: relative; }
|
||||
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
|
||||
.menu.open { display: block; }
|
||||
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
|
||||
.menu-item:hover { background: #f5f6f8; }
|
||||
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
|
||||
.menu-item.danger { color: var(--red); }
|
||||
.menu-item.danger svg { color: var(--red); }
|
||||
.menu-item.danger:hover { background: var(--red-weak); }
|
||||
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
|
||||
.modal-body { padding: 26px 24px 8px; text-align: center; }
|
||||
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
|
||||
.modal-ico svg { width: 22px; height: 22px; }
|
||||
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
|
||||
.modal-desc b { color: var(--text); font-weight: 600; }
|
||||
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
|
||||
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
|
||||
.mbtn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
|
||||
.mbtn.danger:hover { background: #c23a51; }
|
||||
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.mbtn.approve:hover { background: #188a4b; }
|
||||
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.mbtn.request-send:hover { background: var(--accent-hover); }
|
||||
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
|
||||
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.modal-textarea::placeholder { color: var(--text-3); }
|
||||
|
||||
/* approval sidebar card */
|
||||
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
|
||||
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
|
||||
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
|
||||
.ap-card.changes { border-color: var(--red-border); background: var(--red-weak); }
|
||||
.ap-card.changes .ap-card-ico { color: var(--red); border-color: var(--red-border); }
|
||||
.ap-card.changes .ap-card-title { color: var(--red); }
|
||||
.ap-card.changes .ap-card-note { border-color: var(--red-border); }
|
||||
.ap-card-head { display: flex; align-items: center; gap: 9px; }
|
||||
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
|
||||
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
|
||||
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
|
||||
.ap-card-ico svg { width: 17px; height: 17px; }
|
||||
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
|
||||
.ap-card.review .ap-card-title { color: #8a5a12; }
|
||||
.ap-card.request .ap-card-title { color: var(--accent-hover); }
|
||||
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-meta b { color: var(--text); font-weight: 600; }
|
||||
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
|
||||
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
|
||||
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.ap-btn svg { width: 15px; height: 15px; }
|
||||
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
|
||||
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.ap-btn.approve:hover { background: #188a4b; }
|
||||
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.ap-btn.request-send:hover { background: var(--accent-hover); }
|
||||
|
||||
/* approval system event in thread */
|
||||
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
|
||||
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.sys-event .se-ico svg { width: 14px; height: 14px; }
|
||||
.sys-event b { color: var(--text); font-weight: 600; }
|
||||
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* layout */
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
|
||||
.main-card { padding: 22px 26px 26px; }
|
||||
|
||||
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
|
||||
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
|
||||
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
|
||||
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
|
||||
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
|
||||
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
|
||||
.content p { margin-bottom: 11px; }
|
||||
.content p:last-child { margin-bottom: 0; }
|
||||
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
/* checklist */
|
||||
.checklist { display: flex; flex-direction: column; }
|
||||
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
|
||||
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
|
||||
.cbox.done { background: var(--green); border-color: var(--green); }
|
||||
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
|
||||
.cbox.done svg { opacity: 1; }
|
||||
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
|
||||
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* nested subtasks (read-only) */
|
||||
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.citem.parent .ctext { font-weight: 600; }
|
||||
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
|
||||
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
|
||||
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
|
||||
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
|
||||
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
|
||||
.cbox.sm svg { width: 11px; height: 11px; }
|
||||
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
|
||||
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* comments */
|
||||
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.comment:first-of-type { border-top: none; }
|
||||
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.c-body { flex: 1; min-width: 0; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
|
||||
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
|
||||
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
|
||||
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
|
||||
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
|
||||
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.composer .field { flex: 1; }
|
||||
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
|
||||
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.composer textarea::placeholder { color: var(--text-3); }
|
||||
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* sidebar */
|
||||
.side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.side-card { padding: 16px 18px 18px; }
|
||||
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
|
||||
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
|
||||
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
|
||||
.status-badge.review .dot { background: var(--amber); }
|
||||
.status-badge.changes { color: var(--red); background: var(--red-weak); border-color: var(--red-border); }
|
||||
.status-badge.changes .dot { background: var(--red); }
|
||||
.assignees { display: flex; flex-direction: column; gap: 9px; }
|
||||
.ass { display: flex; align-items: center; gap: 9px; }
|
||||
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
|
||||
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
|
||||
.files { display: flex; flex-direction: column; gap: 7px; }
|
||||
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
|
||||
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
|
||||
.file-info { min-width: 0; flex: 1; }
|
||||
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
|
||||
.issuer { display: flex; align-items: center; gap: 10px; }
|
||||
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me" style="background:#3d8bf2">박</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="업무 상세">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">업무</a>
|
||||
<span class="sep">/</span>
|
||||
<b>#9</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<div class="ph-main">
|
||||
<div class="ph-titlerow">
|
||||
<span class="st-badge changes">수정 요청</span>
|
||||
<span class="ph-tid">#9</span>
|
||||
</div>
|
||||
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
|
||||
<div class="more-wrap">
|
||||
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
|
||||
<div class="menu" id="moreMenu">
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
업무 복제
|
||||
</button>
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
링크 복사
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger" id="deleteOpen" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
업무 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- main -->
|
||||
<section class="card main-card">
|
||||
<div class="sect">
|
||||
<div class="sect-label">업무 내용</div>
|
||||
<div class="content">
|
||||
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">해야 할 일
|
||||
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div class="citem done">
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">가로형 메인 배너 1920×1080</span>
|
||||
</div>
|
||||
<div class="citem">
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">세로형 메인 배너 1080×1920</span>
|
||||
</div>
|
||||
<div class="citem">
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">모바일 적응형 600×600</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
|
||||
|
||||
<div class="comment">
|
||||
<span class="c-av av-f">정</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
|
||||
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-b">박</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
|
||||
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
|
||||
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-d">최</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
|
||||
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sys-event">
|
||||
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
|
||||
<span class="se-time">2시간 전</span>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<span class="c-av" style="background:#3d8bf2">박</span>
|
||||
<div class="field">
|
||||
<textarea placeholder="댓글을 입력하세요…"></textarea>
|
||||
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- sidebar -->
|
||||
<aside class="side">
|
||||
<!-- 수정 요청됨: 작업자(박지훈) 시점 -->
|
||||
<div class="card ap-card changes">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg></span>
|
||||
<span class="ap-card-title">수정 요청됨</span>
|
||||
</div>
|
||||
<div class="ap-card-meta"><b>정태경</b>님이 수정을 요청했습니다 · 1시간 전</div>
|
||||
<div class="ap-card-note">세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.</div>
|
||||
<div class="ap-card-actions">
|
||||
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>다시 승인 요청</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">상태</div>
|
||||
<span class="status-badge changes"><span class="dot"></span>수정 요청</span>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">담당자 · 2명</div>
|
||||
<div class="assignees">
|
||||
<div class="ass"><span class="avatar av-b">박</span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
|
||||
<div class="ass"><span class="avatar av-d">최</span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">마감기한</div>
|
||||
<div class="dl">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
6월 18일 (목)
|
||||
<span class="dday">D-3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">첨부파일 · 2개</div>
|
||||
<div class="files">
|
||||
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
|
||||
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">지시자</div>
|
||||
<div class="issuer"><span class="avatar av-f">정</span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
|
||||
<div class="meta-line">6월 8일 지시 · #9</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal-backdrop" id="deleteModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body">
|
||||
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
|
||||
<h2 class="modal-title">업무를 삭제할까요?</h2>
|
||||
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" id="deleteCancel" type="button">취소</button>
|
||||
<button class="mbtn danger" type="button">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 요청 모달 -->
|
||||
<div class="modal-backdrop" id="requestModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
|
||||
<h2 class="modal-title">승인 요청 보내기</h2>
|
||||
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
|
||||
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" data-close="requestModal" type="button">취소</button>
|
||||
<button class="mbtn request-send" type="button">요청 보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var moreBtn = document.getElementById('moreBtn');
|
||||
var moreMenu = document.getElementById('moreMenu');
|
||||
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
|
||||
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
|
||||
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
/* 삭제 모달 */
|
||||
var dModal = document.getElementById('deleteModal');
|
||||
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
|
||||
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
|
||||
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
|
||||
|
||||
/* 승인 요청 모달 */
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(el) { el.classList.remove('open'); }
|
||||
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
|
||||
document.querySelectorAll('[data-close]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
|
||||
});
|
||||
var reqModal = document.getElementById('requestModal');
|
||||
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,463 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>메인 배너 이미지 2종 디자인 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
|
||||
.ph-main { flex: 1; min-width: 0; }
|
||||
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
|
||||
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
|
||||
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
|
||||
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
|
||||
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
|
||||
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.icon { width: 34px; padding: 0; justify-content: center; }
|
||||
|
||||
/* more menu */
|
||||
.more-wrap { position: relative; }
|
||||
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
|
||||
.menu.open { display: block; }
|
||||
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
|
||||
.menu-item:hover { background: #f5f6f8; }
|
||||
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
|
||||
.menu-item.danger { color: var(--red); }
|
||||
.menu-item.danger svg { color: var(--red); }
|
||||
.menu-item.danger:hover { background: var(--red-weak); }
|
||||
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
|
||||
.modal-body { padding: 26px 24px 8px; text-align: center; }
|
||||
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
|
||||
.modal-ico svg { width: 22px; height: 22px; }
|
||||
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
|
||||
.modal-desc b { color: var(--text); font-weight: 600; }
|
||||
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
|
||||
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
|
||||
.mbtn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
|
||||
.mbtn.danger:hover { background: #c23a51; }
|
||||
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.mbtn.approve:hover { background: #188a4b; }
|
||||
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.mbtn.request-send:hover { background: var(--accent-hover); }
|
||||
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
|
||||
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.modal-textarea::placeholder { color: var(--text-3); }
|
||||
|
||||
/* approval sidebar card */
|
||||
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
|
||||
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
|
||||
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
|
||||
.ap-card.done { border-color: #cde8d8; background: var(--green-weak); }
|
||||
.ap-card.done .ap-card-ico { color: var(--green); border-color: #cde8d8; }
|
||||
.ap-card.done .ap-card-title { color: #167a44; }
|
||||
.ap-card-head { display: flex; align-items: center; gap: 9px; }
|
||||
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
|
||||
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
|
||||
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
|
||||
.ap-card-ico svg { width: 17px; height: 17px; }
|
||||
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
|
||||
.ap-card.review .ap-card-title { color: #8a5a12; }
|
||||
.ap-card.request .ap-card-title { color: var(--accent-hover); }
|
||||
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-meta b { color: var(--text); font-weight: 600; }
|
||||
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
|
||||
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
|
||||
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.ap-btn svg { width: 15px; height: 15px; }
|
||||
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
|
||||
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.ap-btn.approve:hover { background: #188a4b; }
|
||||
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.ap-btn.request-send:hover { background: var(--accent-hover); }
|
||||
|
||||
/* approval system event in thread */
|
||||
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
|
||||
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.sys-event .se-ico svg { width: 14px; height: 14px; }
|
||||
.sys-event b { color: var(--text); font-weight: 600; }
|
||||
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* layout */
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
|
||||
.main-card { padding: 22px 26px 26px; }
|
||||
|
||||
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
|
||||
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
|
||||
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
|
||||
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
|
||||
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
|
||||
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
|
||||
.content p { margin-bottom: 11px; }
|
||||
.content p:last-child { margin-bottom: 0; }
|
||||
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
/* checklist */
|
||||
.checklist { display: flex; flex-direction: column; }
|
||||
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
|
||||
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
|
||||
.cbox.done { background: var(--green); border-color: var(--green); }
|
||||
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
|
||||
.cbox.done svg { opacity: 1; }
|
||||
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
|
||||
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* nested subtasks (read-only) */
|
||||
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.citem.parent .ctext { font-weight: 600; }
|
||||
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
|
||||
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
|
||||
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
|
||||
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
|
||||
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
|
||||
.cbox.sm svg { width: 11px; height: 11px; }
|
||||
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
|
||||
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* comments */
|
||||
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.comment:first-of-type { border-top: none; }
|
||||
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.c-body { flex: 1; min-width: 0; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
|
||||
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
|
||||
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
|
||||
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
|
||||
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
|
||||
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.composer .field { flex: 1; }
|
||||
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
|
||||
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.composer textarea::placeholder { color: var(--text-3); }
|
||||
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* sidebar */
|
||||
.side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.side-card { padding: 16px 18px 18px; }
|
||||
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
|
||||
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
|
||||
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
|
||||
.status-badge.review .dot { background: var(--amber); }
|
||||
.status-badge.done { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
|
||||
.status-badge.done .dot { background: var(--green); }
|
||||
.assignees { display: flex; flex-direction: column; gap: 9px; }
|
||||
.ass { display: flex; align-items: center; gap: 9px; }
|
||||
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
|
||||
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
|
||||
.files { display: flex; flex-direction: column; gap: 7px; }
|
||||
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
|
||||
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
|
||||
.file-info { min-width: 0; flex: 1; }
|
||||
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
|
||||
.issuer { display: flex; align-items: center; gap: 10px; }
|
||||
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me" style="background:#3d8bf2">박</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="업무 상세">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">업무</a>
|
||||
<span class="sep">/</span>
|
||||
<b>#9</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<div class="ph-main">
|
||||
<div class="ph-titlerow">
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="ph-tid">#9</span>
|
||||
</div>
|
||||
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
|
||||
<div class="more-wrap">
|
||||
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
|
||||
<div class="menu" id="moreMenu">
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
업무 복제
|
||||
</button>
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
링크 복사
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger" id="deleteOpen" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
업무 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- main -->
|
||||
<section class="card main-card">
|
||||
<div class="sect">
|
||||
<div class="sect-label">업무 내용</div>
|
||||
<div class="content">
|
||||
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">해야 할 일
|
||||
<span class="pr"><span class="progress-bar"><i style="width:100%"></i></span><span class="progress-txt">3 / 3 완료</span></span>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div class="citem done">
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">가로형 메인 배너 1920×1080</span>
|
||||
</div>
|
||||
<div class="citem done">
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">세로형 메인 배너 1080×1920</span>
|
||||
</div>
|
||||
<div class="citem done">
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">모바일 적응형 600×600</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
|
||||
|
||||
<div class="comment">
|
||||
<span class="c-av av-f">정</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
|
||||
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-b">박</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
|
||||
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
|
||||
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-d">최</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
|
||||
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sys-event">
|
||||
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
|
||||
<span class="se-time">2시간 전</span>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<span class="c-av" style="background:#3d8bf2">박</span>
|
||||
<div class="field">
|
||||
<textarea placeholder="댓글을 입력하세요…"></textarea>
|
||||
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- sidebar -->
|
||||
<aside class="side">
|
||||
<!-- 승인 완료 -->
|
||||
<div class="card ap-card done">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4 12 14.01l-3-3"/></svg></span>
|
||||
<span class="ap-card-title">승인 완료</span>
|
||||
</div>
|
||||
<div class="ap-card-meta"><b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 할 일이 모두 완료되었습니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">상태</div>
|
||||
<span class="status-badge done"><span class="dot"></span>완료</span>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">담당자 · 2명</div>
|
||||
<div class="assignees">
|
||||
<div class="ass"><span class="avatar av-b">박</span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
|
||||
<div class="ass"><span class="avatar av-d">최</span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">마감기한</div>
|
||||
<div class="dl">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
6월 18일 (목)
|
||||
<span class="dday">D-3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">첨부파일 · 2개</div>
|
||||
<div class="files">
|
||||
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
|
||||
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">지시자</div>
|
||||
<div class="issuer"><span class="avatar av-f">정</span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
|
||||
<div class="meta-line">6월 8일 지시 · #9</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal-backdrop" id="deleteModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body">
|
||||
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
|
||||
<h2 class="modal-title">업무를 삭제할까요?</h2>
|
||||
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" id="deleteCancel" type="button">취소</button>
|
||||
<button class="mbtn danger" type="button">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 요청 모달 -->
|
||||
<div class="modal-backdrop" id="requestModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
|
||||
<h2 class="modal-title">승인 요청 보내기</h2>
|
||||
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
|
||||
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" data-close="requestModal" type="button">취소</button>
|
||||
<button class="mbtn request-send" type="button">요청 보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var moreBtn = document.getElementById('moreBtn');
|
||||
var moreMenu = document.getElementById('moreMenu');
|
||||
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
|
||||
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
|
||||
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
/* 삭제 모달 */
|
||||
var dModal = document.getElementById('deleteModal');
|
||||
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
|
||||
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
|
||||
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
|
||||
|
||||
/* 승인 요청 모달 */
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(el) { el.classList.remove('open'); }
|
||||
document.querySelectorAll('[data-close]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
|
||||
});
|
||||
var reqModal = document.getElementById('requestModal');
|
||||
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,462 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>메인 배너 이미지 2종 디자인 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 60px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
.pagehead { display: flex; align-items: flex-start; gap: 14px; padding: 12px 0 18px; }
|
||||
.ph-main { flex: 1; min-width: 0; }
|
||||
.ph-titlerow { display: flex; align-items: center; gap: 10px; }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
|
||||
.st-badge.changes { color: var(--red); background: var(--red-weak); border: 1px solid var(--red-border); }
|
||||
.ph-tid { font-size: 14px; font-weight: 600; color: var(--text-3); }
|
||||
h1.ph-title { font-size: 23px; font-weight: 700; letter-spacing: -.5px; margin-top: 8px; line-height: 1.3; }
|
||||
.pagehead .actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; padding-top: 2px; }
|
||||
.btn { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.icon { width: 34px; padding: 0; justify-content: center; }
|
||||
|
||||
/* more menu */
|
||||
.more-wrap { position: relative; }
|
||||
.menu { display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 168px; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 5px; }
|
||||
.menu.open { display: block; }
|
||||
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--text); padding: 8px 9px; border-radius: var(--radius-sm); cursor: pointer; text-align: left; white-space: nowrap; }
|
||||
.menu-item:hover { background: #f5f6f8; }
|
||||
.menu-item svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
|
||||
.menu-item.danger { color: var(--red); }
|
||||
.menu-item.danger svg { color: var(--red); }
|
||||
.menu-item.danger:hover { background: var(--red-weak); }
|
||||
.menu-sep { height: 1px; background: var(--border); margin: 5px 0; }
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { width: 100%; max-width: 440px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; }
|
||||
.modal-body { padding: 26px 24px 8px; text-align: center; }
|
||||
.modal-ico { width: 46px; height: 46px; border-radius: 50%; background: var(--red-weak); color: var(--red); display: grid; place-items: center; margin: 0 auto 14px; }
|
||||
.modal-ico svg { width: 22px; height: 22px; }
|
||||
.modal-title { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.modal-desc { font-size: 13px; color: var(--text-2); line-height: 1.6; margin-top: 8px; }
|
||||
.modal-desc b { color: var(--text); font-weight: 600; }
|
||||
.modal-foot { display: flex; gap: 9px; padding: 18px 24px 20px; }
|
||||
.mbtn { flex: 1; height: 40px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; }
|
||||
.mbtn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.mbtn.danger { background: var(--red); border-color: var(--red); color: #fff; box-shadow: 0 1px 2px rgba(214,69,93,.4); }
|
||||
.mbtn.danger:hover { background: #c23a51; }
|
||||
.mbtn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.mbtn.approve:hover { background: #188a4b; }
|
||||
.mbtn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.mbtn.request-send:hover { background: var(--accent-hover); }
|
||||
.modal-textarea { width: 100%; min-height: 92px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; line-height: 1.6; color: var(--text); resize: vertical; }
|
||||
.modal-textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.modal-textarea::placeholder { color: var(--text-3); }
|
||||
|
||||
/* approval sidebar card */
|
||||
.ap-card { padding: 16px 18px 18px; border-width: 1px; border-style: solid; }
|
||||
.ap-card.review { border-color: var(--amber-border); background: var(--amber-weak); }
|
||||
.ap-card.request { border-color: var(--accent-border); background: var(--accent-weak); }
|
||||
.ap-card-head { display: flex; align-items: center; gap: 9px; }
|
||||
.ap-card-ico { width: 30px; height: 30px; border-radius: 8px; background: #fff; display: grid; place-items: center; flex-shrink: 0; border: 1px solid; }
|
||||
.ap-card.review .ap-card-ico { color: var(--amber); border-color: var(--amber-border); }
|
||||
.ap-card.request .ap-card-ico { color: var(--accent); border-color: var(--accent-border); }
|
||||
.ap-card-ico svg { width: 17px; height: 17px; }
|
||||
.ap-card-title { font-size: 14px; font-weight: 700; white-space: nowrap; }
|
||||
.ap-card.review .ap-card-title { color: #8a5a12; }
|
||||
.ap-card.request .ap-card-title { color: var(--accent-hover); }
|
||||
.ap-card-meta { font-size: 12px; color: var(--text-2); margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-meta b { color: var(--text); font-weight: 600; }
|
||||
.ap-card-desc { font-size: 12.5px; color: var(--text-2); margin-top: 10px; line-height: 1.55; }
|
||||
.ap-card-note { font-size: 12.5px; color: var(--text); background: #fff; border: 1px solid var(--amber-border); border-radius: 8px; padding: 9px 11px; margin-top: 10px; line-height: 1.5; }
|
||||
.ap-card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 13px; }
|
||||
.ap-btn { height: 38px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; cursor: pointer; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.ap-btn svg { width: 15px; height: 15px; }
|
||||
.ap-btn.reject:hover { background: #fdeef0; border-color: var(--red-border); color: var(--red); }
|
||||
.ap-btn.approve { background: var(--green); border-color: var(--green); color: #fff; box-shadow: 0 1px 2px rgba(31,157,87,.4); }
|
||||
.ap-btn.approve:hover { background: #188a4b; }
|
||||
.ap-btn.request-send { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.ap-btn.request-send:hover { background: var(--accent-hover); }
|
||||
|
||||
/* approval system event in thread */
|
||||
.sys-event { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--text-2); }
|
||||
.sys-event .se-ico { width: 26px; height: 26px; border-radius: 50%; background: var(--amber-weak); color: var(--amber); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.sys-event .se-ico svg { width: 14px; height: 14px; }
|
||||
.sys-event b { color: var(--text); font-weight: 600; }
|
||||
.sys-event .se-time { margin-left: auto; font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* layout */
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
|
||||
.main-card { padding: 22px 26px 26px; }
|
||||
|
||||
.sect + .sect { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||
.sect-label { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 12px; white-space: nowrap; }
|
||||
.sect-label .pr { margin-left: auto; display: flex; align-items: center; gap: 9px; }
|
||||
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
|
||||
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
|
||||
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
|
||||
.content { font-size: 14px; color: var(--text); line-height: 1.7; }
|
||||
.content p { margin-bottom: 11px; }
|
||||
.content p:last-child { margin-bottom: 0; }
|
||||
.content .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
/* checklist */
|
||||
.checklist { display: flex; flex-direction: column; }
|
||||
.citem { display: flex; align-items: center; gap: 11px; padding: 8px 0; border-radius: var(--radius-sm); }
|
||||
.cbox { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); flex-shrink: 0; display: grid; place-items: center; background: #fff; }
|
||||
.cbox.done { background: var(--green); border-color: var(--green); }
|
||||
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
|
||||
.cbox.done svg { opacity: 1; }
|
||||
.citem .ctext { font-size: 13.5px; color: var(--text); white-space: nowrap; }
|
||||
.citem.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* nested subtasks (read-only) */
|
||||
.task-group + .task-group { margin-top: 11px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.citem.parent .ctext { font-weight: 600; }
|
||||
.sub-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap; }
|
||||
.citem.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
|
||||
.subtasks { margin-left: 29px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
|
||||
.subtask { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-radius: var(--radius-sm); }
|
||||
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
|
||||
.cbox.sm svg { width: 11px; height: 11px; }
|
||||
.subtask .ctext { font-size: 13px; color: var(--text-2); white-space: nowrap; }
|
||||
.subtask.done .ctext { color: var(--text-3); text-decoration: line-through; }
|
||||
|
||||
/* comments */
|
||||
.comment { display: flex; gap: 11px; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.comment:first-of-type { border-top: none; }
|
||||
.c-av { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.c-body { flex: 1; min-width: 0; }
|
||||
.c-head { display: flex; align-items: center; gap: 8px; }
|
||||
.c-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
|
||||
.c-role { font-size: 11px; font-weight: 600; color: var(--accent); background: var(--accent-weak); border-radius: 20px; padding: 1px 7px; white-space: nowrap; }
|
||||
.c-time { font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
.c-text { font-size: 13.5px; color: var(--text); line-height: 1.6; margin-top: 4px; }
|
||||
.c-file { display: inline-flex; align-items: center; gap: 7px; margin-top: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 12.5px; font-weight: 600; color: var(--text-2); }
|
||||
.c-file .fi { width: 22px; height: 22px; border-radius: 5px; background: #e25950; color: #fff; display: grid; place-items: center; font-size: 8px; font-weight: 800; }
|
||||
.composer { display: flex; gap: 11px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.composer .field { flex: 1; }
|
||||
.composer textarea { width: 100%; min-height: 64px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 12px; font-family: inherit; font-size: 13.5px; resize: vertical; color: var(--text); }
|
||||
.composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.composer textarea::placeholder { color: var(--text-3); }
|
||||
.composer .crow { display: flex; justify-content: flex-end; margin-top: 9px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* sidebar */
|
||||
.side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.side-card { padding: 16px 18px 18px; }
|
||||
.side-field + .side-field { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.side-label { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 9px; white-space: nowrap; }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 8px; height: 30px; padding: 0 13px; border: 1px solid var(--blue-border); background: var(--blue-weak); border-radius: 20px; font-size: 13px; font-weight: 600; color: var(--blue); white-space: nowrap; }
|
||||
.status-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--blue); }
|
||||
.status-badge.review { color: var(--amber); background: var(--amber-weak); border-color: var(--amber-border); }
|
||||
.status-badge.review .dot { background: var(--amber); }
|
||||
.assignees { display: flex; flex-direction: column; gap: 9px; }
|
||||
.ass { display: flex; align-items: center; gap: 9px; }
|
||||
.avatar { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; } .av-d { background: #b3631f; } .av-f { background: #d0982a; }
|
||||
.ass .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.ass .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.dl { display: flex; align-items: center; gap: 9px; font-size: 13.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.dl svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.dl .dday { font-size: 11.5px; font-weight: 700; color: var(--amber); background: #fdf4e3; border: 1px solid #f3e2bd; border-radius: 20px; padding: 1px 7px; margin-left: auto; white-space: nowrap; }
|
||||
.files { display: flex; flex-direction: column; gap: 7px; }
|
||||
.file { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.file-ico { width: 28px; height: 28px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 9px; font-weight: 800; color: #fff; }
|
||||
.fi-img { background: #2aa775; } .fi-zip { background: #8a64d6; }
|
||||
.file-info { min-width: 0; flex: 1; }
|
||||
.file-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
|
||||
.issuer { display: flex; align-items: center; gap: 10px; }
|
||||
.issuer .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.meta-line { font-size: 12px; color: var(--text-3); margin-top: 4px; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me" style="background:#3d8bf2">박</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="업무 상세">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">업무</a>
|
||||
<span class="sep">/</span>
|
||||
<b>#9</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<div class="ph-main">
|
||||
<div class="ph-titlerow">
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
<span class="ph-tid">#9</span>
|
||||
</div>
|
||||
<h1 class="ph-title">메인 배너 이미지 2종 (가로/세로) 디자인</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>수정</a>
|
||||
<div class="more-wrap">
|
||||
<button class="btn icon" id="moreBtn" type="button" title="더보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
|
||||
<div class="menu" id="moreMenu">
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
업무 복제
|
||||
</button>
|
||||
<button class="menu-item" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
링크 복사
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item danger" id="deleteOpen" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
업무 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- main -->
|
||||
<section class="card main-card">
|
||||
<div class="sect">
|
||||
<div class="sect-label">업무 내용</div>
|
||||
<div class="content">
|
||||
<p>9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">해야 할 일
|
||||
<span class="pr"><span class="progress-bar"><i style="width:33%"></i></span><span class="progress-txt">1 / 3 완료</span></span>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div class="citem done">
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">가로형 메인 배너 1920×1080</span>
|
||||
</div>
|
||||
<div class="citem">
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">세로형 메인 배너 1080×1920</span>
|
||||
</div>
|
||||
<div class="citem">
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="ctext">모바일 적응형 600×600</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-label">댓글 <span style="color:var(--text-3);font-weight:500">3</span></div>
|
||||
|
||||
<div class="comment">
|
||||
<span class="c-av av-f">정</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">정태경</span><span class="c-role">지시자</span><span class="c-time">2일 전</span></div>
|
||||
<div class="c-text">가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-b">박</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">박지훈</span><span class="c-time">1일 전</span></div>
|
||||
<div class="c-text">가로형 1차 시안 업로드했습니다. 검토 부탁드려요.</div>
|
||||
<div class="c-file"><span class="fi">IMG</span>main_banner_h_v1.png</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment">
|
||||
<span class="c-av av-d">최</span>
|
||||
<div class="c-body">
|
||||
<div class="c-head"><span class="c-name">최유진</span><span class="c-time">3시간 전</span></div>
|
||||
<div class="c-text">세로형은 내일 오전까지 공유하겠습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sys-event">
|
||||
<span class="se-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<span><b>박지훈</b>님이 승인을 요청했습니다</span>
|
||||
<span class="se-time">2시간 전</span>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<span class="c-av" style="background:#3d8bf2">박</span>
|
||||
<div class="field">
|
||||
<textarea placeholder="댓글을 입력하세요…"></textarea>
|
||||
<div class="crow"><button class="btn primary" type="button">댓글 등록</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- sidebar -->
|
||||
<aside class="side">
|
||||
<!-- 승인 대기: 지시자(정태경) 시점 결정 카드 -->
|
||||
<div class="card ap-card request">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span>
|
||||
<span class="ap-card-title">지시자에게 승인 요청</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">작업을 마쳤다면 지시자 <b style="color:var(--text);font-weight:600">정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.</div>
|
||||
<div class="ap-card-actions">
|
||||
<button class="ap-btn request-send" id="requestOpen" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>승인 요청 보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">상태</div>
|
||||
<span class="status-badge"><span class="dot"></span>진행 중</span>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">담당자 · 2명</div>
|
||||
<div class="assignees">
|
||||
<div class="ass"><span class="avatar av-b">박</span><span><div class="nm">박지훈</div><div class="rl">퍼포먼스 마케팅</div></span></div>
|
||||
<div class="ass"><span class="avatar av-d">최</span><span><div class="nm">최유진</div><div class="rl">UX 디자인</div></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">마감기한</div>
|
||||
<div class="dl">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
6월 18일 (목)
|
||||
<span class="dday">D-3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">첨부파일 · 2개</div>
|
||||
<div class="files">
|
||||
<div class="file"><span class="file-ico fi-img">IMG</span><span class="file-info"><span class="file-name">레퍼런스_무드보드.png</span><span class="file-size">3.2 MB</span></span></div>
|
||||
<div class="file"><span class="file-ico fi-zip">ZIP</span><span class="file-info"><span class="file-name">배너_소스_파일.zip</span><span class="file-size">18.4 MB</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">지시자</div>
|
||||
<div class="issuer"><span class="avatar av-f">정</span><span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span></div>
|
||||
<div class="meta-line">6월 8일 지시 · #9</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div class="modal-backdrop" id="deleteModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body">
|
||||
<div class="modal-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg></div>
|
||||
<h2 class="modal-title">업무를 삭제할까요?</h2>
|
||||
<p class="modal-desc"><b>#9 메인 배너 이미지 2종 (가로/세로) 디자인</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" id="deleteCancel" type="button">취소</button>
|
||||
<button class="mbtn danger" type="button">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 승인 요청 모달 -->
|
||||
<div class="modal-backdrop" id="requestModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-body" style="text-align:left;padding:22px 24px 8px">
|
||||
<h2 class="modal-title">승인 요청 보내기</h2>
|
||||
<p class="modal-desc" style="margin:8px 0 14px">지시자 <b>정태경</b>님에게 검토 승인을 요청합니다. 완료한 작업과 전달할 내용을 간단히 적어주세요. 업무가 <b>승인 대기</b> 상태로 변경됩니다.</p>
|
||||
<textarea class="modal-textarea" placeholder="예) 가로형/세로형 메인 배너 1차 제작을 완료했습니다. 검토 부탁드립니다."></textarea>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="mbtn" data-close="requestModal" type="button">취소</button>
|
||||
<button class="mbtn request-send" type="button">요청 보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var moreBtn = document.getElementById('moreBtn');
|
||||
var moreMenu = document.getElementById('moreMenu');
|
||||
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); moreMenu.classList.toggle('open'); });
|
||||
document.addEventListener('click', function () { moreMenu.classList.remove('open'); });
|
||||
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
|
||||
/* 삭제 모달 */
|
||||
var dModal = document.getElementById('deleteModal');
|
||||
document.getElementById('deleteOpen').addEventListener('click', function () { moreMenu.classList.remove('open'); dModal.classList.add('open'); });
|
||||
document.getElementById('deleteCancel').addEventListener('click', function () { dModal.classList.remove('open'); });
|
||||
dModal.addEventListener('click', function (e) { if (e.target === dModal) dModal.classList.remove('open'); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dModal.classList.remove('open'); });
|
||||
|
||||
/* 승인 요청 모달 */
|
||||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeModal(el) { el.classList.remove('open'); }
|
||||
document.getElementById('requestOpen').addEventListener('click', function () { openModal('requestModal'); });
|
||||
document.querySelectorAll('[data-close]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { closeModal(document.getElementById(b.getAttribute('data-close'))); });
|
||||
});
|
||||
var reqModal = document.getElementById('requestModal');
|
||||
reqModal.addEventListener('click', function (e) { if (e.target === reqModal) closeModal(reqModal); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(reqModal); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,482 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>새 업무 작성 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--panel: #ffffff;
|
||||
--border: #e6e8ec;
|
||||
--border-strong: #d7dae0;
|
||||
--text: #16181d;
|
||||
--text-2: #5b626e;
|
||||
--text-3: #9298a3;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--accent-weak: #eef0fe;
|
||||
--accent-border: #c7ccfb;
|
||||
--green: #1f9d57;
|
||||
--green-weak: #e8f6ee;
|
||||
--amber: #b7791f;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
|
||||
--shadow-pop: 0 8px 28px rgba(20,24,33,.16), 0 2px 6px rgba(20,24,33,.08);
|
||||
}
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* ---------- Top app bar ---------- */
|
||||
.topbar {
|
||||
height: 52px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo {
|
||||
width: 26px; height: 26px; border-radius: 7px;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid; place-items: center; color: #fff;
|
||||
font-size: 15px; font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgba(79,70,229,.35);
|
||||
}
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a {
|
||||
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
|
||||
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
|
||||
}
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn {
|
||||
width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent;
|
||||
color: var(--text-2); display: grid; place-items: center; cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me {
|
||||
width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
|
||||
font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Page header ---------- */
|
||||
.page { max-width: 1240px; margin: 0 auto; padding: 0 24px 60px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 16px 0 0; white-space: nowrap; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.pagehead { display: flex; align-items: center; gap: 16px; padding: 8px 0 18px; }
|
||||
.pagehead h1 { font-size: 21px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
|
||||
.draft-tag {
|
||||
font-size: 11.5px; font-weight: 600; color: var(--amber); background: #fdf4e3;
|
||||
border: 1px solid #f3e2bd; padding: 3px 8px; border-radius: 20px; line-height: 1; white-space: nowrap;
|
||||
}
|
||||
.pagehead .actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.btn {
|
||||
height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600;
|
||||
border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2);
|
||||
cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn.ghost { border-color: transparent; background: transparent; }
|
||||
.btn.ghost:hover { background: #eceef1; }
|
||||
.btn.primary {
|
||||
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79,70,229,.4);
|
||||
}
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.layout { display: grid; grid-template-columns: 1fr 348px; gap: 20px; align-items: start; }
|
||||
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.main-card { padding: 8px 26px 26px; }
|
||||
|
||||
/* Title field */
|
||||
.title-input {
|
||||
width: 100%; border: none; outline: none; background: transparent;
|
||||
font-family: inherit; font-size: 24px; font-weight: 700; letter-spacing: -.5px;
|
||||
color: var(--text); padding: 20px 0 14px; line-height: 1.3;
|
||||
}
|
||||
.title-input::placeholder { color: #c4c8cf; }
|
||||
|
||||
.divider { height: 1px; background: var(--border); margin: 0 -26px; }
|
||||
|
||||
.field { padding-top: 22px; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; white-space: nowrap;
|
||||
}
|
||||
.field-label .req { color: var(--accent); font-weight: 700; }
|
||||
.field-label .hint { margin-left: auto; font-weight: 500; color: var(--text-3); font-size: 12px; }
|
||||
|
||||
/* Content editor */
|
||||
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius); overflow: hidden; }
|
||||
.editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 2px; padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border); background: #fafbfc;
|
||||
}
|
||||
.tb {
|
||||
height: 28px; min-width: 28px; padding: 0 7px; border-radius: var(--radius-sm); border: none; background: transparent;
|
||||
color: var(--text-2); font-size: 13px; font-weight: 600; cursor: pointer; display: grid; place-items: center;
|
||||
}
|
||||
.tb:hover { background: #eceef1; color: var(--text); }
|
||||
.tb svg { width: 16px; height: 16px; }
|
||||
.tb-sep { width: 1px; height: 18px; background: var(--border-strong); margin: 0 5px; }
|
||||
.editor-body { padding: 14px 16px; font-size: 14px; color: var(--text); min-height: 150px; line-height: 1.65; }
|
||||
.editor-body p { margin-bottom: 11px; }
|
||||
.editor-body p:last-child { margin-bottom: 0; }
|
||||
.editor-body .mention { color: var(--accent); font-weight: 600; background: var(--accent-weak); padding: 0 3px; border-radius: 3px; }
|
||||
|
||||
/* Checklist */
|
||||
.check-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.progress-wrap { margin-left: auto; display: flex; align-items: center; gap: 9px; }
|
||||
.progress-bar { width: 92px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
|
||||
.progress-bar > i { display: block; height: 100%; background: var(--green); border-radius: 4px; }
|
||||
.progress-txt { font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
|
||||
|
||||
.checklist { display: flex; flex-direction: column; }
|
||||
.check-item {
|
||||
display: flex; align-items: center; gap: 11px; padding: 9px 0;
|
||||
border-radius: var(--radius-sm); position: relative;
|
||||
}
|
||||
.check-item:hover { background: #f7f8fa; }
|
||||
.check-item .grip { position: absolute; left: -21px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 14px; line-height: 1; letter-spacing: -2px; }
|
||||
.check-item:hover .grip { opacity: 1; }
|
||||
.cbox {
|
||||
width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0; display: grid; place-items: center; background: #fff; cursor: pointer;
|
||||
}
|
||||
.cbox.done { background: var(--green); border-color: var(--green); }
|
||||
.cbox svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
|
||||
.cbox.done svg { opacity: 1; }
|
||||
.check-item .txt { font-size: 13.5px; color: var(--text); flex: 1; }
|
||||
.check-item.done .txt { color: var(--text-3); text-decoration: line-through; }
|
||||
.check-item .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 24px; height: 24px; display: grid; place-items: center; border-radius: 4px; }
|
||||
.check-item:hover .row-del { opacity: 1; }
|
||||
.check-item .row-del:hover { background: #eceef1; color: #d6455d; }
|
||||
.add-row {
|
||||
display: flex; align-items: center; gap: 11px; padding: 10px 0 4px 0; color: var(--text-3);
|
||||
font-size: 13.5px; cursor: text;
|
||||
}
|
||||
.add-row .plus { width: 18px; height: 18px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 13px; line-height: 1; }
|
||||
|
||||
/* nested subtasks */
|
||||
.task-group + .task-group { border-top: 1px solid var(--border); }
|
||||
.check-item.parent .txt { font-weight: 600; }
|
||||
.sub-count {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4;
|
||||
border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; line-height: 1.55; white-space: nowrap;
|
||||
}
|
||||
.check-item.parent.done .sub-count { color: var(--green); background: var(--green-weak); border-color: #cde8d8; }
|
||||
.subtasks { margin-left: 31px; padding-left: 14px; border-left: 1.5px solid var(--border); display: flex; flex-direction: column; }
|
||||
.subtask {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px 0;
|
||||
border-radius: var(--radius-sm); position: relative;
|
||||
}
|
||||
.subtask:hover { background: #f7f8fa; }
|
||||
.subtask .grip { position: absolute; left: -19px; top: 50%; transform: translateY(-50%); color: #c4c8cf; cursor: grab; opacity: 0; font-size: 13px; line-height: 1; letter-spacing: -2px; }
|
||||
.subtask:hover .grip { opacity: 1; }
|
||||
.cbox.sm { width: 16px; height: 16px; border-radius: 5px; }
|
||||
.cbox.sm svg { width: 11px; height: 11px; }
|
||||
.subtask .txt { font-size: 13px; color: var(--text-2); flex: 1; }
|
||||
.subtask.done .txt { color: var(--text-3); text-decoration: line-through; }
|
||||
.subtask .row-del { opacity: 0; color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; }
|
||||
.subtask:hover .row-del { opacity: 1; }
|
||||
.subtask .row-del:hover { background: #eceef1; color: #d6455d; }
|
||||
.add-sub {
|
||||
display: flex; align-items: center; gap: 9px; margin-left: 31px; padding: 7px 0 9px 0;
|
||||
color: var(--text-3); font-size: 12.5px; cursor: text;
|
||||
}
|
||||
.add-sub .plus-sm { width: 16px; height: 16px; border-radius: 5px; border: 1.5px dashed var(--border-strong); display: grid; place-items: center; font-size: 12px; line-height: 1; }
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
.side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.side-card { padding: 16px 18px 18px; }
|
||||
.side-field + .side-field { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.side-label { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 9px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.side-label .req { color: var(--accent); }
|
||||
|
||||
/* assignee combobox */
|
||||
.combo { position: relative; }
|
||||
.combo-field {
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 7px 8px; background: #fff;
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
}
|
||||
.combo-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.combo-caret { color: var(--text-3); display: grid; place-items: center; margin-left: auto; flex-shrink: 0; }
|
||||
.combo-caret svg { width: 16px; height: 16px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border);
|
||||
border-radius: 20px; padding: 3px 8px 3px 3px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.chip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; margin-left: 1px; }
|
||||
.chip .x:hover { color: #d6455d; }
|
||||
.combo-input { flex: 1; min-width: 70px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
|
||||
.combo-input::placeholder { color: var(--text-3); }
|
||||
|
||||
.avatar { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40;
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow-pop);
|
||||
padding: 6px; overflow: hidden;
|
||||
}
|
||||
.dd-search { font-size: 11px; color: var(--text-3); padding: 4px 8px 6px; font-weight: 600; }
|
||||
.dd-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer;
|
||||
}
|
||||
.dd-item:hover { background: #f5f6f8; }
|
||||
.dd-item.sel { background: var(--accent-weak); }
|
||||
.dd-meta { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.dd-name { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; }
|
||||
.dd-role { font-size: 11.5px; color: var(--text-3); line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dd-check { flex-shrink: 0; width: 17px; height: 17px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; }
|
||||
.dd-item.sel .dd-check { background: var(--accent); border-color: var(--accent); }
|
||||
.dd-check svg { width: 11px; height: 11px; color: #fff; opacity: 0; }
|
||||
.dd-item.sel .dd-check svg { opacity: 1; }
|
||||
|
||||
/* date */
|
||||
.input {
|
||||
width: 100%; height: 38px; border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
padding: 0 11px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff;
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
}
|
||||
.input .ico { color: var(--text-3); display: grid; place-items: center; }
|
||||
.input .ico svg { width: 16px; height: 16px; }
|
||||
.date-meta { font-size: 12px; color: var(--amber); font-weight: 600; margin-top: 7px; display: flex; align-items: center; gap: 5px; }
|
||||
.date-meta .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--amber); }
|
||||
|
||||
/* attachments */
|
||||
.dropzone {
|
||||
border: 1.5px dashed var(--border-strong); border-radius: var(--radius); padding: 13px;
|
||||
text-align: center; color: var(--text-3); font-size: 12.5px; cursor: pointer; background: #fafbfc;
|
||||
}
|
||||
.dropzone:hover { border-color: var(--accent-border); background: var(--accent-weak); color: var(--accent); }
|
||||
.dropzone b { color: var(--accent); font-weight: 600; }
|
||||
.files { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; }
|
||||
.file {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 9px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: #fff;
|
||||
}
|
||||
.file-ico { width: 30px; height: 30px; border-radius: 6px; display: grid; place-items: center; flex-shrink: 0; font-size: 10px; font-weight: 800; color: #fff; letter-spacing: -.3px; }
|
||||
.fi-pdf { background: #e25950; } .fi-zip { background: #8a64d6; } .fi-doc { background: #3d8bf2; }
|
||||
.file-info { min-width: 0; flex: 1; }
|
||||
.file-name { font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.file-size { font-size: 11.5px; color: var(--text-3); display: block; }
|
||||
.file .x { color: var(--text-3); cursor: pointer; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; flex-shrink: 0; }
|
||||
.file .x:hover { background: #eceef1; color: #d6455d; }
|
||||
|
||||
/* issuer meta */
|
||||
.issuer { display: flex; align-items: center; gap: 10px; }
|
||||
.issuer .avatar { width: 30px; height: 30px; font-size: 12px; }
|
||||
.issuer .nm { font-size: 13px; font-weight: 600; }
|
||||
.issuer .rl { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.side-foot { font-size: 11.5px; color: var(--text-3); line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="알림">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
</button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> <a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a> <span class="sep">/</span> 새 업무</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 업무 작성</h1>
|
||||
<span class="draft-tag">임시저장됨 · 방금</span>
|
||||
<div class="actions">
|
||||
<a class="btn ghost" href="저장소 상세.html">취소</a>
|
||||
<button class="btn">임시저장</button>
|
||||
<button class="btn primary">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
|
||||
지시 보내기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- ============ MAIN ============ -->
|
||||
<section class="card main-card">
|
||||
<input class="title-input" value="3분기 신제품 런칭 캠페인 소재 제작" placeholder="업무 제목을 입력하세요">
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="field">
|
||||
<div class="field-label"><span class="req">*</span> 업무 내용</div>
|
||||
<div class="editor">
|
||||
<div class="toolbar">
|
||||
<button class="tb" style="font-weight:800">B</button>
|
||||
<button class="tb" style="font-style:italic;font-family:Georgia,serif">I</button>
|
||||
<button class="tb" style="text-decoration:underline">U</button>
|
||||
<span class="tb-sep"></span>
|
||||
<button class="tb" title="목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg></button>
|
||||
<button class="tb" title="번호 목록"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4l2-3H4"/></svg></button>
|
||||
<span class="tb-sep"></span>
|
||||
<button class="tb" title="링크"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg></button>
|
||||
<button class="tb" title="멘션">@</button>
|
||||
<button class="tb" title="코드"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<p>9월 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1차 시안 확정 후 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
|
||||
<p>각 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 -->
|
||||
<div class="field">
|
||||
<div class="check-head">
|
||||
<div class="field-label" style="margin-bottom:0">해야 할 일</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar"><i style="width:40%"></i></div>
|
||||
<span class="progress-txt">2 / 5 완료</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checklist">
|
||||
<div class="check-item done">
|
||||
<span class="grip">⠿</span>
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="txt">캠페인 핵심 메시지 3개 시안 작성</span>
|
||||
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="check-item done">
|
||||
<span class="grip">⠿</span>
|
||||
<span class="cbox done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="txt">레퍼런스 리서치 및 무드보드 정리</span>
|
||||
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="check-item">
|
||||
<span class="grip">⠿</span>
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="txt">메인 배너 이미지 2종 (가로/세로) 디자인</span>
|
||||
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="check-item">
|
||||
<span class="grip">⠿</span>
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="txt">상세페이지 카피라이팅 및 법무 검토</span>
|
||||
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="check-item">
|
||||
<span class="grip">⠿</span>
|
||||
<span class="cbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span class="txt">최종 산출물 공유 드라이브 업로드</span>
|
||||
<span class="row-del"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="add-row"><span class="plus">+</span> 항목 추가</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="side">
|
||||
<div class="card side-card">
|
||||
<!-- 담당자 (열린 드롭다운) -->
|
||||
<div class="side-field">
|
||||
<div class="side-label"><span class="req">*</span> 담당자 <span style="color:var(--text-3);font-weight:500">· 3명</span></div>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<span class="chip"><span class="avatar av-a">김</span>김서연 <span class="x">×</span></span>
|
||||
<span class="chip"><span class="avatar av-b">박</span>박지훈 <span class="x">×</span></span>
|
||||
<span class="chip"><span class="avatar av-c">이</span>이도윤 <span class="x">×</span></span>
|
||||
<input class="combo-input" placeholder="이름 검색…" value="">
|
||||
<span class="combo-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<!-- 마감기한 -->
|
||||
<div class="side-field">
|
||||
<div class="side-label"><span class="req">*</span> 마감기한</div>
|
||||
<div class="input">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></span>
|
||||
2026년 6월 30일 (화) 18:00
|
||||
</div>
|
||||
<div class="date-meta"><span class="dot"></span> 마감까지 15일 남음</div>
|
||||
</div>
|
||||
|
||||
<!-- 첨부파일 -->
|
||||
<div class="side-field">
|
||||
<div class="side-label">첨부파일 <span style="color:var(--text-3);font-weight:500">· 3개</span></div>
|
||||
<div class="files">
|
||||
<div class="file">
|
||||
<span class="file-ico fi-pdf">PDF</span>
|
||||
<span class="file-info"><span class="file-name">캠페인_브리프_v2.pdf</span><span class="file-size">2.4 MB</span></span>
|
||||
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="file">
|
||||
<span class="file-ico fi-pdf">PDF</span>
|
||||
<span class="file-info"><span class="file-name">브랜드_가이드라인.pdf</span><span class="file-size">8.1 MB</span></span>
|
||||
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
<div class="file">
|
||||
<span class="file-ico fi-zip">ZIP</span>
|
||||
<span class="file-info"><span class="file-name">레퍼런스_이미지.zip</span><span class="file-size">34.7 MB</span></span>
|
||||
<span class="x"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropzone">파일을 끌어다 놓거나 <b>찾아보기</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field" style="border:none;padding:0;margin:0">
|
||||
<div class="side-label">지시자</div>
|
||||
<div class="issuer">
|
||||
<span class="avatar av-f">정</span>
|
||||
<span><div class="nm">정태경</div><div class="rl">마케팅 그룹 리드</div></span>
|
||||
</div>
|
||||
<div class="side-foot" style="margin-top:12px">지시를 보내면 담당자 3명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,430 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>q3-launch-campaign · 멤버 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --amber: #b7791f;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
/* btn */
|
||||
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* repo header */
|
||||
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
|
||||
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.repo-ico svg { width: 24px; height: 24px; }
|
||||
.rh-main { flex: 1; min-width: 0; }
|
||||
.rh-title { display: flex; align-items: center; gap: 10px; }
|
||||
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
|
||||
.rh-name .owner { color: var(--text-3); font-weight: 500; }
|
||||
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
|
||||
.vis svg { width: 11px; height: 11px; }
|
||||
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
|
||||
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
|
||||
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.rh-meta .mi svg { width: 13px; height: 13px; }
|
||||
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.avstack { display: flex; align-items: center; }
|
||||
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
|
||||
.avstack .avatar:first-child { margin-left: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
|
||||
|
||||
/* tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 6px; }
|
||||
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tabs a .tb-label { line-height: 1; }
|
||||
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
|
||||
|
||||
.tab-note { font-size: 12.5px; color: var(--text-3); margin: 12px 0 16px; display: flex; align-items: center; gap: 7px; }
|
||||
.tab-note svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
|
||||
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.search svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
|
||||
.search input::placeholder { color: var(--text-3); }
|
||||
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
|
||||
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
|
||||
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
|
||||
.segmented button.active .n { color: var(--accent); }
|
||||
|
||||
/* member list */
|
||||
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.mem-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); }
|
||||
.mem-row:first-child { border-top: none; }
|
||||
.mem-row:hover { background: #fafbfc; }
|
||||
.mem-av { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.mem-info { flex: 1; min-width: 0; }
|
||||
.mem-name { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.you-pill { font-size: 10.5px; font-weight: 700; color: var(--accent); background: var(--accent-weak); border: 1px solid var(--accent-border); border-radius: 20px; padding: 1px 7px; line-height: 1.5; }
|
||||
.mem-sub { font-size: 12.5px; color: var(--text-3); margin-top: 2px; }
|
||||
.mem-sub .dot { margin: 0 6px; opacity: .5; }
|
||||
.mem-tasks { font-size: 12.5px; color: var(--text-2); width: 86px; text-align: right; flex-shrink: 0; white-space: nowrap; }
|
||||
.mem-tasks b { color: var(--text); }
|
||||
.mem-tasks.zero { color: var(--text-3); }
|
||||
|
||||
/* role control */
|
||||
.role { display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 11px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-weight: 600; color: var(--text-2); background: #fff; cursor: pointer; white-space: nowrap; width: 104px; }
|
||||
.role:hover { background: #f7f8fa; }
|
||||
.role .role-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
|
||||
.role.admin { color: var(--accent); border-color: var(--accent-border); background: var(--accent-weak); }
|
||||
.role.admin .role-dot { background: var(--accent); }
|
||||
.role .caret { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
|
||||
.role .caret svg { width: 14px; height: 14px; }
|
||||
.role.fixed { background: #f1f2f4; cursor: not-allowed; color: var(--text-2); border-color: var(--border-strong); }
|
||||
.role.fixed .role-dot { background: var(--accent); }
|
||||
.role.fixed .caret { color: var(--text-3); }
|
||||
.role.fixed .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
|
||||
.role.fixed .lock svg { width: 13px; height: 13px; }
|
||||
|
||||
.mem-remove { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; opacity: 0; }
|
||||
.mem-row:hover .mem-remove { opacity: 1; }
|
||||
.mem-remove:hover { background: #f1f2f4; color: var(--red); }
|
||||
.mem-remove svg { width: 16px; height: 16px; }
|
||||
.mem-remove.disabled { opacity: 0 !important; pointer-events: none; }
|
||||
|
||||
/* invite modal */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,24,33,.5); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { width: 100%; max-width: 524px; background: #fff; border-radius: 14px; box-shadow: 0 24px 64px rgba(20,24,33,.32); overflow: hidden; animation: pop .14s ease-out; }
|
||||
@keyframes pop { from { transform: translateY(8px) scale(.98); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
.modal-head { display: flex; align-items: flex-start; gap: 12px; padding: 22px 24px 16px; }
|
||||
.modal-head h2 { font-size: 18px; font-weight: 700; letter-spacing: -.3px; }
|
||||
.modal-head .mh-sub { font-size: 13px; color: var(--text-2); margin-top: 4px; }
|
||||
.modal-close { margin-left: auto; width: 32px; height: 32px; border: none; background: transparent; color: var(--text-3); border-radius: var(--radius-sm); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; }
|
||||
.modal-close:hover { background: #f1f2f4; color: var(--text); }
|
||||
.modal-close svg { width: 18px; height: 18px; }
|
||||
.modal-body { padding: 4px 24px 8px; }
|
||||
.m-label { font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 8px; display: block; }
|
||||
|
||||
.icombo { position: relative; }
|
||||
.icombo-field { border: 1px solid var(--accent); border-radius: var(--radius); padding: 7px 8px; background: #fff; box-shadow: 0 0 0 3px var(--accent-weak); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.ichip { display: inline-flex; align-items: center; gap: 6px; background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 3px 8px 3px 4px; font-size: 12.5px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.ichip.ext { background: var(--accent-weak); border-color: var(--accent-border); color: var(--accent); }
|
||||
.ichip .x { color: var(--text-3); cursor: pointer; font-size: 13px; line-height: 1; }
|
||||
.ichip .x:hover { color: var(--red); }
|
||||
.icombo-input { flex: 1; min-width: 120px; border: none; outline: none; font-family: inherit; font-size: 13px; padding: 4px 2px; background: transparent; }
|
||||
.icombo-input::placeholder { color: var(--text-3); }
|
||||
.idd { display: none; position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 5; background: #fff; border: 1px solid var(--border); border-radius: 9px; box-shadow: 0 12px 32px rgba(20,24,33,.16); padding: 6px; }
|
||||
.icombo .idd.show { display: block; }
|
||||
.idd-cap { font-size: 11px; color: var(--text-3); font-weight: 600; padding: 5px 8px; }
|
||||
.idd-item { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.idd-item:hover { background: #f5f6f8; }
|
||||
.idd-item .nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.idd-item .em { font-size: 12px; color: var(--text-3); margin-left: auto; white-space: nowrap; flex-shrink: 0; }
|
||||
.idd-invite { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; border-top: 1px solid var(--border); margin-top: 4px; }
|
||||
.idd-invite:hover { background: var(--accent-weak); }
|
||||
.idd-invite .pl { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.idd-invite .pl svg { width: 15px; height: 15px; }
|
||||
.idd-invite .t1 { font-size: 13px; font-weight: 600; color: var(--accent); }
|
||||
.idd-invite .t2 { font-size: 11.5px; color: var(--text-3); }
|
||||
|
||||
.m-row { display: flex; gap: 12px; margin-top: 18px; }
|
||||
.m-role { flex: 0 0 150px; }
|
||||
.m-grow { flex: 1; }
|
||||
.select-wrap { position: relative; }
|
||||
.select-wrap select { appearance: none; -webkit-appearance: none; width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 34px 0 12px; font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text); background: #fff; cursor: pointer; }
|
||||
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.select-wrap .caret { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
|
||||
.select-wrap .caret svg { width: 15px; height: 15px; }
|
||||
.m-input { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 13.5px; color: var(--text); background: #fff; }
|
||||
.m-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.m-input::placeholder { color: var(--text-3); }
|
||||
.role-hint { font-size: 12px; color: var(--text-3); margin-top: 8px; line-height: 1.5; }
|
||||
|
||||
.modal-foot { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; margin-top: 16px; }
|
||||
.modal-foot .fnote { font-size: 12px; color: var(--text-3); margin-right: auto; }
|
||||
.mbtn { height: 38px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: #fff; color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mbtn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.mbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.mbtn.primary:hover { background: var(--accent-hover); }
|
||||
.mbtn svg { width: 15px; height: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="저장소 멤버">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캘페인</a>
|
||||
<span class="sep">/</span>
|
||||
<b>멤버</b>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더 -->
|
||||
<div class="repo-head">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="rh-main">
|
||||
<div class="rh-title">
|
||||
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
|
||||
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
|
||||
<div class="rh-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-f">정</span><span class="avatar av-a">김</span><span class="avatar av-b">박</span><span class="avatar av-c">이</span><span class="avatar av-d">최</span></span>
|
||||
<span class="mi">멤버 5명</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<nav class="tabs">
|
||||
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
|
||||
<a href="#" class="active"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
|
||||
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
|
||||
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
|
||||
</nav>
|
||||
|
||||
<div class="tab-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
||||
이 저장소의 멤버만 업무 담당자로 지정할 수 있습니다. <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>는 업무 지시·멤버 관리가 가능하고, <b style="color:var(--text-2);font-weight:600;white-space:nowrap">멤버</b>는 담당자로 배정되어 업무를 수행합니다.
|
||||
</div>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<input type="text" placeholder="이름·이메일 검색…">
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button class="active">전체 <span class="n">5</span></button>
|
||||
<button>관리자 <span class="n">1</span></button>
|
||||
<button>멤버 <span class="n">4</span></button>
|
||||
</div>
|
||||
<button class="btn primary" id="inviteBtn" type="button" style="margin-left:auto;border:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><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="M19 8v6M22 11h-6"/></svg>
|
||||
멤버 초대
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 목록 -->
|
||||
<div class="list">
|
||||
|
||||
<!-- 관리자 (소유자·현재 사용자) -->
|
||||
<div class="mem-row">
|
||||
<span class="mem-av av-f">정</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">정태경 <span class="you-pill">나</span></div>
|
||||
<div class="mem-sub">tkjung@acme.co <span class="dot">·</span> 리드</div>
|
||||
</div>
|
||||
<div class="mem-tasks"><b>1</b>건 담당</div>
|
||||
<div class="role fixed admin">
|
||||
<span class="role-dot"></span>관리자
|
||||
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
</div>
|
||||
<button class="mem-remove disabled" title="소유자는 제거할 수 없습니다"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 -->
|
||||
<div class="mem-row">
|
||||
<span class="mem-av av-a">김</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">김서연</div>
|
||||
<div class="mem-sub">sykim@acme.co <span class="dot">·</span> 콘텐츠 디자인</div>
|
||||
</div>
|
||||
<div class="mem-tasks"><b>2</b>건 담당</div>
|
||||
<div class="role">
|
||||
<span class="role-dot"></span>멤버
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="mem-row">
|
||||
<span class="mem-av av-b">박</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">박지훈</div>
|
||||
<div class="mem-sub">jhpark@acme.co <span class="dot">·</span> 퍼포먼스 마케팅</div>
|
||||
</div>
|
||||
<div class="mem-tasks"><b>2</b>건 담당</div>
|
||||
<div class="role">
|
||||
<span class="role-dot"></span>멤버
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="mem-row">
|
||||
<span class="mem-av av-c">이</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">이도윤</div>
|
||||
<div class="mem-sub">dylee@acme.co <span class="dot">·</span> 카피라이터</div>
|
||||
</div>
|
||||
<div class="mem-tasks"><b>2</b>건 담당</div>
|
||||
<div class="role">
|
||||
<span class="role-dot"></span>멤버
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="mem-row">
|
||||
<span class="mem-av av-d">최</span>
|
||||
<div class="mem-info">
|
||||
<div class="mem-name">최유진</div>
|
||||
<div class="mem-sub">yjchoi@acme.co <span class="dot">·</span> UX 디자인</div>
|
||||
</div>
|
||||
<div class="mem-tasks"><b>1</b>건 담당</div>
|
||||
<div class="role">
|
||||
<span class="role-dot"></span>멤버
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
<button class="mem-remove" title="멤버 제거"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 초대 모달 -->
|
||||
<div class="modal-backdrop" id="inviteModal">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2>멤버 초대</h2>
|
||||
<div class="mh-sub"><b style="color:var(--text-2);font-weight:600">3분기 신제품 런칭 캠페인</b> 저장소에 멤버를 추가합니다.</div>
|
||||
</div>
|
||||
<button class="modal-close" id="inviteClose" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="m-label">초대할 사람</label>
|
||||
<div class="icombo">
|
||||
<div class="icombo-field">
|
||||
<span class="ichip"><span class="avatar av-f" style="width:18px;height:18px;font-size:9px">한</span>한지민 <span class="x">×</span></span>
|
||||
<span class="ichip ext"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>partner@vendor.com <span class="x">×</span></span>
|
||||
<input class="icombo-input" placeholder="이름 또는 이메일 입력…">
|
||||
</div>
|
||||
<div class="idd">
|
||||
<div class="idd-cap">조직 멤버 · 이 저장소에 없는 사람</div>
|
||||
<div class="idd-item">
|
||||
<span class="avatar av-b" style="width:26px;height:26px;font-size:11px">오</span>
|
||||
<span class="nm">오세훈</span><span class="em">sehoon.oh@acme.co</span>
|
||||
</div>
|
||||
<div class="idd-item">
|
||||
<span class="avatar av-c" style="width:26px;height:26px;font-size:11px">강</span>
|
||||
<span class="nm">강민서</span><span class="em">minseo.kang@acme.co</span>
|
||||
</div>
|
||||
<div class="idd-item">
|
||||
<span class="avatar av-e" style="width:26px;height:26px;font-size:11px">윤</span>
|
||||
<span class="nm">윤하늘</span><span class="em">haneul.yoon@acme.co</span>
|
||||
</div>
|
||||
<div class="idd-invite">
|
||||
<span class="pl"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg></span>
|
||||
<span><div class="t1">이메일로 외부 초대</div><div class="t2">조직에 없는 사람도 이메일로 초대할 수 있어요</div></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-row">
|
||||
<div class="m-role">
|
||||
<label class="m-label">역할</label>
|
||||
<div class="select-wrap">
|
||||
<select>
|
||||
<option>멤버</option>
|
||||
<option>관리자</option>
|
||||
</select>
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-grow">
|
||||
<label class="m-label">초대 메시지 <span style="color:var(--text-3);font-weight:500">(선택)</span></label>
|
||||
<input class="m-input" type="text" placeholder="함께 캠페인 준비해요!">
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-hint"><b style="color:var(--text-2);font-weight:600">멤버</b>는 담당자로 배정되어 업무를 수행하고, <b style="color:var(--text-2);font-weight:600">관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
|
||||
<button class="mbtn" id="inviteCancel" type="button">취소</button>
|
||||
<button class="mbtn primary" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
|
||||
초대 보내기 (2)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var modal = document.getElementById('inviteModal');
|
||||
function openModal() { modal.classList.add('open'); }
|
||||
function closeModal() { modal.classList.remove('open'); }
|
||||
document.getElementById('inviteBtn').addEventListener('click', openModal);
|
||||
document.getElementById('inviteClose').addEventListener('click', closeModal);
|
||||
document.getElementById('inviteCancel').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
|
||||
var iInput = document.querySelector('.icombo-input');
|
||||
var idd = document.querySelector('.idd');
|
||||
iInput.addEventListener('focus', function () { idd.classList.add('show'); });
|
||||
iInput.addEventListener('blur', function () { setTimeout(function () { idd.classList.remove('show'); }, 120); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,301 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>저장소 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --amber: #b7791f;
|
||||
--radius: 6px; --radius-sm: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
.pagehead { display: flex; align-items: center; gap: 11px; padding: 9px 0 4px; }
|
||||
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; }
|
||||
.count-pill { font-size: 13px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); border-radius: 20px; padding: 2px 11px; }
|
||||
.gitea-badge { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap; }
|
||||
.gitea-badge svg { width: 13px; height: 13px; }
|
||||
.lede { color: var(--text-2); font-size: 13.5px; margin: 4px 0 18px; }
|
||||
|
||||
/* btn */
|
||||
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 280px; }
|
||||
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.search svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
|
||||
.search input::placeholder { color: var(--text-3); }
|
||||
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
|
||||
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
|
||||
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
|
||||
/* list */
|
||||
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.repo-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
|
||||
.repo-row:first-child { border-top: none; }
|
||||
.repo-row:hover { background: #fafbfc; }
|
||||
.repo-ico { width: 40px; height: 40px; border-radius: 9px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; align-self: flex-start; margin-top: 2px; }
|
||||
.repo-ico svg { width: 21px; height: 21px; }
|
||||
|
||||
.repo-main { flex: 1; min-width: 0; }
|
||||
.repo-title { display: flex; align-items: center; column-gap: 9px; row-gap: 2px; flex-wrap: wrap; }
|
||||
.repo-name { font-size: 15px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.repo-name .owner { color: var(--text-3); font-weight: 500; }
|
||||
.repo-slug { order: 2; flex-basis: 100%; font-size: 12px; font-weight: 500; color: var(--text-3); margin-top: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.repo-row:hover .repo-name { color: var(--accent); }
|
||||
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
|
||||
.vis svg { width: 11px; height: 11px; }
|
||||
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.vis.public { color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8; }
|
||||
.repo-desc { font-size: 13px; color: var(--text-2); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
|
||||
.repo-meta { display: flex; align-items: center; gap: 15px; margin-top: 10px; font-size: 12px; color: var(--text-3); }
|
||||
.repo-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.repo-meta .mi svg { width: 13px; height: 13px; }
|
||||
.avstack { display: flex; align-items: center; }
|
||||
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
|
||||
.avstack .avatar:first-child { margin-left: 0; }
|
||||
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
|
||||
|
||||
.repo-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; width: 168px; flex-shrink: 0; }
|
||||
.stat-top { font-size: 12.5px; color: var(--text-2); font-weight: 600; white-space: nowrap; }
|
||||
.stat-top b { color: var(--text); }
|
||||
.pbar { width: 156px; height: 6px; border-radius: 4px; background: #eceef1; overflow: hidden; }
|
||||
.pbar > i { display: block; height: 100%; border-radius: 4px; background: var(--green); }
|
||||
.pbar > i.mid { background: var(--accent); }
|
||||
.pbar > i.low { background: var(--amber); }
|
||||
.stat-pct { font-size: 11.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.chev { color: var(--text-3); flex-shrink: 0; display: grid; place-items: center; }
|
||||
.chev svg { width: 18px; height: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="저장소 목록">
|
||||
<div class="crumb"><b>저장소</b></div>
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<span class="count-pill">6</span>
|
||||
<span class="gitea-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg> Gitea 연동됨</span>
|
||||
</div>
|
||||
<p class="lede"><b style="color:var(--text-2)">marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게 전달하세요.</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<input type="text" placeholder="저장소 검색…">
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button class="active">전체</button>
|
||||
<button>비공개</button>
|
||||
<button>공개</button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
|
||||
최근 업데이트순
|
||||
</div>
|
||||
<a class="btn primary" href="저장소 생성.html">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
|
||||
새 저장소
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
|
||||
<!-- 1 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">3분기 신제품 런칭 캘페인</span>
|
||||
<span class="repo-slug">marketing-team/q3-launch-campaign</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-a">김</span><span class="avatar av-b">박</span><span class="avatar av-c">이</span><span class="avatar av-more">+2</span></span>
|
||||
<span class="mi">2시간 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>8</b> / 12 업무</div>
|
||||
<div class="pbar"><i class="mid" style="width:67%"></i></div>
|
||||
<div class="stat-pct">67% 완료</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
<!-- 2 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">브랜드 리뉴얼 2026</span>
|
||||
<span class="repo-slug">marketing-team/brand-refresh-2026</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-d">최</span><span class="avatar av-e">정</span><span class="avatar av-f">한</span><span class="avatar av-more">+1</span></span>
|
||||
<span class="mi">어제 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>5</b> / 9 업무</div>
|
||||
<div class="pbar"><i class="mid" style="width:56%"></i></div>
|
||||
<div class="stat-pct">56% 완료</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
<!-- 3 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">월간 뉴스레터 운영</span>
|
||||
<span class="repo-slug">marketing-team/monthly-newsletter</span>
|
||||
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-c">이</span><span class="avatar av-b">박</span><span class="avatar av-a">김</span></span>
|
||||
<span class="mi">3일 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>14</b> / 14 업무</div>
|
||||
<div class="pbar"><i style="width:100%"></i></div>
|
||||
<div class="stat-pct">100% 완료</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
<!-- 4 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">SEO 콘텐츠 허브</span>
|
||||
<span class="repo-slug">marketing-team/seo-content-hub</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-b">박</span><span class="avatar av-f">한</span><span class="avatar av-c">이</span><span class="avatar av-more">+3</span></span>
|
||||
<span class="mi">5시간 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>2</b> / 11 업무</div>
|
||||
<div class="pbar"><i class="low" style="width:18%"></i></div>
|
||||
<div class="stat-pct">18% 완료</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
<!-- 5 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">사내 행사 핵드북</span>
|
||||
<span class="repo-slug">marketing-team/event-handbook</span>
|
||||
<span class="vis public"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">사내 행사 운영 가이드 및 준비 체크리스트 모음</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-e">정</span><span class="avatar av-a">김</span></span>
|
||||
<span class="mi">1주 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>0</b> / 4 업무</div>
|
||||
<div class="pbar"><i class="low" style="width:0%"></i></div>
|
||||
<div class="stat-pct">시작 전</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
<!-- 6 -->
|
||||
<a class="repo-row" href="저장소 상세.html">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">디자인 시스템 에셋</span>
|
||||
<span class="repo-slug">marketing-team/design-system-assets</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="repo-desc">공통 디자인 시스템 에셋 및 컴포넌트 관리</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>develop</span>
|
||||
<span class="mi avstack"><span class="avatar av-a">김</span><span class="avatar av-d">최</span><span class="avatar av-c">이</span><span class="avatar av-more">+1</span></span>
|
||||
<span class="mi">30분 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repo-stat">
|
||||
<div class="stat-top"><b>9</b> / 10 업무</div>
|
||||
<div class="pbar"><i style="width:90%"></i></div>
|
||||
<div class="stat-pct">90% 완료</div>
|
||||
</div>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,350 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>q3-launch-campaign — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --red-weak: #fdecef; --amber: #b7791f; --amber-weak: #fdf4e3; --amber-border: #f3e2bd;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
/* btn */
|
||||
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* repo header */
|
||||
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
|
||||
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.repo-ico svg { width: 24px; height: 24px; }
|
||||
.rh-main { flex: 1; min-width: 0; }
|
||||
.rh-title { display: flex; align-items: center; gap: 10px; }
|
||||
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
|
||||
.rh-name .owner { color: var(--text-3); font-weight: 500; }
|
||||
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
|
||||
.vis svg { width: 11px; height: 11px; }
|
||||
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
|
||||
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
|
||||
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.rh-meta .mi svg { width: 13px; height: 13px; }
|
||||
.avstack { display: flex; align-items: center; }
|
||||
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
|
||||
.avstack .avatar:first-child { margin-left: 0; }
|
||||
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
|
||||
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* progress strip */
|
||||
.prog-strip { display: flex; align-items: center; gap: 16px; margin-top: 16px; padding-top: 15px; border-top: 1px solid var(--border); }
|
||||
.prog-strip .pbar { flex: 1; height: 8px; border-radius: 5px; background: #eceef1; overflow: hidden; max-width: 360px; }
|
||||
.prog-strip .pbar > i { display: block; height: 100%; border-radius: 5px; background: var(--green); }
|
||||
.prog-txt { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
||||
.prog-txt span { color: var(--text-3); font-weight: 500; }
|
||||
.prog-warn { font-size: 12.5px; font-weight: 600; color: var(--red); display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.prog-warn svg { width: 14px; height: 14px; }
|
||||
|
||||
/* tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
|
||||
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tabs a .tb-label { line-height: 1; }
|
||||
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.search { display: flex; align-items: center; gap: 8px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; width: 240px; }
|
||||
.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.search svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
.search input { border: none; outline: none; flex: 1; min-width: 0; font-family: inherit; font-size: 13.5px; background: transparent; }
|
||||
.search input::placeholder { color: var(--text-3); }
|
||||
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
|
||||
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 12px; border-radius: 4px; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
|
||||
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.segmented button .n { font-size: 11px; color: var(--text-3); font-weight: 600; }
|
||||
.segmented button.active .n { color: var(--accent); }
|
||||
.sort { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.sort svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
|
||||
/* task list */
|
||||
.list { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.task-row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-top: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; }
|
||||
.task-row:first-child { border-top: none; }
|
||||
.task-row:hover { background: #fafbfc; }
|
||||
.st-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.st-dot.done { background: var(--green); } .st-dot.prog { background: var(--blue); } .st-dot.review { background: var(--amber); } .st-dot.todo { background: #c4c8cf; }
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 14px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.task-title .tid { font-size: 12px; font-weight: 600; color: var(--text-3); }
|
||||
.task-row.is-done .task-title { color: var(--text-2); }
|
||||
.task-meta { display: flex; align-items: center; gap: 13px; margin-top: 3px; font-size: 12px; color: var(--text-3); }
|
||||
.task-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.task-meta .mi svg { width: 13px; height: 13px; }
|
||||
.task-meta .mi.overdue { color: var(--red); font-weight: 600; }
|
||||
.task-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||
.st-badge { font-size: 11.5px; font-weight: 600; padding: 3px 10px; border-radius: 20px; white-space: nowrap; min-width: 56px; text-align: center; }
|
||||
.st-badge.done { color: var(--green); background: var(--green-weak); border: 1px solid #cde8d8; }
|
||||
.st-badge.prog { color: var(--blue); background: var(--blue-weak); border: 1px solid var(--blue-border); }
|
||||
.st-badge.review { color: var(--amber); background: var(--amber-weak); border: 1px solid var(--amber-border); }
|
||||
.st-badge.todo { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.chev { color: var(--text-3); display: grid; place-items: center; }
|
||||
.chev svg { width: 18px; height: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="저장소 상세">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<b>3분기 신제품 런칭 캘페인</b>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더 -->
|
||||
<div class="repo-head">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="rh-main">
|
||||
<div class="rh-title">
|
||||
<span class="rh-name">3분기 신제품 런칭 캘페인</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
|
||||
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
|
||||
<div class="rh-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-a">김</span><span class="avatar av-b">박</span><span class="avatar av-c">이</span><span class="avatar av-d">최</span><span class="avatar av-more">+1</span></span>
|
||||
<span class="mi">2시간 전 업데이트</span>
|
||||
</div>
|
||||
<div class="prog-strip">
|
||||
<span class="prog-txt">8 <span>/ 12 업무 완료</span> · 67%</span>
|
||||
<div class="pbar"><i style="width:67%"></i></div>
|
||||
<span class="prog-warn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>지연 1건</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<nav class="tabs">
|
||||
<a href="#" class="active"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
|
||||
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
|
||||
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
|
||||
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
|
||||
</nav>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
<input type="text" placeholder="업무 검색…">
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button class="active">전체 <span class="n">12</span></button>
|
||||
<button>진행 중 <span class="n">2</span></button>
|
||||
<button>대기 <span class="n">2</span></button>
|
||||
<button>완료 <span class="n">8</span></button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/></svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업무 목록 -->
|
||||
<div class="list">
|
||||
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot prog"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">법무 검토 요청 및 회신 반영 <span class="tid">#11</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi overdue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>2일 지남</span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-c">이</span></span>
|
||||
<span class="st-badge prog">진행 중</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot review"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">메인 배너 이미지 2종 (가로/세로) 디자인 <span class="tid">#9</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-3</span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>1/3</span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>2</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-b">박</span><span class="avatar av-d">최</span></span>
|
||||
<span class="st-badge review">승인 대기</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot todo"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">상세페이지 카피라이팅 <span class="tid">#8</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-5</span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/3</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-c">이</span></span>
|
||||
<span class="st-badge todo">대기</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row" href="업무 상세.html">
|
||||
<span class="st-dot todo"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">SNS 콘텐츠 1차 시안 <span class="tid">#7</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>D-8</span>
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>0/4</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-e">정</span></span>
|
||||
<span class="st-badge todo">대기</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row is-done" href="업무 상세.html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">캠페인 핵심 메시지 시안 작성 <span class="tid">#6</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>3/3</span>
|
||||
<span class="mi">6월 9일 완료</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-a">김</span></span>
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row is-done" href="업무 상세.html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">레퍼런스 리서치 및 무드보드 정리 <span class="tid">#5</span></span>
|
||||
<span class="task-meta"><span class="mi">6월 6일 완료</span></span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-a">김</span></span>
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row is-done" href="업무 상세.html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">퍼포먼스 광고 소재 세팅 <span class="tid">#4</span></span>
|
||||
<span class="task-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>2/2</span>
|
||||
<span class="mi">6월 5일 완료</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-b">박</span></span>
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row is-done" href="업무 상세.html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">타깃 오디언스 정의 및 페르소나 정리 <span class="tid">#3</span></span>
|
||||
<span class="task-meta"><span class="mi">6월 4일 완료</span></span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-b">박</span></span>
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a class="task-row is-done" href="업무 상세.html">
|
||||
<span class="st-dot done"></span>
|
||||
<span class="task-main">
|
||||
<span class="task-title">킥오프 미팅 및 일정 수립 <span class="tid">#1</span></span>
|
||||
<span class="task-meta"><span class="mi">6월 2일 완료</span></span>
|
||||
</span>
|
||||
<span class="task-right">
|
||||
<span class="avstack"><span class="avatar av-f">정</span></span>
|
||||
<span class="st-badge done">완료</span>
|
||||
<span class="chev"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg></span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,289 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>새 저장소 만들기 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--panel: #ffffff;
|
||||
--border: #e6e8ec;
|
||||
--border-strong: #d7dae0;
|
||||
--text: #16181d;
|
||||
--text-2: #5b626e;
|
||||
--text-3: #9298a3;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--accent-weak: #eef0fe;
|
||||
--accent-border: #c7ccfb;
|
||||
--green: #1f9d57;
|
||||
--green-weak: #e8f6ee;
|
||||
--amber: #b7791f;
|
||||
--radius: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
--shadow-md: 0 4px 16px rgba(20,24,33,.10), 0 1px 3px rgba(20,24,33,.06);
|
||||
}
|
||||
html, body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: "Pretendard", -apple-system, system-ui, sans-serif;
|
||||
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* ---------- Top app bar ---------- */
|
||||
.topbar {
|
||||
height: 52px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 28px; padding: 0 20px;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo {
|
||||
width: 26px; height: 26px; border-radius: 7px;
|
||||
background: linear-gradient(135deg, #5b54f0, #4338ca);
|
||||
display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800;
|
||||
box-shadow: 0 2px 6px rgba(79,70,229,.35);
|
||||
}
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a {
|
||||
color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500;
|
||||
padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap;
|
||||
}
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* ---------- Page ---------- */
|
||||
.page { max-width: 760px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 0; white-space: nowrap; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.pagehead { display: flex; align-items: center; gap: 12px; padding: 9px 0 6px; }
|
||||
.pagehead h1 { font-size: 22px; font-weight: 700; letter-spacing: -.4px; white-space: nowrap; flex-shrink: 0; }
|
||||
.gitea-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600;
|
||||
color: #1b7a3d; background: var(--green-weak); border: 1px solid #cde8d8;
|
||||
padding: 4px 10px; border-radius: 20px; line-height: 1; white-space: nowrap;
|
||||
}
|
||||
.gitea-badge svg { width: 13px; height: 13px; }
|
||||
.lede { color: var(--text-2); font-size: 13.5px; margin-bottom: 20px; }
|
||||
|
||||
/* ---------- Card / form ---------- */
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); }
|
||||
.fblock { padding: 22px 24px; border-top: 1px solid var(--border); }
|
||||
.fblock:first-child { border-top: none; }
|
||||
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; display: flex; align-items: center; gap: 7px; }
|
||||
.flabel .req { color: var(--accent); font-weight: 700; }
|
||||
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
|
||||
.fhint.below { margin-top: 9px; margin-bottom: 0; }
|
||||
|
||||
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
|
||||
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.tinput::placeholder { color: var(--text-3); }
|
||||
textarea.tarea { min-height: 96px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
|
||||
|
||||
/* owner / repo name */
|
||||
.repo-id { display: flex; }
|
||||
.owner-fixed {
|
||||
display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 13px;
|
||||
background: #f1f2f4; border: 1px solid var(--border-strong); border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius); color: var(--text-2);
|
||||
font-weight: 600; font-size: 14px; cursor: not-allowed; white-space: nowrap; user-select: none;
|
||||
}
|
||||
.owner-fixed .lock { color: var(--text-3); display: grid; place-items: center; }
|
||||
.owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
|
||||
.name-wrap { flex: 1; height: 40px; display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: 0 var(--radius) var(--radius) 0; background: #fff; padding: 0 12px; min-width: 0; }
|
||||
.name-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); position: relative; z-index: 1; }
|
||||
.name-wrap .slash { color: var(--text-3); margin-right: 7px; font-weight: 500; font-size: 15px; }
|
||||
.name-wrap input { flex: 1; min-width: 0; border: none; outline: none; height: 100%; font-family: inherit; font-size: 14px; background: transparent; }
|
||||
.name-wrap input::placeholder { color: var(--text-3); }
|
||||
.avail { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--green); margin-top: 9px; }
|
||||
.avail svg { width: 14px; height: 14px; }
|
||||
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
|
||||
|
||||
/* visibility radio cards */
|
||||
.radio-group { display: flex; flex-direction: column; gap: 10px; }
|
||||
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; transition: border-color .12s, background .12s; }
|
||||
.radio-card:hover { border-color: var(--accent-border); }
|
||||
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
|
||||
.rc-ico { width: 34px; height: 34px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.rc-ico svg { width: 18px; height: 18px; }
|
||||
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
|
||||
.rc-body { flex: 1; }
|
||||
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
|
||||
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 8px; display: grid; place-items: center; background: #fff; }
|
||||
.radio-card.sel .rc-radio { border-color: var(--accent); }
|
||||
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
/* select */
|
||||
.select-wrap { position: relative; }
|
||||
.select-wrap select {
|
||||
appearance: none; -webkit-appearance: none; width: 100%; height: 40px;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
padding: 0 38px 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; cursor: pointer;
|
||||
}
|
||||
.select-wrap select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.select-wrap .caret { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: var(--text-3); pointer-events: none; display: grid; place-items: center; }
|
||||
.select-wrap .caret svg { width: 16px; height: 16px; }
|
||||
|
||||
/* input with icon */
|
||||
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; }
|
||||
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
|
||||
.input-ico .ico svg { width: 16px; height: 16px; }
|
||||
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
|
||||
|
||||
/* footer */
|
||||
.form-footer { display: flex; align-items: center; gap: 9px; padding: 16px 24px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
|
||||
.form-footer .note { color: var(--text-3); font-size: 12.5px; margin-right: auto; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
|
||||
.form-footer .note .step { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); font-size: 10px; font-weight: 700; }
|
||||
.btn { height: 36px; padding: 0 16px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn.ghost { border-color: transparent; background: transparent; }
|
||||
.btn.ghost:hover { background: #eceef1; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="알림">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
</button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<div class="crumb"><a href="저장소 목록.html" class="lnk">저장소</a> <span class="sep">/</span> 새 저장소</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 저장소 만들기</h1>
|
||||
<span class="gitea-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><path d="M8 12h8"/></svg>
|
||||
Gitea 연동됨
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고 담당자에게 전달할 수 있습니다.</p>
|
||||
|
||||
<div class="card">
|
||||
|
||||
<!-- 소유자 / 저장소 이름 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel"><span class="req">*</span> 소유자 / 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
|
||||
<div class="fhint">소유자는 현재 조직으로 고정되어 변경할 수 없습니다.</div>
|
||||
<div class="repo-id">
|
||||
<div class="owner-fixed" title="소유자는 변경할 수 없습니다">
|
||||
<span class="owner-avatar">M</span>
|
||||
marketing-team
|
||||
<span class="lock"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
</div>
|
||||
<div class="name-wrap">
|
||||
<span class="slash">/</span>
|
||||
<input type="text" value="q3-launch-campaign" placeholder="repository-name" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="avail">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
사용 가능한 이름입니다
|
||||
</div>
|
||||
<div class="fhint below">영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.</div>
|
||||
</div>
|
||||
|
||||
<!-- 표시 제목 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel"><span class="req">*</span> 표시 제목</div>
|
||||
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다.</div>
|
||||
<input type="text" class="tinput" value="3분기 신제품 런칭 캠페인" placeholder="예: 3분기 신제품 런칭 캠페인" maxlength="40">
|
||||
</div>
|
||||
|
||||
<!-- 공개 범위 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel"><span class="req">*</span> 공개 범위</div>
|
||||
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
|
||||
<div class="radio-group">
|
||||
<label class="radio-card sel">
|
||||
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">비공개</span>
|
||||
<span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio"></span>
|
||||
</label>
|
||||
<label class="radio-card">
|
||||
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">공개</span>
|
||||
<span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">프로젝트 설명</div>
|
||||
<div class="fhint">저장소 목록과 상단에 표시됩니다. (선택)</div>
|
||||
<textarea class="tinput tarea" placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요.">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
|
||||
</div>
|
||||
|
||||
<!-- 템플릿 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">템플릿</div>
|
||||
<div class="fhint">선택한 템플릿의 폴더 구조와 기본 업무가 새 저장소에 복제됩니다.</div>
|
||||
<div class="select-wrap">
|
||||
<select>
|
||||
<option>없음 (빈 저장소)</option>
|
||||
<option>기본 업무 템플릿</option>
|
||||
<option>디자인 프로젝트 템플릿</option>
|
||||
<option>개발 프로젝트 템플릿</option>
|
||||
</select>
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 브랜치 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel"><span class="req">*</span> 메인 브랜치</div>
|
||||
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
|
||||
<div class="input-ico" style="max-width:280px">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
|
||||
<input type="text" value="main" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div class="form-footer">
|
||||
<span class="note"><span class="step">1</span> 저장소 생성 → 업무 생성 → 전달</span>
|
||||
<a class="btn ghost" href="저장소 목록.html">취소</a>
|
||||
<a class="btn primary" href="저장소 상세.html">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>
|
||||
저장소 생성
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,275 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>q3-launch-campaign · 설정 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --red: #d6455d; --red-weak: #fdeef0; --red-border: #f1c0c8;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* repo header */
|
||||
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
|
||||
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.repo-ico svg { width: 24px; height: 24px; }
|
||||
.rh-main { flex: 1; min-width: 0; }
|
||||
.rh-title { display: flex; align-items: center; gap: 10px; }
|
||||
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
|
||||
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
|
||||
.vis svg { width: 11px; height: 11px; }
|
||||
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
|
||||
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
|
||||
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.rh-meta .mi svg { width: 13px; height: 13px; }
|
||||
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.avstack { display: flex; align-items: center; }
|
||||
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
|
||||
.avstack .avatar:first-child { margin-left: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-f { background: #d0982a; }
|
||||
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 22px; }
|
||||
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tabs a .tb-label { line-height: 1; }
|
||||
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
|
||||
/* settings layout */
|
||||
.settings { max-width: 720px; }
|
||||
.sec-title { font-size: 15px; font-weight: 700; letter-spacing: -.2px; margin: 0 0 12px; }
|
||||
.sec-title.danger { color: var(--red); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); margin-bottom: 30px; }
|
||||
.fblock { padding: 20px 22px; border-top: 1px solid var(--border); }
|
||||
.fblock:first-child { border-top: none; }
|
||||
.flabel { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 5px; display: flex; align-items: center; gap: 7px; }
|
||||
.fhint { font-size: 12.5px; color: var(--text-3); margin-bottom: 11px; line-height: 1.5; }
|
||||
.fixed-pill { font-size: 11px; font-weight: 600; color: var(--text-2); background: #e9ebef; border: 1px solid var(--border-strong); padding: 1px 7px; border-radius: 20px; line-height: 1.5; white-space: nowrap; }
|
||||
|
||||
.tinput { width: 100%; height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 12px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
|
||||
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
textarea.tarea { min-height: 84px; padding: 10px 12px; line-height: 1.6; resize: vertical; height: auto; }
|
||||
|
||||
.readonly { display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 13px; background: #f5f6f8; border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-2); font-size: 14px; font-weight: 500; cursor: not-allowed; }
|
||||
.readonly .owner-avatar { width: 20px; height: 20px; border-radius: 5px; background: #0ea5a3; color: #fff; display: grid; place-items: center; font-size: 10.5px; font-weight: 700; }
|
||||
.readonly .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); font-weight: 600; }
|
||||
.readonly .lock { margin-left: auto; color: var(--text-3); display: grid; place-items: center; }
|
||||
.readonly .lock svg { width: 14px; height: 14px; }
|
||||
|
||||
/* visibility radio cards */
|
||||
.radio-group { display: flex; flex-direction: column; gap: 10px; }
|
||||
.radio-card { display: flex; align-items: flex-start; gap: 13px; padding: 13px 14px; border: 1px solid var(--border-strong); border-radius: 8px; cursor: pointer; background: #fff; }
|
||||
.radio-card:hover { border-color: var(--accent-border); }
|
||||
.radio-card.sel { border-color: var(--accent); background: var(--accent-weak); }
|
||||
.rc-ico { width: 32px; height: 32px; border-radius: 8px; background: #f1f2f4; color: var(--text-2); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.rc-ico svg { width: 17px; height: 17px; }
|
||||
.radio-card.sel .rc-ico { background: #fff; color: var(--accent); }
|
||||
.rc-body { flex: 1; }
|
||||
.rc-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.rc-desc { font-size: 12.5px; color: var(--text-2); margin-top: 2px; line-height: 1.45; }
|
||||
.rc-radio { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border-strong); flex-shrink: 0; margin-top: 7px; display: grid; place-items: center; background: #fff; }
|
||||
.radio-card.sel .rc-radio { border-color: var(--accent); }
|
||||
.radio-card.sel .rc-radio::after { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
.input-ico { display: flex; align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius); background: #fff; padding: 0 12px; gap: 9px; max-width: 300px; }
|
||||
.input-ico:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.input-ico .ico { color: var(--text-3); display: grid; place-items: center; }
|
||||
.input-ico .ico svg { width: 16px; height: 16px; }
|
||||
.input-ico input { flex: 1; border: none; outline: none; height: 40px; font-family: inherit; font-size: 14px; background: transparent; color: var(--text); }
|
||||
|
||||
.card-foot { display: flex; justify-content: flex-end; padding: 14px 22px; border-top: 1px solid var(--border); background: #fafbfc; border-radius: 0 0 10px 10px; }
|
||||
|
||||
/* danger zone */
|
||||
.danger-card { background: #fff; border: 1px solid var(--red-border); border-radius: 10px; box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.danger-row { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border-top: 1px solid #f6dde2; }
|
||||
.danger-row:first-child { border-top: none; }
|
||||
.dr-main { flex: 1; min-width: 0; }
|
||||
.dr-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.dr-desc { font-size: 12.5px; color: var(--text-2); margin-top: 3px; line-height: 1.5; }
|
||||
.btn-danger { height: 34px; padding: 0 14px; border-radius: var(--radius); font-size: 13px; font-weight: 600; border: 1px solid var(--red-border); background: #fff; color: var(--red); cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
||||
.btn-danger:hover { background: var(--red-weak); }
|
||||
.btn-danger.solid { background: var(--red); border-color: var(--red); color: #fff; }
|
||||
.btn-danger.solid:hover { background: #c23a51; }
|
||||
|
||||
.admin-note { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-3); margin-bottom: 18px; }
|
||||
.admin-note svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="저장소 설정">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
|
||||
<span class="sep">/</span>
|
||||
<b>설정</b>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더 -->
|
||||
<div class="repo-head">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="rh-main">
|
||||
<div class="rh-title">
|
||||
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
|
||||
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
|
||||
<div class="rh-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-f">정</span><span class="avatar av-a">김</span><span class="avatar av-b">박</span><span class="avatar av-c">이</span><span class="avatar av-d">최</span></span>
|
||||
<span class="mi">멤버 5명</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<nav class="tabs">
|
||||
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
|
||||
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
|
||||
<a href="저장소 활동.html"><span class="tb-label">활동</span></a>
|
||||
<a href="#" class="active"><span class="tb-label">설정</span></a>
|
||||
</nav>
|
||||
|
||||
<div class="settings">
|
||||
<div class="admin-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
저장소 설정은 <b style="color:var(--text-2);font-weight:600;white-space:nowrap">관리자</b>만 변경할 수 있습니다.
|
||||
</div>
|
||||
|
||||
<!-- ===== 일반 ===== -->
|
||||
<h2 class="sec-title">일반</h2>
|
||||
<div class="card">
|
||||
<div class="fblock">
|
||||
<div class="flabel">표시 제목</div>
|
||||
<div class="fhint">Relay 목록과 화면에 표시되는 이름입니다. (한글 가능)</div>
|
||||
<input class="tinput" type="text" value="3분기 신제품 런칭 캠페인" maxlength="40">
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span></div>
|
||||
<div class="fhint">Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.</div>
|
||||
<div class="readonly">
|
||||
<span class="owner-avatar">M</span> marketing-team <span style="color:var(--text-3)">/</span> <span class="mono">q3-launch-campaign</span>
|
||||
<span class="lock"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">프로젝트 설명</div>
|
||||
<textarea class="tinput tarea">3분기 신제품(라인 클렌저) 런칭 관련 캠페인 소재 제작 및 검수 업무를 관리하는 저장소입니다.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">공개 범위</div>
|
||||
<div class="fhint">저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.</div>
|
||||
<div class="radio-group">
|
||||
<label class="radio-card sel">
|
||||
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<span class="rc-body"><span class="rc-title">비공개</span><span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span></span>
|
||||
<span class="rc-radio"></span>
|
||||
</label>
|
||||
<label class="radio-card">
|
||||
<span class="rc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg></span>
|
||||
<span class="rc-body"><span class="rc-title">공개</span><span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span></span>
|
||||
<span class="rc-radio"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">메인 브랜치</div>
|
||||
<div class="fhint">저장소의 기본 브랜치 이름입니다.</div>
|
||||
<div class="input-ico">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
|
||||
<input type="text" value="main" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<button class="btn primary" type="button">변경 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 위험 구역 ===== -->
|
||||
<h2 class="sec-title danger">위험 구역</h2>
|
||||
<div class="danger-card">
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">Gitea 저장소 이름 변경</div>
|
||||
<div class="dr-desc">영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 수 있습니다.</div>
|
||||
</div>
|
||||
<button class="btn-danger" type="button">이름 변경</button>
|
||||
</div>
|
||||
<div class="danger-row">
|
||||
<div class="dr-main">
|
||||
<div class="dr-title">저장소 삭제</div>
|
||||
<div class="dr-desc">저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 수 없습니다.</div>
|
||||
</div>
|
||||
<button class="btn-danger solid" type="button">저장소 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,286 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>q3-launch-campaign · 활동 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --green-weak: #e8f6ee; --blue: #1d4ed8; --blue-weak: #e8efff; --blue-border: #c9dbff;
|
||||
--red: #d6455d; --amber: #b7791f; --amber-weak: #fdf4e3;
|
||||
--radius: 6px; --radius-sm: 4px; --shadow-sm: 0 1px 2px rgba(20,24,33,.05);
|
||||
}
|
||||
html, body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
body { min-height: 100vh; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { height: 52px; background: var(--panel); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 28px; padding: 0 20px; position: sticky; top: 0; z-index: 50; }
|
||||
.brand { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 15px; letter-spacing: -.2px; }
|
||||
.brand .logo { width: 26px; height: 26px; border-radius: 7px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; box-shadow: 0 2px 6px rgba(79,70,229,.35); }
|
||||
.topnav { display: flex; align-items: center; gap: 2px; }
|
||||
.topnav a { color: var(--text-2); text-decoration: none; font-size: 13.5px; font-weight: 500; padding: 7px 12px; border-radius: var(--radius-sm); line-height: 1; white-space: nowrap; }
|
||||
.topnav a:hover { background: #f1f2f4; color: var(--text); }
|
||||
.topnav a.active { color: var(--accent); background: var(--accent-weak); font-weight: 600; }
|
||||
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.icon-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-2); display: grid; place-items: center; cursor: pointer; }
|
||||
.icon-btn:hover { background: #f1f2f4; color: var(--text); }
|
||||
.me { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 11.5px; font-weight: 700; color: #fff; background: #0ea5a3; margin-left: 4px; }
|
||||
|
||||
/* page */
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 0 24px 70px; }
|
||||
.crumb { display: flex; align-items: center; gap: 7px; color: var(--text-3); font-size: 12.5px; padding: 18px 0 12px; white-space: nowrap; }
|
||||
.crumb .lnk { color: var(--text-2); font-weight: 600; text-decoration: none; }
|
||||
.crumb .lnk:hover { color: var(--accent); }
|
||||
.crumb .sep { opacity: .5; }
|
||||
.crumb b { color: var(--text-2); font-weight: 600; }
|
||||
|
||||
.btn { height: 36px; padding: 0 15px; border-radius: var(--radius); font-size: 13.5px; font-weight: 600; border: 1px solid var(--border-strong); background: var(--panel); color: var(--text-2); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; line-height: 1; text-decoration: none; white-space: nowrap; }
|
||||
.btn:hover { background: #f7f8fa; color: var(--text); }
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79,70,229,.4); }
|
||||
.btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
|
||||
/* repo header */
|
||||
.repo-head { display: flex; align-items: flex-start; gap: 16px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; box-shadow: var(--shadow-sm); }
|
||||
.repo-ico { width: 46px; height: 46px; border-radius: 10px; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.repo-ico svg { width: 24px; height: 24px; }
|
||||
.rh-main { flex: 1; min-width: 0; }
|
||||
.rh-title { display: flex; align-items: center; gap: 10px; }
|
||||
.rh-name { font-size: 18px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; }
|
||||
.rh-slug { font-size: 12.5px; color: var(--text-3); margin-top: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.vis { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; line-height: 1.5; }
|
||||
.vis svg { width: 11px; height: 11px; }
|
||||
.vis.private { color: var(--text-2); background: #f1f2f4; border: 1px solid var(--border); }
|
||||
.rh-desc { font-size: 13.5px; color: var(--text-2); margin-top: 6px; }
|
||||
.rh-meta { display: flex; align-items: center; gap: 15px; margin-top: 12px; font-size: 12px; color: var(--text-3); }
|
||||
.rh-meta .mi { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.rh-meta .mi svg { width: 13px; height: 13px; }
|
||||
.avatar { width: 21px; height: 21px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.avstack { display: flex; align-items: center; }
|
||||
.avstack .avatar { margin-left: -7px; box-shadow: 0 0 0 2px #fff; }
|
||||
.avstack .avatar:first-child { margin-left: 0; }
|
||||
.av-a { background: #e0518d; } .av-b { background: #3d8bf2; } .av-c { background: #16a37b; }
|
||||
.av-d { background: #b3631f; } .av-e { background: #7c5cf0; } .av-f { background: #d0982a; }
|
||||
.av-more { background: #eceef1; color: var(--text-2); box-shadow: 0 0 0 2px #fff; }
|
||||
.rh-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 20px 0 18px; }
|
||||
.tabs a { padding: 10px 14px; font-size: 13.5px; font-weight: 600; color: var(--text-2); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tabs a .tb-label { line-height: 1; }
|
||||
.tabs a .tb-count { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f2f4; border-radius: 20px; padding: 0 7px; height: 17px; min-width: 17px; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
|
||||
.tabs a.active .tb-count { background: var(--accent-weak); color: var(--accent); }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.segmented { display: flex; background: #eceef1; border-radius: var(--radius); padding: 3px; gap: 2px; }
|
||||
.segmented button { border: none; background: transparent; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); padding: 5px 13px; border-radius: 4px; cursor: pointer; white-space: nowrap; }
|
||||
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.mfilter { margin-left: auto; display: flex; align-items: center; gap: 7px; height: 36px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 11px; background: #fff; font-size: 13px; font-weight: 500; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.mfilter svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
|
||||
/* activity feed */
|
||||
.feed { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm); padding: 6px 20px 18px; }
|
||||
.day { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: .2px; padding: 18px 0 6px; }
|
||||
.act { display: flex; align-items: center; gap: 13px; padding: 9px 0; position: relative; }
|
||||
.act-ico { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; flex-shrink: 0; z-index: 1; }
|
||||
.act-ico svg { width: 16px; height: 16px; }
|
||||
.act-ico.task { background: var(--blue-weak); color: var(--blue); }
|
||||
.act-ico.member { background: var(--green-weak); color: var(--green); }
|
||||
.act-ico.setting { background: var(--amber-weak); color: var(--amber); }
|
||||
.act-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
|
||||
.act-av { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; flex-shrink: 0; }
|
||||
.act-text { font-size: 13.5px; color: var(--text-2); line-height: 1.5; }
|
||||
.act-text b { color: var(--text); font-weight: 600; }
|
||||
.act-text .tgt { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.act-text .tgt:hover { text-decoration: underline; }
|
||||
.act-text .st { font-weight: 600; }
|
||||
.act-text .st.prog { color: var(--blue); } .act-text .st.done { color: var(--green); }
|
||||
.act-time { font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; margin-left: 12px; }
|
||||
|
||||
/* vertical connector line */
|
||||
.feed-inner { position: relative; }
|
||||
.act::before { content: ""; position: absolute; left: 15.5px; top: 50%; bottom: -9px; width: 1.5px; background: var(--border); z-index: 0; }
|
||||
.act.last-in-group::before { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="logo">R</div>Relay</div>
|
||||
<nav class="topnav">
|
||||
<a href="내 업무.html">내 업무</a>
|
||||
<a href="저장소 목록.html" class="active">저장소</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" title="검색"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg></button>
|
||||
<button class="icon-btn" title="알림"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg></button>
|
||||
<div class="me">정</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page" data-screen-label="저장소 활동">
|
||||
<div class="crumb">
|
||||
<a href="저장소 목록.html" class="lnk">저장소</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="저장소 상세.html" class="lnk">3분기 신제품 런칭 캠페인</a>
|
||||
<span class="sep">/</span>
|
||||
<b>활동</b>
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더 -->
|
||||
<div class="repo-head">
|
||||
<div class="repo-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div>
|
||||
<div class="rh-main">
|
||||
<div class="rh-title">
|
||||
<span class="rh-name">3분기 신제품 런칭 캠페인</span>
|
||||
<span class="vis private"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>비공개</span>
|
||||
</div>
|
||||
<div class="rh-slug">marketing-team / q3-launch-campaign</div>
|
||||
<div class="rh-desc">3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수</div>
|
||||
<div class="rh-meta">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>main</span>
|
||||
<span class="mi avstack"><span class="avatar av-f">정</span><span class="avatar av-a">김</span><span class="avatar av-b">박</span><span class="avatar av-c">이</span><span class="avatar av-d">최</span></span>
|
||||
<span class="mi">2시간 전 업데이트</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<a class="btn primary" href="업무 작성.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></svg>새 업무</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<nav class="tabs">
|
||||
<a href="저장소 상세.html"><span class="tb-label">업무</span> <span class="tb-count">12</span></a>
|
||||
<a href="저장소 멤버.html"><span class="tb-label">멤버</span> <span class="tb-count">5</span></a>
|
||||
<a href="#" class="active"><span class="tb-label">활동</span></a>
|
||||
<a href="저장소 설정.html"><span class="tb-label">설정</span></a>
|
||||
</nav>
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button class="active">전체</button>
|
||||
<button>업무</button>
|
||||
<button>멤버</button>
|
||||
<button>설정</button>
|
||||
</div>
|
||||
<div class="mfilter">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
모든 멤버
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<div class="feed">
|
||||
<div class="feed-inner">
|
||||
|
||||
<div class="day">오늘</div>
|
||||
<div class="act">
|
||||
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-c">이</span>
|
||||
<span class="act-text"><b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">2시간 전</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-b">박</span>
|
||||
<span class="act-text"><b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">3시간 전</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="M19 8v6M22 11h-6"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-f">정</span>
|
||||
<span class="act-text"><b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">5시간 전</span>
|
||||
</div>
|
||||
<div class="act last-in-group">
|
||||
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="m17 11 2 2 4-4"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-d">한</span>
|
||||
<span class="act-text"><b>한지민</b>님이 멤버로 합류했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">5시간 전</span>
|
||||
</div>
|
||||
|
||||
<div class="day">어제</div>
|
||||
<div class="act">
|
||||
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-d">최</span>
|
||||
<span class="act-text"><b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">어제 18:40</span>
|
||||
</div>
|
||||
<div class="act last-in-group">
|
||||
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-f">정</span>
|
||||
<span class="act-text"><b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span></span>
|
||||
</div>
|
||||
<span class="act-time">어제 14:10</span>
|
||||
</div>
|
||||
|
||||
<div class="day">이번 주</div>
|
||||
<div class="act">
|
||||
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-f">정</span>
|
||||
<span class="act-text"><b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">6월 11일</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<span class="act-ico member"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 11h-6"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-b">박</span>
|
||||
<span class="act-text"><b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다</span>
|
||||
</div>
|
||||
<span class="act-time">6월 11일</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-f">정</span>
|
||||
<span class="act-text"><b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">6월 10일</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<span class="act-ico task"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-a">김</span>
|
||||
<span class="act-text"><b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">6월 9일</span>
|
||||
</div>
|
||||
<div class="act last-in-group">
|
||||
<span class="act-ico setting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av av-f">정</span>
|
||||
<span class="act-text"><b>정태경</b>님이 저장소를 생성했습니다</span>
|
||||
</div>
|
||||
<span class="act-time">6월 2일</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,222 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>회원가입 — Relay</title>
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f4f5f7; --panel: #ffffff; --border: #e6e8ec; --border-strong: #d7dae0;
|
||||
--text: #16181d; --text-2: #5b626e; --text-3: #9298a3;
|
||||
--accent: #4f46e5; --accent-hover: #4338ca; --accent-weak: #eef0fe; --accent-border: #c7ccfb;
|
||||
--green: #1f9d57; --kakao: #FEE500; --kakao-text: #191600;
|
||||
--radius: 8px; --radius-sm: 5px;
|
||||
}
|
||||
html, body { height: 100%; }
|
||||
body { background: var(--bg); color: var(--text); font-family: "Pretendard", -apple-system, system-ui, sans-serif; font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
.auth { display: flex; min-height: 100vh; }
|
||||
|
||||
/* brand panel */
|
||||
.brand-panel { flex: 0 0 46%; max-width: 620px; position: relative; overflow: hidden; background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%); color: #fff; padding: 44px 48px; display: flex; flex-direction: column; }
|
||||
.brand-panel .blob { position: absolute; border-radius: 50%; pointer-events: none; }
|
||||
.brand-panel .b1 { width: 360px; height: 360px; right: -120px; top: -90px; background: rgba(255,255,255,.10); }
|
||||
.brand-panel .b2 { width: 260px; height: 260px; left: -90px; bottom: -70px; background: rgba(255,255,255,.07); }
|
||||
.brand-panel .ring { position: absolute; right: 60px; bottom: 80px; width: 180px; height: 180px; border: 1.5px solid rgba(255,255,255,.14); border-radius: 50%; }
|
||||
.brand-panel .ring::after { content: ""; position: absolute; inset: 34px; border: 1.5px solid rgba(255,255,255,.10); border-radius: 50%; }
|
||||
.bp-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; letter-spacing: -.2px; position: relative; z-index: 1; }
|
||||
.bp-logo .logo { width: 32px; height: 32px; border-radius: 9px; background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.22); display: grid; place-items: center; font-size: 17px; font-weight: 800; }
|
||||
.bp-body { margin-top: auto; position: relative; z-index: 1; }
|
||||
.bp-head { font-size: 34px; font-weight: 700; line-height: 1.32; letter-spacing: -.8px; text-wrap: balance; }
|
||||
.bp-sub { margin-top: 18px; font-size: 15px; line-height: 1.7; color: rgba(255,255,255,.82); max-width: 400px; }
|
||||
.bp-points { margin-top: 30px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.bp-point { display: flex; align-items: center; gap: 11px; font-size: 14px; color: rgba(255,255,255,.92); }
|
||||
.bp-point .ic { width: 22px; height: 22px; border-radius: 50%; background: rgba(255,255,255,.16); display: grid; place-items: center; flex-shrink: 0; }
|
||||
.bp-point .ic svg { width: 13px; height: 13px; }
|
||||
.bp-foot { margin-top: 40px; font-size: 12.5px; color: rgba(255,255,255,.6); position: relative; z-index: 1; }
|
||||
|
||||
/* form panel */
|
||||
.form-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
|
||||
.auth-card { width: 100%; max-width: 384px; }
|
||||
.ac-logo { display: none; }
|
||||
.ac-title { font-size: 24px; font-weight: 700; letter-spacing: -.5px; }
|
||||
.ac-sub { color: var(--text-2); font-size: 14px; margin-top: 7px; }
|
||||
|
||||
.socials { display: flex; flex-direction: column; gap: 10px; margin-top: 26px; }
|
||||
.sbtn { height: 46px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; display: flex; align-items: center; justify-content: center; gap: 10px; cursor: pointer; font-family: inherit; font-size: 14.5px; font-weight: 600; color: var(--text); text-decoration: none; position: relative; transition: background .12s; }
|
||||
.sbtn:hover { background: #f7f8fa; }
|
||||
.sbtn .sico { position: absolute; left: 16px; display: grid; place-items: center; }
|
||||
.sbtn.kakao { background: var(--kakao); border-color: var(--kakao); color: var(--kakao-text); }
|
||||
.sbtn.kakao:hover { background: #f2d900; }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 14px; margin: 20px 0; color: var(--text-3); font-size: 12.5px; }
|
||||
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
|
||||
|
||||
.field { margin-bottom: 13px; }
|
||||
.flabel { font-size: 13px; font-weight: 600; color: var(--text-2); margin-bottom: 7px; display: block; }
|
||||
.tinput { width: 100%; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 0 13px; font-family: inherit; font-size: 14px; color: var(--text); background: #fff; }
|
||||
.tinput:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
.tinput::placeholder { color: var(--text-3); }
|
||||
.pw-wrap { position: relative; }
|
||||
.pw-wrap .tinput { padding-right: 44px; }
|
||||
.pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 34px; height: 34px; border: none; background: transparent; color: var(--text-3); display: grid; place-items: center; cursor: pointer; border-radius: 6px; }
|
||||
.pw-toggle:hover { background: #f1f2f4; color: var(--text-2); }
|
||||
.pw-toggle svg { width: 18px; height: 18px; }
|
||||
.field-hint { font-size: 12px; color: var(--text-3); margin-top: 7px; display: flex; align-items: center; gap: 5px; }
|
||||
.field-hint svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
|
||||
.terms { display: flex; align-items: flex-start; gap: 9px; margin: 6px 0 18px; font-size: 13px; color: var(--text-2); cursor: pointer; user-select: none; line-height: 1.5; }
|
||||
.terms .box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid var(--border-strong); display: grid; place-items: center; background: #fff; flex-shrink: 0; margin-top: 1px; }
|
||||
.terms .box.on { background: var(--accent); border-color: var(--accent); }
|
||||
.terms .box svg { width: 12px; height: 12px; color: #fff; opacity: 0; }
|
||||
.terms .box.on svg { opacity: 1; }
|
||||
.terms a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.terms a:hover { text-decoration: underline; }
|
||||
|
||||
.submit { width: 100%; height: 46px; border-radius: var(--radius); border: none; background: var(--accent); color: #fff; font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(79,70,229,.4); display: flex; align-items: center; justify-content: center; gap: 7px; }
|
||||
.submit:hover { background: var(--accent-hover); }
|
||||
.submit svg { width: 17px; height: 17px; }
|
||||
|
||||
.alt { margin-top: 22px; text-align: center; font-size: 13.5px; color: var(--text-2); }
|
||||
.alt a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.alt a:hover { text-decoration: underline; }
|
||||
|
||||
/* verification sent state */
|
||||
.verify { text-align: center; display: none; }
|
||||
.verify .vicon { width: 66px; height: 66px; border-radius: 50%; background: var(--accent-weak); color: var(--accent); display: grid; place-items: center; margin: 0 auto 22px; }
|
||||
.verify .vicon svg { width: 30px; height: 30px; }
|
||||
.verify h1 { font-size: 23px; font-weight: 700; letter-spacing: -.4px; }
|
||||
.verify .vtext { color: var(--text-2); font-size: 14px; line-height: 1.7; margin-top: 12px; }
|
||||
.verify .vmail { display: inline-flex; align-items: center; gap: 6px; margin-top: 16px; padding: 9px 14px; background: #f7f8fa; border: 1px solid var(--border); border-radius: var(--radius); font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.verify .vmail svg { width: 15px; height: 15px; color: var(--text-3); }
|
||||
.verify .vspam { font-size: 12.5px; color: var(--text-3); margin-top: 18px; line-height: 1.6; }
|
||||
.verify .vactions { margin-top: 26px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.btn-ghost { height: 44px; border-radius: var(--radius); border: 1px solid var(--border-strong); background: #fff; color: var(--text); font-family: inherit; font-size: 14px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; text-decoration: none; }
|
||||
.btn-ghost:hover { background: #f7f8fa; }
|
||||
.btn-ghost svg { width: 16px; height: 16px; }
|
||||
.vchange { background: none; border: none; font-family: inherit; font-size: 13px; color: var(--text-2); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.vchange:hover { color: var(--text); }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.brand-panel { display: none; }
|
||||
.ac-logo { display: flex; align-items: center; gap: 9px; font-weight: 700; font-size: 17px; margin-bottom: 24px; }
|
||||
.ac-logo .logo { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg, #5b54f0, #4338ca); display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth">
|
||||
|
||||
<aside class="brand-panel">
|
||||
<div class="blob b1"></div>
|
||||
<div class="blob b2"></div>
|
||||
<div class="ring"></div>
|
||||
<div class="bp-logo"><span class="logo">R</span> Relay</div>
|
||||
<div class="bp-body">
|
||||
<div class="bp-head">팀에 합류하고<br>업무 지시를 시작하세요</div>
|
||||
<div class="bp-sub">계정을 만들면 저장소에 초대되어, 받은 업무를 확인하고 직접 업무를 지시할 수 있습니다.</div>
|
||||
<div class="bp-points">
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일로 1분이면 가입 완료</div>
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 이메일 인증으로 안전한 가입</div>
|
||||
<div class="bp-point"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span> 가입 후 바로 업무 흐름에 참여</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bp-foot">© 2026 Relay · 사내 업무 지시 플랫폼</div>
|
||||
</aside>
|
||||
|
||||
<main class="form-panel">
|
||||
<div class="auth-card">
|
||||
<div class="ac-logo"><span class="logo">R</span> Relay</div>
|
||||
|
||||
<!-- ===== 상태 1: 가입 폼 ===== -->
|
||||
<div class="signup-form">
|
||||
<h1 class="ac-title">이메일로 회원가입</h1>
|
||||
<p class="ac-sub">구글·카카오 계정은 <a href="로그인.html" style="color:var(--accent);font-weight:600;text-decoration:none">로그인 화면</a>에서 바로 시작할 수 있어요.</p>
|
||||
|
||||
<div class="field" style="margin-top:26px">
|
||||
<label class="flabel">이름</label>
|
||||
<input class="tinput" type="text" placeholder="홍길동" value="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="flabel">이메일</label>
|
||||
<input class="tinput" id="email" type="email" placeholder="name@acme.co" value="">
|
||||
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg> 가입 후 이 주소로 인증 메일이 발송됩니다.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="flabel">비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
<input class="tinput" type="password" placeholder="비밀번호 입력" value="">
|
||||
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
<div class="field-hint"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> 8자 이상, 영문과 숫자를 포함해주세요.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="flabel">비밀번호 확인</label>
|
||||
<div class="pw-wrap">
|
||||
<input class="tinput" type="password" placeholder="비밀번호 다시 입력" value="">
|
||||
<button class="pw-toggle" type="button" title="비밀번호 표시"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="terms" id="terms">
|
||||
<span class="box on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span>
|
||||
<span><a href="#">이용약관</a> 및 <a href="#">개인정보 처리방침</a>에 동의합니다. (필수)</span>
|
||||
</label>
|
||||
|
||||
<button class="submit" type="button" id="submitBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg>
|
||||
인증 메일 받고 가입하기
|
||||
</button>
|
||||
|
||||
<div class="alt">이미 계정이 있으신가요? <a href="로그인.html">로그인</a></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 상태 2: 인증 메일 전송됨 ===== -->
|
||||
<div class="verify" id="verifyView">
|
||||
<div class="vicon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg></div>
|
||||
<h1>이메일을 확인해주세요</h1>
|
||||
<div class="vtext">아래 주소로 인증 메일을 보냈습니다.<br>메일 속 링크를 클릭하면 가입이 완료됩니다.</div>
|
||||
<div class="vmail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 6-10 7L2 6"/></svg><span id="verifyEmail">name@acme.co</span></div>
|
||||
<div class="vspam">메일이 보이지 않나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.</div>
|
||||
<div class="vactions">
|
||||
<button class="btn-ghost" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> 인증 메일 다시 보내기</button>
|
||||
<button class="vchange" type="button" id="backBtn">다른 이메일로 가입하기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 비밀번호 표시 토글
|
||||
document.querySelectorAll('.pw-toggle').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var input = this.parentElement.querySelector('input');
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
});
|
||||
// 약관 동의 토글
|
||||
document.getElementById('terms').addEventListener('click', function (e) {
|
||||
if (e.target.closest('a')) return;
|
||||
e.preventDefault();
|
||||
this.querySelector('.box').classList.toggle('on');
|
||||
});
|
||||
// 가입하기 → 인증 메일 전송 상태로 전환
|
||||
document.getElementById('submitBtn').addEventListener('click', function () {
|
||||
var email = document.getElementById('email').value.trim() || 'name@acme.co';
|
||||
document.getElementById('verifyEmail').textContent = email;
|
||||
document.querySelector('.signup-form').style.display = 'none';
|
||||
document.getElementById('verifyView').style.display = 'block';
|
||||
});
|
||||
// 다른 이메일로 가입하기 → 폼으로 복귀
|
||||
document.getElementById('backBtn').addEventListener('click', function () {
|
||||
document.getElementById('verifyView').style.display = 'none';
|
||||
document.querySelector('.signup-form').style.display = 'block';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,31 +1,59 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
|
||||
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.development 에서 주입한다.
|
||||
# .example 을 복사해 .env.development 를 만들고 실제 값으로 채운다.
|
||||
# 백엔드 고유 설정 (development)
|
||||
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
|
||||
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.development 에서 주입.
|
||||
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
|
||||
# ==========================================
|
||||
|
||||
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
|
||||
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
# ==========================================
|
||||
# JWT / 인증 토큰
|
||||
# ==========================================
|
||||
# 시크릿은 강력한 랜덤 문자열로 교체:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
JWT_ACCESS_SECRET=change-me-access-secret
|
||||
JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
|
||||
# 토큰 만료 시간
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=14d
|
||||
|
||||
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
|
||||
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
|
||||
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
|
||||
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
|
||||
GITEA_BASE_URL=https://gitea.example.com
|
||||
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
|
||||
GITEA_TOKEN=change-me-gitea-token
|
||||
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
|
||||
GITEA_ORG=marketing-team
|
||||
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
|
||||
GITEA_TEMPLATE_OWNER=ttipo
|
||||
GITEA_TEMPLATE_REPO=project-base
|
||||
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
|
||||
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
|
||||
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
|
||||
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
|
||||
# ==========================================
|
||||
# 앱 URL (리다이렉트)
|
||||
# ==========================================
|
||||
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용. 미설정 시 localhost:3100/api 폴백.
|
||||
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트. 미설정 시 CORS_ORIGIN→localhost:5273 폴백.
|
||||
APP_API_URL=http://localhost:3100/api
|
||||
FRONTEND_URL=http://localhost:5273
|
||||
|
||||
# ==========================================
|
||||
# 소셜 로그인 (OAuth)
|
||||
# ==========================================
|
||||
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
|
||||
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
|
||||
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
|
||||
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
|
||||
KAKAO_CALLBACK_URL=http://localhost:3100/api/auth/kakao/callback
|
||||
GOOGLE_CLIENT_ID=change-me-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
|
||||
|
||||
# ==========================================
|
||||
# AI 에이전트 (Claude)
|
||||
# ==========================================
|
||||
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔 → 마이페이지 → 인증키 관리(또는 서브계정)에서 발급한 키.
|
||||
# endpoint/region/bucket 은 루트 .env 에서 docker-compose 가 주입한다.
|
||||
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
|
||||
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.production 에서 주입한다.
|
||||
# .example 을 복사해 .env.production 를 만들고 실제 값으로 채운다.
|
||||
# 백엔드 고유 설정 (production)
|
||||
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
|
||||
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.production 에서 주입.
|
||||
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
|
||||
# ==========================================
|
||||
|
||||
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
|
||||
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
# ==========================================
|
||||
# JWT / 인증 토큰
|
||||
# ==========================================
|
||||
# 시크릿은 강력한 랜덤 문자열로 교체:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
JWT_ACCESS_SECRET=change-me-access-secret
|
||||
JWT_REFRESH_SECRET=change-me-refresh-secret
|
||||
|
||||
# 토큰 만료 시간
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=14d
|
||||
|
||||
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
|
||||
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
|
||||
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
|
||||
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
|
||||
GITEA_BASE_URL=https://gitea.example.com
|
||||
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
|
||||
GITEA_TOKEN=change-me-gitea-token
|
||||
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
|
||||
GITEA_ORG=marketing-team
|
||||
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
|
||||
GITEA_TEMPLATE_OWNER=ttipo
|
||||
GITEA_TEMPLATE_REPO=project-base
|
||||
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
|
||||
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
|
||||
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
|
||||
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
|
||||
# ==========================================
|
||||
# 앱 URL (리다이렉트) — 운영 도메인으로 교체
|
||||
# ==========================================
|
||||
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용.
|
||||
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트.
|
||||
APP_API_URL=https://your-domain.com/api
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# ==========================================
|
||||
# 소셜 로그인 (OAuth) — 운영 키/도메인으로 교체
|
||||
# ==========================================
|
||||
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
|
||||
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
|
||||
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
|
||||
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
|
||||
KAKAO_CALLBACK_URL=https://your-domain.com/api/auth/kakao/callback
|
||||
GOOGLE_CLIENT_ID=change-me-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
|
||||
GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
|
||||
|
||||
# ==========================================
|
||||
# AI 에이전트 (Claude)
|
||||
# ==========================================
|
||||
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔에서 발급한 운영용 키. endpoint/region/bucket 은 루트 .env 가 주입.
|
||||
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
Generated
+2009
-122
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -17,9 +17,16 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d ./src/data-source.ts",
|
||||
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d ./src/data-source.ts",
|
||||
"migration:create": "typeorm-ts-node-commonjs migration:create",
|
||||
"migration:run": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
|
||||
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -33,21 +40,30 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"livekit-server-sdk": "^2.15.5",
|
||||
"mammoth": "^1.12.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-kakao": "^1.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ScheduleModule as CronModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { createKeyv } from '@keyv/redis';
|
||||
@@ -10,17 +10,22 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { RepoModule } from './modules/repo/repo.module';
|
||||
import { TaskModule } from './modules/task/task.module';
|
||||
import { ActivityModule } from './modules/activity/activity.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
import { DriveModule } from './modules/drive/drive.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
CronModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
|
||||
@@ -55,10 +60,11 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
},
|
||||
}),
|
||||
// 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능
|
||||
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌릴 것.
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60_000,
|
||||
limit: 100,
|
||||
limit: 1000,
|
||||
},
|
||||
]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
@@ -88,13 +94,18 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
};
|
||||
},
|
||||
}),
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
RepoModule,
|
||||
TaskModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
AiModule,
|
||||
ProjectModule,
|
||||
ScheduleModule,
|
||||
MeetingModule,
|
||||
DriveModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ValueTransformer } from 'typeorm';
|
||||
|
||||
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
|
||||
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
|
||||
export const numericColumnTransformer: ValueTransformer = {
|
||||
// 저장: number → DB(bigint). null/undefined 는 그대로.
|
||||
to(value: number | null | undefined): number | null | undefined {
|
||||
return value;
|
||||
},
|
||||
// 조회: DB 문자열 → number. null 은 그대로.
|
||||
from(value: string | null): number | null {
|
||||
return value === null ? null : Number(value);
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
// multer 업로드 오류(크기 초과 등)는 HttpException 이 아니므로 별도 400(VAL_001) 매핑
|
||||
if (this.isMulterError(exception)) {
|
||||
const muMessage =
|
||||
exception.code === 'LIMIT_FILE_SIZE'
|
||||
? '파일 크기가 허용 한도(25MB)를 초과했습니다.'
|
||||
: '파일 업로드에 실패했습니다. 파일을 확인해 주세요.';
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
success: false,
|
||||
error: { code: 'VAL_001', message: muMessage },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
@@ -83,6 +96,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
});
|
||||
}
|
||||
|
||||
// multer 업로드 오류 판별 (name === 'MulterError', code 보유)
|
||||
private isMulterError(e: unknown): e is { name: string; code?: string } {
|
||||
return (
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
(e as { name?: unknown }).name === 'MulterError'
|
||||
);
|
||||
}
|
||||
|
||||
// 예외 응답에서 명시적 { code, message } 추출 (둘 다 문자열일 때만)
|
||||
private extractExplicitError(
|
||||
res: string | object,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { StorageService } from './storage.service';
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// presigned URL 기본 유효 시간(초)
|
||||
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
|
||||
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
|
||||
|
||||
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
|
||||
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
|
||||
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
|
||||
// OBJECT_STORAGE_REGION 예) kr-standard
|
||||
// OBJECT_STORAGE_BUCKET 버킷명
|
||||
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
|
||||
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
|
||||
@Injectable()
|
||||
export class NcpObjectStorageService extends StorageService {
|
||||
private readonly logger = new Logger(NcpObjectStorageService.name);
|
||||
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
|
||||
private readonly client: S3Client | null;
|
||||
private readonly bucketName: string;
|
||||
private readonly missing: string[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
|
||||
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
|
||||
const bucket = process.env.OBJECT_STORAGE_BUCKET;
|
||||
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
|
||||
|
||||
this.bucketName = bucket || '';
|
||||
this.missing = [
|
||||
['OBJECT_STORAGE_ENDPOINT', endpoint],
|
||||
['OBJECT_STORAGE_BUCKET', bucket],
|
||||
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
|
||||
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
|
||||
]
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k as string);
|
||||
|
||||
if (this.missing.length > 0) {
|
||||
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
|
||||
this.client = null;
|
||||
this.logger.warn(
|
||||
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: accessKeyId as string,
|
||||
secretAccessKey: secretAccessKey as string,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
|
||||
);
|
||||
}
|
||||
|
||||
get bucket(): string {
|
||||
return this.bucketName;
|
||||
}
|
||||
|
||||
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
|
||||
private ensureConfigured(): S3Client {
|
||||
if (!this.client) {
|
||||
throw new Error(
|
||||
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
|
||||
);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, { expiresIn });
|
||||
return {
|
||||
url,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': input.contentType },
|
||||
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async presignDownload(input: PresignDownloadInput): Promise<string> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
|
||||
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
|
||||
input.filename,
|
||||
)}`,
|
||||
ResponseContentType: input.contentType,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
const body = res.Body as
|
||||
| { transformToByteArray?: () => Promise<Uint8Array> }
|
||||
| undefined;
|
||||
// AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집
|
||||
if (!body?.transformToByteArray) return null;
|
||||
const bytes = await body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch (err: unknown) {
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<ObjectHead | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
return {
|
||||
size: res.ContentLength ?? 0,
|
||||
contentType: res.ContentType ?? null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
|
||||
if (this.isNotFound(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
|
||||
private isNotFound(err: unknown): boolean {
|
||||
const e = err as {
|
||||
name?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
return (
|
||||
e?.$metadata?.httpStatusCode === 404 ||
|
||||
e?.name === 'NotFound' ||
|
||||
e?.name === 'NoSuchKey'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { NcpObjectStorageService } from './ncp-object-storage.service';
|
||||
|
||||
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
|
||||
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
|
||||
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: StorageService,
|
||||
useClass: NcpObjectStorageService,
|
||||
},
|
||||
],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// 오브젝트 스토리지 추상 인터페이스.
|
||||
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
|
||||
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
|
||||
export abstract class StorageService {
|
||||
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
|
||||
abstract get bucket(): string;
|
||||
|
||||
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
|
||||
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
|
||||
|
||||
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
|
||||
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
|
||||
|
||||
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
|
||||
abstract headObject(key: string): Promise<ObjectHead | null>;
|
||||
|
||||
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
|
||||
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
|
||||
|
||||
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
|
||||
abstract deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
|
||||
|
||||
// Presigned 업로드 발급 입력
|
||||
export interface PresignUploadInput {
|
||||
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
|
||||
key: string;
|
||||
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// Presigned 업로드 발급 결과
|
||||
export interface PresignedUpload {
|
||||
// 클라이언트가 직접 PUT 할 URL
|
||||
url: string;
|
||||
// HTTP 메서드 (PUT 고정)
|
||||
method: 'PUT';
|
||||
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
|
||||
headers: Record<string, string>;
|
||||
// 만료 시각(클라이언트 표시/검증용)
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Presigned 다운로드 발급 입력
|
||||
export interface PresignDownloadInput {
|
||||
key: string;
|
||||
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
|
||||
filename: string;
|
||||
// 응답 Content-Type
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
|
||||
export interface ObjectHead {
|
||||
// 실제 저장된 바이트 크기
|
||||
size: number;
|
||||
// 실제 저장된 MIME 타입(없을 수 있음)
|
||||
contentType: string | null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
// TypeORM CLI 전용 DataSource — 마이그레이션 생성/실행/되돌리기(generate/run/revert)에 사용한다.
|
||||
// 런타임(앱)은 app.module 의 TypeOrmModule 설정을 사용하며, 운영에서는 migrationsRun=true 로
|
||||
// 부팅 시 이 폴더(dist/migrations)의 마이그레이션을 자동 실행한다.
|
||||
// DB 접속값은 환경변수에서 읽는다(dev/CI/운영에서 주입). app.module 과 동일한 키를 사용.
|
||||
export default new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
username: process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'password',
|
||||
database: process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
|
||||
// CLI 는 절대 스키마 자동 동기화를 하지 않는다(마이그레이션만 사용)
|
||||
synchronize: false,
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
});
|
||||
+24
-15
@@ -43,8 +43,15 @@ async function bootstrap() {
|
||||
app.use(helmet());
|
||||
|
||||
// 2. 화이트리스트 기반 CORS 정책
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const corsOriginEnv = process.env.CORS_ORIGIN;
|
||||
// 운영에서는 로컬 개발 오리진(localhost:3000)을 허용 목록에서 제외
|
||||
const corsWhitelist = [
|
||||
frontendUrl,
|
||||
...(isProduction ? [] : ['http://localhost:3000']),
|
||||
...(corsOriginEnv ? [corsOriginEnv] : []),
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: (
|
||||
@@ -52,10 +59,7 @@ async function bootstrap() {
|
||||
callback: (err: Error | null, allow?: boolean) => void,
|
||||
) => {
|
||||
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
||||
const whitelist = [frontendUrl, 'http://localhost:3000'];
|
||||
if (corsOriginEnv) whitelist.push(corsOriginEnv);
|
||||
|
||||
if (!origin || whitelist.indexOf(origin) !== -1) {
|
||||
if (!origin || corsWhitelist.indexOf(origin) !== -1) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// 클라이언트에는 403 금지 에러로 리턴됨
|
||||
@@ -66,10 +70,11 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
|
||||
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌리거나 env 화 필요.
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15분
|
||||
max: 150, // 15분 IP당 최대 150개 요청
|
||||
windowMs: 60 * 1000, // 1분
|
||||
max: 1000, // 1분 IP당 최대 1000개 요청
|
||||
message: {
|
||||
success: false,
|
||||
error: {
|
||||
@@ -94,15 +99,19 @@ async function bootstrap() {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
|
||||
// 6. Swagger API 문서 설정
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
// 6. Swagger API 문서 설정 — 운영 환경에서는 노출하지 않는다(공격 표면 축소)
|
||||
if (!isProduction) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription(
|
||||
'The API documentation conforming to the global guidelines',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
}
|
||||
|
||||
// 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등
|
||||
// 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateInitialSchema1782060613058 implements MigrationInterface {
|
||||
name = 'CreateInitialSchema1782060613058';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 베이스라인 가드 — 이미 스키마가 존재하는 기존 DB(과거 synchronize 로 생성·데이터 보존)면
|
||||
// 초기 마이그레이션을 '베이스라인'으로 간주하고 DDL 을 건너뛴다. 마이그레이션은 '적용됨'으로
|
||||
// 기록되어 다음 변경 마이그레이션부터 정상 진행되며, 기존 데이터/스키마는 보존된다.
|
||||
// (빈 DB 면 이 가드를 통과해 아래 DDL 로 전체 스키마를 생성한다.)
|
||||
if (await queryRunner.hasTable('users')) {
|
||||
return;
|
||||
}
|
||||
// uuid_generate_v4() 기본값에 필요한 확장 — 신규(빈) 운영 DB 에서도 보장
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password_hash" character varying, "provider" character varying NOT NULL DEFAULT 'local', "email_verified" boolean NOT NULL DEFAULT false, "email_verify_token_hash" character varying, "email_verify_expires_at" TIMESTAMP WITH TIME ZONE, "password_reset_token_hash" character varying, "password_reset_expires_at" TIMESTAMP WITH TIME ZONE, "name" character varying NOT NULL, "role" character varying, "avatar_url" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_members" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role_type" character varying NOT NULL DEFAULT 'member', "is_owner" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "projectId" uuid, "userId" uuid, CONSTRAINT "UQ_326b2a901eb18ac24eabc9b0581" UNIQUE ("projectId", "userId"), CONSTRAINT "PK_0b2f46f804be4aea9234c78bcc9" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "checklist_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" character varying NOT NULL, "category" character varying NOT NULL DEFAULT '필수', "done" boolean NOT NULL DEFAULT false, "review_note" text, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "taskId" uuid, CONSTRAINT "PK_bae00945a1d4789bd648e583e29" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "seq" integer NOT NULL, "title" character varying NOT NULL, "content" jsonb NOT NULL DEFAULT '[]', "status" character varying NOT NULL DEFAULT 'todo', "due_date" TIMESTAMP WITH TIME ZONE, "approval_note" text, "approval_requested_at" TIMESTAMP WITH TIME ZONE, "changes_note" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "issuerId" uuid, "approval_requested_by" uuid, CONSTRAINT "UQ_011566614ffa01ec7281ee29754" UNIQUE ("project_id", "seq"), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_73f95adb41a77c1bfd7e19dca7" ON "tasks" ("seq") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_5e1f050bcff31e3084a1d662412" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "author_id" uuid, "parent_id" uuid, "attachment_id" uuid, CONSTRAINT "PK_8bf68bc960f2b69e818bdb90dcb" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "read" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" uuid, CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_92be92313f09987bc6ad70ab41" ON "notifications" ("recipient_id", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "refresh_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "current_jti" uuid NOT NULL, "prev_jti" uuid, "prev_at" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_9190032f6967b7971dca07d69f3" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "activities" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "task_seq" integer, "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "actor_id" uuid, CONSTRAINT "PK_7f4004429f731ffb9c88eb486a8" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7297db78eb572fa29db5aaf815" ON "activities" ("project_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_8291258c0c575bdab03443ef47" ON "activities" ("task_seq") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_assignees" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_7ae8012667c1cc4ca8266002afc" PRIMARY KEY ("task_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_0141288f2306f20da9a60ec8d6" ON "task_assignees" ("task_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_bb8051e376a2b083e074678cb6" ON "task_assignees" ("user_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_d19892d8f03928e5bfc7313780c" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checklist_items" ADD CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_950b234a262e85a895b9f9e634e" FOREIGN KEY ("issuerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd" FOREIGN KEY ("approval_requested_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_e62fd181b97caa6b150b09220b1" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" ADD CONSTRAINT "FK_18c2493067c11f44efb35ca0e03" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" ADD CONSTRAINT "FK_e6d38899c31997c45d128a8973b" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" ADD CONSTRAINT "FK_d6f93329801a93536da4241e386" FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" ADD CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351" FOREIGN KEY ("attachment_id") REFERENCES "attachments"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "notifications" ADD CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d" FOREIGN KEY ("recipient_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" ADD CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "activities" ADD CONSTRAINT "FK_7297db78eb572fa29db5aaf8158" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "activities" ADD CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_0141288f2306f20da9a60ec8d69" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_bb8051e376a2b083e074678cb60" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_bb8051e376a2b083e074678cb60"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_0141288f2306f20da9a60ec8d69"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "activities" DROP CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "activities" DROP CONSTRAINT "FK_7297db78eb572fa29db5aaf8158"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" DROP CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "notifications" DROP CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" DROP CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" DROP CONSTRAINT "FK_d6f93329801a93536da4241e386"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" DROP CONSTRAINT "FK_e6d38899c31997c45d128a8973b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "comments" DROP CONSTRAINT "FK_18c2493067c11f44efb35ca0e03"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_e62fd181b97caa6b150b09220b1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_950b234a262e85a895b9f9e634e"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checklist_items" DROP CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_d19892d8f03928e5bfc7313780c"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_bb8051e376a2b083e074678cb6"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_0141288f2306f20da9a60ec8d6"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "task_assignees"`);
|
||||
await queryRunner.query(`DROP TABLE "ai_messages"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_8291258c0c575bdab03443ef47"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7297db78eb572fa29db5aaf815"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "activities"`);
|
||||
await queryRunner.query(`DROP TABLE "ai_conversations"`);
|
||||
await queryRunner.query(`DROP TABLE "refresh_sessions"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_92be92313f09987bc6ad70ab41"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "notifications"`);
|
||||
await queryRunner.query(`DROP TABLE "comments"`);
|
||||
await queryRunner.query(`DROP TABLE "attachments"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_73f95adb41a77c1bfd7e19dca7"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "tasks"`);
|
||||
await queryRunner.query(`DROP TABLE "checklist_items"`);
|
||||
await queryRunner.query(`DROP TABLE "projects"`);
|
||||
await queryRunner.query(`DROP TABLE "project_members"`);
|
||||
await queryRunner.query(`DROP TABLE "users"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 중간 점검(체크포인트) 기능 — 체크리스트 작업자 진행상태 + 점검일/보고 테이블 추가.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskCheckpoints1782200000000 implements MigrationInterface {
|
||||
name = 'AddTaskCheckpoints1782200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 체크리스트 작업자 진행상태(자가보고) — 미착수/진행중/완료
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checklist_items" ADD "work_status" character varying NOT NULL DEFAULT 'todo'`,
|
||||
);
|
||||
|
||||
// 중간 점검일
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_checkpoints" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "check_at" TIMESTAMP WITH TIME ZONE NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, CONSTRAINT "PK_task_checkpoints" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
|
||||
// 중간 점검 보고(스냅샷)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "checkpoint_reports" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "note" text, "items_snapshot" jsonb NOT NULL DEFAULT '[]', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "checkpoint_id" uuid, "reporter_id" uuid, CONSTRAINT "PK_checkpoint_reports" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
|
||||
// FK
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_checkpoints" ADD CONSTRAINT "FK_task_checkpoints_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_checkpoint" FOREIGN KEY ("checkpoint_id") REFERENCES "task_checkpoints"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_reporter" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_reporter"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_checkpoint"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_task"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_checkpoints" DROP CONSTRAINT "FK_task_checkpoints_task"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "checkpoint_reports"`);
|
||||
await queryRunner.query(`DROP TABLE "task_checkpoints"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "checklist_items" DROP COLUMN "work_status"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 전사 공유 일정(캘린더) — 카테고리/일정/참석자 테이블 추가.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// (기본 카테고리 시드는 ScheduleService.onModuleInit 가 멱등 처리하므로 여기서 넣지 않는다.)
|
||||
export class AddSchedule1782300000000 implements MigrationInterface {
|
||||
name = 'AddSchedule1782300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 카테고리(레인)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "schedule_categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "label" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_schedule_categories" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
|
||||
// 일정(이벤트)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "time" character varying NOT NULL DEFAULT '', "place" character varying, "description" text, "all_hands" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category_id" uuid, "created_by" uuid, CONSTRAINT "PK_schedule_events" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_dates" ON "schedule_events" ("start_date", "end_date")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_category" ON "schedule_events" ("category_id")`,
|
||||
);
|
||||
|
||||
// 참석자(다대다 조인)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "schedule_event_attendees" ("event_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_schedule_event_attendees" PRIMARY KEY ("event_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_event_attendees_event" ON "schedule_event_attendees" ("event_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_event_attendees_user" ON "schedule_event_attendees" ("user_id")`,
|
||||
);
|
||||
|
||||
// FK
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_category" FOREIGN KEY ("category_id") REFERENCES "schedule_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_event" FOREIGN KEY ("event_id") REFERENCES "schedule_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_event"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_created_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_category"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "schedule_event_attendees"`);
|
||||
await queryRunner.query(`DROP TABLE "schedule_events"`);
|
||||
await queryRunner.query(`DROP TABLE "schedule_categories"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 로그인 유지(keepLogin) — refresh 세션에 persistent 플래그 추가.
|
||||
// true(기본): 영속 쿠키(refresh TTL), false: 세션 쿠키(브라우저 종료 시 만료).
|
||||
// 기존 세션은 기존 동작(영속) 유지를 위해 default true.
|
||||
export class AddRefreshSessionPersistent1782400000000 implements MigrationInterface {
|
||||
name = 'AddRefreshSessionPersistent1782400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
|
||||
// (업무 첨부 attachments 와 동일 구조, project_id FK)
|
||||
export class AddProjectAttachments1782500000000 implements MigrationInterface {
|
||||
name = 'AddProjectAttachments1782500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "project_attachments"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
|
||||
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
|
||||
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
|
||||
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
|
||||
export class AddDriveTables1782600000000 implements MigrationInterface {
|
||||
name = 'AddDriveTables1782600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== drive_folders =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
|
||||
);
|
||||
|
||||
// ===== drive_files =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
|
||||
);
|
||||
|
||||
// ===== project_folder_links =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
|
||||
);
|
||||
// 한 폴더는 한 프로젝트에 한 번만 연동
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
|
||||
);
|
||||
|
||||
// ===== 외래키 =====
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 외래키 → 테이블 순서 역순 정리
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "project_folder_links"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_files"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_folders"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 첨부 문서의 추출 본문 캐시 컬럼 추가.
|
||||
// 업로드 시 1회 추출해 보관하고, AI 업무 추천에서 꺼내 읽는다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddProjectAttachmentExtractedText1782600000000 implements MigrationInterface {
|
||||
name = 'AddProjectAttachmentExtractedText1782600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD "extracted_text" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP COLUMN "extracted_text"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRooms1782700000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRooms1782700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRoomFields1782800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방에 입장 코드(code) 추가 — 이름과 식별자를 분리한다.
|
||||
// code = 입장 URL·LiveKit 방·초대 링크의 안정적 키(불변). 이름은 표시용 라벨로 강등(고유 해제).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRoomCode1782900000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRoomCode1782900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) 코드 컬럼 추가(우선 nullable) → 기존 행 백필 → NOT NULL 승격
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "code" character varying`,
|
||||
);
|
||||
// 기존 방에 고유 코드 백필(md5(random) 12자리 16진수 — 충돌 사실상 없음)
|
||||
await queryRunner.query(
|
||||
`UPDATE "meeting_rooms" SET "code" = substr(md5(random()::text || "id"::text), 1, 12) WHERE "code" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ALTER COLUMN "code" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_code" ON "meeting_rooms" ("code")`,
|
||||
);
|
||||
// 2) 이름은 더 이상 고유가 아니다(표시용 라벨) → 고유 인덱스 제거
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_code"`);
|
||||
await queryRunner.query(`ALTER TABLE "meeting_rooms" DROP COLUMN "code"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 AI 검토 문서를 "드라이브 폴더 연동"으로 전환.
|
||||
// - drive_files 에 AI 추출 캐시 컬럼(extracted_text, extract_status) 추가
|
||||
// → 연동 폴더 문서를 NCP 에서 받아 텍스트 추출·캐싱(AI 업무 추천 주입)
|
||||
// - 프로젝트 직접 업로드(project_attachments) 폐기 → 테이블 제거(완전 대체)
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
//
|
||||
// 멱등·견고하게 작성: 과거 synchronize 로 만들어진 운영 DB(제약 이름 상이)나
|
||||
// 부분 적용 상태에서도 실패하지 않도록 IF [NOT] EXISTS / CASCADE 를 사용한다.
|
||||
export class AddDriveFileExtractDropProjectAttachments1783000000000 implements MigrationInterface {
|
||||
name = 'AddDriveFileExtractDropProjectAttachments1783000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) drive_files AI 추출 캐시 컬럼 (이미 있으면 건너뜀)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extracted_text" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extract_status" character varying NOT NULL DEFAULT 'pending'`,
|
||||
);
|
||||
|
||||
// 2) project_attachments 폐기 (드라이브 연동으로 대체)
|
||||
// 제약 이름에 의존하지 않도록 CASCADE 로 테이블·FK 를 한 번에 제거.
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "project_attachments" CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) project_attachments 복원 (AddProjectAttachments + ExtractedText 와 동일 스키마)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "extracted_text" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
// 2) drive_files 추출 캐시 컬럼 제거
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extract_status"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extracted_text"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가.
|
||||
// - deleted_at: 삭제 시각(null 이면 미삭제)
|
||||
// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL)
|
||||
// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해
|
||||
// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface {
|
||||
name = 'AddDriveFileSoftDeleteAudit1783100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`,
|
||||
);
|
||||
// FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비)
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by'
|
||||
) THEN
|
||||
ALTER TABLE "drive_files"
|
||||
ADD CONSTRAINT "FK_drive_files_deleted_by"
|
||||
FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 전사 공유 드라이브 전환 + 전역 감사 로그.
|
||||
// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관).
|
||||
// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경
|
||||
// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비).
|
||||
export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface {
|
||||
name = 'AddDriveSharedAndAudit1783200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== 감사 로그 테이블 =====
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "drive_activities" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"type" character varying NOT NULL,
|
||||
"payload" jsonb NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"actor_id" uuid,
|
||||
CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor'
|
||||
) THEN
|
||||
ALTER TABLE "drive_activities"
|
||||
ADD CONSTRAINT "FK_drive_activities_actor"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) =====
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
// 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`,
|
||||
);
|
||||
// owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성)
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
|
||||
name = 'AddPushSubscriptions1783300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"user_id" uuid,
|
||||
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
|
||||
) THEN
|
||||
ALTER TABLE "push_subscriptions"
|
||||
ADD CONSTRAINT "FK_push_subscriptions_user"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
|
||||
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
|
||||
name = 'AddTaskCoIssuers1783400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
|
||||
);
|
||||
|
||||
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
|
||||
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
|
||||
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
|
||||
name = 'AddTaskReviewedBy1783500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
|
||||
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
|
||||
name = 'AddTaskDueNotifyFlags1783600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,33 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ActivityService, type ActivityResponse } from './activity.service';
|
||||
|
||||
// 활동 피드 컨트롤러 — 읽기 전용. 조직 모델상 인증 사용자면 열람 가능
|
||||
@ApiTags('Activity')
|
||||
@Controller('repos/:repoId/activity')
|
||||
@Controller('projects/:projectId/activity')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '저장소 활동 피드 (최신순)' })
|
||||
@ApiOperation({ summary: '프로젝트 활동 피드 (최신순, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
list(@Param('repoId') repoId: string): Promise<ActivityResponse[]> {
|
||||
return this.activityService.listForRepo(repoId);
|
||||
list(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<ActivityResponse>> {
|
||||
return this.activityService.listForProject(projectId, page, size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Activity } from './entities/activity.entity';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { ActivityService } from './activity.service';
|
||||
import { ActivityController } from './activity.controller';
|
||||
|
||||
// 활동 피드 모듈 — 다른 도메인 모듈(repo/task)이 import 해 ActivityService 로 이벤트를 적재한다.
|
||||
// (Activity 는 taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
|
||||
// 활동 피드 모듈 — 다른 도메인 모듈(project/task)이 import 해 ActivityService 로 이벤트를 적재한다.
|
||||
// (Activity 는 projectId + taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Activity, Repo])],
|
||||
imports: [TypeOrmModule.forFeature([Activity])],
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Activity, type ActivityType } from './entities/activity.entity';
|
||||
|
||||
// 업무 상세 활동 탭 조회 상한 — 매우 활발한 업무에서 전량 렌더링을 방지(최신 N건)
|
||||
const TASK_ACTIVITY_LIMIT = 100;
|
||||
|
||||
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
export interface ActivityResponse {
|
||||
id: string;
|
||||
@@ -17,7 +25,7 @@ export interface ActivityResponse {
|
||||
|
||||
// 활동 적재 파라미터
|
||||
export interface RecordActivityParams {
|
||||
repoId: string; // Repo uuid
|
||||
projectId: string; // Project uuid
|
||||
actorId: string | null;
|
||||
type: ActivityType;
|
||||
payload?: Record<string, unknown>;
|
||||
@@ -25,7 +33,7 @@ export interface RecordActivityParams {
|
||||
}
|
||||
|
||||
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
|
||||
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
// 프로젝트/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
private readonly logger = new Logger(ActivityService.name);
|
||||
@@ -33,15 +41,13 @@ export class ActivityService {
|
||||
constructor(
|
||||
@InjectRepository(Activity)
|
||||
private readonly activityRepo: Repository<Activity>,
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
) {}
|
||||
|
||||
// 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
|
||||
async record(params: RecordActivityParams): Promise<void> {
|
||||
try {
|
||||
const activity = this.activityRepo.create({
|
||||
repo: { id: params.repoId } as Repo,
|
||||
project: { id: params.projectId } as Project,
|
||||
actor: params.actorId ? { id: params.actorId } : null,
|
||||
type: params.type,
|
||||
payload: params.payload ?? {},
|
||||
@@ -56,45 +62,38 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
|
||||
async listForRepo(slugName: string): Promise<ActivityResponse[]> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repo.id } },
|
||||
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 오프셋 페이지네이션). 날짜 그룹핑은 프론트.
|
||||
async listForProject(
|
||||
projectId: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ActivityResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [rows, total] = await this.activityRepo.findAndCount({
|
||||
where: { project: { id: projectId } },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
return paginated(
|
||||
rows.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
// 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
// 전량 렌더링 방지를 위해 최신 TASK_ACTIVITY_LIMIT 건으로 제한한다.
|
||||
async listForTask(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskSeq: number,
|
||||
): Promise<ActivityResponse[]> {
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repoId }, taskSeq },
|
||||
where: { project: { id: projectId }, taskSeq },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 저장소 단위(업무 무관) 활동만 — 필요 시 사용. taskSeq IS NULL
|
||||
async listRepoOnly(slugName: string): Promise<ActivityResponse[]> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repo.id }, taskSeq: IsNull() },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
take: TASK_ACTIVITY_LIMIT,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
@@ -9,14 +9,13 @@ import {
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Repo } from '../../repo/entities/repo.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
|
||||
// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
|
||||
// 활동 이벤트 타입 — 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
|
||||
export type ActivityType =
|
||||
// 저장소(설정)
|
||||
| 'repo.created'
|
||||
| 'repo.renamed'
|
||||
| 'repo.visibility_changed'
|
||||
// 프로젝트(설정)
|
||||
| 'project.created'
|
||||
| 'project.renamed'
|
||||
// 멤버
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
@@ -29,8 +28,13 @@ export type ActivityType =
|
||||
| 'task.assignees_changed'
|
||||
| 'task.due_changed'
|
||||
| 'task.edited'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed';
|
||||
| 'task.removed'
|
||||
// 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적)
|
||||
| 'drive.file_uploaded'
|
||||
| 'drive.file_deleted'
|
||||
| 'drive.folder_deleted';
|
||||
|
||||
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
|
||||
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
|
||||
@@ -39,14 +43,14 @@ export class Activity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 저장소 (저장소 삭제 시 함께 삭제)
|
||||
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피
|
||||
@Index()
|
||||
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'repo_id' })
|
||||
repo!: Relation<Repo>;
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
|
||||
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 프로젝트 활동이면 null
|
||||
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
|
||||
@Index()
|
||||
@Column({ name: 'task_seq', type: 'int', nullable: true })
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
|
||||
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
|
||||
|
||||
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
|
||||
@ApiTags('AI')
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiSuggestController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
|
||||
@Post('suggest-checklist')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '할 일(체크리스트) 추천 — 프로젝트·업무 기반' })
|
||||
@ApiResponse({ status: 200, description: '카테고리별 추천 항목' })
|
||||
@ApiResponse({ status: 404, description: '프로젝트 없음 (RES_001)' })
|
||||
suggestChecklist(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: SuggestChecklistDto,
|
||||
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
|
||||
return this.aiService.suggestChecklist(user.id, {
|
||||
projectId: dto.projectId,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import {
|
||||
AiService,
|
||||
type ConversationDetail,
|
||||
type ConversationSummary,
|
||||
type SendResult,
|
||||
} from './ai.service';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
|
||||
// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프).
|
||||
@ApiTags('AI')
|
||||
@Controller('ai/conversations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
list(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<ConversationSummary>> {
|
||||
return this.aiService.listConversations(user.id, page, size);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '대화 상세 (메시지 포함)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
|
||||
get(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ConversationDetail> {
|
||||
return this.aiService.getConversation(user.id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '새 대화 시작 (첫 메시지)' })
|
||||
@ApiResponse({ status: 201, description: '생성 + AI 응답' })
|
||||
start(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: SendMessageDto,
|
||||
): Promise<SendResult> {
|
||||
return this.aiService.startConversation(user.id, dto.content);
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '대화에 메시지 추가' })
|
||||
@ApiResponse({ status: 200, description: 'AI 응답' })
|
||||
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
|
||||
send(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SendMessageDto,
|
||||
): Promise<SendResult> {
|
||||
return this.aiService.addMessage(user.id, id, dto.content);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '대화 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
|
||||
async remove(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<null> {
|
||||
await this.aiService.deleteConversation(user.id, id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiSuggestController } from './ai-suggest.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
|
||||
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속.
|
||||
// Task/ProjectModule 을 가져와 사용자 업무·프로젝트 컨텍스트를 시스템 프롬프트에 주입한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AiConversation, AiMessage]),
|
||||
TaskModule,
|
||||
ProjectModule,
|
||||
],
|
||||
controllers: [AiController, AiSuggestController],
|
||||
providers: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import type { TaskStatus } from '../task/entities/task.entity';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { AiConversation } from './entities/ai-conversation.entity';
|
||||
import { AiMessage } from './entities/ai-message.entity';
|
||||
|
||||
// Anthropic Messages API 응답(필요 필드만)
|
||||
interface AnthropicResponse {
|
||||
content?: { type: string; text?: string }[];
|
||||
}
|
||||
|
||||
// 외부 노출 형태
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
export interface ConversationDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: { role: 'user' | 'assistant'; content: string }[];
|
||||
}
|
||||
export interface SendResult {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
reply: string;
|
||||
}
|
||||
|
||||
// 할 일 추천 — 카테고리별 항목
|
||||
export interface ChecklistSuggestionGroup {
|
||||
category: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
// AI 요청 타임아웃 기본값(ms) — env AI_TIMEOUT_MS 로 재정의. 프론트와 동일 값을 사용한다.
|
||||
const DEFAULT_AI_TIMEOUT_MS = 60_000;
|
||||
const MAX_TOKENS = 1024;
|
||||
const TITLE_MAX = 60;
|
||||
// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약)
|
||||
const CONTEXT_TASK_LIMIT = 30;
|
||||
// 컨텍스트로 주입할 프로젝트 최대 개수
|
||||
const CONTEXT_PROJECT_LIMIT = 30;
|
||||
|
||||
// 주제 한정(가드레일) + 데이터 근거 지시
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.',
|
||||
'',
|
||||
'[역할]',
|
||||
"- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.",
|
||||
'- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.',
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.',
|
||||
'- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.',
|
||||
"- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.",
|
||||
"- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.",
|
||||
'- 프로젝트는 한글 표시 제목으로 설명하세요.',
|
||||
].join('\n');
|
||||
|
||||
// 업무 상태 → 한국어 라벨(컨텍스트 표기용)
|
||||
const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
todo: '할 일',
|
||||
prog: '진행 중',
|
||||
review: '검토 대기',
|
||||
changes: '수정 요청',
|
||||
done: '완료',
|
||||
};
|
||||
|
||||
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
|
||||
const SUGGEST_SYSTEM_PROMPT = [
|
||||
'당신은 소프트웨어/업무 기획 보조자입니다.',
|
||||
"주어진 프로젝트와 업무 정보를 바탕으로, 이 업무(기능)를 완성하기 위해 필요한 '할 일(체크리스트)' 항목을 제안합니다.",
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
|
||||
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
|
||||
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
|
||||
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
|
||||
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
|
||||
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
|
||||
'- [프로젝트 문서] 섹션이 주어지면(첨부 문서에서 추출한 내용) 그 내용을 적극 반영해 더 구체적으로 제안하세요.',
|
||||
'',
|
||||
'[형식]',
|
||||
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
|
||||
].join('\n');
|
||||
|
||||
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
|
||||
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly model: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly projectService: ProjectService,
|
||||
@InjectRepository(AiConversation)
|
||||
private readonly convRepo: Repository<AiConversation>,
|
||||
@InjectRepository(AiMessage)
|
||||
private readonly msgRepo: Repository<AiMessage>,
|
||||
) {
|
||||
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
|
||||
this.model = (
|
||||
config.get<string>('CLAUDE_MODEL') ?? 'claude-sonnet-4-6'
|
||||
).trim();
|
||||
// AI 타임아웃 — env AI_TIMEOUT_MS(ms), 미설정/비정상이면 기본값
|
||||
const t = Number(config.get<string>('AI_TIMEOUT_MS'));
|
||||
this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_AI_TIMEOUT_MS;
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화');
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.apiKey.length > 0;
|
||||
}
|
||||
|
||||
// 내 대화 목록(최근순, 페이지네이션)
|
||||
async listConversations(
|
||||
userId: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ConversationSummary>> {
|
||||
const params = resolvePage(page, size);
|
||||
const [rows, total] = await this.convRepo.findAndCount({
|
||||
where: { user: { id: userId } },
|
||||
order: { updatedAt: 'DESC' },
|
||||
skip: params.skip,
|
||||
take: params.take,
|
||||
});
|
||||
return paginated(
|
||||
rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })),
|
||||
total,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// 대화 상세(본인 소유만) — 메시지 시간순
|
||||
async getConversation(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationDetail> {
|
||||
const conv = await this.getOwnedConversation(userId, conversationId);
|
||||
const messages = await this.msgRepo.find({
|
||||
where: { conversation: { id: conv.id } },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
return {
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
};
|
||||
}
|
||||
|
||||
// 새 대화 시작 — 첫 메시지로 대화 생성 후 응답
|
||||
async startConversation(
|
||||
userId: string,
|
||||
content: string,
|
||||
): Promise<SendResult> {
|
||||
this.ensureEnabled();
|
||||
const conv = await this.convRepo.save(
|
||||
this.convRepo.create({
|
||||
user: { id: userId },
|
||||
title: this.makeTitle(content),
|
||||
}),
|
||||
);
|
||||
return this.appendAndReply(conv, content, userId);
|
||||
}
|
||||
|
||||
// 기존 대화에 메시지 추가 후 응답(본인 소유만)
|
||||
async addMessage(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
content: string,
|
||||
): Promise<SendResult> {
|
||||
this.ensureEnabled();
|
||||
const conv = await this.getOwnedConversation(userId, conversationId);
|
||||
return this.appendAndReply(conv, content, userId);
|
||||
}
|
||||
|
||||
// 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
|
||||
async suggestChecklist(
|
||||
userId: string,
|
||||
input: { projectId: string; title: string; content?: string[] },
|
||||
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
|
||||
this.ensureEnabled();
|
||||
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
|
||||
const project = await this.projectService.findOne(input.projectId, userId);
|
||||
const content = (input.content ?? []).join('\n').trim() || '(없음)';
|
||||
// 프로젝트 첨부 문서의 캐싱된 추출 본문(업로드 시 보관) — 있으면 추천에 반영
|
||||
const docsText = await this.projectService.getAttachmentsTextForAi(
|
||||
input.projectId,
|
||||
);
|
||||
const userMsg = [
|
||||
`[프로젝트] 표시 제목: ${project.name}`,
|
||||
`설명: ${project.description ?? '(없음)'}`,
|
||||
...(docsText
|
||||
? ['', '[프로젝트 문서] (첨부 문서에서 추출한 내용)', docsText]
|
||||
: []),
|
||||
'',
|
||||
`[업무] 제목: ${input.title}`,
|
||||
'내용:',
|
||||
content,
|
||||
].join('\n');
|
||||
|
||||
// 4개 카테고리 × 항목 수 무제한의 구조화 JSON 이라 토큰 여유를 넉넉히 준다
|
||||
// (부족하면 응답이 중간에 잘려 JSON 파싱이 실패할 수 있음)
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
8000,
|
||||
);
|
||||
return { groups: this.parseSuggestions(raw) };
|
||||
}
|
||||
|
||||
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
|
||||
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
|
||||
const text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```\s*$/, '')
|
||||
.trim();
|
||||
|
||||
// 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse
|
||||
let groups: unknown[] = [];
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(start, end + 1)) as {
|
||||
groups?: unknown;
|
||||
};
|
||||
if (Array.isArray(parsed.groups)) groups = parsed.groups;
|
||||
} catch {
|
||||
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도
|
||||
// "category"/"items" 패턴을 정규식으로 직접 복구한다.
|
||||
if (groups.length === 0) {
|
||||
const recovered = this.recoverGroups(text);
|
||||
if (recovered.length > 0) {
|
||||
groups = recovered;
|
||||
this.logger.warn(
|
||||
`추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}자`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result: ChecklistSuggestionGroup[] = [];
|
||||
for (const g of groups.slice(0, 8)) {
|
||||
const obj = g as { category?: unknown; items?: unknown };
|
||||
const category =
|
||||
typeof obj.category === 'string' ? obj.category.trim() : '';
|
||||
if (!category || !Array.isArray(obj.items)) continue;
|
||||
// 카테고리당 항목 수 제한 없음 — 응답 길이는 max_tokens 가 자연 제한
|
||||
const items = obj.items
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (items.length > 0) result.push({ category, items });
|
||||
}
|
||||
if (result.length === 0) {
|
||||
this.logger.warn(
|
||||
`추천 결과가 비어 있음 — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
|
||||
);
|
||||
throw this.suggestFailed();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어).
|
||||
// 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다.
|
||||
private recoverGroups(text: string): { category: string; items: string[] }[] {
|
||||
const out: { category: string; items: string[] }[] = [];
|
||||
const groupRe =
|
||||
/"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g;
|
||||
const itemRe = /"(?:[^"\\]|\\.)*"/g;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = groupRe.exec(text)) !== null) {
|
||||
let category: string;
|
||||
try {
|
||||
category = JSON.parse(gm[1]) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const items: string[] = [];
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = itemRe.exec(gm[2])) !== null) {
|
||||
try {
|
||||
const s = JSON.parse(im[0]) as unknown;
|
||||
if (typeof s === 'string') items.push(s);
|
||||
} catch {
|
||||
// 깨진 토큰은 건너뛴다
|
||||
}
|
||||
}
|
||||
out.push({ category, items });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private suggestFailed(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'추천을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
// 대화 삭제(본인 소유만) — 메시지 CASCADE
|
||||
async deleteConversation(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const conv = await this.getOwnedConversation(userId, conversationId);
|
||||
await this.convRepo.remove(conv);
|
||||
}
|
||||
|
||||
// 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신.
|
||||
// Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지.
|
||||
private async appendAndReply(
|
||||
conv: AiConversation,
|
||||
content: string,
|
||||
userId: string,
|
||||
): Promise<SendResult> {
|
||||
const userMsg = await this.msgRepo.save(
|
||||
this.msgRepo.create({
|
||||
conversation: { id: conv.id },
|
||||
role: 'user',
|
||||
content,
|
||||
}),
|
||||
);
|
||||
const history = await this.msgRepo.find({
|
||||
where: { conversation: { id: conv.id } },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
let reply: string;
|
||||
try {
|
||||
const system = await this.buildSystemPrompt(userId);
|
||||
reply = await this.callClaude(
|
||||
history.map((m) => ({ role: m.role, content: m.content })),
|
||||
system,
|
||||
);
|
||||
} catch (e) {
|
||||
// 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제
|
||||
await this.msgRepo.delete(userMsg.id);
|
||||
const remaining = await this.msgRepo.count({
|
||||
where: { conversation: { id: conv.id } },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
await this.convRepo.delete(conv.id);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await this.msgRepo.save(
|
||||
this.msgRepo.create({
|
||||
conversation: { id: conv.id },
|
||||
role: 'assistant',
|
||||
content: reply,
|
||||
}),
|
||||
);
|
||||
// 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn)
|
||||
await this.convRepo.save(conv);
|
||||
return { conversationId: conv.id, title: conv.title, reply };
|
||||
}
|
||||
|
||||
// 소유권 확인 — 본인 대화가 아니면 404(존재 비노출)
|
||||
private async getOwnedConversation(
|
||||
userId: string,
|
||||
conversationId: string,
|
||||
): Promise<AiConversation> {
|
||||
const conv = await this.convRepo.findOne({
|
||||
where: { id: conversationId, user: { id: userId } },
|
||||
});
|
||||
if (!conv) {
|
||||
throw new BusinessException(
|
||||
'RES_001',
|
||||
'대화를 찾을 수 없습니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
return conv;
|
||||
}
|
||||
|
||||
private makeTitle(content: string): string {
|
||||
const firstLine = content.trim().split('\n')[0].trim();
|
||||
if (!firstLine) return '새 대화';
|
||||
return firstLine.length > TITLE_MAX
|
||||
? `${firstLine.slice(0, TITLE_MAX)}…`
|
||||
: firstLine;
|
||||
}
|
||||
|
||||
private ensureEnabled(): void {
|
||||
if (!this.enabled) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'AI 기능이 설정되지 않았습니다. 관리자에게 문의해 주세요.',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트
|
||||
private async buildSystemPrompt(userId: string): Promise<string> {
|
||||
const context = await this.buildUserContext(userId);
|
||||
return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`;
|
||||
}
|
||||
|
||||
// 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행
|
||||
private async buildUserContext(userId: string): Promise<string> {
|
||||
const today = new Date(Date.now() + 9 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10); // KST 기준 오늘
|
||||
try {
|
||||
const assigned = await this.taskService.listAssigned(
|
||||
userId,
|
||||
1,
|
||||
CONTEXT_TASK_LIMIT,
|
||||
);
|
||||
const issued = await this.taskService.listIssued(userId, 1, 1);
|
||||
const projects = await this.projectService.findAll(
|
||||
1,
|
||||
CONTEXT_PROJECT_LIMIT,
|
||||
);
|
||||
|
||||
const lines = [`오늘 날짜: ${today} (KST)`, ''];
|
||||
|
||||
// 담당 업무
|
||||
lines.push(`내가 담당한 업무 (${assigned.total}건):`);
|
||||
if (assigned.items.length === 0) {
|
||||
lines.push('- (없음)');
|
||||
} else {
|
||||
for (const t of assigned.items) {
|
||||
const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음';
|
||||
const [done, totalC] = t.checklist;
|
||||
lines.push(
|
||||
`- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`,
|
||||
);
|
||||
}
|
||||
if (assigned.total > assigned.items.length) {
|
||||
lines.push(`- … 외 ${assigned.total - assigned.items.length}건`);
|
||||
}
|
||||
}
|
||||
lines.push('', `내가 지시한 업무: ${issued.total}건`);
|
||||
|
||||
// 프로젝트 목록(인증 사용자는 전체 열람 가능)
|
||||
lines.push('', `프로젝트 목록 (${projects.total}개):`);
|
||||
if (projects.items.length === 0) {
|
||||
lines.push('- (없음)');
|
||||
} else {
|
||||
for (const p of projects.items) {
|
||||
lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}건`);
|
||||
}
|
||||
if (projects.total > projects.items.length) {
|
||||
lines.push(`- … 외 ${projects.total - projects.items.length}개`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구
|
||||
private async callClaude(
|
||||
messages: { role: 'user' | 'assistant'; content: string }[],
|
||||
system: string,
|
||||
maxTokens: number = MAX_TOKENS,
|
||||
): Promise<string> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: maxTokens,
|
||||
system,
|
||||
messages,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
this.logger.error(
|
||||
`Anthropic API 오류 ${response.status}: ${detail.slice(0, 500)}`,
|
||||
);
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnthropicResponse;
|
||||
const text = (data.content ?? [])
|
||||
.filter((b) => b.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.trim();
|
||||
return text || '(빈 응답을 받았습니다.)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다.
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ description: '사용자 메시지 내용' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
|
||||
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
|
||||
content!: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 할 일(체크리스트) 추천 요청 — 프로젝트·업무 정보를 바탕으로 AI 가 항목 제안
|
||||
export class SuggestChecklistDto {
|
||||
@ApiProperty({ description: '프로젝트 식별자(slugName)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '프로젝트가 필요합니다.' })
|
||||
projectId!: string;
|
||||
|
||||
@ApiProperty({ description: '업무 제목' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업무 내용(문단 배열)',
|
||||
required: false,
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(5000, { each: true })
|
||||
content?: string[];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N).
|
||||
@Entity('ai_conversations')
|
||||
export class AiConversation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: Relation<User>;
|
||||
|
||||
// 첫 사용자 메시지로 자동 생성되는 제목
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
title!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// 새 메시지 추가 시 갱신(최근 대화 정렬용)
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { AiConversation } from './ai-conversation.entity';
|
||||
|
||||
// AI 대화 메시지 1건 — 대화에 종속(CASCADE).
|
||||
@Entity('ai_messages')
|
||||
export class AiMessage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false })
|
||||
@JoinColumn({ name: 'conversation_id' })
|
||||
conversation!: Relation<AiConversation>;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
role!: 'user' | 'assistant';
|
||||
|
||||
@Column({ type: 'text' })
|
||||
content!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -5,18 +5,25 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CookieOptions, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { AuthService, type SignupResult } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { ResendVerificationDto } from './dto/resend-verification.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
|
||||
// 쿠키 만료 시간(ms)
|
||||
@@ -34,6 +41,15 @@ export class AuthController {
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
// 프론트엔드(웹) 기준 URL — 소셜 로그인/이메일 인증 후 리다이렉트 대상
|
||||
private webUrl(): string {
|
||||
const url =
|
||||
this.config.get<string>('FRONTEND_URL') ||
|
||||
this.config.get<string>('CORS_ORIGIN') ||
|
||||
'http://localhost:5273';
|
||||
return url.split(',')[0].trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// HttpOnly·Secure 쿠키 공통 옵션
|
||||
private cookieBase(): CookieOptions {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
@@ -44,21 +60,23 @@ export class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
// access/refresh 토큰을 쿠키로 설정
|
||||
// access/refresh 토큰을 쿠키로 설정.
|
||||
// persistent=true 면 maxAge 부여(영속 쿠키), false 면 maxAge 생략(세션 쿠키 — 브라우저 종료 시 만료).
|
||||
private setAuthCookies(
|
||||
res: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
persistent: boolean,
|
||||
): void {
|
||||
res.cookie('access_token', accessToken, {
|
||||
...this.cookieBase(),
|
||||
path: '/',
|
||||
maxAge: ACCESS_MAX_AGE,
|
||||
...(persistent ? { maxAge: ACCESS_MAX_AGE } : {}),
|
||||
});
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
...this.cookieBase(),
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: REFRESH_MAX_AGE,
|
||||
...(persistent ? { maxAge: REFRESH_MAX_AGE } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,23 +89,92 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
|
||||
// keepLogin: 로그인 유지 여부(영속 쿠키 vs 세션 쿠키)
|
||||
private async loginWith(
|
||||
res: Response,
|
||||
user: PublicUser,
|
||||
keepLogin: boolean,
|
||||
): Promise<void> {
|
||||
const tokens = await this.authService.issueTokensForNewSession(
|
||||
user,
|
||||
keepLogin,
|
||||
);
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.persistent,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
|
||||
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
|
||||
// 가입 남용/메일 폭탄 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
|
||||
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
|
||||
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
|
||||
async signup(
|
||||
@Body() dto: SignupDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.authService.signup(dto);
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
return user;
|
||||
signup(@Body() dto: SignupDto): Promise<SignupResult> {
|
||||
// 이메일 인증 완료 전까지 로그인 불가 — 쿠키를 발급하지 않는다
|
||||
return this.authService.signup(dto);
|
||||
}
|
||||
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 메일 폭탄 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '인증 메일 재발송' })
|
||||
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
|
||||
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
|
||||
await this.authService.resendVerification(dto.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 메일 폭탄/계정 탐색 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '비밀번호 재설정 요청 (재설정 메일 발송)' })
|
||||
@ApiResponse({ status: 200, description: '요청 접수(존재 여부 비노출)' })
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<null> {
|
||||
await this.authService.requestPasswordReset(dto.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 토큰 추측 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '비밀번호 재설정 (토큰 + 새 비밀번호)' })
|
||||
@ApiResponse({ status: 200, description: '비밀번호 변경 성공' })
|
||||
@ApiResponse({ status: 400, description: '토큰 만료/무효 (BIZ_001)' })
|
||||
async resetPassword(@Body() dto: ResetPasswordDto): Promise<null> {
|
||||
await this.authService.resetPassword(dto.token, dto.password);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입(보통 새 탭).
|
||||
// 검증 후 결과 페이지로 리다이렉트하며, 그 페이지는 완료를 표시하고 창을 닫는다.
|
||||
@Get('verify-email')
|
||||
@SkipTransform()
|
||||
@ApiOperation({ summary: '이메일 인증 (메일 링크)' })
|
||||
async verifyEmail(
|
||||
@Query('token') token: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.authService.verifyEmail(token);
|
||||
res.redirect(`${this.webUrl()}/email-verified?ok=1`);
|
||||
} catch {
|
||||
res.redirect(`${this.webUrl()}/email-verified?ok=0`);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// 무차별 대입 방지 — 운영 권장 10회/분 (현재 테스트용 1000회로 완화)
|
||||
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: '로그인' })
|
||||
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
|
||||
@ApiResponse({
|
||||
@@ -99,36 +186,100 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.authService.validateUser(dto);
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
// keepLogin 미지정(API 직접 호출 등)이면 세션 쿠키(false)로 안전하게 처리
|
||||
await this.loginWith(res, user, dto.keepLogin ?? false);
|
||||
return user;
|
||||
}
|
||||
|
||||
// ── 구글 OAuth ──
|
||||
@Get('google')
|
||||
@SkipTransform()
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@ApiOperation({ summary: '구글 로그인 시작 (구글 동의 화면으로 이동)' })
|
||||
googleAuth(): void {
|
||||
// 가드(AuthGuard)가 구글 인증 페이지로 리다이렉트 — 본문 없음
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
@SkipTransform()
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@ApiOperation({ summary: '구글 로그인 콜백' })
|
||||
async googleCallback(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/schedule`);
|
||||
}
|
||||
|
||||
// ── 카카오 OAuth ──
|
||||
@Get('kakao')
|
||||
@SkipTransform()
|
||||
@UseGuards(AuthGuard('kakao'))
|
||||
@ApiOperation({ summary: '카카오 로그인 시작 (카카오 동의 화면으로 이동)' })
|
||||
kakaoAuth(): void {
|
||||
// 가드(AuthGuard)가 카카오 인증 페이지로 리다이렉트 — 본문 없음
|
||||
}
|
||||
|
||||
@Get('kakao/callback')
|
||||
@SkipTransform()
|
||||
@UseGuards(AuthGuard('kakao'))
|
||||
@ApiOperation({ summary: '카카오 로그인 콜백' })
|
||||
async kakaoCallback(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/schedule`);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
|
||||
@ApiOperation({ summary: '로그아웃 (세션 폐기 + 인증 쿠키 제거)' })
|
||||
@ApiResponse({ status: 200, description: '로그아웃 성공' })
|
||||
logout(@Res({ passthrough: true }) res: Response): null {
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
// 이 세션(패밀리)을 서버측에서 폐기 — 이후 해당 refresh 토큰 재사용 차단
|
||||
const cookies = req.cookies as Record<string, string> | undefined;
|
||||
await this.authService.revokeSession(cookies?.refresh_token);
|
||||
this.clearAuthCookies(res);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtRefreshGuard)
|
||||
@ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' })
|
||||
@ApiResponse({ status: 200, description: '토큰 재발급 성공' })
|
||||
@ApiOperation({ summary: 'access/refresh 토큰 회전 (refresh 쿠키 필요)' })
|
||||
@ApiResponse({ status: 200, description: '토큰 회전 성공' })
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'refresh 토큰 만료/누락 (AUTH_001)',
|
||||
description: 'refresh 토큰 만료/누락/재사용 (AUTH_001)',
|
||||
})
|
||||
async refresh(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
const tokens = await this.authService.issueTokens(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
return null;
|
||||
const cookies = req.cookies as Record<string, string> | undefined;
|
||||
try {
|
||||
// 회전형 refresh — 재사용 탐지 시 401(AUTH_001), 정상 시 새 토큰 쌍으로 교체
|
||||
const tokens = await this.authService.rotateTokens(
|
||||
cookies?.refresh_token,
|
||||
);
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.persistent,
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
|
||||
this.clearAuthCookies(res);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
|
||||
@@ -1,22 +1,64 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger, Module, type Provider } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RefreshSession } from './entities/refresh-session.entity';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { KakaoStrategy } from './strategies/kakao.strategy';
|
||||
|
||||
// 인증 모듈 — JWT(access/refresh) + Passport 전략
|
||||
// 구글 OAuth 전략 — 키가 설정된 경우에만 등록(미설정 시 null → /auth/google 비활성).
|
||||
const googleStrategyProvider: Provider = {
|
||||
provide: GoogleStrategy,
|
||||
useFactory: (config: ConfigService, authService: AuthService) => {
|
||||
if (!config.get<string>('GOOGLE_CLIENT_ID')) {
|
||||
new Logger('AuthModule').warn(
|
||||
'GOOGLE_CLIENT_ID 미설정 — 구글 로그인 비활성화',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return new GoogleStrategy(config, authService);
|
||||
},
|
||||
inject: [ConfigService, AuthService],
|
||||
};
|
||||
|
||||
// 카카오 OAuth 전략 — 키가 설정된 경우에만 등록.
|
||||
const kakaoStrategyProvider: Provider = {
|
||||
provide: KakaoStrategy,
|
||||
useFactory: (config: ConfigService, authService: AuthService) => {
|
||||
if (!config.get<string>('KAKAO_CLIENT_ID')) {
|
||||
new Logger('AuthModule').warn(
|
||||
'KAKAO_CLIENT_ID 미설정 — 카카오 로그인 비활성화',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return new KakaoStrategy(config, authService);
|
||||
},
|
||||
inject: [ConfigService, AuthService],
|
||||
};
|
||||
|
||||
// 인증 모듈 — JWT(access/refresh) + Passport 전략(JWT/구글/카카오) + 메일(인증)
|
||||
@Module({
|
||||
imports: [
|
||||
UserModule,
|
||||
MailModule,
|
||||
PassportModule,
|
||||
TypeOrmModule.forFeature([RefreshSession]),
|
||||
// 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다
|
||||
JwtModule.register({}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
googleStrategyProvider,
|
||||
kakaoStrategyProvider,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash, randomBytes, randomUUID } from 'crypto';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import type { User } from '../user/entities/user.entity';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { RefreshSession } from './entities/refresh-session.entity';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import type { JwtPayload } from './types/jwt-payload';
|
||||
|
||||
// bcrypt 해시 라운드
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
// 이메일 인증 토큰 유효 시간 (24시간)
|
||||
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
// 비밀번호 재설정 토큰 유효 시간 (1시간 — 보안상 짧게)
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
|
||||
// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다.
|
||||
const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음
|
||||
const REFRESH_GRACE_MS = 15 * 1000;
|
||||
|
||||
// 발급된 토큰 쌍
|
||||
export interface TokenPair {
|
||||
@@ -16,20 +31,46 @@ export interface TokenPair {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
|
||||
// 세션 토큰 + 쿠키 영속 여부(keepLogin) — 컨트롤러가 쿠키 maxAge 결정에 사용
|
||||
export interface SessionTokens extends TokenPair {
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
|
||||
export interface SignupResult {
|
||||
email: string;
|
||||
verificationPending: true;
|
||||
}
|
||||
|
||||
// 소셜 로그인 프로필 — 각 전략(google/kakao)이 공통 형태로 정규화해 전달
|
||||
export interface OAuthProfile {
|
||||
provider: string;
|
||||
email: string | null;
|
||||
// 제공자가 이메일 소유를 검증했는지 — 미검증이면 계정 매칭/생성을 거부(탈취/선점 방지)
|
||||
emailVerified: boolean;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
// 인증 비즈니스 로직 — 가입, 로그인 검증, 이메일 인증, 소셜 로그인, 토큰 발급
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly mailService: MailService,
|
||||
@InjectRepository(RefreshSession)
|
||||
private readonly refreshRepo: Repository<RefreshSession>,
|
||||
) {}
|
||||
|
||||
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
|
||||
async signup(dto: SignupDto): Promise<PublicUser> {
|
||||
const exists = await this.userService.findByEmail(dto.email);
|
||||
if (exists) {
|
||||
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
|
||||
// 회원가입 — 인증 완료 계정만 중복 거부. 미인증(가입 대기) 계정은 재가입을 허용한다.
|
||||
// (인증 메일을 받기 전 사이트를 닫아 가입을 못 끝낸 경우, 같은 이메일로 다시 가입해
|
||||
// 새 인증 메일을 받아 가입을 완료할 수 있게 한다. 자동 로그인은 하지 않음)
|
||||
async signup(dto: SignupDto): Promise<SignupResult> {
|
||||
const existing = await this.userService.findByEmail(dto.email);
|
||||
// 이미 인증을 마친 계정(로컬·소셜)은 재가입 불가
|
||||
if (existing && (existing.emailVerified || existing.provider !== 'local')) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 가입된 이메일입니다.',
|
||||
@@ -37,33 +78,283 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
|
||||
// 미인증 로컬 계정이 있으면 입력값으로 갱신 후 인증 메일만 재발송(중복 계정 생성 X)
|
||||
if (existing) {
|
||||
await this.userService.updateRegistration(existing.id, {
|
||||
passwordHash,
|
||||
name: dto.name,
|
||||
role: dto.role ?? null,
|
||||
});
|
||||
existing.name = dto.name; // 메일 인사말에 최신 이름 반영
|
||||
await this.sendVerification(existing);
|
||||
return { email: existing.email, verificationPending: true };
|
||||
}
|
||||
|
||||
const user = await this.userService.create({
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
name: dto.name,
|
||||
role: dto.role ?? null,
|
||||
});
|
||||
return UserService.toPublic(user);
|
||||
await this.sendVerification(user);
|
||||
return { email: user.email, verificationPending: true };
|
||||
}
|
||||
|
||||
// 로그인 검증 — 이메일/비밀번호 일치 확인
|
||||
async validateUser(dto: LoginDto): Promise<PublicUser> {
|
||||
const user = await this.userService.findByEmailWithPassword(dto.email);
|
||||
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
|
||||
// 인증 메일 재발송 — 사용자 존재/상태를 노출하지 않기 위해 항상 동일하게 응답(미인증 로컬만 실제 발송)
|
||||
async resendVerification(email: string): Promise<void> {
|
||||
const user = await this.userService.findByEmail(email);
|
||||
if (user && user.provider === 'local' && !user.emailVerified) {
|
||||
await this.sendVerification(user);
|
||||
}
|
||||
}
|
||||
|
||||
// 이메일 인증 — 토큰(원문) 해시로 사용자 조회 후 만료 검사하여 인증 완료 처리
|
||||
async verifyEmail(token: string): Promise<void> {
|
||||
if (!token) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
'잘못된 인증 링크입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
const tokenHash = this.hashToken(token);
|
||||
const user = await this.userService.findByEmailVerifyTokenHash(tokenHash);
|
||||
if (
|
||||
!user ||
|
||||
!user.emailVerifyExpiresAt ||
|
||||
user.emailVerifyExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
await this.userService.markEmailVerified(user.id);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 요청 — 존재/상태를 노출하지 않기 위해 항상 동일하게 응답.
|
||||
// 로컬(이메일) 계정만 실제 재설정 메일 발송(소셜 전용은 비밀번호가 없음).
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.userService.findByEmail(email);
|
||||
if (!user || user.provider !== 'local') {
|
||||
return;
|
||||
}
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
|
||||
await this.userService.setPasswordResetToken(user.id, tokenHash, expiresAt);
|
||||
|
||||
const resetUrl = `${this.webUrl()}/reset-password?token=${rawToken}`;
|
||||
this.mailService.sendPasswordResetEmail(user.email, user.name, resetUrl);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 — 토큰(원문) 해시로 조회 + 만료 검사 후 새 비밀번호 저장.
|
||||
// 보안상 비밀번호 변경 시 해당 사용자의 모든 세션을 폐기(전 기기 강제 로그아웃).
|
||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
if (!token) {
|
||||
throw this.invalidResetLink();
|
||||
}
|
||||
const tokenHash = this.hashToken(token);
|
||||
const user = await this.userService.findByPasswordResetTokenHash(tokenHash);
|
||||
if (
|
||||
!user ||
|
||||
!user.passwordResetExpiresAt ||
|
||||
user.passwordResetExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
throw this.invalidResetLink();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
await this.userService.updatePassword(user.id, passwordHash);
|
||||
await this.revokeAllSessions(user.id);
|
||||
}
|
||||
|
||||
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
|
||||
async validateUser(dto: LoginDto): Promise<PublicUser> {
|
||||
const user = await this.userService.findByEmailWithPassword(dto.email);
|
||||
if (!user) {
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
// 비밀번호 미설정 = 소셜 전용 계정 → 소셜 로그인 안내
|
||||
if (user.passwordHash == null) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소셜(구글/카카오) 계정으로 가입된 이메일입니다. 소셜 로그인을 이용해 주세요.',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
|
||||
throw this.invalidCredentials();
|
||||
}
|
||||
// 이메일 미인증 → 가입 미완료로 로그인 차단
|
||||
if (!user.emailVerified) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크를 클릭해 가입을 완료해 주세요.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// access/refresh 토큰 발급
|
||||
async issueTokens(user: PublicUser): Promise<TokenPair> {
|
||||
// 소셜 로그인 — 이메일로 기존 계정 매칭(있으면 로그인/인증보정), 없으면 신규 생성
|
||||
async validateOrCreateOAuthUser(profile: OAuthProfile): Promise<PublicUser> {
|
||||
if (!profile.email) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소셜 계정에서 이메일을 가져오지 못했습니다. 이메일 제공에 동의한 뒤 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// 제공자가 이메일을 검증하지 않았으면 거부 — 미검증 이메일로 타인 계정 탈취/선점 방지
|
||||
if (!profile.emailVerified) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소셜 계정의 이메일이 인증되지 않았습니다. 제공자에서 이메일 인증을 완료한 뒤 다시 시도해 주세요.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
let user = await this.userService.findByEmail(profile.email);
|
||||
if (!user) {
|
||||
user = await this.userService.createOAuthUser({
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
provider: profile.provider,
|
||||
avatarUrl: profile.avatarUrl ?? null,
|
||||
});
|
||||
} else if (!user.emailVerified) {
|
||||
// 기존(미인증 로컬) 계정과 이메일이 같으면 소셜 인증으로 인증 완료 처리하고 로그인
|
||||
await this.userService.markEmailVerified(user.id);
|
||||
user.emailVerified = true;
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급.
|
||||
// keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록.
|
||||
async issueTokensForNewSession(
|
||||
user: PublicUser,
|
||||
keepLogin: boolean,
|
||||
): Promise<SessionTokens> {
|
||||
// 만료된 이 사용자의 세션 정리(누적 방지)
|
||||
await this.refreshRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('user_id = :userId', { userId: user.id })
|
||||
.andWhere('expires_at < :now', { now: new Date() })
|
||||
.execute();
|
||||
|
||||
const jti = randomUUID();
|
||||
const session = await this.refreshRepo.save(
|
||||
this.refreshRepo.create({
|
||||
user: { id: user.id },
|
||||
currentJti: jti,
|
||||
prevJti: null,
|
||||
prevAt: null,
|
||||
persistent: keepLogin,
|
||||
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
|
||||
}),
|
||||
);
|
||||
const tokens = await this.signTokens(user.id, user.email, session.id, jti);
|
||||
return { ...tokens, persistent: keepLogin };
|
||||
}
|
||||
|
||||
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
|
||||
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
|
||||
async rotateTokens(refreshToken: string | undefined): Promise<SessionTokens> {
|
||||
if (!refreshToken) throw new UnauthorizedException();
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!payload.sub || !payload.fid || !payload.jti) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const session = await this.refreshRepo.findOne({
|
||||
where: { id: payload.fid },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!session || session.user?.id !== payload.sub) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// 1) 정상 회전 — 현재 jti 일치 → 새 jti 발급, 직전 jti 기록
|
||||
if (payload.jti === session.currentJti) {
|
||||
const newJti = randomUUID();
|
||||
session.prevJti = session.currentJti;
|
||||
session.prevAt = new Date();
|
||||
session.currentJti = newJti;
|
||||
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
|
||||
await this.refreshRepo.save(session);
|
||||
const tokens = await this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
newJti,
|
||||
);
|
||||
return { ...tokens, persistent: session.persistent };
|
||||
}
|
||||
|
||||
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
|
||||
if (
|
||||
payload.jti === session.prevJti &&
|
||||
session.prevAt &&
|
||||
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
|
||||
) {
|
||||
const tokens = await this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
session.currentJti,
|
||||
);
|
||||
return { ...tokens, persistent: session.persistent };
|
||||
}
|
||||
|
||||
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
|
||||
await this.refreshRepo.delete({ id: session.id });
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// 로그아웃 — refresh 토큰의 세션(패밀리)만 폐기. 무효 토큰이면 조용히 무시.
|
||||
async revokeSession(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(
|
||||
refreshToken,
|
||||
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') },
|
||||
);
|
||||
if (payload?.fid) {
|
||||
await this.refreshRepo.delete({ id: payload.fid });
|
||||
}
|
||||
} catch {
|
||||
// 무효/만료 refresh 토큰 — 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자의 모든 세션 폐기 — 비밀번호 변경/계정 보안 시 호출(전 기기 강제 로그아웃). 현재 미사용.
|
||||
async revokeAllSessions(userId: string): Promise<void> {
|
||||
await this.refreshRepo
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('user_id = :userId', { userId })
|
||||
.execute();
|
||||
}
|
||||
|
||||
// access/refresh 토큰 서명 — refresh 에는 세션 fid + 현재 jti 를 담는다
|
||||
private async signTokens(
|
||||
sub: string,
|
||||
email: string | undefined,
|
||||
fid: string,
|
||||
jti: string,
|
||||
): Promise<TokenPair> {
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, email: user.email },
|
||||
{ sub, email },
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
|
||||
@@ -71,7 +362,7 @@ export class AuthService {
|
||||
},
|
||||
);
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id },
|
||||
{ sub, fid, jti },
|
||||
{
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
|
||||
@@ -80,4 +371,50 @@ export class AuthService {
|
||||
);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
|
||||
private async sendVerification(user: User): Promise<void> {
|
||||
const rawToken = randomBytes(32).toString('hex');
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + VERIFY_TTL_MS);
|
||||
await this.userService.setEmailVerifyToken(user.id, tokenHash, expiresAt);
|
||||
|
||||
const apiUrl = (
|
||||
this.config.get<string>('APP_API_URL') ?? 'http://localhost:3100/api'
|
||||
).replace(/\/$/, '');
|
||||
const verifyUrl = `${apiUrl}/auth/verify-email?token=${rawToken}`;
|
||||
this.mailService.sendVerificationEmail(user.email, user.name, verifyUrl);
|
||||
}
|
||||
|
||||
// 토큰 원문 → SHA-256 해시(원문은 메일 링크에만, DB엔 해시만 저장)
|
||||
private hashToken(raw: string): string {
|
||||
return createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
// 프론트엔드(웹) 기준 URL — 비밀번호 재설정 폼 링크 대상(컨트롤러 webUrl 과 동일 정책)
|
||||
private webUrl(): string {
|
||||
const url =
|
||||
this.config.get<string>('FRONTEND_URL') ||
|
||||
this.config.get<string>('CORS_ORIGIN') ||
|
||||
'http://localhost:5273';
|
||||
return url.split(',')[0].trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// 재설정 링크 무효/만료 공통 예외
|
||||
private invalidResetLink(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다. 다시 요청해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
|
||||
private invalidCredentials(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
'이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 비밀번호 재설정 요청 DTO — 대상 이메일
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty({
|
||||
description: '비밀번호 재설정 메일을 받을 이메일',
|
||||
example: 'sykim@acme.co',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 인증 메일 재발송 DTO — 대상 이메일
|
||||
export class ResendVerificationDto {
|
||||
@ApiProperty({
|
||||
description: '인증 메일을 받을 이메일',
|
||||
example: 'sykim@acme.co',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 비밀번호 재설정 DTO — 메일 링크의 토큰 + 새 비밀번호(가입과 동일 규칙)
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ description: '재설정 토큰(메일 링크의 token 값)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '재설정 토큰이 필요합니다.' })
|
||||
token!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '새 비밀번호 (8~72자, 영문·숫자 포함)',
|
||||
example: 'newpass123',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
|
||||
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
|
||||
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
|
||||
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
|
||||
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
@@ -14,9 +16,17 @@ export class SignupDto {
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
|
||||
@ApiProperty({
|
||||
description: '비밀번호 (8~72자, 영문·숫자 포함)',
|
||||
example: 'password123',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
|
||||
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
|
||||
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
|
||||
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
|
||||
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
|
||||
})
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: '사용자 이름', example: '김서연' })
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// refresh 토큰 세션(패밀리) — 회전형 refresh 토큰 + 재사용 탐지의 단위.
|
||||
// 한 로그인(기기)당 1행. id=패밀리 식별자(fid), currentJti=현재 유효한 refresh 토큰 표식.
|
||||
// 갱신 시 currentJti 를 새로 발급하고, 이미 회전된(옛) 토큰이 다시 제출되면 탈취로 간주해
|
||||
// 행을 삭제(패밀리 폐기)한다. 동시 갱신(재시도) 오탐 방지를 위해 직전 jti 를 짧은 유예 동안 허용.
|
||||
@Entity('refresh_sessions')
|
||||
export class RefreshSession {
|
||||
// 패밀리 식별자(fid) — refresh JWT payload 에 담긴다
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: Relation<User>;
|
||||
|
||||
// 현재 유효한 refresh 토큰의 jti
|
||||
@Column({ name: 'current_jti', type: 'uuid' })
|
||||
currentJti!: string;
|
||||
|
||||
// 직전 jti + 회전 시각 — 동시 갱신(재시도) 유예 판정용
|
||||
@Column({ name: 'prev_jti', type: 'uuid', nullable: true })
|
||||
prevJti!: string | null;
|
||||
|
||||
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
|
||||
prevAt!: Date | null;
|
||||
|
||||
// 로그인 유지(keepLogin) 여부 — true 면 영속 쿠키(refresh TTL 만큼), false 면
|
||||
// 세션 쿠키(브라우저 종료 시 만료). refresh 회전 시에도 이 값을 따라 쿠키를 재발급한다.
|
||||
@Column({ type: 'boolean', default: true })
|
||||
persistent!: boolean;
|
||||
|
||||
// 세션 만료(refresh TTL) — 만료분 정리용
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
// refresh 토큰 기반 가드 — /auth/refresh 전용
|
||||
@Injectable()
|
||||
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
// OAuth CSRF 방지용 state 저장소 — 세션을 쓰지 않는 무상태(JWT) 구조라
|
||||
// passport-oauth2 의 기본 SessionStore 대신 httpOnly 쿠키에 1회용 난수 state 를 보관한다.
|
||||
// 인가 시작 시 store() 로 쿠키 발급 + state 반환(인가 URL 에 부착), 콜백에서 verify() 로
|
||||
// 쿠키와 제공자 반환 state 일치를 확인한다(불일치 시 인증 거부).
|
||||
const STATE_COOKIE = 'oauth_state';
|
||||
const STATE_TTL_MS = 10 * 60 * 1000; // 10분
|
||||
|
||||
type StoreCallback = (err: Error | null, state?: string) => void;
|
||||
type VerifyCallback = (
|
||||
err: Error | null,
|
||||
ok?: boolean,
|
||||
info?: { message: string },
|
||||
) => void;
|
||||
|
||||
function cookieOptions() {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: STATE_TTL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export class CookieStateStore {
|
||||
// passport 버전에 따라 (req, cb) 또는 (req, meta, cb) 로 호출되므로 마지막 인자를 콜백으로 사용
|
||||
store(req: Request, ...rest: unknown[]): void {
|
||||
const cb = rest[rest.length - 1] as StoreCallback;
|
||||
const state = randomBytes(16).toString('hex');
|
||||
req.res?.cookie(STATE_COOKIE, state, cookieOptions());
|
||||
cb(null, state);
|
||||
}
|
||||
|
||||
verify(req: Request, providedState: string, ...rest: unknown[]): void {
|
||||
const cb = rest[rest.length - 1] as VerifyCallback;
|
||||
const cookies = req.cookies as Record<string, string> | undefined;
|
||||
const expected = cookies?.[STATE_COOKIE];
|
||||
// 1회용 — 검증 직후 쿠키 제거
|
||||
req.res?.clearCookie(STATE_COOKIE, { path: '/api/auth' });
|
||||
if (!expected || expected !== providedState) {
|
||||
cb(null, false, { message: 'OAuth state 불일치' });
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
Strategy,
|
||||
type Profile,
|
||||
type StrategyOptions,
|
||||
} from 'passport-google-oauth20';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { CookieStateStore } from './cookie-state.store';
|
||||
import type { PublicUser } from '../../user/user.service';
|
||||
|
||||
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
|
||||
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
|
||||
const options: StrategyOptions & { store: CookieStateStore } = {
|
||||
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
|
||||
scope: ['email', 'profile'],
|
||||
store: new CookieStateStore(),
|
||||
};
|
||||
super(options);
|
||||
}
|
||||
|
||||
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
): Promise<PublicUser> {
|
||||
const email = profile.emails?.[0]?.value ?? null;
|
||||
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
|
||||
const avatarUrl = profile.photos?.[0]?.value ?? null;
|
||||
// 구글이 이메일 소유를 검증했는지(email_verified)
|
||||
const json = profile._json as { email_verified?: boolean } | undefined;
|
||||
const emailVerified = json?.email_verified === true;
|
||||
return this.authService.validateOrCreateOAuthUser({
|
||||
provider: 'google',
|
||||
email,
|
||||
emailVerified,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import { UserService, type PublicUser } from '../../user/user.service';
|
||||
import type { JwtPayload } from '../types/jwt-payload';
|
||||
|
||||
// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출
|
||||
@Injectable()
|
||||
export class JwtRefreshStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
'jwt-refresh',
|
||||
) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: Request): string | null =>
|
||||
(req?.cookies as Record<string, string> | undefined)?.refresh_token ??
|
||||
null,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PublicUser> {
|
||||
const user = await this.userService.findById(payload.sub);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy, type KakaoProfile } from 'passport-kakao';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { CookieStateStore } from './cookie-state.store';
|
||||
import type { PublicUser } from '../../user/user.service';
|
||||
|
||||
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
|
||||
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
|
||||
@Injectable()
|
||||
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
|
||||
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
|
||||
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
|
||||
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
|
||||
store: new CookieStateStore(),
|
||||
});
|
||||
}
|
||||
|
||||
// 카카오 프로필(_json)에서 이메일/닉네임/프로필이미지 정규화 후 위임
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: KakaoProfile,
|
||||
): Promise<PublicUser> {
|
||||
const account = profile._json?.kakao_account;
|
||||
const email = account?.email ?? null;
|
||||
// 카카오가 이메일 보유+검증을 확인했는지(is_email_verified)
|
||||
const emailVerified = account?.is_email_verified === true;
|
||||
const name =
|
||||
account?.profile?.nickname ||
|
||||
profile._json?.properties?.nickname ||
|
||||
profile.displayName ||
|
||||
email?.split('@')[0] ||
|
||||
'카카오 사용자';
|
||||
const avatarUrl =
|
||||
account?.profile?.profile_image_url ??
|
||||
profile._json?.properties?.profile_image ??
|
||||
null;
|
||||
return this.authService.validateOrCreateOAuthUser({
|
||||
provider: 'kakao',
|
||||
email,
|
||||
emailVerified,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,8 @@ export interface JwtPayload {
|
||||
sub: string;
|
||||
// 이메일 (access 토큰에만 포함)
|
||||
email?: string;
|
||||
// 세션(패밀리) id (refresh 토큰에만 포함)
|
||||
fid?: string;
|
||||
// 현재 토큰 표식 jti (refresh 토큰에만 포함) — RefreshSession.currentJti 와 일치해야 유효
|
||||
jti?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// passport-kakao 공식 타입 미제공 — 사용 범위에 맞춘 최소 선언.
|
||||
declare module 'passport-kakao' {
|
||||
export interface KakaoProfile {
|
||||
id: number | string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
_json?: {
|
||||
id: number;
|
||||
kakao_account?: {
|
||||
email?: string;
|
||||
// 카카오 이메일 보유/검증 플래그 — 미검증 이메일은 신뢰하지 않는다
|
||||
has_email?: boolean;
|
||||
is_email_verified?: boolean;
|
||||
profile?: {
|
||||
nickname?: string;
|
||||
profile_image_url?: string;
|
||||
thumbnail_image_url?: string;
|
||||
};
|
||||
};
|
||||
properties?: {
|
||||
nickname?: string;
|
||||
profile_image?: string;
|
||||
thumbnail_image?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface StrategyOptions {
|
||||
clientID: string;
|
||||
clientSecret?: string;
|
||||
callbackURL: string;
|
||||
// passport-oauth2 호환 state 저장소(OAuth CSRF 방지)
|
||||
store?: unknown;
|
||||
}
|
||||
|
||||
export type VerifyCallback = (
|
||||
error: unknown,
|
||||
user?: unknown,
|
||||
info?: unknown,
|
||||
) => void;
|
||||
|
||||
export class Strategy {
|
||||
constructor(
|
||||
options: StrategyOptions,
|
||||
verify: (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
profile: KakaoProfile,
|
||||
done: VerifyCallback,
|
||||
) => void,
|
||||
);
|
||||
name: string;
|
||||
authenticate(req: unknown, options?: unknown): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveActivity,
|
||||
type DriveActivityType,
|
||||
} from './entities/drive-activity.entity';
|
||||
|
||||
// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립
|
||||
export interface DriveActivityResponse {
|
||||
id: string;
|
||||
type: DriveActivityType;
|
||||
actor: PublicUser | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 감사 로그 적재 파라미터
|
||||
export interface RecordDriveActivityParams {
|
||||
actorId: string | null;
|
||||
type: DriveActivityType;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고,
|
||||
// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class DriveAuditService {
|
||||
private readonly logger = new Logger(DriveAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveActivity)
|
||||
private readonly activityRepo: Repository<DriveActivity>,
|
||||
) {}
|
||||
|
||||
// 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
|
||||
async record(params: RecordDriveActivityParams): Promise<void> {
|
||||
try {
|
||||
const activity = this.activityRepo.create({
|
||||
actor: params.actorId ? { id: params.actorId } : null,
|
||||
type: params.type,
|
||||
payload: params.payload ?? {},
|
||||
});
|
||||
await this.activityRepo.save(activity);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`드라이브 감사 로그 적재 실패: ${params.type}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트.
|
||||
async list(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [rows, total] = await this.activityRepo.findAndCount({
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
return paginated(
|
||||
rows.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(a: DriveActivity): DriveActivityResponse {
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
actor: a.actor ? UserService.toPublic(a.actor) : null,
|
||||
payload: a.payload ?? {},
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname } from 'path';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 1건 최대 크기(기본 200MB) — env 로 조정 가능.
|
||||
// presigned PUT 은 크기를 서명으로 강제하지 못하므로, 최종 검증은 complete 단계의 HEAD 로 한다.
|
||||
export const DRIVE_MAX_FILE_SIZE = (() => {
|
||||
const mb = Number(process.env.DRIVE_MAX_FILE_MB);
|
||||
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 200 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
// 허용 MIME 화이트리스트 — 실행/스크립트(text/html, image/svg+xml 등)는 차단(심층 방어).
|
||||
// 문서 첨부(upload.config) 기준에 드라이브 용도의 미디어 타입을 추가했다.
|
||||
export const DRIVE_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/json',
|
||||
// 압축
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
// 미디어(드라이브 용도)
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
]);
|
||||
|
||||
// 허용 MIME 여부
|
||||
export function isAllowedMime(mime: string): boolean {
|
||||
return DRIVE_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
// MIME/확장자로 아이콘 종류 파생(첨부와 동일 분류 재사용)
|
||||
export function deriveKind(mime: string, name: string): AttachmentKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'img';
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
|
||||
if (
|
||||
mime.includes('zip') ||
|
||||
mime.includes('compressed') ||
|
||||
lower.endsWith('.zip')
|
||||
) {
|
||||
return 'zip';
|
||||
}
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
// 버킷 객체 키 생성 — {APP_ENV}/drive/{userId}/{uuid}{ext}
|
||||
// 원본 파일명은 DB(name)에만 보관하고, 키는 충돌/인코딩 안전을 위해 uuid 기반으로 만든다.
|
||||
export function buildObjectKey(userId: string, filename: string): string {
|
||||
const env = process.env.APP_ENV || process.env.NODE_ENV || 'development';
|
||||
const ext = extname(filename).toLowerCase().slice(0, 16); // 비정상적으로 긴 확장자 방어
|
||||
return `${env}/drive/${userId}/${randomUUID()}${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveService,
|
||||
type DriveFileResponse,
|
||||
type DriveFolderResponse,
|
||||
type DriveListResponse,
|
||||
type PresignUploadResponse,
|
||||
} from './drive.service';
|
||||
import {
|
||||
DriveAuditService,
|
||||
type DriveActivityResponse,
|
||||
} from './drive-audit.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
import { RenameDto } from './dto/rename.dto';
|
||||
|
||||
// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요.
|
||||
@ApiTags('Drive')
|
||||
@Controller('drive')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DriveController {
|
||||
constructor(
|
||||
private readonly driveService: DriveService,
|
||||
private readonly auditService: DriveAuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
list(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('folderId') folderId?: string,
|
||||
): Promise<DriveListResponse> {
|
||||
return this.driveService.list(user.id, folderId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
activity(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
return this.auditService.list(page, size);
|
||||
}
|
||||
|
||||
/* ===================== 폴더 ===================== */
|
||||
|
||||
@Post('folders')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '폴더 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.createFolder(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('folders/:folderId')
|
||||
@ApiOperation({ summary: '폴더 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.renameFolder(user.id, folderId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('folders/:folderId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '폴더 삭제(하위 폴더·파일 포함)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFolder(user.id, folderId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 파일 ===================== */
|
||||
|
||||
@Post('files/presign')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '업로드용 presigned URL 발급(+pending 메타 생성)' })
|
||||
@ApiResponse({ status: 201, description: '발급 성공' })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '허용되지 않는 형식/크기 (VAL_001)',
|
||||
})
|
||||
presignUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
return this.driveService.presignUpload(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('files/:fileId/complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '업로드 확정(HEAD 검증 후 active 전환)' })
|
||||
@ApiResponse({ status: 200, description: '확정 성공' })
|
||||
@ApiResponse({ status: 400, description: '업로드 미완료/검증 실패' })
|
||||
completeUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.completeUpload(user.id, fileId);
|
||||
}
|
||||
|
||||
@Get('files/:fileId/download-url')
|
||||
@ApiOperation({ summary: '다운로드용 단기 presigned URL 발급' })
|
||||
@ApiResponse({ status: 200, description: '발급 성공' })
|
||||
@ApiResponse({ status: 404, description: '파일 없음/미확정 (RES_001)' })
|
||||
getDownloadUrl(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
return this.driveService.getDownloadUrl(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch('files/:fileId')
|
||||
@ApiOperation({ summary: '파일 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.renameFile(user.id, fileId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '파일 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFile(user.id, fileId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import { DriveActivity } from './entities/drive-activity.entity';
|
||||
import { DriveService } from './drive.service';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveController } from './drive.controller';
|
||||
|
||||
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
|
||||
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
|
||||
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
DriveActivity,
|
||||
]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [DriveController],
|
||||
providers: [DriveService, DriveAuditService],
|
||||
exports: [DriveService],
|
||||
})
|
||||
export class DriveModule {}
|
||||
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { In, IsNull, LessThan, Repository } from 'typeorm';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import type { PresignedUpload } from '../../common/storage/storage.types';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import type { ActivityType } from '../activity/entities/activity.entity';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import type { DriveFileStatus } from './entities/drive-file.entity';
|
||||
import {
|
||||
DRIVE_MAX_FILE_SIZE,
|
||||
buildObjectKey,
|
||||
deriveKind,
|
||||
isAllowedMime,
|
||||
} from './drive.config';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
|
||||
// --- 응답 형태 ---
|
||||
export interface DriveFolderResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveFileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
kind: AttachmentKind;
|
||||
status: DriveFileStatus;
|
||||
folderId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveListResponse {
|
||||
// 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
|
||||
current: DriveFolderResponse | null;
|
||||
breadcrumb: { id: string; name: string }[];
|
||||
folders: DriveFolderResponse[];
|
||||
files: DriveFileResponse[];
|
||||
}
|
||||
|
||||
export interface PresignUploadResponse {
|
||||
fileId: string;
|
||||
file: DriveFileResponse;
|
||||
upload: PresignedUpload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DriveService {
|
||||
private readonly logger = new Logger(DriveService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly audit: DriveAuditService,
|
||||
) {}
|
||||
|
||||
/* ===================== 조회 ===================== */
|
||||
|
||||
// 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더.
|
||||
// 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음).
|
||||
async list(_userId: string, folderId?: string): Promise<DriveListResponse> {
|
||||
if (!folderId) {
|
||||
// 루트 — 공유 최상위 폴더/파일 전체
|
||||
const [folders, files] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: IsNull() },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { folder: IsNull(), status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
current: null,
|
||||
breadcrumb: [],
|
||||
folders: folders.map((f) => this.toFolderResponse(f, null)),
|
||||
files: files.map((f) => this.toFileResponse(f, null)),
|
||||
};
|
||||
}
|
||||
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const [folders, files, breadcrumb] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: { id: folder.id } },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { folder: { id: folder.id }, status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.buildBreadcrumb(folder),
|
||||
]);
|
||||
return {
|
||||
current: this.toFolderResponse(folder, folder.parent?.id ?? null),
|
||||
breadcrumb,
|
||||
folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
|
||||
files: files.map((f) => this.toFileResponse(f, folder.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ===================== 폴더 CRUD ===================== */
|
||||
|
||||
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
|
||||
async createFolder(
|
||||
userId: string,
|
||||
dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
let parent: DriveFolder | null = null;
|
||||
if (dto.parentId) {
|
||||
parent = await this.loadFolder(dto.parentId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
|
||||
const created = await this.folderRepo.save(
|
||||
this.folderRepo.create({
|
||||
owner,
|
||||
parent: parent ? { id: parent.id } : null,
|
||||
name: dto.name,
|
||||
path: '',
|
||||
}),
|
||||
);
|
||||
created.path = `${parent ? parent.path : ''}${created.id}/`;
|
||||
await this.folderRepo.save(created);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_created',
|
||||
payload: { name: created.name },
|
||||
});
|
||||
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<DriveFolderResponse> {
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const fromName = folder.name;
|
||||
folder.name = name;
|
||||
await this.folderRepo.save(folder);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFolderResponse(folder, folder.parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리.
|
||||
async deleteFolder(userId: string, folderId: string): Promise<void> {
|
||||
const folder = await this.loadFolder(folderId);
|
||||
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
|
||||
const descendantFiles = await this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.innerJoin('f.folder', 'fd')
|
||||
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
|
||||
.getMany();
|
||||
// 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다.
|
||||
const projectIds = await this.resolveLinkedProjectIds(
|
||||
this.parseFolderPath(folder.path),
|
||||
);
|
||||
await this.folderRepo.remove(folder);
|
||||
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_deleted',
|
||||
payload: { name: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId: userId,
|
||||
type: 'drive.folder_deleted',
|
||||
payload: { folderName: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 파일 업로드(Presigned) ===================== */
|
||||
|
||||
// ① 업로드용 presigned URL 발급 + pending 메타 생성.
|
||||
async presignUpload(
|
||||
userId: string,
|
||||
dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
if (!isAllowedMime(dto.contentType)) {
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
let folder: DriveFolder | null = null;
|
||||
if (dto.folderId) {
|
||||
folder = await this.loadFolder(dto.folderId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
const objectKey = buildObjectKey(userId, dto.filename);
|
||||
const saved = await this.fileRepo.save(
|
||||
this.fileRepo.create({
|
||||
owner,
|
||||
folder: folder ? { id: folder.id } : null,
|
||||
name: dto.filename,
|
||||
bucket: this.storage.bucket,
|
||||
objectKey,
|
||||
size: dto.size,
|
||||
mime: dto.contentType,
|
||||
kind: deriveKind(dto.contentType, dto.filename),
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
const upload = await this.storage.presignUpload({
|
||||
key: objectKey,
|
||||
contentType: dto.contentType,
|
||||
});
|
||||
return {
|
||||
fileId: saved.id,
|
||||
file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
|
||||
upload,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
|
||||
async completeUpload(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관)
|
||||
if (file.owner?.id !== userId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
if (file.status === 'deleted') {
|
||||
// 이미 삭제된 묘비 행 — 확정 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (file.status === 'active') {
|
||||
// 멱등 — 이미 확정된 경우 현재 상태 반환
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
const head = await this.storage.headObject(file.objectKey);
|
||||
if (!head) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
|
||||
if (head.size > DRIVE_MAX_FILE_SIZE) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'허용 최대 파일 크기를 초과했습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (head.contentType && !isAllowedMime(head.contentType)) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
file.size = head.size;
|
||||
file.status = 'active';
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_uploaded',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_uploaded');
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
/* ===================== 파일 CRUD ===================== */
|
||||
|
||||
// 파일 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFile(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
name: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.loadFile(fileId);
|
||||
const fromName = file.name;
|
||||
file.name = name;
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
// 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고,
|
||||
// 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다.
|
||||
async deleteFile(userId: string, fileId: string): Promise<void> {
|
||||
const file = await this.loadFile(fileId);
|
||||
file.status = 'deleted';
|
||||
file.deletedAt = new Date();
|
||||
file.deletedBy = { id: userId } as User;
|
||||
await this.fileRepo.save(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_deleted',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_deleted');
|
||||
}
|
||||
|
||||
// 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능.
|
||||
async getDownloadUrl(
|
||||
_userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
const file = await this.loadFile(fileId);
|
||||
if (file.status !== 'active') {
|
||||
// 아직 업로드 확정 전 — 다운로드 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const url = await this.storage.presignDownload({
|
||||
key: file.objectKey,
|
||||
filename: file.name,
|
||||
contentType: file.mime,
|
||||
});
|
||||
return { url };
|
||||
}
|
||||
|
||||
/* ===================== 로딩 ===================== */
|
||||
|
||||
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
|
||||
private async loadFolder(folderId: string): Promise<DriveFolder> {
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: folderId },
|
||||
relations: { owner: true, parent: true },
|
||||
});
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
|
||||
private async loadFile(fileId: string): Promise<DriveFile> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file || file.status === 'deleted') {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
|
||||
private parseFolderPath(path: string): string[] {
|
||||
return path.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
// 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
|
||||
private async buildBreadcrumb(
|
||||
folder: DriveFolder,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const ids = this.parseFolderPath(folder.path);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.folderRepo.find({
|
||||
where: { id: In(ids) },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const byId = new Map(rows.map((r) => [r.id, r.name]));
|
||||
return ids
|
||||
.filter((id) => byId.has(id))
|
||||
.map((id) => ({ id, name: byId.get(id) as string }));
|
||||
}
|
||||
|
||||
/* ===================== 내부 유틸 ===================== */
|
||||
|
||||
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
|
||||
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
|
||||
private async recordFileActivity(
|
||||
file: DriveFile,
|
||||
actorId: string,
|
||||
type: ActivityType,
|
||||
): Promise<void> {
|
||||
const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : [];
|
||||
const projectIds = await this.resolveLinkedProjectIds(folderIds);
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId,
|
||||
type,
|
||||
payload: { fileName: file.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환.
|
||||
private async resolveLinkedProjectIds(
|
||||
folderIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (folderIds.length === 0) return [];
|
||||
const links = await this.linkRepo.find({
|
||||
where: { folder: { id: In(folderIds) } },
|
||||
relations: { project: true },
|
||||
});
|
||||
return [...new Set(links.map((l) => l.project.id))];
|
||||
}
|
||||
|
||||
private async getUserOrThrow(userId: string): Promise<User> {
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
|
||||
private async discardFile(file: DriveFile): Promise<void> {
|
||||
await this.fileRepo.remove(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
}
|
||||
|
||||
// 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
|
||||
private async deleteObjects(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await this.storage.deleteObject(key);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`객체 삭제 실패(무시): ${key} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async cleanupStalePending(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const stale = await this.fileRepo.find({
|
||||
where: { status: 'pending', createdAt: LessThan(cutoff) },
|
||||
});
|
||||
if (stale.length === 0) return;
|
||||
await this.deleteObjects(stale.map((f) => f.objectKey));
|
||||
await this.fileRepo.remove(stale);
|
||||
this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 매핑 ===================== */
|
||||
|
||||
private toFolderResponse(
|
||||
folder: DriveFolder,
|
||||
parentId: string | null,
|
||||
): DriveFolderResponse {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId,
|
||||
owner: folder.owner ? UserService.toPublic(folder.owner) : null,
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
updatedAt: folder.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toFileResponse(
|
||||
file: DriveFile,
|
||||
folderId: string | null,
|
||||
): DriveFileResponse {
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime: file.mime,
|
||||
kind: file.kind,
|
||||
status: file.status,
|
||||
folderId,
|
||||
owner: file.owner ? UserService.toPublic(file.owner) : null,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
updatedAt: file.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
// 폴더 생성 요청
|
||||
export class CreateFolderDto {
|
||||
@ApiProperty({ description: '폴더명', example: '디자인 시안' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '폴더명은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '상위 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 상위 폴더 ID가 아닙니다.' })
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DRIVE_MAX_FILE_SIZE } from '../drive.config';
|
||||
|
||||
// 업로드용 presigned URL 발급 요청
|
||||
export class PresignUploadDto {
|
||||
@ApiProperty({ description: '원본 파일명', example: '기획서.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '파일명은 1~255자로 입력해 주세요.' })
|
||||
filename!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MIME 타입(업로드 시 동일 Content-Type 으로 전송해야 함)',
|
||||
example: 'application/pdf',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 255)
|
||||
contentType!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 크기(바이트)',
|
||||
example: 1048576,
|
||||
maximum: DRIVE_MAX_FILE_SIZE,
|
||||
})
|
||||
@IsInt({ message: '파일 크기가 올바르지 않습니다.' })
|
||||
@Min(1, { message: '빈 파일은 업로드할 수 없습니다.' })
|
||||
@Max(DRIVE_MAX_FILE_SIZE, { message: '허용 최대 파일 크기를 초과했습니다.' })
|
||||
size!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '업로드 대상 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
|
||||
folderId?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 폴더/파일 이름 변경 요청 (공용)
|
||||
export class RenameDto {
|
||||
@ApiProperty({ description: '변경할 이름', example: '최종_시안.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '이름은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용)
|
||||
export type DriveActivityType =
|
||||
| 'file_uploaded'
|
||||
| 'file_renamed'
|
||||
| 'file_deleted'
|
||||
| 'folder_created'
|
||||
| 'folder_renamed'
|
||||
| 'folder_deleted';
|
||||
|
||||
// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적.
|
||||
// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다.
|
||||
// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙).
|
||||
@Entity('drive_activities')
|
||||
export class DriveActivity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'actor_id' })
|
||||
actor!: User | null;
|
||||
|
||||
// 이벤트 타입
|
||||
@Column({ type: 'varchar' })
|
||||
type!: DriveActivityType;
|
||||
|
||||
// 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제)
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
import { numericColumnTransformer } from '../../../common/database/numeric-column.transformer';
|
||||
import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 업로드 상태
|
||||
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
|
||||
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
|
||||
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
|
||||
|
||||
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
|
||||
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
|
||||
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
|
||||
|
||||
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
|
||||
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
|
||||
@Entity('drive_files')
|
||||
export class DriveFile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder> | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 저장 버킷 — 추후 버킷 이전 대비해 메타에 함께 보관
|
||||
@Column()
|
||||
bucket!: string;
|
||||
|
||||
// 버킷 내 객체 키(고유). 예: development/drive/{userId}/{uuid}-name.pdf
|
||||
@Index({ unique: true })
|
||||
@Column({ name: 'object_key' })
|
||||
objectKey!: string;
|
||||
|
||||
// 파일 크기(바이트) — 대용량 대비 bigint(문자열) → number 변환
|
||||
@Column({ type: 'bigint', transformer: numericColumnTransformer })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생) — 업무/프로젝트 첨부와 동일 분류 재사용
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
// 업로드 상태 — pending 은 미완료(목록/다운로드 비노출), 정리 크론 대상
|
||||
@Index()
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status!: DriveFileStatus;
|
||||
|
||||
// AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음
|
||||
@Column({
|
||||
name: 'extracted_text',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
extractedText!: string | null;
|
||||
|
||||
// AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용
|
||||
@Column({ name: 'extract_status', type: 'varchar', default: 'pending' })
|
||||
extractStatus!: DriveExtractStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다.
|
||||
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
|
||||
deletedAt!: Date | null;
|
||||
|
||||
// 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'deleted_by_id' })
|
||||
deletedBy!: User | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
|
||||
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
|
||||
@Entity('drive_folders')
|
||||
export class DriveFolder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
|
||||
// Relation<> 래퍼: 자기참조 순환 회피
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent!: Relation<DriveFolder> | null;
|
||||
|
||||
// 폴더명
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 머터리얼라이즈드 패스 — 루트부터 자기 자신까지의 폴더 id 를 '/' 로 이어 저장(자기 포함, 끝에 '/').
|
||||
// 예: "rootId/childId/" . 조상 연동 여부 판정과 하위 트리 조회(LIKE)에 사용한다.
|
||||
// (parent_id 가 트리의 SSOT 이고, path 는 빠른 조회를 위한 파생값)
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
path!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
|
||||
// 연동 권한 — read: 보기/다운로드만, read-write: 프로젝트 멤버도 업로드 가능
|
||||
export type DriveLinkPermission = 'read' | 'read-write';
|
||||
|
||||
// 프로젝트-폴더 연동 — 드라이브 폴더를 프로젝트에 공유한다.
|
||||
// 연동된 폴더와 그 하위 트리 전체가 해당 프로젝트 멤버에게 노출된다.
|
||||
@Entity('project_folder_links')
|
||||
@Unique('UQ_project_folder_link', ['project', 'folder'])
|
||||
export class ProjectFolderLink {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 대상 프로젝트 (프로젝트 삭제 시 연동 해제)
|
||||
@Index()
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder>;
|
||||
|
||||
// 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'linked_by_id' })
|
||||
linkedBy!: User | null;
|
||||
|
||||
// 연동 권한 — 기본 read(안전)
|
||||
@Column({ type: 'varchar', default: 'read' })
|
||||
permission!: DriveLinkPermission;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GiteaService } from './gitea.service';
|
||||
|
||||
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
|
||||
@Module({
|
||||
providers: [GiteaService],
|
||||
exports: [GiteaService],
|
||||
})
|
||||
export class GiteaModule {}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GiteaApiError, GiteaService } from './gitea.service';
|
||||
|
||||
// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환
|
||||
function configStub(values: Record<string, string>): ConfigService {
|
||||
return {
|
||||
get: (key: string): string | undefined => values[key],
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
// fetch 응답 간이 모킹
|
||||
function mockResponse(status: number, body: unknown): Promise<Response> {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : ''),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('GiteaService', () => {
|
||||
const fullConfig = {
|
||||
GITEA_BASE_URL: 'https://gitea.example.com/', // 끝 슬래시 포함 — 제거되어야 함
|
||||
GITEA_TOKEN: 'secret-token',
|
||||
GITEA_ORG: 'marketing-team',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('enabled', () => {
|
||||
it('필수 env 가 모두 있으면 활성화', () => {
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
expect(svc.enabled).toBe(true);
|
||||
expect(svc.org).toBe('marketing-team');
|
||||
});
|
||||
|
||||
it('하나라도 비면 비활성화', () => {
|
||||
const svc = new GiteaService(
|
||||
configStub({ GITEA_BASE_URL: 'https://gitea.example.com' }),
|
||||
);
|
||||
expect(svc.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRepo', () => {
|
||||
it('조직 경로로 POST 하고 응답을 매핑한다(베이스 URL 끝 슬래시 제거)', async () => {
|
||||
const res = mockResponse(201, {
|
||||
id: 42,
|
||||
name: 'q3-launch',
|
||||
full_name: 'marketing-team/q3-launch',
|
||||
description: 'q3 — 런칭',
|
||||
private: true,
|
||||
clone_url: 'https://gitea.example.com/marketing-team/q3-launch.git',
|
||||
html_url: 'https://gitea.example.com/marketing-team/q3-launch',
|
||||
default_branch: 'main',
|
||||
});
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
|
||||
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
const result = await svc.createRepo({
|
||||
name: 'q3-launch',
|
||||
description: '런칭',
|
||||
private: true,
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
'https://gitea.example.com/api/v1/orgs/marketing-team/repos',
|
||||
);
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||||
name: 'q3-launch',
|
||||
description: '런칭',
|
||||
private: true,
|
||||
default_branch: 'main',
|
||||
auto_init: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 42,
|
||||
name: 'q3-launch',
|
||||
fullName: 'marketing-team/q3-launch',
|
||||
description: 'q3 — 런칭',
|
||||
private: true,
|
||||
cloneUrl: 'https://gitea.example.com/marketing-team/q3-launch.git',
|
||||
htmlUrl: 'https://gitea.example.com/marketing-team/q3-launch',
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
it('실패 응답은 상태코드를 보존한 GiteaApiError 로 던진다', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockReturnValue(mockResponse(409, 'conflict'));
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
|
||||
const call = () =>
|
||||
svc.createRepo({
|
||||
name: 'dup',
|
||||
description: '',
|
||||
private: false,
|
||||
defaultBranch: 'main',
|
||||
});
|
||||
|
||||
await expect(call()).rejects.toBeInstanceOf(GiteaApiError);
|
||||
await expect(call()).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listOrgRepos', () => {
|
||||
it('조직 저장소를 페이지네이션 순회하며 매핑한다', async () => {
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `repo-${i + 1}`,
|
||||
full_name: `marketing-team/repo-${i + 1}`,
|
||||
description: '',
|
||||
private: false,
|
||||
clone_url: `https://gitea.example.com/marketing-team/repo-${i + 1}.git`,
|
||||
html_url: `https://gitea.example.com/marketing-team/repo-${i + 1}`,
|
||||
default_branch: 'main',
|
||||
}));
|
||||
const page2 = [
|
||||
{
|
||||
id: 51,
|
||||
name: 'repo-51',
|
||||
full_name: 'marketing-team/repo-51',
|
||||
description: '표시명 — 설명',
|
||||
private: true,
|
||||
clone_url: 'https://gitea.example.com/marketing-team/repo-51.git',
|
||||
html_url: 'https://gitea.example.com/marketing-team/repo-51',
|
||||
default_branch: 'develop',
|
||||
},
|
||||
];
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockReturnValueOnce(mockResponse(200, page1))
|
||||
.mockReturnValueOnce(mockResponse(200, page2));
|
||||
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
const result = await svc.listOrgRepos();
|
||||
|
||||
// 첫 페이지가 한도(50)면 다음 페이지까지 요청
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
'https://gitea.example.com/api/v1/orgs/marketing-team/repos?page=1&limit=50',
|
||||
);
|
||||
expect(result).toHaveLength(51);
|
||||
expect(result[50]).toEqual({
|
||||
id: 51,
|
||||
name: 'repo-51',
|
||||
fullName: 'marketing-team/repo-51',
|
||||
description: '표시명 — 설명',
|
||||
private: true,
|
||||
cloneUrl: 'https://gitea.example.com/marketing-team/repo-51.git',
|
||||
htmlUrl: 'https://gitea.example.com/marketing-team/repo-51',
|
||||
defaultBranch: 'develop',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRepo', () => {
|
||||
it('repo 경로로 DELETE 한다', async () => {
|
||||
const res = mockResponse(204, '');
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
|
||||
const svc = new GiteaService(configStub(fullConfig));
|
||||
|
||||
await svc.deleteRepo('q3-launch');
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
'https://gitea.example.com/api/v1/repos/marketing-team/q3-launch',
|
||||
);
|
||||
expect(init?.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림)
|
||||
export interface GiteaRepoResult {
|
||||
id: number;
|
||||
name: string; // 저장소 이름(= Relay slugName)
|
||||
fullName: string; // 'org/slug'
|
||||
description: string; // 설명(없으면 빈 문자열)
|
||||
private: boolean; // 비공개 여부
|
||||
cloneUrl: string; // HTTPS clone URL
|
||||
htmlUrl: string; // 웹 UI URL
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
// Gitea API 호출 실패 — 상태 코드를 보존해 호출부에서 분기(예: 409 → 이름 충돌)
|
||||
export class GiteaApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GiteaApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea REST API(/api/v1) 응답 중 사용하는 필드만 정의
|
||||
interface GiteaRepoApi {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
private: boolean;
|
||||
clone_url: string;
|
||||
html_url: string;
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
// 외부 Gitea 인스턴스와 통신하는 얇은 API 클라이언트.
|
||||
// 조직(GITEA_ORG) 아래 저장소를 생성/수정/삭제한다.
|
||||
// 필수 env(GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG)가 비어 있으면 비활성화 상태가 되어,
|
||||
// 저장소 CRUD 는 Gitea 호출 없이 기존과 동일하게 동작한다(로컬/미연동 환경 대비).
|
||||
@Injectable()
|
||||
export class GiteaService {
|
||||
private readonly logger = new Logger(GiteaService.name);
|
||||
private readonly baseUrl: string; // 끝 슬래시 제거된 형태
|
||||
private readonly token: string;
|
||||
readonly org: string;
|
||||
// 템플릿 저장소(이 저장소를 복제해 새 repo 생성). 기본값: TtiPo/project-base
|
||||
readonly templateOwner: string;
|
||||
readonly templateRepo: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.baseUrl = (config.get<string>('GITEA_BASE_URL') ?? '').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
this.token = config.get<string>('GITEA_TOKEN') ?? '';
|
||||
this.org = config.get<string>('GITEA_ORG') ?? '';
|
||||
this.templateOwner = config.get<string>('GITEA_TEMPLATE_OWNER') ?? 'TtiPo';
|
||||
this.templateRepo =
|
||||
config.get<string>('GITEA_TEMPLATE_REPO') ?? 'project-base';
|
||||
|
||||
if (this.enabled) {
|
||||
this.logger.log(`Gitea 연동 활성화 — ${this.baseUrl} (org: ${this.org})`);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'Gitea 연동 비활성화 — GITEA_BASE_URL/GITEA_TOKEN/GITEA_ORG 미설정. 저장소는 Gitea 동기화 없이 동작합니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 설정이 모두 있으면 연동 활성화
|
||||
get enabled(): boolean {
|
||||
return Boolean(this.baseUrl && this.token && this.org);
|
||||
}
|
||||
|
||||
// 템플릿 식별자 (owner/repo) — 프론트 표시·로깅용
|
||||
get templateSlug(): string {
|
||||
return `${this.templateOwner}/${this.templateRepo}`;
|
||||
}
|
||||
|
||||
// 템플릿 복제 사용 가능 여부 — 연동 활성 + 템플릿 owner/repo 설정
|
||||
get templateEnabled(): boolean {
|
||||
return this.enabled && Boolean(this.templateOwner && this.templateRepo);
|
||||
}
|
||||
|
||||
// 조직 아래 저장소 생성 (초기 커밋/기본 브랜치 포함)
|
||||
async createRepo(input: {
|
||||
name: string; // = Relay slugName
|
||||
description: string;
|
||||
private: boolean;
|
||||
defaultBranch: string;
|
||||
}): Promise<GiteaRepoResult> {
|
||||
const data = await this.request<GiteaRepoApi>(
|
||||
'POST',
|
||||
`/orgs/${encodeURIComponent(this.org)}/repos`,
|
||||
{
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
private: input.private,
|
||||
default_branch: input.defaultBranch,
|
||||
auto_init: true, // 초기 커밋 + 기본 브랜치 생성
|
||||
},
|
||||
);
|
||||
return this.toResult(data);
|
||||
}
|
||||
|
||||
// 템플릿 저장소를 복제해 조직 아래 새 저장소 생성 (git 콘텐츠 포함).
|
||||
// 생성된 저장소의 기본 브랜치는 템플릿을 따른다.
|
||||
async generateFromTemplate(input: {
|
||||
name: string; // = Relay slugName
|
||||
description: string;
|
||||
private: boolean;
|
||||
}): Promise<GiteaRepoResult> {
|
||||
const data = await this.request<GiteaRepoApi>(
|
||||
'POST',
|
||||
`/repos/${encodeURIComponent(this.templateOwner)}/${encodeURIComponent(
|
||||
this.templateRepo,
|
||||
)}/generate`,
|
||||
{
|
||||
owner: this.org, // 대상 조직
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
private: input.private,
|
||||
git_content: true, // 템플릿의 파일/폴더 구조 복제
|
||||
},
|
||||
);
|
||||
return this.toResult(data);
|
||||
}
|
||||
|
||||
// 저장소 메타데이터(설명/공개범위) 수정 — slug(name) 는 변경하지 않음
|
||||
async updateRepo(
|
||||
name: string,
|
||||
patch: { description?: string; private?: boolean },
|
||||
): Promise<void> {
|
||||
await this.request<GiteaRepoApi>(
|
||||
'PATCH',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
description: patch.description,
|
||||
private: patch.private,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
async deleteRepo(name: string): Promise<void> {
|
||||
await this.request<null>(
|
||||
'DELETE',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용
|
||||
async listOrgRepos(): Promise<GiteaRepoResult[]> {
|
||||
const limit = 50; // Gitea 기본 최대 페이지 크기
|
||||
const all: GiteaRepoResult[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const data = await this.request<GiteaRepoApi[]>(
|
||||
'GET',
|
||||
`/orgs/${encodeURIComponent(this.org)}/repos?page=${page}&limit=${limit}`,
|
||||
);
|
||||
if (!data || data.length === 0) break;
|
||||
all.push(...data.map((d) => this.toResult(d)));
|
||||
// 마지막 페이지(요청 한도 미만)면 종료
|
||||
if (data.length < limit) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError
|
||||
private async request<T>(
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `token ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new GiteaApiError(
|
||||
res.status,
|
||||
`Gitea ${method} ${path} 실패 → ${res.status} ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 204 No Content(삭제 등) 또는 빈 본문 대응
|
||||
if (res.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// API 응답 → 보관용 결과 매핑
|
||||
private toResult(data: GiteaRepoApi): GiteaRepoResult {
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
fullName: data.full_name,
|
||||
description: data.description ?? '',
|
||||
private: data.private,
|
||||
cloneUrl: data.clone_url,
|
||||
htmlUrl: data.html_url,
|
||||
defaultBranch: data.default_branch,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공.
|
||||
@Module({
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
// 메일 발송 서비스 (플레이스홀더) —
|
||||
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다(개발/운영 공통).
|
||||
// 실 메일러(nodemailer 등) 도입 전까지는 인증/재설정 링크를 로그에서 확인한다.
|
||||
// ⚠️ 보안: 링크에는 원문 토큰이 포함된다. 실 메일러 도입 시 반드시 이 로그 출력을 제거하고
|
||||
// 실제 이메일 전송으로 교체할 것(운영 로그에 토큰을 남기지 않기 위함).
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
// 가입 인증 메일 — 실 메일러 전까지 인증 링크(원문 토큰 포함)를 로그로 출력한다(개발/운영 공통).
|
||||
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
'──────────────── [메일 발송 - 가입 인증] ────────────────',
|
||||
`받는 사람 : ${name} <${to}>`,
|
||||
'제목 : [Relay] 이메일 인증을 완료해 주세요',
|
||||
'본문 : 아래 링크를 클릭하면 회원가입이 완료됩니다.',
|
||||
`인증 링크 : ${verifyUrl}`,
|
||||
'──────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 메일 — 실 메일러 전까지 재설정 링크(원문 토큰 포함)를 로그로 출력한다(개발/운영 공통).
|
||||
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
|
||||
`받는 사람 : ${name} <${to}>`,
|
||||
'제목 : [Relay] 비밀번호 재설정 안내',
|
||||
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
|
||||
`재설정 링크 : ${resetUrl}`,
|
||||
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
|
||||
'────────────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
// 화상회의 방 생성 요청
|
||||
export class CreateMeetingRoomDto {
|
||||
@ApiProperty({
|
||||
description: '방 이름(한글/영문/숫자/-/_ 허용, 공백 불가, 고유)',
|
||||
example: '주간-회의',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
|
||||
@Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, {
|
||||
message:
|
||||
'방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
|
||||
example: '2026-07-01T10:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
|
||||
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
|
||||
allowedUserIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
|
||||
export class CreateMeetingTokenDto {
|
||||
@ApiProperty({
|
||||
description: '참여할 회의 방 입장 코드(영문/숫자)',
|
||||
example: 'a1b2c3d4e5',
|
||||
})
|
||||
@IsString()
|
||||
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
|
||||
@Matches(/^[A-Za-z0-9]+$/, {
|
||||
message: '입장 코드 형식이 올바르지 않습니다.',
|
||||
})
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
|
||||
|
||||
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
|
||||
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
|
||||
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
|
||||
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
|
||||
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
|
||||
@Entity('meeting_rooms')
|
||||
export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
|
||||
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
code!: string;
|
||||
|
||||
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'host_id' })
|
||||
host!: Relation<User> | null;
|
||||
|
||||
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
|
||||
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
|
||||
scheduledAt!: Date | null;
|
||||
|
||||
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
|
||||
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
|
||||
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
|
||||
allowedUserIds!: string[] | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
MeetingService,
|
||||
type MeetingTokenResult,
|
||||
type MeetingRoomResponse,
|
||||
} from './meeting.service';
|
||||
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
|
||||
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
|
||||
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
|
||||
|
||||
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
|
||||
@ApiTags('화상회의')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('meetings')
|
||||
export class MeetingController {
|
||||
constructor(private readonly meetingService: MeetingService) {}
|
||||
|
||||
@Get('rooms')
|
||||
@ApiOperation({ summary: '회의 방 목록' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
|
||||
return this.meetingService.listRooms(user.id);
|
||||
}
|
||||
|
||||
@Post('rooms')
|
||||
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
return this.meetingService.createRoom(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: '회의 방 수정(진행자만)' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
updateRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('roomId', ParseUUIDPipe) roomId: string,
|
||||
@Body() dto: UpdateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
return this.meetingService.updateRoom(user.id, roomId, dto);
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
|
||||
@ApiResponse({ status: 204, description: '삭제 성공' })
|
||||
async deleteRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('roomId', ParseUUIDPipe) roomId: string,
|
||||
): Promise<void> {
|
||||
await this.meetingService.deleteRoom(user.id, roomId);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
|
||||
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
|
||||
issueToken(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
|
||||
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
|
||||
// User repo 는 참여 제한(allow-list) 멤버를 진행자 편집 폼용으로 조회하는 데 사용한다.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MeetingRoom, User])],
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
})
|
||||
export class MeetingModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user