Compare commits
72 Commits
42db9b5d00
...
9e29d0d0b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,23 @@ 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
|
||||
|
||||
+20
-5
@@ -1,5 +1,11 @@
|
||||
# ==========================================
|
||||
# 프로젝트 전역 설정
|
||||
# 루트 .env (production) — 모든 설정의 단일 진실 공급원(SSOT)
|
||||
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
|
||||
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
|
||||
# ==========================================
|
||||
|
||||
# ==========================================
|
||||
# 프로젝트 전역
|
||||
# ==========================================
|
||||
PROJECT_NAME=comrelay
|
||||
APP_ENV=production
|
||||
@@ -8,7 +14,7 @@ FRONTEND_DIR=frontend
|
||||
BACKEND_DIR=backend
|
||||
|
||||
# ==========================================
|
||||
# 포트 설정
|
||||
# 포트
|
||||
# ==========================================
|
||||
FRONTEND_PORT=5173
|
||||
BACKEND_PORT=3000
|
||||
@@ -17,7 +23,7 @@ DB_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# Database (PostgreSQL)
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
DB_USER=prd_ttipo
|
||||
@@ -26,14 +32,23 @@ 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
|
||||
|
||||
@@ -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,44 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 설정 — 루트 .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
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
# ==========================================
|
||||
# 백엔드 고유 설정 — 루트 .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
|
||||
|
||||
Generated
+125
-11
@@ -22,6 +22,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -32,7 +33,9 @@
|
||||
"ioredis": "^5.11.1",
|
||||
"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",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -3897,7 +3900,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -3908,7 +3910,6 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -3965,7 +3966,6 @@
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -3978,7 +3978,6 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -3998,7 +3997,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
@@ -4085,16 +4083,35 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/oauth": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
|
||||
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
"integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-google-oauth20": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
|
||||
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*",
|
||||
"@types/passport-oauth2": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
@@ -4106,6 +4123,17 @@
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-oauth2": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/oauth": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
@@ -4121,21 +4149,18 @@
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -4145,7 +4170,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -5589,6 +5613,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64url": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
|
||||
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.27",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
|
||||
@@ -8511,7 +8544,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
|
||||
"integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.10.0",
|
||||
"cluster-key-slot": "1.1.1",
|
||||
@@ -10496,6 +10528,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -10778,6 +10816,18 @@
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-google-oauth20": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-oauth2": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
@@ -10788,6 +10838,55 @@
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-kakao": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz",
|
||||
"integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-oauth2": "~1.1.2",
|
||||
"pkginfo": "~0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-kakao/node_modules/oauth": {
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/passport-kakao/node_modules/passport-oauth2": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz",
|
||||
"integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==",
|
||||
"dependencies": {
|
||||
"oauth": "0.9.x",
|
||||
"passport-strategy": "1.x.x",
|
||||
"uid2": "0.0.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-oauth2": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64url": "3.x.x",
|
||||
"oauth": "0.10.x",
|
||||
"passport-strategy": "1.x.x",
|
||||
"uid2": "0.0.x",
|
||||
"utils-merge": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
@@ -11083,6 +11182,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pkginfo": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz",
|
||||
"integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
@@ -13129,6 +13237,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/uid2": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -43,7 +44,9 @@
|
||||
"ioredis": "^5.11.1",
|
||||
"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",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -13,10 +13,11 @@ import { AppService } from './app.service';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -55,10 +56,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({
|
||||
@@ -91,10 +93,11 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
HealthModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
RepoModule,
|
||||
TaskModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
AiModule,
|
||||
ProjectModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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). 미설정 시 정리 훅이 호출되지 않는다.
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
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,
|
||||
): Promise<ActivityResponse[]> {
|
||||
return this.activityService.listForProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +1,8 @@
|
||||
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 { 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';
|
||||
|
||||
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
@@ -17,7 +17,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 +25,7 @@ export interface RecordActivityParams {
|
||||
}
|
||||
|
||||
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
|
||||
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
// 프로젝트/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
private readonly logger = new Logger(ActivityService.name);
|
||||
@@ -33,15 +33,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,14 +54,10 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
|
||||
async listForRepo(slugName: string): Promise<ActivityResponse[]> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
|
||||
async listForProject(projectId: string): Promise<ActivityResponse[]> {
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repo.id } },
|
||||
where: { project: { id: projectId } },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
@@ -71,34 +65,19 @@ export class ActivityService {
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
// 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
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,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(a: Activity): ActivityResponse {
|
||||
return {
|
||||
|
||||
@@ -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'
|
||||
@@ -39,14 +38,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,487 @@
|
||||
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 만 출력합니다.',
|
||||
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 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 userMsg = [
|
||||
`[프로젝트] 표시 제목: ${project.name}`,
|
||||
`설명: ${project.description ?? '(없음)'}`,
|
||||
'',
|
||||
`[업무] 제목: ${input.title}`,
|
||||
'내용:',
|
||||
content,
|
||||
].join('\n');
|
||||
|
||||
// 4개 카테고리 × 항목 수 무제한의 구조화 JSON 이라 토큰 여유를 넉넉히 준다
|
||||
// (부족하면 응답이 중간에 잘려 JSON 파싱이 실패할 수 있음)
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
4096,
|
||||
);
|
||||
return { groups: this.parseSuggestions(raw) };
|
||||
}
|
||||
|
||||
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
|
||||
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
|
||||
let text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/, '')
|
||||
.trim();
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) text = text.slice(start, end + 1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
|
||||
);
|
||||
throw this.suggestFailed();
|
||||
}
|
||||
|
||||
const rawGroups = (parsed as { groups?: unknown }).groups;
|
||||
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
|
||||
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;
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -71,23 +87,79 @@ export class AuthController {
|
||||
});
|
||||
}
|
||||
|
||||
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
|
||||
private async loginWith(res: Response, user: PublicUser): Promise<void> {
|
||||
const tokens = await this.authService.issueTokensForNewSession(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
}
|
||||
|
||||
@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 +171,92 @@ 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);
|
||||
await this.loginWith(res, user);
|
||||
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);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
// ── 카카오 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);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
@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);
|
||||
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,17 +31,36 @@ export interface TokenPair {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
|
||||
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
|
||||
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> {
|
||||
// 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X)
|
||||
async signup(dto: SignupDto): Promise<SignupResult> {
|
||||
const exists = await this.userService.findByEmail(dto.email);
|
||||
if (exists) {
|
||||
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
|
||||
@@ -43,27 +77,256 @@ export class AuthService {
|
||||
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 토큰 발급
|
||||
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
|
||||
// 만료된 이 사용자의 세션 정리(누적 방지)
|
||||
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,
|
||||
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
|
||||
}),
|
||||
);
|
||||
return this.signTokens(user.id, user.email, session.id, jti);
|
||||
}
|
||||
|
||||
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
|
||||
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
|
||||
async rotateTokens(refreshToken: string | undefined): Promise<TokenPair> {
|
||||
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);
|
||||
return this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
newJti,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
|
||||
if (
|
||||
payload.jti === session.prevJti &&
|
||||
session.prevAt &&
|
||||
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
|
||||
) {
|
||||
return this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
session.currentJti,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 +334,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 +343,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,43 @@
|
||||
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;
|
||||
|
||||
// 세션 만료(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;
|
||||
}
|
||||
}
|
||||
@@ -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,220 +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) 는 변경하지 않음.
|
||||
// default_branch 는 Gitea 에 실제로 존재하는 브랜치여야 한다(없으면 Gitea 가 에러).
|
||||
async updateRepo(
|
||||
name: string,
|
||||
patch: { description?: string; private?: boolean; defaultBranch?: string },
|
||||
): Promise<void> {
|
||||
await this.request<GiteaRepoApi>(
|
||||
'PATCH',
|
||||
`/repos/${encodeURIComponent(this.org)}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
description: patch.description,
|
||||
private: patch.private,
|
||||
default_branch: patch.defaultBranch,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 삭제
|
||||
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,61 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
// 메일 발송 서비스 (플레이스홀더) —
|
||||
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다.
|
||||
// 추후 nodemailer 등 도입 시 이 서비스 내부 구현만 교체하면 호출부는 그대로 유지된다.
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
// 가입 인증 메일 — 개발 환경에서만 인증 링크(원문 토큰 포함)를 로그로 출력한다.
|
||||
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
|
||||
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
if (isProd) {
|
||||
this.logger.log(
|
||||
`[메일 발송 - 가입 인증] 수신자=${to} (인증 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
'──────────────── [메일 발송 - 가입 인증] ────────────────',
|
||||
`받는 사람 : ${name} <${to}>`,
|
||||
'제목 : [Relay] 이메일 인증을 완료해 주세요',
|
||||
'본문 : 아래 링크를 클릭하면 회원가입이 완료됩니다.',
|
||||
`인증 링크 : ${verifyUrl}`,
|
||||
'──────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 메일 — 개발 환경에서만 재설정 링크(원문 토큰 포함)를 로그로 출력한다.
|
||||
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
|
||||
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
|
||||
const isProd = this.config.get<string>('NODE_ENV') === 'production';
|
||||
if (isProd) {
|
||||
this.logger.log(
|
||||
`[메일 발송 - 비밀번호 재설정] 수신자=${to} (재설정 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
[
|
||||
'',
|
||||
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
|
||||
`받는 사람 : ${name} <${to}>`,
|
||||
'제목 : [Relay] 비밀번호 재설정 안내',
|
||||
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
|
||||
`재설정 링크 : ${resetUrl}`,
|
||||
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
|
||||
'────────────────────────────────────────────────────────────',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 알림 타입 — 수신자 관점의 이벤트(활동 피드와 별개: 활동은 저장소 전체 로그, 알림은 개인 수신함)
|
||||
// 알림 타입 — 수신자 관점의 이벤트(활동 피드와 별개: 활동은 프로젝트 전체 로그, 알림은 개인 수신함)
|
||||
export type NotificationType =
|
||||
| 'task.assigned' // 나에게 업무가 지시됨
|
||||
| 'task.unassigned' // 업무 담당에서 제외됨
|
||||
@@ -20,9 +20,9 @@ export type NotificationType =
|
||||
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'member.invited' // 저장소에 멤버로 초대됨
|
||||
| 'member.role_changed' // 저장소 내 내 역할이 변경됨
|
||||
| 'member.removed'; // 저장소에서 제외됨
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
|
||||
| 'member.removed'; // 프로젝트에서 제외됨
|
||||
|
||||
// 알림 — 사용자별 수신함. 표시 문구/아이콘은 프론트가 type+payload 로 조립한다.
|
||||
@Entity('notifications')
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 프로젝트 생성 DTO
|
||||
export class CreateProjectDto {
|
||||
@ApiProperty({
|
||||
description: '표시 제목',
|
||||
example: '3분기 신제품 런칭 캠페인',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '프로젝트 이름을 입력해 주세요.' })
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { IsEmail, IsIn, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { MemberRoleType } from '../entities/repo-member.entity';
|
||||
import type { ProjectRoleType } from '../entities/project-member.entity';
|
||||
|
||||
// 멤버 초대 DTO — 가입된 사용자를 이메일로 초대(별도 초대 메일/계정 생성 없음)
|
||||
export class InviteMemberDto {
|
||||
// 프로젝트 멤버 초대 DTO — 가입된 사용자를 이메일로 초대
|
||||
export class InviteProjectMemberDto {
|
||||
@ApiProperty({
|
||||
description: '초대할 사용자 이메일(이미 가입된 사용자)',
|
||||
example: 'sykim@acme.co',
|
||||
@@ -18,5 +18,5 @@ export class InviteMemberDto {
|
||||
example: 'member',
|
||||
})
|
||||
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
|
||||
roleType!: MemberRoleType;
|
||||
roleType!: ProjectRoleType;
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { MemberRoleType } from '../entities/repo-member.entity';
|
||||
import type { ProjectRoleType } from '../entities/project-member.entity';
|
||||
|
||||
// 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
|
||||
export class UpdateMemberDto {
|
||||
// 프로젝트 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
|
||||
export class UpdateProjectMemberDto {
|
||||
@ApiProperty({
|
||||
description: '권한 역할',
|
||||
enum: ['admin', 'member'],
|
||||
@@ -11,5 +11,5 @@ export class UpdateMemberDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
|
||||
roleType?: MemberRoleType;
|
||||
roleType?: ProjectRoleType;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 프로젝트 수정 DTO — 제공된 필드만 갱신
|
||||
export class UpdateProjectDto {
|
||||
@ApiProperty({ description: '표시 제목', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from './project.entity';
|
||||
|
||||
// 프로젝트 내 권한 역할
|
||||
export type ProjectRoleType = 'admin' | 'member';
|
||||
|
||||
// 프로젝트 멤버 (Project ↔ User 조인 + 역할). 권한·담당자 풀의 기준 단위.
|
||||
@Entity('project_members')
|
||||
@Unique(['project', 'user'])
|
||||
export class ProjectMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
|
||||
@ManyToOne(() => Project, (project) => project.members, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
// 권한 역할 (admin: 프로젝트 관리, member: 일반)
|
||||
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
|
||||
roleType!: ProjectRoleType;
|
||||
|
||||
// 소유자 여부(프로젝트 생성자) — 역할 변경/제거 불가, 프로젝트 삭제 권한
|
||||
@Column({ name: 'is_owner', default: false })
|
||||
isOwner!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ProjectMember } from './project-member.entity';
|
||||
|
||||
// 프로젝트 엔티티 — 작업의 기본 단위. 업무·멤버·활동이 프로젝트에 연동된다.
|
||||
@Entity('projects')
|
||||
export class Project {
|
||||
// 내부 식별자 (UUID) — 라우팅에도 사용
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 설명(선택)
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// 멤버 목록
|
||||
@OneToMany(() => ProjectMember, (member) => member.project)
|
||||
members!: Relation<ProjectMember>[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
+29
-30
@@ -8,6 +8,7 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
@@ -17,28 +18,30 @@ import { ApiOperation, ApiResponse, ApiTags } 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 { MemberService, type RepoMemberResponse } from './member.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
import {
|
||||
ProjectMemberService,
|
||||
type ProjectMemberResponse,
|
||||
} from './project-member.service';
|
||||
import { InviteProjectMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateProjectMemberDto } from './dto/update-member.dto';
|
||||
|
||||
// 저장소 멤버 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName)
|
||||
@ApiTags('Member')
|
||||
@Controller('repos/:repoId/members')
|
||||
// 프로젝트 멤버 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Project')
|
||||
@Controller('projects/:projectId/members')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MemberController {
|
||||
constructor(private readonly memberService: MemberService) {}
|
||||
export class ProjectMemberController {
|
||||
constructor(private readonly memberService: ProjectMemberService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '저장소 멤버 목록 (페이지네이션)' })
|
||||
@ApiOperation({ summary: '프로젝트 멤버 목록 (페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
list(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<RepoMemberResponse>> {
|
||||
return this.memberService.list(repoId, page, size);
|
||||
): Promise<PaginatedResult<ProjectMemberResponse>> {
|
||||
return this.memberService.list(projectId, page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -46,30 +49,26 @@ export class MemberController {
|
||||
@ApiOperation({ summary: '멤버 초대 (admin)' })
|
||||
@ApiResponse({ status: 201, description: '초대 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '가입된 사용자를 찾을 수 없음 (BIZ_001)',
|
||||
})
|
||||
@ApiResponse({ status: 409, description: '이미 멤버 (BIZ_001)' })
|
||||
invite(
|
||||
@Param('repoId') repoId: string,
|
||||
@Body() dto: InviteMemberDto,
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Body() dto: InviteProjectMemberDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoMemberResponse> {
|
||||
return this.memberService.invite(repoId, user.id, dto);
|
||||
): Promise<ProjectMemberResponse> {
|
||||
return this.memberService.invite(projectId, user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':userId')
|
||||
@ApiOperation({ summary: '멤버 역할/직무 변경 (admin, owner 제외)' })
|
||||
@ApiOperation({ summary: '멤버 역할 변경 (admin, owner 제외)' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 변경 불가' })
|
||||
update(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: UpdateMemberDto,
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
@Body() dto: UpdateProjectMemberDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoMemberResponse> {
|
||||
return this.memberService.update(repoId, user.id, userId, dto);
|
||||
): Promise<ProjectMemberResponse> {
|
||||
return this.memberService.update(projectId, user.id, userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':userId')
|
||||
@@ -78,11 +77,11 @@ export class MemberController {
|
||||
@ApiResponse({ status: 200, description: '제거 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 / 소유자 제거 불가' })
|
||||
async remove(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.memberService.remove(repoId, user.id, userId);
|
||||
await this.memberService.remove(projectId, user.id, userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Project } from './entities/project.entity';
|
||||
import {
|
||||
ProjectMember,
|
||||
type ProjectRoleType,
|
||||
} from './entities/project-member.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { InviteProjectMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateProjectMemberDto } from './dto/update-member.dto';
|
||||
|
||||
// 프로젝트 멤버 응답 — isYou 는 프론트가 현재 사용자와 비교해 산출
|
||||
export interface ProjectMemberResponse {
|
||||
user: PublicUser;
|
||||
email: string;
|
||||
taskCount: number; // 프로젝트 내 담당 업무 수
|
||||
roleType: ProjectRoleType;
|
||||
owner: boolean;
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 관리 비즈니스 로직 — 초대/역할변경/제거 시 활동 적재·알림 발송
|
||||
@Injectable()
|
||||
export class ProjectMemberService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
private readonly userService: UserService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly notification: NotificationService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 인증 사용자면 열람. 쓰기는 admin 가드.
|
||||
async list(
|
||||
projectId: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ProjectMemberResponse>> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
const pg = resolvePage(page, size);
|
||||
const [members, total] = await this.memberRepo.findAndCount({
|
||||
where: { project: { id: project.id } },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'ASC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
const counts = await this.assigneeCounts(project.id);
|
||||
return paginated(
|
||||
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
|
||||
async invite(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
dto: InviteProjectMemberDto,
|
||||
): Promise<ProjectMemberResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertAdmin(project.id, actingUserId);
|
||||
|
||||
const user = await this.userService.findByEmail(dto.email);
|
||||
if (!user) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.memberRepo.findOne({
|
||||
where: { project: { id: project.id }, user: { id: user.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 프로젝트에 속한 멤버입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.memberRepo.create({
|
||||
project,
|
||||
user,
|
||||
roleType: dto.roleType,
|
||||
isOwner: false,
|
||||
});
|
||||
const saved = await this.memberRepo.save(member);
|
||||
saved.user = user;
|
||||
|
||||
// 활동 적재 + 초대 대상에게 알림
|
||||
await this.activity.record({
|
||||
projectId: project.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.invited',
|
||||
payload: { targetName: user.name },
|
||||
});
|
||||
await this.notification.notify({
|
||||
recipientIds: [user.id],
|
||||
actorId: actingUserId,
|
||||
type: 'member.invited',
|
||||
payload: {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
// 멤버 역할 변경 — admin 권한. 소유자는 변경 불가
|
||||
async update(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
dto: UpdateProjectMemberDto,
|
||||
): Promise<ProjectMemberResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertAdmin(project.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(project.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자의 권한은 변경할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
if (dto.roleType !== undefined) member.roleType = dto.roleType;
|
||||
await this.memberRepo.save(member);
|
||||
|
||||
// 활동 적재 + 역할 변경 대상에게 알림
|
||||
await this.activity.record({
|
||||
projectId: project.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.role_changed',
|
||||
payload: { targetName: member.user.name, roleType: member.roleType },
|
||||
});
|
||||
await this.notification.notify({
|
||||
recipientIds: [targetUserId],
|
||||
actorId: actingUserId,
|
||||
type: 'member.role_changed',
|
||||
payload: {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
roleType: member.roleType,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
return this.toResponse(member);
|
||||
}
|
||||
|
||||
// 멤버 제거 — admin 권한. 소유자는 제거 불가
|
||||
async remove(
|
||||
projectId: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertAdmin(project.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(project.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자는 프로젝트에서 제거할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
await this.memberRepo.remove(member);
|
||||
|
||||
// 활동 적재 + 제외 대상에게 알림(remove 직후 메모리의 user 사용)
|
||||
await this.activity.record({
|
||||
projectId: project.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.removed',
|
||||
payload: { targetName: member.user.name },
|
||||
});
|
||||
await this.notification.notify({
|
||||
recipientIds: [targetUserId],
|
||||
actorId: actingUserId,
|
||||
type: 'member.removed',
|
||||
payload: {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// 프로젝트 내 사용자별 담당 업무 수 — 멤버 목록 taskCount 집계용
|
||||
private async assigneeCounts(
|
||||
projectId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.taskRepo
|
||||
.createQueryBuilder('task')
|
||||
.innerJoin('task_assignees', 'ta', 'ta.task_id = task.id')
|
||||
.select('ta.user_id', 'userId')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('task.project_id = :projectId', { projectId })
|
||||
.groupBy('ta.user_id')
|
||||
.getRawMany<{ userId: string; count: string }>();
|
||||
const map = new Map<string, number>();
|
||||
for (const r of rows) map.set(r.userId, Number(r.count));
|
||||
return map;
|
||||
}
|
||||
|
||||
private async getProjectOrThrow(projectId: string): Promise<Project> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: projectId },
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
// 행위자 표시 이름 — 알림 payload 의 actorName 용. 없으면 '' (프론트가 '누군가'로 폴백)
|
||||
private async actorName(userId: string): Promise<string> {
|
||||
const user = await this.userService.findById(userId);
|
||||
return user?.name ?? '';
|
||||
}
|
||||
|
||||
private async getMemberOrThrow(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectMember> {
|
||||
const member = await this.memberRepo.findOne({
|
||||
where: { project: { id: projectId }, user: { id: userId } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!member) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
private async assertAdmin(projectId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { project: { id: projectId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership || membership.roleType !== 'admin') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
member: ProjectMember,
|
||||
taskCount = 0,
|
||||
): ProjectMemberResponse {
|
||||
return {
|
||||
user: UserService.toPublic(member.user),
|
||||
email: member.user.email,
|
||||
taskCount,
|
||||
roleType: member.roleType,
|
||||
owner: member.isOwner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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 { 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 { ProjectService, type ProjectResponse } from './project.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Project')
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectController {
|
||||
constructor(private readonly projectService: ProjectService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '프로젝트 전체 목록 (페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
findAll(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<ProjectResponse>> {
|
||||
return this.projectService.findAll(page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '프로젝트 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
create(
|
||||
@Body() dto: CreateProjectDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectResponse> {
|
||||
return this.projectService.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Get(':projectId')
|
||||
@ApiOperation({ summary: '프로젝트 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '프로젝트를 찾을 수 없음 (RES_001)',
|
||||
})
|
||||
findOne(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectResponse> {
|
||||
return this.projectService.findOne(projectId, user.id);
|
||||
}
|
||||
|
||||
@Patch(':projectId')
|
||||
@ApiOperation({ summary: '프로젝트 수정 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
update(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Body() dto: UpdateProjectDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectResponse> {
|
||||
return this.projectService.update(projectId, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.projectService.remove(projectId, user.id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { ProjectService } from './project.service';
|
||||
import { ProjectMemberService } from './project-member.service';
|
||||
import { ProjectController } from './project.controller';
|
||||
import { ProjectMemberController } from './project-member.controller';
|
||||
|
||||
// 프로젝트 모듈 — 작업의 기본 단위. 업무(Task)·활동(Activity)이 프로젝트에 연동된다.
|
||||
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [ProjectController, ProjectMemberController],
|
||||
providers: [ProjectService, ProjectMemberService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
|
||||
export interface ProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: PublicUser[];
|
||||
memberCount: number;
|
||||
doneCount: number; // 완료 업무 수
|
||||
totalCount: number; // 전체 업무 수
|
||||
updatedAt: string;
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한
|
||||
canManage?: boolean; // admin
|
||||
isOwner?: boolean; // 소유자
|
||||
}
|
||||
|
||||
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private readonly projectRepo: Repository<Project>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션.
|
||||
async findAll(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<ProjectResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [projects, total] = await this.projectRepo.findAndCount({
|
||||
relations: ['members', 'members.user'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
const ids = projects.map((p) => p.id);
|
||||
const taskMap = await this.taskCountsByProject(ids);
|
||||
return paginated(
|
||||
projects.map((p) => this.toResponse(p, taskMap)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 프로젝트 단건 — 인증 사용자면 열람 가능. 현재 사용자 권한(canManage/isOwner) 포함.
|
||||
async findOne(projectId: string, userId: string): Promise<ProjectResponse> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: projectId },
|
||||
relations: ['members', 'members.user'],
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { project: { id: project.id }, user: { id: userId } },
|
||||
});
|
||||
const taskMap = await this.taskCountsByProject([project.id]);
|
||||
const base = this.toResponse(project, taskMap);
|
||||
return {
|
||||
...base,
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// 프로젝트 생성 — 생성자를 owner-admin 멤버로 등록
|
||||
async create(
|
||||
dto: CreateProjectDto,
|
||||
userId: string,
|
||||
): Promise<ProjectResponse> {
|
||||
const project = await this.projectRepo.save(
|
||||
this.projectRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
}),
|
||||
);
|
||||
const creator = await this.userService.findById(userId);
|
||||
if (creator) {
|
||||
await this.memberRepo.save(
|
||||
this.memberRepo.create({
|
||||
project,
|
||||
user: creator,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.findOne(project.id, userId);
|
||||
}
|
||||
|
||||
// 프로젝트 수정 — admin 권한.
|
||||
async update(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
dto: UpdateProjectDto,
|
||||
): Promise<ProjectResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
if (dto.name !== undefined) project.name = dto.name;
|
||||
if (dto.description !== undefined) project.description = dto.description;
|
||||
await this.projectRepo.save(project);
|
||||
return this.findOne(project.id, userId);
|
||||
}
|
||||
|
||||
// 프로젝트 삭제 — 소유자 권한.
|
||||
async remove(projectId: string, userId: string): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { project: { id: project.id }, user: { id: userId } },
|
||||
});
|
||||
if (!membership?.isOwner) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.projectRepo.remove(project);
|
||||
}
|
||||
|
||||
// --- 다른 모듈에서 쓰는 헬퍼 ---
|
||||
|
||||
// projectId 로 프로젝트 로딩, 없으면 404
|
||||
async getProjectOrThrow(projectId: string): Promise<Project> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { id: projectId },
|
||||
});
|
||||
if (!project) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
// 프로젝트 멤버 확인 — 아니면 403
|
||||
async assertProjectMember(projectId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { project: { id: projectId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 admin 확인 — 아니면 403
|
||||
async assertProjectAdmin(projectId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { project: { id: projectId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership || membership.roleType !== 'admin') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
|
||||
private async taskCountsByProject(
|
||||
ids: string[],
|
||||
): Promise<Map<string, { done: number; total: number }>> {
|
||||
const map = new Map<string, { done: number; total: number }>();
|
||||
if (ids.length === 0) return map;
|
||||
const rows = await this.taskRepo
|
||||
.createQueryBuilder('task')
|
||||
.select('task.project_id', 'projectId')
|
||||
.addSelect('COUNT(*)', 'total')
|
||||
.addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done')
|
||||
.where('task.project_id IN (:...ids)', { ids })
|
||||
.groupBy('task.project_id')
|
||||
.getRawMany<{ projectId: string; total: string; done: string }>();
|
||||
for (const r of rows) {
|
||||
map.set(r.projectId, { done: Number(r.done), total: Number(r.total) });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑. 카운트 맵에서 프로젝트별 집계를 주입한다.
|
||||
private toResponse(
|
||||
project: Project,
|
||||
taskMap: Map<string, { done: number; total: number }>,
|
||||
): ProjectResponse {
|
||||
const members = project.members ?? [];
|
||||
const counts = taskMap.get(project.id);
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
members: members
|
||||
.filter((m) => m.user)
|
||||
.map((m) => UserService.toPublic(m.user)),
|
||||
memberCount: members.length,
|
||||
doneCount: counts?.done ?? 0,
|
||||
totalCount: counts?.total ?? 0,
|
||||
updatedAt: project.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { RepoVisibility } from '../entities/repo.entity';
|
||||
|
||||
// 저장소 생성 DTO
|
||||
export class CreateRepoDto {
|
||||
@ApiProperty({
|
||||
description: '저장소 slug(영문 소문자·숫자·하이픈·밑줄)',
|
||||
example: 'q3-launch-campaign',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '저장소 이름은 필수 입력 항목입니다.' })
|
||||
@Matches(/^[a-z0-9-_]+$/, {
|
||||
message:
|
||||
'저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.',
|
||||
})
|
||||
@MaxLength(60)
|
||||
slug!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '표시 제목(한글)',
|
||||
example: '3분기 신제품 런칭 캠페인',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '표시 제목은 필수 입력 항목입니다.' })
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '공개 범위',
|
||||
enum: ['private', 'public'],
|
||||
example: 'private',
|
||||
})
|
||||
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
|
||||
visibility!: RepoVisibility;
|
||||
|
||||
@ApiProperty({
|
||||
description: '프로젝트 설명(필수)',
|
||||
example: '3분기 신제품 런칭을 위한 마케팅 캠페인 저장소',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '프로젝트 설명은 필수 입력 항목입니다.' })
|
||||
@MaxLength(300)
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ description: '메인 브랜치', example: 'main', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'템플릿 사용 여부 — true 면 설정된 템플릿 저장소(GITEA_TEMPLATE)를 복제해 생성',
|
||||
example: true,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
useTemplate?: boolean;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { RepoVisibility } from '../entities/repo.entity';
|
||||
|
||||
// 저장소 수정 DTO — 표시 제목/설명/공개범위/브랜치 (slug·owner 는 변경 불가)
|
||||
export class UpdateRepoDto {
|
||||
@ApiProperty({ description: '표시 제목(한글)', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: '프로젝트 설명', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '공개 범위',
|
||||
enum: ['private', 'public'],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['private', 'public'], { message: '공개 범위를 선택해 주세요.' })
|
||||
visibility?: RepoVisibility;
|
||||
|
||||
@ApiProperty({ description: '메인 브랜치', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branch?: string;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Repo } from './repo.entity';
|
||||
|
||||
// 저장소 내 권한 역할
|
||||
export type MemberRoleType = 'admin' | 'member';
|
||||
|
||||
// 저장소 멤버 (Repo ↔ User 조인 + 역할 정보)
|
||||
@Entity('repo_members')
|
||||
@Unique(['repo', 'user'])
|
||||
export class RepoMember {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 저장소 (저장소 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
|
||||
@ManyToOne(() => Repo, (repo) => repo.members, { onDelete: 'CASCADE' })
|
||||
repo!: Relation<Repo>;
|
||||
|
||||
// 멤버 사용자 (사용자 삭제 시 함께 삭제) — 관계는 호출부에서 명시적으로 로딩
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
// 권한 역할 (admin: 저장소 관리, member: 일반)
|
||||
@Column({ name: 'role_type', type: 'varchar', default: 'member' })
|
||||
roleType!: MemberRoleType;
|
||||
|
||||
// 소유자 여부(저장소 생성자) — 역할 변경/제거 불가 대상
|
||||
@Column({ name: 'is_owner', default: false })
|
||||
isOwner!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { RepoMember } from './repo-member.entity';
|
||||
|
||||
// 저장소 공개 범위
|
||||
export type RepoVisibility = 'private' | 'public';
|
||||
|
||||
// 저장소 엔티티
|
||||
@Entity('repos')
|
||||
export class Repo {
|
||||
// 내부 식별자 (UUID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 라우팅/URL 식별자(영문 slug 세그먼트, 예: 'q3-launch-campaign') — 전역 유일
|
||||
@Column({ name: 'slug_name', unique: true })
|
||||
slugName!: string;
|
||||
|
||||
// 소유 조직 slug (예: 'marketing-team') — 현재는 단일 조직 고정
|
||||
@Column()
|
||||
owner!: string;
|
||||
|
||||
// 표시 제목(한글, 예: '3분기 신제품 런칭 캠페인')
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 프로젝트 설명(선택)
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// 공개 범위
|
||||
@Column({ type: 'varchar', default: 'private' })
|
||||
visibility!: RepoVisibility;
|
||||
|
||||
// 메인 브랜치
|
||||
@Column({ default: 'main' })
|
||||
branch!: string;
|
||||
|
||||
// --- Gitea 연동 정보 (연동 비활성/생성 실패 시 null) ---
|
||||
|
||||
// Gitea 저장소 내부 ID
|
||||
@Column({ name: 'gitea_repo_id', type: 'int', nullable: true })
|
||||
giteaRepoId!: number | null;
|
||||
|
||||
// Gitea 저장소 전체 이름 (예: 'marketing-team/q3-launch-campaign')
|
||||
@Column({ name: 'gitea_full_name', type: 'varchar', nullable: true })
|
||||
giteaFullName!: string | null;
|
||||
|
||||
// HTTPS clone URL
|
||||
@Column({ name: 'clone_url', type: 'varchar', nullable: true })
|
||||
cloneUrl!: string | null;
|
||||
|
||||
// Gitea 웹 UI URL
|
||||
@Column({ name: 'html_url', type: 'varchar', nullable: true })
|
||||
htmlUrl!: string | null;
|
||||
|
||||
// Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신).
|
||||
// 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지.
|
||||
@Column({ name: 'gitea_missing', default: false })
|
||||
giteaMissing!: boolean;
|
||||
|
||||
// 멤버 목록
|
||||
@OneToMany(() => RepoMember, (member) => member.repo)
|
||||
members!: Relation<RepoMember>[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { MemberService } from './member.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
|
||||
// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹
|
||||
function makeService() {
|
||||
const repoRepo = { findOne: jest.fn() };
|
||||
const memberRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findAndCount: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
};
|
||||
const userService = {
|
||||
findByEmail: jest.fn(),
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
// 담당 업무 수 집계는 빈 Map(0건)으로 모킹
|
||||
const taskService = {
|
||||
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
// 활동 적재는 no-op 모킹
|
||||
const activity = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
// 알림 적재는 no-op 모킹
|
||||
const notification = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
// 목록 캐시 무효화는 no-op 모킹
|
||||
const repoCache = { invalidateList: jest.fn().mockResolvedValue(undefined) };
|
||||
const svc = new MemberService(
|
||||
repoRepo as unknown as Repository<Repo>,
|
||||
memberRepo as unknown as Repository<RepoMember>,
|
||||
userService as unknown as UserService,
|
||||
taskService as unknown as TaskService,
|
||||
activity as unknown as ActivityService,
|
||||
notification as unknown as NotificationService,
|
||||
repoCache as unknown as RepoCacheService,
|
||||
);
|
||||
return {
|
||||
svc,
|
||||
repoRepo,
|
||||
memberRepo,
|
||||
userService,
|
||||
taskService,
|
||||
activity,
|
||||
notification,
|
||||
repoCache,
|
||||
};
|
||||
}
|
||||
|
||||
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
|
||||
const ADMIN = { roleType: 'admin' };
|
||||
|
||||
describe('MemberService', () => {
|
||||
describe('list', () => {
|
||||
it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
|
||||
memberRepo.findAndCount.mockResolvedValue([
|
||||
[
|
||||
{
|
||||
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
|
||||
email: 'a@b.c',
|
||||
roleType: 'member',
|
||||
isOwner: false,
|
||||
},
|
||||
],
|
||||
1,
|
||||
]);
|
||||
|
||||
const result = await svc.list('q3-launch');
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].user.id).toBe('someone-else');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invite', () => {
|
||||
it('가입되지 않은 이메일이면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo, userService } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValueOnce(ADMIN); // assertAdmin 통과
|
||||
userService.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'admin-user', {
|
||||
email: 'ghost@acme.co',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('이미 멤버면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo, userService } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ id: 'existing-member' }); // 기존 멤버십 존재
|
||||
userService.findByEmail.mockResolvedValue({ id: 'u1', email: 'u@b.c' });
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'admin-user', {
|
||||
email: 'u@b.c',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('admin 이 아니면 ForbiddenException', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValueOnce({ roleType: 'member' }); // 일반 멤버
|
||||
|
||||
await expect(
|
||||
svc.invite('q3-launch', 'member-user', {
|
||||
email: 'u@b.c',
|
||||
roleType: 'member',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update / remove — 소유자 보호', () => {
|
||||
it('소유자의 역할은 변경할 수 없다', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
|
||||
|
||||
await expect(
|
||||
svc.update('q3-launch', 'admin-user', 'owner', { roleType: 'member' }),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
});
|
||||
|
||||
it('소유자는 제거할 수 없다', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne
|
||||
.mockResolvedValueOnce(ADMIN) // assertAdmin
|
||||
.mockResolvedValueOnce({ isOwner: true, user: { id: 'owner' } }); // 대상=소유자
|
||||
|
||||
await expect(
|
||||
svc.remove('q3-launch', 'admin-user', 'owner'),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(memberRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
|
||||
// 멤버 응답 형태 — isYou 는 프론트가 현재 사용자와 비교해 산출
|
||||
export interface RepoMemberResponse {
|
||||
user: PublicUser;
|
||||
email: string;
|
||||
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
|
||||
roleType: MemberRoleType;
|
||||
owner: boolean; // 소유자 여부(isOwner)
|
||||
}
|
||||
|
||||
// 저장소 멤버 관리 비즈니스 로직
|
||||
@Injectable()
|
||||
export class MemberService {
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly notification: NotificationService,
|
||||
private readonly repoCache: RepoCacheService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션.
|
||||
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
|
||||
async list(
|
||||
slugName: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<RepoMemberResponse>> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
const pg = resolvePage(page, size);
|
||||
const [members, total] = await this.memberRepo.findAndCount({
|
||||
where: { repo: { id: repo.id } },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'ASC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
// 사용자별 담당 업무 수 집계(taskCount)
|
||||
const counts = await this.taskService.assigneeCounts(repo.id);
|
||||
return paginated(
|
||||
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
|
||||
async invite(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
dto: InviteMemberDto,
|
||||
): Promise<RepoMemberResponse> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
|
||||
const user = await this.userService.findByEmail(dto.email);
|
||||
if (!user) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'해당 이메일의 가입된 사용자를 찾을 수 없습니다. 먼저 가입이 필요합니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: user.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 저장소에 속한 멤버입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.memberRepo.create({
|
||||
repo,
|
||||
user,
|
||||
roleType: dto.roleType,
|
||||
isOwner: false,
|
||||
});
|
||||
const saved = await this.memberRepo.save(member);
|
||||
saved.user = user; // 관계 보장(응답 매핑용)
|
||||
|
||||
// 활동 적재 — 멤버 초대
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.invited',
|
||||
payload: { targetName: user.name, roleType: dto.roleType },
|
||||
});
|
||||
|
||||
// 알림 — 초대된 사용자에게 '저장소 초대'
|
||||
await this.notification.notify({
|
||||
recipientIds: [user.id],
|
||||
actorId: actingUserId,
|
||||
type: 'member.invited',
|
||||
payload: {
|
||||
repoId: repo.slugName,
|
||||
repoName: repo.name,
|
||||
roleType: dto.roleType,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
|
||||
// 저장소 목록 캐시 무효화 — 멤버 추가로 목록 카드의 멤버 수/아바타가 바뀜
|
||||
await this.repoCache.invalidateList();
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 — admin 권한. 소유자(owner)는 변경 불가
|
||||
async update(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
dto: UpdateMemberDto,
|
||||
): Promise<RepoMemberResponse> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(repo.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자의 권한은 변경할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.roleType !== undefined) member.roleType = dto.roleType;
|
||||
|
||||
await this.memberRepo.save(member);
|
||||
|
||||
// 활동 적재 — 멤버 역할 변경
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.role_changed',
|
||||
payload: { targetName: member.user.name, roleType: member.roleType },
|
||||
});
|
||||
|
||||
// 알림 — 역할이 변경된 당사자에게(본인이 본인 역할을 바꾼 경우는 자동 제외)
|
||||
await this.notification.notify({
|
||||
recipientIds: [member.user.id],
|
||||
actorId: actingUserId,
|
||||
type: 'member.role_changed',
|
||||
payload: {
|
||||
repoId: repo.slugName,
|
||||
repoName: repo.name,
|
||||
roleType: member.roleType,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
|
||||
// (역할 변경은 저장소 목록 응답의 멤버 수/아바타를 바꾸지 않으므로 캐시 무효화 불필요)
|
||||
return this.toResponse(member);
|
||||
}
|
||||
|
||||
// 멤버 제거 — admin 권한. 소유자(owner)는 제거 불가
|
||||
async remove(
|
||||
slugName: string,
|
||||
actingUserId: string,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, actingUserId);
|
||||
const member = await this.getMemberOrThrow(repo.id, targetUserId);
|
||||
if (member.isOwner) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'소유자는 저장소에서 제거할 수 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
const targetName = member.user.name; // 제거 전 이름 캡처
|
||||
const removedUserId = member.user.id; // 제거 전 id 캡처(알림 수신자)
|
||||
await this.memberRepo.remove(member);
|
||||
|
||||
// 활동 적재 — 멤버 제거
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.removed',
|
||||
payload: { targetName },
|
||||
});
|
||||
|
||||
// 알림 — 제거된 당사자에게(조직 모델상 저장소 열람은 여전히 가능)
|
||||
await this.notification.notify({
|
||||
recipientIds: [removedUserId],
|
||||
actorId: actingUserId,
|
||||
type: 'member.removed',
|
||||
payload: {
|
||||
repoId: repo.slugName,
|
||||
repoName: repo.name,
|
||||
actorName: await this.actorName(actingUserId),
|
||||
},
|
||||
});
|
||||
|
||||
// 저장소 목록 캐시 무효화 — 멤버 제거로 목록 카드의 멤버 수/아바타가 바뀜
|
||||
await this.repoCache.invalidateList();
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// 행위자 표시 이름(알림 payload 용) — 없으면 빈 문자열
|
||||
private async actorName(userId: string): Promise<string> {
|
||||
const actor = await this.userService.findById(userId);
|
||||
return actor?.name ?? '';
|
||||
}
|
||||
|
||||
// slugName 으로 저장소 로딩, 없으면 404
|
||||
private async getRepoOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
// 저장소-사용자 멤버십 로딩(+user), 없으면 404
|
||||
private async getMemberOrThrow(
|
||||
repoId: string,
|
||||
userId: string,
|
||||
): Promise<RepoMember> {
|
||||
const member = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repoId }, user: { id: userId } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!member) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
// admin 권한 확인 (아니면 403)
|
||||
private async assertAdmin(repoId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repoId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership || membership.roleType !== 'admin') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑 (taskCount 미제공 시 0)
|
||||
private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse {
|
||||
return {
|
||||
user: UserService.toPublic(member.user),
|
||||
email: member.user.email,
|
||||
taskCount,
|
||||
roleType: member.roleType,
|
||||
owner: member.isOwner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import type { RepoResponse } from './repo.service';
|
||||
|
||||
// 저장소 목록 캐시 — 버전 네임스페이스 방식으로 O(1) 무효화.
|
||||
// 키에 버전을 포함하고, 변경(생성/수정/삭제/동기화) 시 버전을 올려 기존 키를 통째로 폐기한다.
|
||||
// (keyv 는 와일드카드 삭제를 지원하지 않으므로 버전 증가로 일괄 무효화한다)
|
||||
// 모든 캐시 접근은 실패 시 무시하고 DB 조회로 폴백 — 캐시 장애가 요청을 깨지 않는다.
|
||||
@Injectable()
|
||||
export class RepoCacheService {
|
||||
private static readonly VER_KEY = 'repos:list:ver';
|
||||
private static readonly LIST_TTL = 60_000; // 60초(업무 카운트 staleness 백스톱)
|
||||
|
||||
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
|
||||
|
||||
// 현재 목록 캐시 버전(없으면 1로 초기화)
|
||||
private async version(): Promise<number> {
|
||||
try {
|
||||
const v = await this.cache.get<number>(RepoCacheService.VER_KEY);
|
||||
if (typeof v === 'number') return v;
|
||||
await this.cache.set(RepoCacheService.VER_KEY, 1);
|
||||
} catch {
|
||||
// 캐시 장애 — 버전 1로 취급(매번 미스 → DB 조회)
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 버전 증가 → 이전 버전의 모든 목록 캐시 키를 무효화(TTL 로 자연 소멸)
|
||||
async invalidateList(): Promise<void> {
|
||||
try {
|
||||
const v = await this.version();
|
||||
await this.cache.set(RepoCacheService.VER_KEY, v + 1);
|
||||
} catch {
|
||||
// 무시(다음 조회는 구버전 키를 보지만 TTL 60초 내 소멸)
|
||||
}
|
||||
}
|
||||
|
||||
// 목록 캐시 get-or-compute — 히트 시 캐시, 미스 시 compute() 결과를 적재 후 반환
|
||||
async listOrCompute(
|
||||
page: number,
|
||||
size: number,
|
||||
compute: () => Promise<PaginatedResult<RepoResponse>>,
|
||||
): Promise<PaginatedResult<RepoResponse>> {
|
||||
const ver = await this.version();
|
||||
const key = `repos:list:v${ver}:p${page}:s${size}`;
|
||||
try {
|
||||
const hit = await this.cache.get<PaginatedResult<RepoResponse>>(key);
|
||||
if (hit) return hit;
|
||||
} catch {
|
||||
// 캐시 장애 — 아래 DB 조회로 폴백
|
||||
}
|
||||
const value = await compute();
|
||||
try {
|
||||
await this.cache.set(key, value, RepoCacheService.LIST_TTL);
|
||||
} catch {
|
||||
// 적재 실패 무시
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService } from '../user/user.service';
|
||||
import type { User } from '../user/entities/user.entity';
|
||||
import { GiteaService, type GiteaRepoResult } from '../gitea/gitea.service';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
|
||||
// Gitea description 에서 분리한 표시명/설명
|
||||
interface ParsedDescription {
|
||||
name: string;
|
||||
desc: string | null;
|
||||
}
|
||||
|
||||
// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스.
|
||||
// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다.
|
||||
// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill.
|
||||
// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다.
|
||||
// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다.
|
||||
@Injectable()
|
||||
export class RepoSyncService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(RepoSyncService.name);
|
||||
// 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우)
|
||||
private running = false;
|
||||
// import(멤버 0명) 저장소에 기본 owner-admin 으로 지정할 사용자 이메일.
|
||||
// 동기화는 시스템 작업이라 실행 주체가 없으므로 env 로 지정한다. 비어 있으면 멤버 0명으로 둔다.
|
||||
private readonly importAdminEmail: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly gitea: GiteaService,
|
||||
private readonly repoCache: RepoCacheService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.importAdminEmail = (
|
||||
config.get<string>('REPO_IMPORT_ADMIN_EMAIL') ?? ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
// 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행
|
||||
onApplicationBootstrap(): void {
|
||||
void this.syncFromGitea('부트스트랩');
|
||||
}
|
||||
|
||||
// 10분마다 동기화
|
||||
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||
async handleScheduledSync(): Promise<void> {
|
||||
await this.syncFromGitea('스케줄(10분)');
|
||||
}
|
||||
|
||||
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
|
||||
async syncFromGitea(trigger: string): Promise<void> {
|
||||
if (!this.gitea.enabled) {
|
||||
this.logger.debug(`Gitea 미연동 — 동기화 건너뜀 (${trigger})`);
|
||||
return;
|
||||
}
|
||||
if (this.running) {
|
||||
this.logger.warn(`이전 동기화가 진행 중 — 건너뜀 (${trigger})`);
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const remote = await this.gitea.listOrgRepos();
|
||||
const existing = await this.repoRepo.find();
|
||||
|
||||
// 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭
|
||||
const byGiteaId = new Map<number, Repo>();
|
||||
const bySlug = new Map<string, Repo>();
|
||||
for (const r of existing) {
|
||||
if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r);
|
||||
bySlug.set(r.slugName, r);
|
||||
}
|
||||
|
||||
const seenGiteaIds = new Set<number>();
|
||||
// 이번 동기화에서 이미 매칭된 Relay 저장소(uuid) — 중복 매칭 방지
|
||||
const matchedRepoIds = new Set<string>();
|
||||
let imported = 0;
|
||||
let relinked = 0;
|
||||
|
||||
for (const gr of remote) {
|
||||
seenGiteaIds.add(gr.id);
|
||||
// Gitea ID 우선, 없으면 옛 slug 로 매칭. 단 이미 매칭된 저장소는 제외
|
||||
// (Gitea 에서 rename 후 옛 이름으로 새 저장소를 만든 경우의 stale-slug 충돌 방지)
|
||||
const candidate = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
|
||||
const match =
|
||||
candidate && !matchedRepoIds.has(candidate.id) ? candidate : null;
|
||||
if (match) {
|
||||
matchedRepoIds.add(match.id);
|
||||
// Gitea 를 SSOT 로 — 이름(slug)/표시명/설명/공개범위/브랜치 + 연동정보를 Gitea 기준에 맞춘다.
|
||||
// (Gitea 에서 직접 변경한 내용을 Relay 로 끌어온다. rename 시 slugName 도 따라가 라우팅·쓰기 동기화 일치)
|
||||
if (this.reconcileFromGitea(match, gr)) {
|
||||
await this.repoRepo.save(match);
|
||||
relinked++;
|
||||
}
|
||||
} else {
|
||||
// Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음)
|
||||
const { name, desc } = this.parseDescription(gr.description, gr.name);
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: gr.name,
|
||||
owner: this.gitea.org,
|
||||
name,
|
||||
description: desc,
|
||||
visibility: gr.private ? 'private' : 'public',
|
||||
branch: gr.defaultBranch || 'main',
|
||||
giteaRepoId: gr.id,
|
||||
giteaFullName: gr.fullName,
|
||||
cloneUrl: gr.cloneUrl,
|
||||
htmlUrl: gr.htmlUrl,
|
||||
giteaMissing: false,
|
||||
});
|
||||
await this.repoRepo.save(repo);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 표기만 한다.
|
||||
let missing = 0;
|
||||
for (const r of existing) {
|
||||
if (
|
||||
r.giteaRepoId !== null &&
|
||||
!seenGiteaIds.has(r.giteaRepoId) &&
|
||||
!r.giteaMissing
|
||||
) {
|
||||
r.giteaMissing = true;
|
||||
await this.repoRepo.save(r);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
// import(멤버 0명) 저장소에 기본 owner-admin 지정 — 멤버 할당 데드락 방지.
|
||||
// (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀)
|
||||
const adminAssigned = await this.ensureImportAdmin();
|
||||
|
||||
// 동기화로 저장소 목록이 바뀌었으면 캐시 무효화(새 import/누락표시/admin 즉시 반영)
|
||||
if (imported + relinked + missing + adminAssigned > 0) {
|
||||
await this.repoCache.invalidateList();
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Gitea 동기화 실패 (${trigger})`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// import(멤버 0명) Gitea 연동 저장소에 기본 owner-admin 을 지정한다.
|
||||
// env REPO_IMPORT_ADMIN_EMAIL 의 가입 사용자를 owner-admin 으로 등록(신규 import + 기존 멤버 0명 모두 대상).
|
||||
// 반환: 이번 실행에서 admin 을 새로 붙인 저장소 수.
|
||||
private async ensureImportAdmin(): Promise<number> {
|
||||
if (!this.importAdminEmail) return 0;
|
||||
|
||||
const admin = await this.userService.findByEmail(this.importAdminEmail);
|
||||
if (!admin) {
|
||||
this.logger.warn(
|
||||
`REPO_IMPORT_ADMIN_EMAIL(${this.importAdminEmail}) 에 해당하는 가입 사용자가 없어 import 저장소 admin 지정을 건너뜁니다. (가입 후 다음 동기화에서 지정됨)`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Gitea 연동(import) 저장소 중 멤버가 0명인 것만 대상.
|
||||
// Relay 생성 저장소는 생성자(owner)가 항상 멤버이므로 제외된다.
|
||||
const linked = await this.repoRepo.find({ relations: ['members'] });
|
||||
let assigned = 0;
|
||||
for (const repo of linked) {
|
||||
if (repo.giteaRepoId === null) continue;
|
||||
if ((repo.members?.length ?? 0) > 0) continue;
|
||||
await this.assignOwnerAdmin(repo, admin);
|
||||
assigned++;
|
||||
this.logger.log(
|
||||
`import 저장소 owner-admin 지정: ${repo.slugName} ← ${admin.email}`,
|
||||
);
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
// 저장소에 사용자를 owner-admin 멤버로 등록(Relay 생성 시 owner 등록과 동일 규약)
|
||||
private async assignOwnerAdmin(repo: Repo, user: User): Promise<void> {
|
||||
const member = this.memberRepo.create({
|
||||
repo,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
});
|
||||
await this.memberRepo.save(member);
|
||||
}
|
||||
|
||||
// Gitea 를 기준으로 Relay 저장소 메타를 맞춘다 — Gitea 의 네이티브 필드와 깔끔히 1:1 대응되는
|
||||
// 이름(slug)/공개범위/기본 브랜치 + 연동정보만 동기화한다. 변경이 있었으면 true.
|
||||
// ※ 표시명/설명은 Gitea 단일 description 에 "표시제목 — 설명" 으로 합쳐 저장하므로,
|
||||
// Gitea 에서 description 을 직접 편집하면 표시명이 깨질 수 있어 Relay 가 SSOT 로 유지한다.
|
||||
private reconcileFromGitea(repo: Repo, gr: GiteaRepoResult): boolean {
|
||||
let changed = false;
|
||||
|
||||
// 이름(slug) — Gitea rename 반영. Relay 라우팅 URL 과 이후 Gitea 쓰기 경로가 새 이름을 따른다
|
||||
if (repo.slugName !== gr.name) {
|
||||
repo.slugName = gr.name;
|
||||
changed = true;
|
||||
}
|
||||
// 공개범위
|
||||
const visibility = gr.private ? 'private' : 'public';
|
||||
if (repo.visibility !== visibility) {
|
||||
repo.visibility = visibility;
|
||||
changed = true;
|
||||
}
|
||||
// 기본 브랜치
|
||||
if (gr.defaultBranch && repo.branch !== gr.defaultBranch) {
|
||||
repo.branch = gr.defaultBranch;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 연동정보(linkage) + 누락표시 해제
|
||||
if (repo.giteaRepoId !== gr.id) {
|
||||
repo.giteaRepoId = gr.id;
|
||||
changed = true;
|
||||
}
|
||||
if (repo.giteaFullName !== gr.fullName) {
|
||||
repo.giteaFullName = gr.fullName;
|
||||
changed = true;
|
||||
}
|
||||
if (repo.cloneUrl !== gr.cloneUrl) {
|
||||
repo.cloneUrl = gr.cloneUrl;
|
||||
changed = true;
|
||||
}
|
||||
if (repo.htmlUrl !== gr.htmlUrl) {
|
||||
repo.htmlUrl = gr.htmlUrl;
|
||||
changed = true;
|
||||
}
|
||||
if (repo.giteaMissing) {
|
||||
repo.giteaMissing = false;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Gitea description("표시제목 — 설명") 에서 표시명/설명 분리.
|
||||
// Relay 생성 저장소는 buildGiteaDescription 규칙을 따르고,
|
||||
// 구분자가 없는 Gitea-native 저장소는 slug 를 표시명으로 사용한다.
|
||||
private parseDescription(
|
||||
description: string,
|
||||
slug: string,
|
||||
): ParsedDescription {
|
||||
const sep = ' — ';
|
||||
const d = (description ?? '').trim();
|
||||
if (!d) return { name: slug, desc: null };
|
||||
const idx = d.indexOf(sep);
|
||||
if (idx > 0) {
|
||||
return {
|
||||
name: d.slice(0, idx).trim(),
|
||||
desc: d.slice(idx + sep.length).trim() || null,
|
||||
};
|
||||
}
|
||||
return { name: slug, desc: d };
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } 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 { RepoService, type RepoResponse } from './repo.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// 저장소 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Repo')
|
||||
@Controller('repos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RepoController {
|
||||
constructor(private readonly repoService: RepoService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '조직 저장소 전체 목록 (페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
findAll(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<RepoResponse>> {
|
||||
return this.repoService.findAll(page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '저장소 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: '이미 사용 중인 저장소 이름 (BIZ_001)',
|
||||
})
|
||||
create(
|
||||
@Body() dto: CreateRepoDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.create(dto, user.id);
|
||||
}
|
||||
|
||||
// ':repoId' 보다 먼저 선언해야 'meta' 가 파라미터로 매칭되지 않음
|
||||
@Get('meta')
|
||||
@ApiOperation({ summary: '저장소 생성 폼 메타(소유자/템플릿) 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
getCreateMeta(): {
|
||||
owner: string;
|
||||
template: { available: boolean; slug: string };
|
||||
} {
|
||||
return this.repoService.getCreateMeta();
|
||||
}
|
||||
|
||||
@Get(':repoId')
|
||||
@ApiOperation({ summary: '저장소 단건 조회' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
findOne(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.findOne(repoId, user.id);
|
||||
}
|
||||
|
||||
@Patch(':repoId')
|
||||
@ApiOperation({ summary: '저장소 수정 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
update(
|
||||
@Param('repoId') repoId: string,
|
||||
@Body() dto: UpdateRepoDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<RepoResponse> {
|
||||
return this.repoService.update(repoId, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':repoId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '저장소 삭제 (소유자)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@Param('repoId') repoId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.repoService.remove(repoId, user.id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoService } from './repo.service';
|
||||
import { RepoController } from './repo.controller';
|
||||
import { MemberService } from './member.service';
|
||||
import { MemberController } from './member.controller';
|
||||
import { RepoSyncService } from './repo-sync.service';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
|
||||
// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화)
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Repo, RepoMember]),
|
||||
UserModule,
|
||||
GiteaModule,
|
||||
TaskModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [RepoController, MemberController],
|
||||
providers: [RepoService, MemberService, RepoSyncService, RepoCacheService],
|
||||
exports: [RepoService, MemberService],
|
||||
})
|
||||
export class RepoModule {}
|
||||
@@ -1,462 +0,0 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import {
|
||||
GiteaApiError,
|
||||
GiteaService,
|
||||
type GiteaRepoResult,
|
||||
} from '../gitea/gitea.service';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Repo, type RepoVisibility } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoCacheService } from './repo-cache.service';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
// Gitea 연동 비활성 시 사용할 기본 소유자 (조직 모델 도입 시 교체)
|
||||
const DEFAULT_OWNER = 'marketing-team';
|
||||
|
||||
// Gitea 저장소 이름 충돌 시 반환되는 HTTP 상태 (enum 비교 회피용 숫자 상수)
|
||||
const HTTP_CONFLICT = 409;
|
||||
|
||||
// 저장소 응답 형태 — 파생 라벨/진행률은 프론트에서 계산하므로 원시값 위주로 내린다
|
||||
export interface RepoResponse {
|
||||
id: string; // slugName (라우팅 식별자)
|
||||
name: string;
|
||||
owner: string;
|
||||
slug: string; // owner/slugName
|
||||
desc: string | null;
|
||||
visibility: RepoVisibility;
|
||||
branch: string;
|
||||
members: PublicUser[];
|
||||
memberCount: number;
|
||||
doneCount: number;
|
||||
totalCount: number;
|
||||
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
|
||||
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
|
||||
giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
|
||||
updatedAt: string;
|
||||
// 현재 사용자의 이 저장소 권한 — 단건(findOne)에서만 채워진다(목록은 캐시·사용자 무관이라 미포함).
|
||||
canManage?: boolean; // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean; // 소유자 (저장소 삭제)
|
||||
}
|
||||
|
||||
// 저장소 비즈니스 로직
|
||||
@Injectable()
|
||||
export class RepoService {
|
||||
private readonly logger = new Logger(RepoService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
@InjectRepository(RepoMember)
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly gitea: GiteaService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly repoCache: RepoCacheService,
|
||||
) {}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
|
||||
// 저장소 생성 전 프론트가 미리 조회하여 표시한다.
|
||||
getCreateMeta(): {
|
||||
owner: string;
|
||||
template: { available: boolean; slug: string };
|
||||
} {
|
||||
return {
|
||||
// 연동 시 owner = GITEA_ORG, 아니면 기본 상수
|
||||
owner: this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER,
|
||||
template: {
|
||||
available: this.gitea.templateEnabled,
|
||||
slug: this.gitea.templateSlug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. 오프셋 페이지네이션.
|
||||
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
|
||||
async findAll(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<RepoResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
// 페이지 단위로 캐시(버전 네임스페이스). 구조 변경 시 버전 증가로 일괄 무효화하고,
|
||||
// 업무 카운트 staleness 는 60초 TTL 로 자연 보정한다.
|
||||
return this.repoCache.listOrCompute(pg.page, pg.size, async () => {
|
||||
const [repos, total] = await this.repoRepo.findAndCount({
|
||||
relations: ['members', 'members.user'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
// 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영
|
||||
const counts = await this.taskService.countsByRepo(
|
||||
repos.map((r) => r.id),
|
||||
);
|
||||
return paginated(
|
||||
repos.map((repo) => this.toResponse(repo, counts.get(repo.id))),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
|
||||
// 응답에 현재 사용자의 권한(canManage/isOwner)을 함께 내려, 프론트가 설정 탭 노출·진입을 결정한다.
|
||||
async findOne(slugName: string, userId: string): Promise<RepoResponse> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
return this.toResponse(repo, await this.taskCountsOf(repo.id), {
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
|
||||
async create(dto: CreateRepoDto, userId: string): Promise<RepoResponse> {
|
||||
const existing = await this.repoRepo.findOne({
|
||||
where: { slugName: dto.slug },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 사용 중인 저장소 이름입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const description = dto.description;
|
||||
const visibility = dto.visibility;
|
||||
const requestedBranch = dto.branch?.trim() || 'main';
|
||||
// 연동 시 소유자는 Gitea 조직명 — slug `owner/slugName` 이 Gitea full_name 과 일치
|
||||
const owner = this.gitea.enabled ? this.gitea.org : DEFAULT_OWNER;
|
||||
|
||||
// 1) Gitea 조직에 실제 저장소 생성 (연동 활성 시)
|
||||
// - useTemplate: 템플릿 저장소를 복제해 생성(브랜치는 템플릿을 따름)
|
||||
// - 아니면 빈 저장소(auto_init, 요청 브랜치)
|
||||
let giteaRepo: GiteaRepoResult | null = null;
|
||||
if (this.gitea.enabled) {
|
||||
giteaRepo = await this.createGiteaRepo(dto.slug, {
|
||||
name: dto.name,
|
||||
description,
|
||||
visibility,
|
||||
branch: requestedBranch,
|
||||
useTemplate: dto.useTemplate ?? false,
|
||||
});
|
||||
}
|
||||
// 템플릿 복제 시 실제 기본 브랜치는 Gitea 응답값을 우선 사용
|
||||
const branch = giteaRepo?.defaultBranch || requestedBranch;
|
||||
|
||||
// 2) Relay 메타데이터 저장 (Gitea 연동 정보 포함)
|
||||
try {
|
||||
const repo = this.repoRepo.create({
|
||||
slugName: dto.slug,
|
||||
owner,
|
||||
name: dto.name,
|
||||
description,
|
||||
visibility,
|
||||
branch,
|
||||
giteaRepoId: giteaRepo?.id ?? null,
|
||||
giteaFullName: giteaRepo?.fullName ?? null,
|
||||
cloneUrl: giteaRepo?.cloneUrl ?? null,
|
||||
htmlUrl: giteaRepo?.htmlUrl ?? null,
|
||||
});
|
||||
const saved = await this.repoRepo.save(repo);
|
||||
|
||||
const ownerMember = this.memberRepo.create({
|
||||
repo: saved,
|
||||
user,
|
||||
roleType: 'admin',
|
||||
isOwner: true,
|
||||
});
|
||||
await this.memberRepo.save(ownerMember);
|
||||
|
||||
// 활동 적재 — 저장소 생성
|
||||
await this.activity.record({
|
||||
repoId: saved.id,
|
||||
actorId: userId,
|
||||
type: 'repo.created',
|
||||
payload: { repoName: saved.name },
|
||||
});
|
||||
|
||||
// 목록 캐시 무효화(새 저장소 즉시 반영)
|
||||
await this.repoCache.invalidateList();
|
||||
|
||||
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
} catch (err) {
|
||||
// Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트)
|
||||
if (giteaRepo) {
|
||||
await this.gitea.deleteRepo(dto.slug).catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 고아 저장소 정리 실패: ${dto.slug}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 수정 — admin 권한 필요
|
||||
async update(
|
||||
slugName: string,
|
||||
userId: string,
|
||||
dto: UpdateRepoDto,
|
||||
): Promise<RepoResponse> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
await this.assertAdmin(repo.id, userId);
|
||||
|
||||
// 변경 감지 — 폼이 모든 필드를 항상 보내므로, 실제로 바뀐 값만 동기화·활동·캐시에 반영한다
|
||||
// (이전엔 필드 존재 여부로 판단해 저장할 때마다 허위 'renamed'/'visibility_changed' 활동이 쌓였음)
|
||||
const prevName = repo.name;
|
||||
const prevDescription = repo.description ?? '';
|
||||
const prevVisibility = repo.visibility;
|
||||
const prevBranch = repo.branch;
|
||||
|
||||
if (dto.name !== undefined) repo.name = dto.name;
|
||||
if (dto.description !== undefined) repo.description = dto.description;
|
||||
if (dto.visibility !== undefined) repo.visibility = dto.visibility;
|
||||
if (dto.branch !== undefined && dto.branch.trim())
|
||||
repo.branch = dto.branch.trim();
|
||||
|
||||
const nameChanged = repo.name !== prevName;
|
||||
const descriptionChanged = (repo.description ?? '') !== prevDescription;
|
||||
const visibilityChanged = repo.visibility !== prevVisibility;
|
||||
const branchChanged = repo.branch !== prevBranch;
|
||||
|
||||
await this.repoRepo.save(repo);
|
||||
|
||||
// Gitea 메타데이터 동기화 (베스트 에포트) — 표시명/설명/공개범위/브랜치가 실제 바뀐 경우에만.
|
||||
// (브랜치도 Gitea→Relay 로 역동기화되므로, Relay 변경분을 Gitea 에 밀어 양쪽을 일치시킨다)
|
||||
const giteaDescChanged = nameChanged || descriptionChanged;
|
||||
if (
|
||||
this.gitea.enabled &&
|
||||
repo.giteaRepoId !== null &&
|
||||
(giteaDescChanged || visibilityChanged || branchChanged)
|
||||
) {
|
||||
await this.gitea
|
||||
.updateRepo(repo.slugName, {
|
||||
description: giteaDescChanged
|
||||
? this.buildGiteaDescription(repo.name, repo.description)
|
||||
: undefined,
|
||||
private: visibilityChanged
|
||||
? repo.visibility === 'private'
|
||||
: undefined,
|
||||
defaultBranch: branchChanged ? repo.branch : undefined,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 수정 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 활동 적재 — 실제로 바뀐 측면만(표시 제목 변경 / 공개범위 변경)
|
||||
if (nameChanged) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
type: 'repo.renamed',
|
||||
payload: { newName: repo.name },
|
||||
});
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
type: 'repo.visibility_changed',
|
||||
payload: { visibility: repo.visibility },
|
||||
});
|
||||
}
|
||||
|
||||
// 목록 캐시 무효화 — 목록 카드에 노출되는 값(표시명/설명/공개범위/브랜치)이 바뀐 경우에만
|
||||
if (
|
||||
nameChanged ||
|
||||
descriptionChanged ||
|
||||
visibilityChanged ||
|
||||
branchChanged
|
||||
) {
|
||||
await this.repoCache.invalidateList();
|
||||
}
|
||||
|
||||
// 응답에도 현재 사용자 권한을 포함(프론트가 저장 후에도 canManage/isOwner 를 유지하도록)
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
return this.toResponse(
|
||||
await this.getEntityOrThrow(slugName),
|
||||
await this.taskCountsOf(repo.id),
|
||||
{
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 삭제 — 소유자만 가능
|
||||
async remove(slugName: string, userId: string): Promise<void> {
|
||||
const repo = await this.getEntityOrThrow(slugName);
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repo.id }, user: { id: userId } },
|
||||
});
|
||||
if (!membership?.isOwner) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.repoRepo.remove(repo);
|
||||
|
||||
// 목록 캐시 무효화(삭제 즉시 반영)
|
||||
await this.repoCache.invalidateList();
|
||||
|
||||
// Gitea 저장소도 삭제 (베스트 에포트 — 실패 시 로그만 남기고 Relay 삭제는 유지)
|
||||
if (this.gitea.enabled && repo.giteaRepoId !== null) {
|
||||
await this.gitea.deleteRepo(repo.slugName).catch((e: unknown) => {
|
||||
this.logger.error(
|
||||
`Gitea 저장소 삭제 동기화 실패: ${repo.slugName}`,
|
||||
e instanceof Error ? e.stack : String(e),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
// Gitea 조직에 저장소 생성 — 실패 시 사용자 노출 가능한 BusinessException 으로 변환.
|
||||
// useTemplate 이면 템플릿 저장소를 복제(generate), 아니면 빈 저장소(auto_init).
|
||||
private async createGiteaRepo(
|
||||
slug: string,
|
||||
meta: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: RepoVisibility;
|
||||
branch: string;
|
||||
useTemplate: boolean;
|
||||
},
|
||||
): Promise<GiteaRepoResult> {
|
||||
const description = this.buildGiteaDescription(meta.name, meta.description);
|
||||
const isPrivate = meta.visibility === 'private';
|
||||
try {
|
||||
if (meta.useTemplate) {
|
||||
return await this.gitea.generateFromTemplate({
|
||||
name: slug,
|
||||
description,
|
||||
private: isPrivate,
|
||||
});
|
||||
}
|
||||
return await this.gitea.createRepo({
|
||||
name: slug,
|
||||
description,
|
||||
private: isPrivate,
|
||||
defaultBranch: meta.branch,
|
||||
});
|
||||
} catch (err) {
|
||||
// Gitea 측 이름 충돌 → Relay 와 동일한 중복 문구로 안내
|
||||
if (err instanceof GiteaApiError && err.status === HTTP_CONFLICT) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 사용 중인 저장소 이름입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
this.logger.error(
|
||||
`Gitea 저장소 생성 실패: ${slug}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'Gitea 저장소 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea 저장소 설명 문자열 구성 — Gitea 에 별도 표시명 필드가 없어 '표시제목 — 설명' 으로 합친다
|
||||
private buildGiteaDescription(
|
||||
name: string,
|
||||
description: string | null,
|
||||
): string {
|
||||
return description ? `${name} — ${description}` : name;
|
||||
}
|
||||
|
||||
// slugName 으로 저장소(+멤버) 로딩, 없으면 404
|
||||
private async getEntityOrThrow(slugName: string): Promise<Repo> {
|
||||
const repo = await this.repoRepo.findOne({
|
||||
where: { slugName },
|
||||
relations: ['members', 'members.user'],
|
||||
});
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
// admin 권한 확인 (아니면 403)
|
||||
private async assertAdmin(repoId: string, userId: string): Promise<void> {
|
||||
const membership = await this.memberRepo.findOne({
|
||||
where: { repo: { id: repoId }, user: { id: userId } },
|
||||
});
|
||||
if (!membership || membership.roleType !== 'admin') {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// 단일 저장소의 업무 완료/전체 수 집계
|
||||
private async taskCountsOf(
|
||||
repoId: string,
|
||||
): Promise<{ done: number; total: number } | undefined> {
|
||||
return (await this.taskService.countsByRepo([repoId])).get(repoId);
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0). perms 는 단건 조회에서만 채운다.
|
||||
private toResponse(
|
||||
repo: Repo,
|
||||
counts?: { done: number; total: number },
|
||||
perms?: { canManage: boolean; isOwner: boolean },
|
||||
): RepoResponse {
|
||||
const members = (repo.members ?? []).map((m) =>
|
||||
UserService.toPublic(m.user),
|
||||
);
|
||||
return {
|
||||
id: repo.slugName,
|
||||
name: repo.name,
|
||||
owner: repo.owner,
|
||||
slug: `${repo.owner}/${repo.slugName}`,
|
||||
desc: repo.description,
|
||||
visibility: repo.visibility,
|
||||
branch: repo.branch,
|
||||
members,
|
||||
memberCount: members.length,
|
||||
// 업무 모듈 집계(없으면 0/0)
|
||||
doneCount: counts?.done ?? 0,
|
||||
totalCount: counts?.total ?? 0,
|
||||
cloneUrl: repo.cloneUrl,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
giteaMissing: repo.giteaMissing,
|
||||
updatedAt: repo.updatedAt.toISOString(),
|
||||
canManage: perms?.canManage,
|
||||
isOwner: perms?.isOwner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 승인 요청(담당자 → 지시자) — 메시지 선택
|
||||
@@ -14,14 +24,47 @@ export class ApprovalRequestDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수정 요청(지시자 → 담당자) — 보완 내용 필수
|
||||
// 수정 요청 시 항목별 완료 검토 결과(지시자가 체크/X 표시 + 항목별 보완 내용)
|
||||
export class ChecklistReviewDto {
|
||||
@ApiProperty({ description: '체크리스트 항목 ID' })
|
||||
@IsUUID('4')
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '완료 여부 — true=완성(체크) / false=미완성(X)' })
|
||||
@IsBoolean()
|
||||
done!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '항목별 보완 내용(미완성 항목에만 적용). 생략 시 보완 내용 없음',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수정 요청(지시자 → 담당자) — 보완 메시지(선택) + 항목별 완료 체크(선택)
|
||||
export class ApprovalChangesDto {
|
||||
@ApiProperty({
|
||||
description: '수정 요청 메시지(보완이 필요한 내용)',
|
||||
description: '수정 요청 메시지(선택)',
|
||||
required: false,
|
||||
example: '세로형 카피 가독성이 낮으니 자간과 대비를 조정해 주세요.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '수정 요청 내용을 입력해 주세요.' })
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
note?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '항목별 완료 검토 결과(체크/X). 생략 시 체크리스트 변경 없음',
|
||||
type: [ChecklistReviewDto],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChecklistReviewDto)
|
||||
checklist?: ChecklistReviewDto[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -11,6 +13,10 @@ import {
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
CHECKLIST_CATEGORIES,
|
||||
type ChecklistCategory,
|
||||
} from '../entities/checklist-item.entity';
|
||||
|
||||
// 체크리스트 입력 항목 (생성 시 함께 등록)
|
||||
export class ChecklistInputDto {
|
||||
@@ -22,6 +28,15 @@ export class ChecklistInputDto {
|
||||
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
|
||||
@MaxLength(200)
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '카테고리(탭)',
|
||||
enum: CHECKLIST_CATEGORIES,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(CHECKLIST_CATEGORIES)
|
||||
category?: ChecklistCategory;
|
||||
}
|
||||
|
||||
// 업무 생성 요청
|
||||
@@ -36,23 +51,25 @@ export class CreateTaskDto {
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '본문 문단 배열(선택)',
|
||||
description: '본문 문단 배열(필수)',
|
||||
type: [String],
|
||||
required: false,
|
||||
example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: '업무 내용을 입력해 주세요.' })
|
||||
@ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' })
|
||||
@IsString({ each: true })
|
||||
content?: string[];
|
||||
@MaxLength(5000, { each: true })
|
||||
content!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
|
||||
description: '담당자 사용자 ID 배열(프로젝트 멤버만 가능)',
|
||||
type: [String],
|
||||
example: ['a1b2c3d4-...'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
|
||||
@ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds!: string[];
|
||||
|
||||
@@ -72,6 +89,7 @@ export class CreateTaskDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChecklistInputDto)
|
||||
checklist?: ChecklistInputDto[];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -11,6 +13,10 @@ import {
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
CHECKLIST_CATEGORIES,
|
||||
type ChecklistCategory,
|
||||
} from '../entities/checklist-item.entity';
|
||||
|
||||
// 수정 시 체크리스트 항목 — id 있으면 기존 항목 갱신(완료 상태 보존), 없으면 신규 추가
|
||||
export class UpdateChecklistItemInput {
|
||||
@@ -30,6 +36,15 @@ export class UpdateChecklistItemInput {
|
||||
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
|
||||
@MaxLength(200)
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '카테고리(탭)',
|
||||
enum: CHECKLIST_CATEGORIES,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(CHECKLIST_CATEGORIES)
|
||||
category?: ChecklistCategory;
|
||||
}
|
||||
|
||||
// 업무 수정 요청 — 제공된 필드만 갱신
|
||||
@@ -48,17 +63,20 @@ export class UpdateTaskDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200, { message: '본문 문단이 너무 많습니다.' })
|
||||
@IsString({ each: true })
|
||||
@MaxLength(5000, { each: true })
|
||||
content?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
|
||||
description: '담당자 사용자 ID 배열(프로젝트 멤버만 가능)',
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
|
||||
@ArrayMaxSize(50, { message: '담당자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@@ -79,6 +97,7 @@ export class UpdateTaskDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100, { message: '체크리스트 항목이 너무 많습니다.' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateChecklistItemInput)
|
||||
checklist?: UpdateChecklistItemInput[];
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Task } from './task.entity';
|
||||
|
||||
// 체크리스트 카테고리(탭) — AI 추천 4개 영역과 동일
|
||||
export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const;
|
||||
export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number];
|
||||
|
||||
// 업무 체크리스트 항목
|
||||
@Entity('checklist_items')
|
||||
export class ChecklistItem {
|
||||
@@ -23,10 +27,18 @@ export class ChecklistItem {
|
||||
@Column()
|
||||
text!: string;
|
||||
|
||||
// 카테고리(탭) — 필수/관리/보안/부가
|
||||
@Column({ type: 'varchar', default: '필수' })
|
||||
category!: ChecklistCategory;
|
||||
|
||||
// 완료 여부
|
||||
@Column({ default: false })
|
||||
done!: boolean;
|
||||
|
||||
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
|
||||
@Column({ name: 'review_note', type: 'text', nullable: true })
|
||||
reviewNote!: string | null;
|
||||
|
||||
// 표시 순서(생성 순)
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@@ -14,31 +14,31 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Repo } from '../../repo/entities/repo.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { ChecklistItem } from './checklist-item.entity';
|
||||
|
||||
// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review)
|
||||
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes';
|
||||
|
||||
// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9)
|
||||
// 업무 엔티티 — 프로젝트에 종속, 프로젝트 내 순번(seq)으로 식별(#9)
|
||||
@Entity('tasks')
|
||||
@Unique(['repo', 'seq'])
|
||||
@Unique(['project', 'seq'])
|
||||
export class Task {
|
||||
// 내부 식별자 (UUID)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가
|
||||
// 프로젝트 내 표시 순번(#9) — 라우팅/표시 식별자. 프로젝트별로 1부터 증가
|
||||
@Index()
|
||||
@Column({ type: 'int' })
|
||||
seq!: number;
|
||||
|
||||
// 소속 저장소 (저장소 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
|
||||
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치)
|
||||
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'repo_id' })
|
||||
repo!: Relation<Repo>;
|
||||
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피.
|
||||
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.project_id 와 일치)
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 업무 제목
|
||||
@Column()
|
||||
@@ -60,7 +60,7 @@ export class Task {
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
issuer!: User | null;
|
||||
|
||||
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
|
||||
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
name: 'task_assignees',
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { PublicUser } from '../user/user.service';
|
||||
import { TaskService, type MyTaskRowResponse } from './task.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
|
||||
// 내 업무(저장소 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
|
||||
// 내 업무(프로젝트 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
|
||||
@ApiTags('MyTask')
|
||||
@Controller('tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -21,7 +21,7 @@ export class MyTaskController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
@Get('assigned')
|
||||
@ApiOperation({ summary: '내가 담당인 업무 (저장소 횡단, 페이지네이션)' })
|
||||
@ApiOperation({ summary: '내가 담당인 업무 (프로젝트 횡단, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listAssigned(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@@ -32,7 +32,7 @@ export class MyTaskController {
|
||||
}
|
||||
|
||||
@Get('issued')
|
||||
@ApiOperation({ summary: '내가 지시한 업무 (저장소 횡단, 페이지네이션)' })
|
||||
@ApiOperation({ summary: '내가 지시한 업무 (프로젝트 횡단, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listIssued(
|
||||
@CurrentUser() user: PublicUser,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type AttachmentResponse,
|
||||
type CommentReplyResponse,
|
||||
type CommentResponse,
|
||||
type RepoTaskListResult,
|
||||
type ProjectTaskListResult,
|
||||
type TaskDetailResponse,
|
||||
} from './task.service';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
@@ -44,12 +44,14 @@ import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
|
||||
import {
|
||||
MAX_FILE_SIZE,
|
||||
UPLOAD_DIR,
|
||||
fileMimeFilter,
|
||||
type UploadedDiskFile,
|
||||
} from './upload.config';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
|
||||
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
|
||||
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (projectId = slugName, taskId = repo 내 순번)
|
||||
@ApiTags('Task')
|
||||
@Controller('repos/:repoId/tasks')
|
||||
@Controller('projects/:projectId/tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TaskController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
@@ -59,16 +61,24 @@ export class TaskController {
|
||||
summary: '업무 목록 (세그먼트/검색 + 페이지네이션 + 세그먼트 카운트)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: '프로젝트를 찾을 수 없음 (RES_001)',
|
||||
})
|
||||
list(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
@Query('segment') segment?: string,
|
||||
@Query('q') q?: string,
|
||||
@Query('assignee') assignee?: string,
|
||||
): Promise<RepoTaskListResult> {
|
||||
return this.taskService.list(repoId, { segment, q, assignee }, page, size);
|
||||
): Promise<ProjectTaskListResult> {
|
||||
return this.taskService.list(
|
||||
projectId,
|
||||
{ segment, q, assignee },
|
||||
page,
|
||||
size,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -78,14 +88,14 @@ export class TaskController {
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '담당자가 저장소 멤버가 아님 (BIZ_001)',
|
||||
description: '담당자가 프로젝트 멤버가 아님 (BIZ_001)',
|
||||
})
|
||||
create(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: CreateTaskDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.create(repoId, user.id, dto);
|
||||
return this.taskService.create(projectId, user.id, dto);
|
||||
}
|
||||
|
||||
@Get(':taskId')
|
||||
@@ -93,10 +103,10 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' })
|
||||
findOne(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.findOne(repoId, taskId);
|
||||
return this.taskService.findOne(projectId, taskId);
|
||||
}
|
||||
|
||||
@Patch(':taskId')
|
||||
@@ -104,12 +114,12 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
update(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: UpdateTaskDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.update(repoId, user.id, taskId, dto);
|
||||
return this.taskService.update(projectId, user.id, taskId, dto);
|
||||
}
|
||||
|
||||
@Delete(':taskId')
|
||||
@@ -118,11 +128,11 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.taskService.remove(repoId, user.id, taskId);
|
||||
await this.taskService.remove(projectId, user.id, taskId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -136,12 +146,17 @@ export class TaskController {
|
||||
description: '허용되지 않는 상태 전이 (BIZ_001)',
|
||||
})
|
||||
changeStatus(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: ChangeStatusDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
|
||||
return this.taskService.changeStatus(
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
dto.status,
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
@@ -151,13 +166,13 @@ export class TaskController {
|
||||
@ApiOperation({ summary: '댓글 작성 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '작성 성공' })
|
||||
addComment(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: CreateCommentDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<CommentResponse> {
|
||||
return this.taskService.addComment(
|
||||
repoId,
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
dto.text,
|
||||
@@ -170,14 +185,14 @@ export class TaskController {
|
||||
@ApiOperation({ summary: '답글 작성 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '작성 성공' })
|
||||
addReply(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('commentId', ParseUUIDPipe) commentId: string,
|
||||
@Body() dto: CreateReplyDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<CommentReplyResponse> {
|
||||
return this.taskService.addReply(
|
||||
repoId,
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
commentId,
|
||||
@@ -193,18 +208,27 @@ export class TaskController {
|
||||
FileInterceptor('file', {
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: fileMimeFilter,
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: '파일 업로드 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '업로드 성공' })
|
||||
addFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@UploadedFile() file: UploadedDiskFile,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<AttachmentResponse> {
|
||||
return this.taskService.addFile(repoId, user.id, taskId, file);
|
||||
// 파일 누락 또는 허용되지 않는 형식(fileFilter 가 걸러 file 미존재)
|
||||
if (!file) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return this.taskService.addFile(projectId, user.id, taskId, file);
|
||||
}
|
||||
|
||||
@Delete(':taskId/files/:fileId')
|
||||
@@ -212,12 +236,12 @@ export class TaskController {
|
||||
@ApiOperation({ summary: '파일 삭제 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async removeFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.taskService.removeFile(repoId, user.id, taskId, fileId);
|
||||
await this.taskService.removeFile(projectId, user.id, taskId, fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -225,7 +249,7 @@ export class TaskController {
|
||||
@ApiOperation({ summary: '파일 다운로드 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '파일 스트림' })
|
||||
async downloadFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
@@ -233,16 +257,18 @@ export class TaskController {
|
||||
): Promise<void> {
|
||||
const { attachment, absolutePath } =
|
||||
await this.taskService.getFileForDownload(
|
||||
repoId,
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
fileId,
|
||||
);
|
||||
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
|
||||
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res).
|
||||
// attachment(다운로드 강제) + nosniff 로 브라우저 인라인 렌더링/스니핑 차단(저장형 XSS 방지)
|
||||
res.setHeader('Content-Type', attachment.mime);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
@@ -255,12 +281,17 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '요청 성공' })
|
||||
@ApiResponse({ status: 409, description: '허용되지 않는 전이 (BIZ_001)' })
|
||||
requestApproval(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: ApprovalRequestDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.requestApproval(repoId, user.id, taskId, dto.note);
|
||||
return this.taskService.requestApproval(
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':taskId/approval/approve')
|
||||
@@ -269,11 +300,11 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '승인 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
approve(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.approve(repoId, user.id, taskId);
|
||||
return this.taskService.approve(projectId, user.id, taskId);
|
||||
}
|
||||
|
||||
@Post(':taskId/approval/changes')
|
||||
@@ -282,11 +313,17 @@ export class TaskController {
|
||||
@ApiResponse({ status: 200, description: '요청 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
requestChanges(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: ApprovalChangesDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.requestChanges(repoId, user.id, taskId, dto.note);
|
||||
return this.taskService.requestChanges(
|
||||
projectId,
|
||||
user.id,
|
||||
taskId,
|
||||
dto.note,
|
||||
dto.checklist,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { RepoMember } from '../repo/entities/repo-member.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { Task } from './entities/task.entity';
|
||||
import { ChecklistItem } from './entities/checklist-item.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
@@ -12,7 +12,7 @@ import { TaskService } from './task.service';
|
||||
import { TaskController } from './task.controller';
|
||||
import { MyTaskController } from './my-task.controller';
|
||||
|
||||
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인)
|
||||
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인) — 프로젝트 종속
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
@@ -20,8 +20,8 @@ import { MyTaskController } from './my-task.controller';
|
||||
ChecklistItem,
|
||||
Comment,
|
||||
Attachment,
|
||||
Repo,
|
||||
RepoMember,
|
||||
Project,
|
||||
ProjectMember,
|
||||
]),
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ForbiddenException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TaskService } from './task.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { RepoMember } from '../repo/entities/repo-member.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { Task } from './entities/task.entity';
|
||||
import { ChecklistItem } from './entities/checklist-item.entity';
|
||||
import { Comment } from './entities/comment.entity';
|
||||
@@ -21,7 +21,7 @@ function makeService() {
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const checklistRepo = { create: jest.fn(), save: jest.fn() };
|
||||
const repoRepo = { findOne: jest.fn() };
|
||||
const projectRepo = { findOne: jest.fn() };
|
||||
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
|
||||
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
|
||||
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
@@ -36,8 +36,8 @@ function makeService() {
|
||||
const svc = new TaskService(
|
||||
taskRepo as unknown as Repository<Task>,
|
||||
checklistRepo as unknown as Repository<ChecklistItem>,
|
||||
repoRepo as unknown as Repository<Repo>,
|
||||
memberRepo as unknown as Repository<RepoMember>,
|
||||
projectRepo as unknown as Repository<Project>,
|
||||
memberRepo as unknown as Repository<ProjectMember>,
|
||||
commentRepo as unknown as Repository<Comment>,
|
||||
attachmentRepo as unknown as Repository<Attachment>,
|
||||
activity as unknown as ActivityService,
|
||||
@@ -47,7 +47,7 @@ function makeService() {
|
||||
svc,
|
||||
taskRepo,
|
||||
checklistRepo,
|
||||
repoRepo,
|
||||
projectRepo,
|
||||
memberRepo,
|
||||
commentRepo,
|
||||
attachmentRepo,
|
||||
@@ -60,8 +60,8 @@ const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
|
||||
describe('TaskService', () => {
|
||||
describe('changeStatus — 상태 머신', () => {
|
||||
it('허용되지 않는 전이(todo→done)는 BusinessException', async () => {
|
||||
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' });
|
||||
taskRepo.findOne.mockResolvedValue({
|
||||
seq: 1,
|
||||
@@ -77,8 +77,8 @@ describe('TaskService', () => {
|
||||
});
|
||||
|
||||
it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => {
|
||||
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
// 요청자는 일반 멤버(담당자)
|
||||
memberRepo.findOne.mockResolvedValue({ roleType: 'member' });
|
||||
taskRepo.findOne.mockResolvedValue({
|
||||
@@ -95,8 +95,8 @@ describe('TaskService', () => {
|
||||
});
|
||||
|
||||
it('비멤버는 상태 변경 불가(Forbidden)', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
const { svc, projectRepo, memberRepo } = makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음
|
||||
|
||||
await expect(
|
||||
@@ -105,9 +105,9 @@ describe('TaskService', () => {
|
||||
});
|
||||
|
||||
it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => {
|
||||
const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } =
|
||||
const { svc, taskRepo, checklistRepo, projectRepo, memberRepo } =
|
||||
makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
// 지시자/admin — assertMember 는 user 관계까지 로드(알림 actorName 용)
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'admin',
|
||||
@@ -142,8 +142,8 @@ describe('TaskService', () => {
|
||||
|
||||
describe('승인 워크플로우 (5단계)', () => {
|
||||
it('수정 요청(review→changes)은 지시자/admin 이 아니면 Forbidden', async () => {
|
||||
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
const { svc, taskRepo, projectRepo, memberRepo } = makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'member',
|
||||
user: { id: 'assignee' },
|
||||
@@ -161,16 +161,62 @@ describe('TaskService', () => {
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('수정 요청 시 전달된 항목만 완료 체크(체크/X)를 반영한다', async () => {
|
||||
const { svc, taskRepo, checklistRepo, projectRepo, memberRepo } =
|
||||
makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'admin',
|
||||
user: { id: 'admin', name: 'A' },
|
||||
});
|
||||
const task = {
|
||||
seq: 9,
|
||||
status: 'review',
|
||||
title: '배너',
|
||||
content: [],
|
||||
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
|
||||
assignees: [],
|
||||
checklist: [
|
||||
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
|
||||
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
|
||||
{ id: 'c3', text: '모바일', done: true, sortOrder: 2 },
|
||||
],
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-06-08T00:00:00Z'),
|
||||
};
|
||||
taskRepo.findOne.mockResolvedValue(task);
|
||||
taskRepo.save.mockResolvedValue(task);
|
||||
checklistRepo.save.mockResolvedValue(task.checklist);
|
||||
|
||||
const result = await svc.requestChanges('q3-launch', 'admin', 9, '보완 바랍니다', [
|
||||
{ id: 'c1', done: false, note: '여백을 더 주세요' }, // 완성 → 미완성(X) + 보완 내용
|
||||
{ id: 'c2', done: true, note: '무시됨' }, // 미완성 → 완성(체크): 보완 내용 비움
|
||||
// c3 은 미전달 → 기존값(true) 유지
|
||||
]);
|
||||
|
||||
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
|
||||
const c1 = task.checklist.find((c) => c.id === 'c1');
|
||||
const c2 = task.checklist.find((c) => c.id === 'c2');
|
||||
const c3 = task.checklist.find((c) => c.id === 'c3');
|
||||
expect(c1?.done).toBe(false);
|
||||
expect(c1?.reviewNote).toBe('여백을 더 주세요'); // 미완성 항목은 보완 내용 보관
|
||||
expect(c2?.done).toBe(true);
|
||||
expect(c2?.reviewNote).toBeNull(); // 완료 항목은 보완 내용 비움
|
||||
expect(c3?.done).toBe(true);
|
||||
expect(result.status).toBe('changes');
|
||||
expect(result.changesNote).toBe('보완 바랍니다');
|
||||
});
|
||||
|
||||
it('승인 요청(prog→review)은 담당자가 가능', async () => {
|
||||
const {
|
||||
svc,
|
||||
taskRepo,
|
||||
repoRepo,
|
||||
projectRepo,
|
||||
memberRepo,
|
||||
commentRepo,
|
||||
attachmentRepo,
|
||||
} = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'member',
|
||||
user: { id: 'assignee', email: 'a@b.c', name: 'A', role: null },
|
||||
@@ -212,9 +258,9 @@ describe('TaskService', () => {
|
||||
});
|
||||
|
||||
describe('create — 담당자 멤버 검증', () => {
|
||||
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
it('담당자가 프로젝트 멤버가 아니면 BusinessException', async () => {
|
||||
const { svc, projectRepo, memberRepo } = makeService();
|
||||
projectRepo.findOne.mockResolvedValue(REPO);
|
||||
// assertAdmin 통과
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'admin',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,40 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// 첨부 1건당 최대 크기(25MB)
|
||||
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
// 허용 MIME 화이트리스트 — 실행 가능/스크립트(예: text/html, image/svg+xml) 차단.
|
||||
// 다운로드는 attachment + nosniff 로 제공하지만, 업로드 단계에서도 1차로 막는다(심층 방어).
|
||||
export const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'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/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
]);
|
||||
|
||||
// multer fileFilter — 허용 MIME 만 통과. 거부 시 파일이 저장되지 않아 컨트롤러가 400 처리.
|
||||
export function fileMimeFilter(
|
||||
_req: unknown,
|
||||
file: { mimetype: string },
|
||||
cb: (error: Error | null, acceptFile: boolean) => void,
|
||||
): void {
|
||||
cb(null, ALLOWED_MIME_TYPES.has(file.mimetype));
|
||||
}
|
||||
|
||||
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
|
||||
export interface UploadedDiskFile {
|
||||
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
|
||||
|
||||
@@ -17,9 +17,60 @@ export class User {
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false)
|
||||
@Column({ name: 'password_hash', select: false })
|
||||
passwordHash!: string;
|
||||
// 비밀번호 해시 — 평문 저장 금지. 조회 시 기본 제외(select: false).
|
||||
// 소셜 전용 계정은 비밀번호가 없으므로 null.
|
||||
@Column({
|
||||
name: 'password_hash',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
passwordHash!: string | null;
|
||||
|
||||
// 가입 경로 — 'local'(이메일) | 'google' | 'kakao'. 로그인 안내·계정 연동 판별에 사용.
|
||||
@Column({ type: 'varchar', default: 'local' })
|
||||
provider!: string;
|
||||
|
||||
// 이메일 인증 완료 여부 — 로컬 가입은 인증 메일 클릭 후 true, 소셜 가입은 즉시 true.
|
||||
// 미인증 로컬 계정은 로그인이 차단된다.
|
||||
@Column({ name: 'email_verified', type: 'boolean', default: false })
|
||||
emailVerified!: boolean;
|
||||
|
||||
// 이메일 인증 토큰 해시(SHA-256) — 원문은 메일 링크에만 담고 DB엔 해시만 저장. 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'email_verify_token_hash',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
emailVerifyTokenHash!: string | null;
|
||||
|
||||
// 이메일 인증 토큰 만료 시각 — 만료 후 클릭은 무효. 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'email_verify_expires_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
emailVerifyExpiresAt!: Date | null;
|
||||
|
||||
// 비밀번호 재설정 토큰 해시(SHA-256) — 원문은 메일 링크에만, DB엔 해시만. 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'password_reset_token_hash',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
passwordResetTokenHash!: string | null;
|
||||
|
||||
// 비밀번호 재설정 토큰 만료 시각 — 보안상 짧게(1시간). 조회 기본 제외.
|
||||
@Column({
|
||||
name: 'password_reset_expires_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
passwordResetExpiresAt!: Date | null;
|
||||
|
||||
// 표시 이름
|
||||
@Column()
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ILike, Repository } from 'typeorm';
|
||||
import { ILike, IsNull, LessThan, Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
// 이메일 인증 도입 컷오프 — 이 시각 이전에 생성된 로컬 계정만 그랜드페더링 대상.
|
||||
// 이후 가입은 항상 정상 인증 절차를 거치므로 토큰 상태와 무관하게 자동 인증되지 않는다.
|
||||
const LEGACY_VERIFY_CUTOFF = new Date('2026-06-20T00:00:00Z');
|
||||
|
||||
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
@@ -14,12 +22,35 @@ export interface PublicUser {
|
||||
|
||||
// 사용자 데이터 접근 계층 — 인증 모듈이 주입하여 사용
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
export class UserService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
// 이메일 인증 도입 이전에 만들어진 로컬 계정 그랜드페더링 —
|
||||
// 컷오프 이전 생성 + 인증 토큰이 없는 로컬 계정만 인증 완료로 간주.
|
||||
// 컷오프 이후 가입은 토큰 상태와 무관하게 대상에서 제외되어, 정상 인증 절차를 항상 거친다.
|
||||
// 멱등(idempotent): 최초 1회만 실제로 갱신되고 이후에는 대상이 없다.
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const result = await this.userRepository.update(
|
||||
{
|
||||
provider: 'local',
|
||||
emailVerified: false,
|
||||
emailVerifyTokenHash: IsNull(),
|
||||
createdAt: LessThan(LEGACY_VERIFY_CUTOFF),
|
||||
},
|
||||
{ emailVerified: true },
|
||||
);
|
||||
if (result.affected && result.affected > 0) {
|
||||
this.logger.log(
|
||||
`이메일 인증 도입 전 기존 로컬 계정 ${result.affected}건을 인증 완료로 보정`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// id 단건 조회 (없으면 null)
|
||||
findById(id: string): Promise<User | null> {
|
||||
return this.userRepository.findOne({ where: { id } });
|
||||
@@ -54,7 +85,17 @@ export class UserService {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
// 신규 사용자 생성 (비밀번호는 해시된 상태로 전달받음)
|
||||
// 이메일 인증 토큰 해시로 조회 — 인증 토큰 컬럼은 select:false 라 명시적으로 포함.
|
||||
findByEmailVerifyTokenHash(tokenHash: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.emailVerifyTokenHash')
|
||||
.addSelect('user.emailVerifyExpiresAt')
|
||||
.where('user.emailVerifyTokenHash = :tokenHash', { tokenHash })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
// 신규 로컬 사용자 생성 (비밀번호는 해시된 상태로 전달받음, 기본 미인증)
|
||||
create(payload: {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
@@ -66,10 +107,82 @@ export class UserService {
|
||||
passwordHash: payload.passwordHash,
|
||||
name: payload.name,
|
||||
role: payload.role ?? null,
|
||||
provider: 'local',
|
||||
emailVerified: false,
|
||||
});
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
// 소셜(OAuth) 사용자 생성 — 비밀번호 없음, 제공자가 이메일을 검증하므로 인증 완료 상태.
|
||||
createOAuthUser(payload: {
|
||||
email: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
avatarUrl?: string | null;
|
||||
}): Promise<User> {
|
||||
const user = this.userRepository.create({
|
||||
email: payload.email,
|
||||
passwordHash: null,
|
||||
name: payload.name,
|
||||
provider: payload.provider,
|
||||
avatarUrl: payload.avatarUrl ?? null,
|
||||
emailVerified: true,
|
||||
});
|
||||
return this.userRepository.save(user);
|
||||
}
|
||||
|
||||
// 이메일 인증 토큰(해시)·만료 시각 저장
|
||||
async setEmailVerifyToken(
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
emailVerifyTokenHash: tokenHash,
|
||||
emailVerifyExpiresAt: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 인증 완료 처리 — 인증 플래그 true, 토큰 폐기
|
||||
async markEmailVerified(userId: string): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
emailVerified: true,
|
||||
emailVerifyTokenHash: null,
|
||||
emailVerifyExpiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 토큰(해시)·만료 시각 저장
|
||||
async setPasswordResetToken(
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
passwordResetTokenHash: tokenHash,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 재설정 토큰 해시로 조회 — 토큰 컬럼은 select:false 라 명시적으로 포함.
|
||||
findByPasswordResetTokenHash(tokenHash: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordResetTokenHash')
|
||||
.addSelect('user.passwordResetExpiresAt')
|
||||
.where('user.passwordResetTokenHash = :tokenHash', { tokenHash })
|
||||
.getOne();
|
||||
}
|
||||
|
||||
// 비밀번호 변경 — 새 해시 저장 + 재설정 토큰 폐기
|
||||
async updatePassword(userId: string, passwordHash: string): Promise<void> {
|
||||
await this.userRepository.update(userId, {
|
||||
passwordHash,
|
||||
passwordResetTokenHash: null,
|
||||
passwordResetExpiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
|
||||
static toPublic(user: User): PublicUser {
|
||||
return {
|
||||
|
||||
+8
-2
@@ -10,6 +10,7 @@ services:
|
||||
args:
|
||||
- BUILD_MODE=${BUILD_MODE}
|
||||
- VITE_API_BASE_URL=${BACKEND_API_URL}
|
||||
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
@@ -21,7 +22,8 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
|
||||
# localhost 는 일부 이미지에서 IPv6(::1) 로 먼저 풀려 IPv4 전용 바인딩에 연결 실패 → 127.0.0.1 고정
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -58,6 +60,8 @@ services:
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
|
||||
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
@@ -68,7 +72,9 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:${BACKEND_PORT}/ || exit 1"]
|
||||
# API 는 전역 프리픽스 /api 하위에 마운트됨 — 루트(/)는 404 라 /api/health 를 프로브.
|
||||
# localhost→IPv6(::1) 회피를 위해 127.0.0.1 고정.
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+43
-7
@@ -59,10 +59,14 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/auth/signup` | `{ email, password, name }` | `{ user }` | 가입 후 자동 로그인(쿠키 set) |
|
||||
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set |
|
||||
| POST | `/auth/signup` | `{ email, password, name }` | `{ email, verificationPending }` | **자동 로그인 X** — 인증 메일 발송, 쿠키 미발급 |
|
||||
| POST | `/auth/resend-verification` | `{ email }` | `null` | 인증 메일 재발송(존재 여부 비노출) |
|
||||
| GET | `/auth/verify-email` | `?token` | 302 redirect | 메일 링크. 검증 후 `FRONTEND_URL/login?verified=1\|0` 로 |
|
||||
| POST | `/auth/login` | `{ email, password, keepLogin? }` | `{ user }` | access/refresh 쿠키 set. 미인증/소셜전용 계정은 차단 |
|
||||
| GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) |
|
||||
| GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 |
|
||||
| POST | `/auth/logout` | — | `null` | 쿠키 clear |
|
||||
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 |
|
||||
| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | **회전형** — access/refresh 모두 교체. 재사용/만료/누락 시 401(쿠키 제거) |
|
||||
| GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 |
|
||||
|
||||
`User` 응답 형태:
|
||||
@@ -72,12 +76,19 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
"id": "uuid",
|
||||
"email": "sykim@acme.co",
|
||||
"name": "김서연",
|
||||
"role": "콘텐츠 기획" // 전사 직무(선택)
|
||||
"role": "콘텐츠 기획", // 전사 직무(선택)
|
||||
"avatarUrl": null // 프로필 이미지 URL (소셜 가입 시 제공자 이미지)
|
||||
}
|
||||
```
|
||||
|
||||
> 프론트 매핑: `auth.store.ts`(신규) + `useAuth` composable. 라우터 가드 TODO 를
|
||||
> `me` 부트스트랩 + 401→`/login` 리다이렉트로 교체. `LoginPage`/`SignupPage` 의 `router.push` 를 실제 호출로.
|
||||
### 회원가입 · 이메일 인증 · 소셜 로그인 흐름
|
||||
|
||||
- **이메일 가입**: `signup` → 미인증 계정 생성 + 인증 메일 발송(현재는 **백엔드 로그로 링크 출력** — `MailService` 플레이스홀더). 사용자가 메일의 `verify-email` 링크 클릭 → 인증 완료 → `/login?verified=1` 로 리다이렉트. **인증 전에는 로그인 차단**(`BIZ_001` 403).
|
||||
- **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다.
|
||||
- **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성.
|
||||
- **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료).
|
||||
- **세션/토큰 회전**: refresh 는 **회전형 + 재사용 탐지**. `refresh_sessions`(패밀리 단위, 한 로그인=1행) 에 현재 jti 를 두고, `/auth/refresh` 마다 새 jti 로 회전. 이미 회전된 옛 토큰이 다시 오면 탈취로 간주해 패밀리 폐기(전 세션 강제 로그아웃). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight(`useApi`)로 방지. logout=해당 패밀리만 폐기. (access 토큰은 15분 만료까지 유효 — 즉시 폐기 denylist 미적용.)
|
||||
- **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +98,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
|---|---|---|---|
|
||||
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) |
|
||||
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
|
||||
| GET | `/repos/name-available` | `?slug` | `{ available: boolean }` — 이름(slug) 중복 체크(`:repoId` 보다 먼저 매칭) |
|
||||
| POST | `/repos` | `CreateRepoDto` | `Repo` |
|
||||
| GET | `/repos/:repoId` | — | `Repo`(상세) |
|
||||
| PATCH | `/repos/:repoId` | `UpdateRepoDto` | `Repo` |
|
||||
@@ -95,6 +107,9 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
|
||||
`GET /repos/meta` 응답: `{ owner: string, template: { available: boolean, slug: string } }`
|
||||
— 생성 전 소유자(조직) 고정 표시 + 템플릿 옵션 노출용.
|
||||
|
||||
`GET /repos/name-available?slug=` 응답: `{ available }` — 생성 폼의 **실시간 이름 가용성 체크**용(Relay slug 중복만 검사).
|
||||
프론트는 형식(`^[a-z0-9-_]+$`)을 즉시 검사하고, 통과 시 디바운스로 이 API 를 호출한다. Gitea 이름 충돌은 생성(`POST /repos`) 시 최종 확인(`BIZ_001`).
|
||||
|
||||
`CreateRepoDto`: `{ slug, name, visibility('private'|'public'), description, branch?, useTemplate? }`
|
||||
(**description 필수**, `useTemplate=true` 면 템플릿 저장소를 복제해 생성)
|
||||
`Repo` 응답(원시값 중심):
|
||||
@@ -317,9 +332,30 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
|
||||
전역 CacheModule 을 Redis(keyv)로 전환(`REDIS_*` 미설정 시 인메모리 폴백, 기본 TTL 60초). **`GET /repos`(저장소 목록)**을 버전 네임스페이스로 캐시 — 키 `repos:list:v{ver}:p{page}:s{size}`, **저장소 생성/수정/삭제·Gitea 동기화·멤버 초대/제거** 시 버전 증가로 일괄 무효화(멤버 역할 변경은 목록 응답 불변이라 제외). 버전 카운터가 공유 Redis 에 있어 **무효화가 다중 인스턴스에서도 정합**. 업무 카운트(`doneCount/totalCount`)는 ≤60초 지연 허용. 응답 계약 불변(캐시는 투명) — 프론트 영향 없음. `GET /repos/meta` 는 DB 미접근 인메모리 getter 라 캐싱 제외.
|
||||
|
||||
### 소셜 로그인 · 회원가입 · 이메일 인증 — **✅ 완료**
|
||||
|
||||
Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조.
|
||||
|
||||
### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)**
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) |
|
||||
| GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) |
|
||||
| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 |
|
||||
| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 |
|
||||
| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) |
|
||||
| POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 |
|
||||
|
||||
- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기).
|
||||
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지).
|
||||
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트).
|
||||
- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크).
|
||||
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
|
||||
|
||||
### 그 외(미착수)
|
||||
|
||||
소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.)
|
||||
메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
**스택**: `@nestjs/cache-manager@3` + `cache-manager@6`(Keyv 기반) → Redis 연결은 신규 의존성 **`@keyv/redis`**. docker-compose 가 이미 redis 컨테이너 + `REDIS_HOST/PORT/PASSWORD` 주입.
|
||||
|
||||
**백엔드**
|
||||
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(단독 실행/테스트). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
|
||||
- **CacheModule → Redis 전환**(`app.module.ts` `registerAsync`): `REDIS_HOST` 있으면 `createKeyv(redis://…)` 스토어, **없으면 인메모리 폴백**(REDIS_HOST 미설정 시). keyv `error` 이벤트를 흡수해 Redis 장애가 프로세스를 죽이지 않음(캐시 미스→DB 폴백). 기본 TTL 60초.
|
||||
- **저장소 목록 캐싱**(`GET /repos`): 신규 `RepoCacheService`(repo 모듈) — **버전 네임스페이스 무효화**. 키 `repos:list:v{ver}:p{page}:s{size}`, 변경 시 `repos:list:ver` 증가로 기존 키 일괄 폐기(keyv 와일드카드 삭제 미지원 회피). 모든 캐시 접근은 try/catch 로 실패 시 DB 조회 폴백.
|
||||
- **무효화 지점**: RepoService 생성/수정/삭제 + RepoSyncService 동기화 + **MemberService 초대/제거**(목록 카드 멤버 수·아바타 반영, 역할 변경은 목록 불변이라 제외 — *2026-06-19 보강*). `RepoCacheService` 공유.
|
||||
- **보강(2026-06-19, 검토 [`redis-cache-review.md`](./redis-cache-review.md))**: ① C1 멤버 변경 무효화, ② 기동 시 캐시 모드 로그(Redis/인메모리 가시화), ③ `main.ts` `enableShutdownHooks()` — 종료 시 알림 Redis pub/sub 연결 graceful close(이전엔 훅 미설정으로 `onModuleDestroy` 가 死코드였음).
|
||||
@@ -265,8 +265,62 @@
|
||||
|
||||
> **알려진 한계**: ① 캐싱 대상은 현재 저장소 목록 1종(가장 트래픽 큰 랜딩) — 업무 목록 등은 세그먼트/검색/페이지 조합이 많아 추후 확장 후보. ② 업무 카운트 ≤60초 지연(위). ③ **의존성 추가(`@keyv/redis`)라 dev 컨테이너는 `up -d --build -V`(익명 node_modules 볼륨 갱신) 필요.**
|
||||
|
||||
#### 8-4. 그 외(미착수)
|
||||
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, AI 에이전트 패널 실연동.
|
||||
#### 8-4. 소셜 로그인 + 회원가입 이메일 인증 ✅ (2026-06-19)
|
||||
|
||||
**지시(2026-06-19)**: 소셜 키는 백엔드 env 에 등록됨 → 바로 연동. 회원가입은 인증 메일의 버튼 클릭 시 완료. **메일 전송은 우선 백엔드 로그로 대체.**
|
||||
|
||||
**백엔드**
|
||||
- **User 엔티티 확장**(`modules/user/entities/user.entity.ts`): `passwordHash` **nullable**(소셜 전용 계정=null), `provider`('local'|'google'|'kakao', default 'local'), `emailVerified`(bool, default false), `emailVerifyTokenHash`/`emailVerifyExpiresAt`(둘 다 select:false). 원문 인증 토큰은 메일 링크에만 담고 DB엔 **SHA-256 해시 + 만료(24h)** 만 저장.
|
||||
- **그랜드페더링**(`UserService.onApplicationBootstrap`): 인증 도입 이전 로컬 계정 락아웃 방지 — `provider='local' & 토큰 null & 미인증` 행을 인증완료로 보정(멱등 — 진행 중 미인증 계정은 토큰을 보유해 제외).
|
||||
- **메일 모듈**(신규 `modules/mail/`): `MailService.sendVerificationEmail()` 플레이스홀더 — **인증 링크를 백엔드 Logger 로 출력**(추후 SMTP 도입 시 이 서비스만 교체).
|
||||
- **AuthService**: `signup` **자동 로그인 폐지** → 미인증 계정 생성 + 인증 메일 발송, 응답 `{email, verificationPending}`. `verifyEmail(token)`(해시 매칭 + 만료 검사 → 인증 완료, 토큰 폐기), `resendVerification`(존재/상태 비노출), `validateUser` 에 **로그인 게이트**(미인증 로컬=`BIZ_001` 403, 비밀번호 없는 소셜 전용=소셜 안내), `validateOrCreateOAuthUser`(이메일로 매칭→없으면 생성 emailVerified=true, 미인증 로컬과 이메일 같으면 인증 보정).
|
||||
- **소셜 전략**(passport-google-oauth20·passport-kakao 신규 의존성, kakao 무타입→`auth/types/passport-kakao.d.ts` 앰비언트 선언): `GoogleStrategy`/`KakaoStrategy` 가 프로필 정규화 후 위임. **키(`*_CLIENT_ID`) 미설정 시 전략 미등록**(AuthModule `useFactory` 가드 — null 반환).
|
||||
- **AuthController**: `signup`(쿠키 미발급)·`resend-verification`·**`GET verify-email`**(`@SkipTransform`+`@Res` 302 → `FRONTEND_URL/login?verified=1|0`)·**`GET google|kakao` + `/callback`**(`@SkipTransform`, 콜백서 쿠키 발급 → `FRONTEND_URL/repos`). `loginWith` 헬퍼로 토큰 발급+쿠키 공통화.
|
||||
- **env**: `APP_API_URL`(메일 링크용 백엔드 주소, 폴백 `localhost:3100/api`)·`FRONTEND_URL`(리다이렉트, 폴백 CORS_ORIGIN→5273) + `GOOGLE_*`/`KAKAO_*`(콜백 URL 은 백엔드 포트 **3100**, `/api/auth/{provider}/callback`) → `backend/.env.development.example` 문서화.
|
||||
|
||||
**프론트**
|
||||
- `types/auth.ts` `SignupResult`, `useAuth`/`auth.store`: signup→대기(user 미갱신) + `resendVerification`.
|
||||
- **SignupPage**: 가입 성공 시 자동 로그인 대신 **인증 메일 안내(verify 단계)** 표시 + 재발송 버튼 연동.
|
||||
- **LoginPage**: 소셜 버튼 실연동(`window.location.href = ${VITE_API_BASE_URL}/auth/{provider}` 전체 페이지 이동) + `?verified=1|0` 결과 배너.
|
||||
|
||||
**검증**: 백엔드 build/lint 0 err·20 tests pass(MailService 가 AuthService 4번째 생성자 인자 — 영향 spec 없음), 프론트 type-check/lint/build 0 err.
|
||||
|
||||
> **알려진 한계**: ① **런타임 미검증** — DB + 소셜 콘솔에 콜백 URL 등록 필요(코드 레벨까지). ② 메일은 **로그 출력**(실발송 SMTP 미구현). ③ **의존성 추가(passport-google-oauth20/passport-kakao)라 dev 컨테이너 `up -d --build -V` 필요.** ④ 소셜 계정은 이메일 기준 매칭(별도 providerId 컬럼 없음) — 동일 이메일이면 동일인으로 연동.
|
||||
|
||||
#### 8-5. 보안 점검 + 보완 (배치 A+B) ✅ (2026-06-19)
|
||||
|
||||
전 작업분 대상 보안 감사(4영역 병렬) → `docs/security-review.md`. 서버측 기반은 견고(IDOR/권한상승/SQL인젝션/에러누출 없음), 최약점은 신규 소셜 로그인. 사용자 결정: **HIGH+MEDIUM 적용**, 조직 전체 읽기 모델은 의도된 설계로 유지.
|
||||
|
||||
- **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부.
|
||||
- **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0).
|
||||
- **H3** refresh 회전/폐기 부재 → (초기) `User.tokenVersion` 폐기 → **(강화) 세션 기반 완전 회전 + 재사용 탐지로 교체**. `RefreshSession`(패밀리, 한 로그인=1행: id/currentJti/prevJti+prevAt/expiresAt) 도입. `/auth/refresh` 가 현재 jti면 새 jti로 회전, 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 `useApi` single-flight 로 방지. logout=패밀리 폐기, `revokeAllSessions`(전 기기) 준비. `tokenVersion`·`jwt-refresh.strategy`·`JwtRefreshGuard` 제거(refresh 는 가드 없이 서비스 직접 처리).
|
||||
- **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단).
|
||||
- **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분).
|
||||
- **M3** 첨부 삭제 업로더/admin 한정.
|
||||
- **M4** 인증 토큰 로그 prod 게이트(원문 토큰 운영 로그 미기록).
|
||||
|
||||
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. **런타임 미검증**(OAuth 제공자 콜백+DB 필요).
|
||||
|
||||
**배치 C (LOW 하드닝, L2~L6) ✅** — L1(에이전트 v-html)은 에이전트 패널 미연동이라 사용자 요청으로 보류.
|
||||
- **L2** DTO 길이 바운드(task `content`/`assigneeIds`/`checklist` `@ArrayMaxSize`+요소 `@MaxLength`, `UpdateRepoDto.description` `@MaxLength(300)`).
|
||||
- **L3** 비번 정책(`@MaxLength(72)`+영문·숫자 `@Matches`) + bcrypt 10→12.
|
||||
- **L4** 그랜드페더링 컷오프(`2026-06-20` 이전 생성 한정, `createdAt LessThan`).
|
||||
- **L5** multer 오류 → 400(VAL_001) 매핑(전역 필터).
|
||||
- **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정.
|
||||
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음.
|
||||
|
||||
#### 8-6. AI 채팅 패널 ✅ (2026-06-19)
|
||||
|
||||
**지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨.
|
||||
|
||||
- **백엔드** 신규 `modules/ai/`: `AiService`(Anthropic Messages API 프록시 — 글로벌 `fetch` + `AbortSignal.timeout(30s)`, 키는 서버 env 만, 미설정 503·외부오류 502·내부정보 비노출), `AiController` `POST /ai/chat`(JwtAuthGuard + `@Throttle` 20/분), `ChatDto`(role enum + content MaxLength 8000 + 메시지 ArrayMaxSize 50). 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). `AiModule` → app.module.
|
||||
- **프론트** 신규 `components/AgentChatPanel.vue`(우측 슬라이드 채팅 패널, 환영문구·말풍선·타이핑 인디케이터·에러·새대화, plain text + `white-space:pre-wrap`로 XSS 안전), `types/ai.ts`·`composables/useAi.ts`(대화 이력 전체 전송, 서버 무상태). **TaskDetailPage 의 기존 목업 에이전트 패널(스크립트·템플릿·전역 스타일 전부) 제거**. 이로써 보안 L1(에이전트 v-html)도 해소.
|
||||
- **버튼은 AppShell 헤더로 이동 — 모든 화면에서 상시 노출/열림**(`agentOpen` 은 AppShell 가 보유, `<AgentChatPanel>` 도 AppShell 에서 렌더).
|
||||
- **DB 영속 + 다중 대화로 확장(2026-06-20)**: 사용자 요청 "어디서든 본인 기록 조회 → DB 필요". 무상태 `/ai/chat` → **대화 스코프 API**(`AiConversation`·`AiMessage` 엔티티, user CASCADE). 엔드포인트 `GET /ai/conversations`(목록)·`GET/:id`(상세)·`POST /ai/conversations`(새 대화)·`POST /:id/messages`(추가)·`DELETE /:id` — 전부 **본인 소유 스코프**(타인 대화 404=IDOR 차단). **클라는 새 메시지 1건만 보내고 서버가 DB 이력으로 Claude 호출**(보안·페이로드 개선). **Claude 실패 시 사용자 메시지·빈 대화 롤백**. 프론트 `AgentChatPanel` 에 **채팅↔지난 대화 목록 뷰**(열기/삭제), `ai.store` 가 목록·현재 대화 관리. **이제 다른 기기·재로그인·새로고침에도 기록 유지.** SendMessageDto(content 8000자). 검증 백엔드 build/lint 0·20 tests, 프론트 0. 런타임 미검증(키·DB 필요).
|
||||
- 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. **런타임 미검증**(실제 API 키 호출 필요). 모델 ID는 콘솔 가용 모델로 `CLAUDE_MODEL` 교체 가능.
|
||||
|
||||
#### 8-7. 그 외(미착수)
|
||||
- 메일 실발송(SMTP), 비번 재설정 플로우, 보안 후속(계정잠금/CAPTCHA).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 프로젝트 우선 구조 리팩터 계획 (Project-First)
|
||||
|
||||
> 작성 2026-06-20. 현재 **저장소(Repo)=프로젝트 1:1** 구조를 **프로젝트 우선 + 프로젝트 1:N 저장소** 로 전환한다.
|
||||
> 이 문서가 리팩터의 단일 진실 공급원(SSOT). 단계별로 갱신한다.
|
||||
|
||||
## 1. 목표 구조
|
||||
|
||||
```
|
||||
Project (이름/설명/공개범위) ← 작업의 기본 단위(우선)
|
||||
├─ ProjectMember (admin/member, isOwner) ← 멤버십을 프로젝트로 이동(RepoMember 폐지)
|
||||
├─ Task (project, seq[프로젝트 내 순번], …) ← 업무는 프로젝트 소속
|
||||
│ └─ checklist / comment / attachment (기존 그대로, Task 종속)
|
||||
├─ Activity (project, taskSeq?, …) ← 활동도 프로젝트 단위
|
||||
└─ Repo[] (project FK, gitea 필드…) ← 0~N개 저장소 연동(선택, 코드 연동 전용)
|
||||
```
|
||||
|
||||
- **일반 프로젝트** = 연동 저장소 0개. 업무·멤버·활동 정상 사용.
|
||||
- **저장소 연동 프로젝트** = 저장소 1~N개 링크. 추가로 Gitea/코드참조.
|
||||
|
||||
## 2. 확정된 설계 결정 (사용자 합의 2026-06-20)
|
||||
|
||||
1. **업무·멤버·활동 = 프로젝트 단위.** `Task→Repo` 종속을 `Task→Project` 로 이동. `RepoMember` 폐지 → `ProjectMember`.
|
||||
2. **Gitea 자동 가져오기(역방향 동기화)** → import 저장소는 **"미분류" 기본 프로젝트**에 연결. 관리자가 나중에 적절한 프로젝트로 이동.
|
||||
3. **dev DB 초기화**(volume 재생성). 마이그레이션 스크립트 불필요.
|
||||
|
||||
## 3. 추가 설계 결정 (구현자 판단 — 변경 가능)
|
||||
|
||||
- **라우팅**: 업무는 프로젝트 직속, 저장소는 형제 부속.
|
||||
- `/projects` · `/projects/:projectId` (상세=업무목록) · `/projects/:projectId/{members,activity,settings}`
|
||||
- `/projects/:projectId/tasks/:seq` (업무 상세) · `/projects/:projectId/tasks/new`
|
||||
- `/projects/:projectId/repos` (연동 저장소 목록·링크) · `/projects/:projectId/repos/:repoSlug` (저장소 상세/설정)
|
||||
- `/tasks` (내 업무, 프로젝트 횡단 — 경로 유지)
|
||||
- **저장소 식별자**: `slugName` 은 Gitea 조직 내 유일이므로 **전역 유일 유지**(프로젝트 내 유일로 바꾸지 않음). Repo 에 `project_id` FK만 추가.
|
||||
- **Task.seq**: `@Unique(['repo','seq'])` → `@Unique(['project','seq'])` (프로젝트 내 순번 #N).
|
||||
- **권한**: `assertMember/assertAdmin(repo.id, …)` → `assertProjectMember/assertProjectAdmin(project.id, …)`. 저장소 단위 세분 권한은 두지 않음(프로젝트 멤버면 연동 저장소 전체 접근). 향후 옵션.
|
||||
- **저장소 자체엔 멤버 없음.** 저장소는 순수 Gitea 연동 메타. 멤버십/권한은 소유 프로젝트가 결정.
|
||||
- **"미분류" 기본 프로젝트**: 시스템 예약 프로젝트 1개(부팅 시 보장 생성). Gitea import 대상.
|
||||
|
||||
## 4. 단계별 실행 계획
|
||||
|
||||
### Phase 1 — 백엔드 데이터 모델 + Project 모듈 (핵심)
|
||||
- [ ] `Project`·`ProjectMember` 엔티티 + `ProjectModule`/`ProjectService`/`ProjectController`(CRUD + 멤버 초대/역할/제거 + 권한 가드)
|
||||
- [ ] "미분류" 기본 프로젝트 부팅 보장(OnApplicationBootstrap)
|
||||
- [ ] `Repo` 에 `project` FK 추가. `RepoMember` 폐지. Repo 생성/수정/삭제가 프로젝트 권한으로 게이트.
|
||||
- [ ] `Task` 를 `project` 종속으로 변경(`@Unique(['project','seq'])`), 권한 체인 프로젝트로 교체, 담당자=프로젝트 멤버.
|
||||
- [ ] `Activity` 를 `project` 종속으로. (taskSeq 유지)
|
||||
- [ ] `RepoSyncService`: import → "미분류" 프로젝트 연결.
|
||||
- [ ] `AiService.suggestChecklist`: 프로젝트/저장소 컨텍스트 정합.
|
||||
- [ ] 컨트롤러 라우트 재배치(`/projects/...`). 기존 `/repos/...` 제거 또는 이전.
|
||||
- [ ] 검증: build/lint/test.
|
||||
|
||||
### Phase 2 — 프론트 프로젝트 중심 재구성
|
||||
- [ ] `project.store`·`useProject`·`types/project.ts`
|
||||
- [ ] 신규 페이지: ProjectListPage(진입점)·ProjectDetailPage(업무목록)·ProjectSettingsPage·ProjectMembersPage·ProjectActivityPage
|
||||
- [ ] 저장소: 프로젝트 내 "연동 저장소" 섹션 + RepoLinkPage(기존 RepoCreate 재구성)
|
||||
- [ ] task/member/activity 스토어를 projectId 키로 재설계
|
||||
- [ ] 라우팅 전면 교체, AppShell 네비(프로젝트/내 업무)
|
||||
- [ ] 검증: type-check/lint/build.
|
||||
|
||||
### Phase 3 — 마무리
|
||||
- [ ] 문서(api-contract.md)·메모리 갱신, 잔여 UI 정리, 런타임 점검.
|
||||
|
||||
## 5. 진행 로그
|
||||
- Phase 0: 계획 수립 ✅
|
||||
- Phase 1-a: Project/ProjectMember 모듈 기반(엔티티·서비스·컨트롤러·"미분류" 부팅) ✅ (커밋 2f66cdd)
|
||||
- Phase 1-b: 백엔드 이전 ✅ — Activity(project FK·listForProject)·Repo(project FK, RepoMember 폐지)·Task(project 종속, seq 프로젝트 내 순번, 권한=ProjectMember) 전환. RepoService 프로젝트 종속(`/projects/:projectId/repos`, 멤버·업무카운트·캐시 제거). Task/Activity 컨트롤러 `/projects/...`. RepoSync→"미분류". AiService 프로젝트 컨텍스트. member.service/controller·repo-member·repo-cache 삭제. 검증 build/lint 0·14 tests.
|
||||
- **Phase 1 잔여(소소, 후속)**: ProjectResponse 카운트(repoCount/doneCount/totalCount 현재 0 stub) 집계 배선, ProjectMemberService 활동·알림 배선(TODO), TaskService.assigneeCounts→ProjectMemberService taskCount 연결.
|
||||
- Phase 2-a: 프론트 프로젝트 중심 재구성 ✅ — `types/project`·`useProject`·`project.store` 신설. `useTask`/`useMember`/`useActivity` 를 `/projects/:projectId/...` 로, task/my-task/activity 타입·스토어를 projectId/projectName 으로 전환. 알림 store 라우팅/문구 프로젝트화. 라우팅 `/projects/...` 전면 교체. 페이지 rename(Repo*→Project*): List/Create(간단 폼)/Detail/Settings/Members/Activity. `RepoHeader`→`ProjectHeader`, `RepoTabs`→`ProjectTabs`. AppShell 네비 "프로젝트". 죽은 파일(repo.store/useRepo/GiteaRepoBar) 삭제. 검증 type-check/lint/build 0.
|
||||
- Phase 1 카운트 배선 ✅ — ProjectService 가 프로젝트별 연동 저장소 수·업무 완료/전체 수를 그룹 쿼리로 집계(Repo/Task forFeature 등록). ProjectMemberService 멤버 taskCount 집계 + 초대/역할변경/제거 시 활동 적재·대상자 알림 발송(Activity/Notification 모듈 주입). TaskService 미사용 집계 메서드 제거. 검증 build/lint 0·14 tests.
|
||||
- Phase 2b 저장소 연동 UI ✅ — `useRepo`(프로젝트 종속) 복구, `types/repo` ApiRepo 를 새 RepoResponse(projectId 추가·members/counts 제거)로 정합. `ProjectTabs` 에 "저장소" 탭(repoCount) 추가, 신규 `ProjectReposPage`(`/projects/:id/repos`) — 연동 저장소 목록 + slug 가용성 체크 포함 연동(생성) 폼 + 연동 해제(admin). 라우트 추가. 화면 잔여 "저장소" 문구를 "프로젝트"로 정리(crumb·설정 문구·멤버 모달·주석). 검증 type-check/lint/build 0.
|
||||
- Phase 3 마무리 ✅ — **런타임 점검 완료(2026-06-20)**. dev DB 초기화(데이터 디렉터리 비우고 `up -d --build`) 후 새 스키마 정상 생성, 부팅 시 "미분류" 시스템 프로젝트 생성 + Gitea 동기화가 원격 저장소 1개를 "미분류"에 import(repoCount=1) 확인. 스모크: signup→이메일 인증(로그 링크)→login→프로젝트 생성(소유자 owner/admin 등록)→프로젝트 종속 업무 생성(담당자=멤버)→목록/카운트→프로젝트 상세 totalCount·멤버 taskCount·repoCount 집계 반영→내 업무/활동 피드→연동 저장소 목록/meta/name-available 전부 정상. 프론트 200. **부수 발견·수정**: 백엔드/프론트 헬스체크 오탐(루트 프로브 404·localhost→IPv6) 교정 → 4개 컨테이너 모두 healthy(커밋 9aa22f0).
|
||||
- **리팩터 완료.** 후속(별건): Phase 1 잔여 소소 항목은 카운트 배선으로 해소됨. 신규 저장소 연동의 실제 Gitea 생성 경로는 런타임 미검증(외부 부작용 회피 — 역방향 import 로 간접 검증됨).
|
||||
@@ -0,0 +1,113 @@
|
||||
# 보안 점검 보고서 (2026-06-19)
|
||||
|
||||
지금까지 작업분(인증/저장소/멤버/업무/활동/알림 + 소셜로그인·이메일 인증)을 대상으로 한 보안 점검 결과와 보완 계획. 4개 영역 병렬 감사(권한·IDOR / 인증·세션·OAuth / 입력검증·실패처리 / 프론트·시크릿).
|
||||
|
||||
## 총평
|
||||
|
||||
서버측 기반은 **대체로 견고**하다. 전 엔드포인트 `JwtAuthGuard`, 전역 `ValidationPipe`(whitelist+forbidNonWhitelisted+transform), 파라미터 바인딩(SQL 인젝션 없음), 에러 누출 차단(catch-all→SYS_001), 쿠키 HttpOnly·env별 secure/sameSite, 토큰 해시 저장·select:false, 페이지네이션 상한(100), 경로 traversal 안전. **IDOR/권한 상승은 발견되지 않음**(업무·멤버·알림 전부 서버측 멤버십/역할 게이트, 교차 저장소 위조 불가).
|
||||
|
||||
가장 약한 표면은 **신규 소셜 로그인 플로우**다. 아래 HIGH 3건은 운영 노출 전 우선 처리 권장.
|
||||
|
||||
## 심각도 요약
|
||||
|
||||
| # | 심각도 | 항목 | 위치 |
|
||||
|---|---|---|---|
|
||||
| H1 | HIGH | OAuth 제공자 이메일 검증 미확인 → 계정 탈취/선점 | `auth.service.ts` validateOrCreateOAuthUser, google/kakao.strategy |
|
||||
| H2 | HIGH | OAuth `state` 누락 → 로그인 CSRF(세션 고정) | google/kakao.strategy |
|
||||
| H3 | HIGH | refresh 토큰 회전/폐기 부재 → 탈취 시 14일 재사용, 로그아웃 후도 유효 | auth.service/controller, jwt-refresh.strategy |
|
||||
| M1 | MEDIUM | 파일 업로드 MIME 미제한 + 다운로드 `inline` → 저장형 XSS(SVG/HTML) | task.controller 업로드/다운로드 |
|
||||
| M2 | MEDIUM | 인증 라우트 throttling 부족 → 로그인 무차별·메일 폭탄 | main.ts/app.module, auth.controller |
|
||||
| M3 | MEDIUM | 첨부 삭제에 업로더/관리자 확인 없음 → 타 멤버 첨부 삭제 | task.service removeFile |
|
||||
| M4 | MEDIUM | 인증 토큰이 로그·GET 쿼리로 노출(플레이스홀더 메일러) | mail.service, auth.service |
|
||||
| M5 | MEDIUM | 가입 이메일 열거(409 "이미 가입된 이메일") | auth.service signup |
|
||||
| L1 | LOW | AI 에이전트 터미널 v-html 사용자 입력 미이스케이프(목업, self-XSS) | TaskDetailPage.vue:478 |
|
||||
| L2 | LOW | DTO 배열/문자열 길이 무제한(content/assigneeIds/checklist, UpdateRepo.description) | task/repo DTO |
|
||||
| L3 | LOW | 비밀번호 정책 약함(MinLength 8만), bcrypt rounds 10 | signup.dto, auth.service |
|
||||
| L4 | LOW | 그랜드페더링이 매 부팅 전수 검증(취약한 결합) | user.service onApplicationBootstrap |
|
||||
| L5 | LOW | multer 크기초과→일반 500(400 아님) | upload.config / task.controller |
|
||||
| L6 | LOW | CORS prod에 localhost:3000 고정, Swagger 항상 활성 | main.ts |
|
||||
|
||||
## 상세 + 보완 방안
|
||||
|
||||
### H1. OAuth 제공자 이메일 검증 미확인 (계정 탈취/선점)
|
||||
`validateOrCreateOAuthUser` 가 **이메일 문자열만으로** 기존 계정 매칭/신규 생성하며, 전략이 제공자의 이메일 검증 플래그(Google `email_verified`, Kakao `is_email_verified`)를 확인하지 않는다.
|
||||
- (a) 공격자가 제공자에 피해자 이메일을 **미검증** 상태로 설정해 로그인 → 기존 계정 로그인. 특히 **미인증 로컬 계정**이면 코드가 `emailVerified=true` 로 승격까지 함(이메일 인증 우회 탈취).
|
||||
- (b) 공격자가 소유하지 않은 이메일로 소셜 가입 → `emailVerified:true` 행 생성, 실소유자는 영영 가입 불가.
|
||||
**보완**: 각 전략에서 제공자 검증 플래그를 읽어 `OAuthProfile.emailVerified` 로 전달, 미검증이면 거부. 검증된 이메일일 때만 매칭/자동연동 허용(미인증 로컬 승격도 검증 이메일 한정).
|
||||
|
||||
### H2. OAuth `state` 누락 (로그인 CSRF)
|
||||
두 전략 모두 `state` 미사용 → 인가코드 플로우에 CSRF 보호 없음. 공격자가 자기 계정으로 시작한 콜백을 피해자에게 완성시키면 피해자 브라우저가 **공격자 계정**으로 로그인.
|
||||
**보완**: 리다이렉트 직전 서명·짧은 수명 쿠키에 난수 state 저장 → 콜백에서 검증(또는 passport `state:true` + 세션 스토어). 가능하면 PKCE.
|
||||
|
||||
### H3. refresh 토큰 회전/폐기 부재
|
||||
refresh는 무상태 JWT로 회전·서버측 폐기목록이 없다. `/auth/refresh` 가 새 쌍을 재발급해도 이전 토큰은 14일 내내 유효, `logout` 은 쿠키만 지움(서버 무효화 X). 탈취 시 14일 재생, 비번 변경으로도 기존 세션 강제 종료 불가.
|
||||
**보완(실용안)**: User에 `tokenVersion`(int) 추가 → refresh payload에 포함, jwt-refresh.strategy에서 `payload.ver === user.tokenVersion` 검증, **logout·비밀번호 변경 시 증가**(전 세션 무효화). (완전 회전+재사용 탐지는 후속.)
|
||||
|
||||
### M1. 파일 업로드 MIME 미제한 + 다운로드 inline
|
||||
`FileInterceptor` 에 크기 제한(25MB)만 있고 `fileFilter` 없음. 다운로드가 저장 `Content-Type` + `Content-Disposition: inline` 으로 스트리밍 → `text/html`·`image/svg+xml` 업로드를 직접 URL로 열면 **API 오리진에서 스크립트 실행(저장형 XSS)**.
|
||||
**보완**: `fileFilter` 로 허용 MIME 화이트리스트(image/pdf/zip/office) + 다운로드를 `attachment` 로 강제 + 해당 라우트 `X-Content-Type-Options: nosniff`.
|
||||
|
||||
### M2. 인증 라우트 throttling 부족
|
||||
전역 한도만 존재(express-rate-limit 150/15분, ThrottlerGuard 100/60초). `login`/`signup`/`resend-verification` 에 라우트별 강화 한도 없음 → 분당 ~100회 비번 추측, 메일 폭탄(signup/resend) 가능.
|
||||
**보완**: `@Throttle` 로 login ~5/분, resend/signup ~3/분 + 이메일별 쿨다운. (계정 잠금/CAPTCHA는 후속.)
|
||||
|
||||
### M3. 첨부 삭제 권한 확인 없음
|
||||
`removeFile` 가 `assertMember` + 첨부-업무 소속만 확인하고 **업로더/관리자 확인 없음** → 같은 저장소 멤버가 타인의 첨부(및 디스크 파일) 삭제 가능.
|
||||
**보완**: `attachment.uploader.id === actingUserId || roleType==='admin'` 아니면 Forbidden.
|
||||
|
||||
### M4. 인증 토큰 로그/GET 쿼리 노출
|
||||
`MailService` 가 **원문 토큰 포함 전체 링크**를 로거로 출력. 로그 접근자가 24h 내 인증 가로채기 가능. (현재는 dev 플레이스홀더라 **로그 출력이 의도된 동작**이나, 실 메일러 전환 전 처리 필요.)
|
||||
**보완**: `NODE_ENV!=='production'` 일 때만 전체 링크 로그(dev 편의 유지), prod는 수신자만 로그. 실 메일러 도입 시 짧은 TTL.
|
||||
|
||||
### M5. 가입 이메일 열거
|
||||
signup이 존재 이메일에 `409` 고유 메시지 → 계정 존재 열거 가능(login·resend는 비노출 — 일관성 차이).
|
||||
**보완(트레이드오프)**: 메시지 유지하되 M2 throttle/CAPTCHA로 비용↑. (완전 비열거는 UX 충돌이라 보류 가능.)
|
||||
|
||||
### L1~L6 (하드닝)
|
||||
- **L1**: `TaskDetailPage.vue:478` 에이전트 터미널 `tool.body` 에 사용자 입력 `req` 를 `escapeHtml()` 처리(현재 목업이라 self-XSS, 1줄 수정).
|
||||
- **L2**: `assigneeIds`/`checklist`/`content` 에 `@ArrayMaxSize`, `content` 요소 `@MaxLength(_,{each:true})`, `UpdateRepoDto.description` 에 `@MaxLength(300)`.
|
||||
- **L3**: 서버측 비밀번호 복잡도(영문+숫자) `@Matches` + 최대 길이, bcrypt rounds 10→12.
|
||||
- **L4**: 그랜드페더링을 매부팅 전수 → 생성일 컷오프/일회성 플래그로 한정.
|
||||
- **L5**: multer `LIMIT_FILE_SIZE` 를 400/BIZ로 매핑.
|
||||
- **L6**: CORS의 `localhost:3000` 을 dev 한정, Swagger를 prod에서 비활성(또는 보호).
|
||||
|
||||
## 정상 확인(변경 불필요)
|
||||
IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignment/역할상승 불가, SQL 인젝션 없음(파라미터 바인딩), 다운로드 경로 traversal 안전(UUID→DB 랜덤 storedName), 에러 스택 비노출(catch-all→SYS_001), 시크릿 비로그(Gitea 토큰/JWT/비번/쿠키), 프론트 번들 비밀 없음(VITE_*만), `.env` 미커밋(*.example만, 플레이스홀더), PublicUser/엔티티 select:false로 민감필드 차단, 알림/활동 v-html은 escapeHtml 적용, best-effort 부수효과 격리(활동/알림 실패가 본작업 안 깨뜨림), Redis/캐시 장애 크래시 안전, 페이지네이션 상한.
|
||||
|
||||
## 제품 확인 필요(코드 아님)
|
||||
- **조직 전체 읽기 모델**: 인증 사용자는 모든 저장소·활동·업무를 READ 가능(단일 조직 의도). 저장소를 비공개로 가둘 의도면 읽기에도 멤버십 게이트 필요 — 의도 확인.
|
||||
|
||||
## 보완 배치(권장 순서)
|
||||
- **A(HIGH·우선)**: H1 OAuth 이메일검증 + 안전연동 / H3 refresh tokenVersion 폐기 / H2 OAuth state CSRF
|
||||
- **B(MEDIUM)**: M1 업로드 MIME+attachment+nosniff / M2 인증 throttle / M3 첨부삭제 권한 / M4 토큰로그 prod 게이트 / (M5는 M2로 완화)
|
||||
- **C(LOW 하드닝)**: L1 에이전트 escape / L2 DTO 바운드 / L3 비번정책+bcrypt / L4 그랜드페더링 컷오프 / L5 multer 매핑 / L6 CORS·Swagger prod
|
||||
|
||||
## 적용 현황 (2026-06-19) — 배치 A+B 완료
|
||||
|
||||
사용자 결정: **A+B 적용**, 조직 전체 읽기 모델은 **의도된 설계로 유지**(읽기 게이트 미적용).
|
||||
|
||||
- **H1 ✅** 각 전략이 제공자 이메일 검증 플래그를 읽어 `OAuthProfile.emailVerified` 전달, `validateOrCreateOAuthUser` 가 미검증이면 403 거부(검증 이메일일 때만 매칭/생성/미인증 로컬 승격). Google `_json.email_verified`, Kakao `kakao_account.is_email_verified`.
|
||||
- **H2 ✅** 세션 없는 구조라 `CookieStateStore`(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에 `store` 주입(새 의존성 0). *런타임은 실제 제공자 콜백으로만 최종 확인 가능.*
|
||||
- **H3 ✅ → 완전 회전+재사용 탐지로 강화(2026-06-19)**: 초기엔 `tokenVersion` 폐기 수준이었으나, **세션(패밀리) 테이블 기반 회전형 refresh** 로 교체. `RefreshSession`(id=fid, currentJti, prevJti+prevAt, expiresAt) 한 로그인당 1행. `/auth/refresh` 가 현재 jti면 새 jti로 **회전**, 이미 회전된 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 전체 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight로 방지. logout=해당 패밀리만 폐기, `revokeAllSessions`(전 기기) 메서드 준비(비번 재설정 시 사용). `tokenVersion`/`jwt-refresh.strategy`/`JwtRefreshGuard` 제거(refresh는 가드 없이 서비스에서 직접 처리).
|
||||
- **M1 ✅** 업로드 `fileFilter` MIME 화이트리스트(html/svg 등 차단) + 거부 시 400, 다운로드 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`.
|
||||
- **M2 ✅** `@Throttle` 라우트별 — login 10/분, signup 5/분, resend 3/분(IP 기준). 계정 잠금/CAPTCHA·이메일별 쿨다운은 후속.
|
||||
- **M3 ✅** `removeFile` 가 업로더 본인 또는 admin 만 허용(`getAttachmentOrThrow` 에 uploader 로드).
|
||||
- **M4 ✅** `MailService` 가 prod 에서는 인증 링크(원문 토큰)를 로그에 남기지 않고 수신자만 기록(dev 는 링크 출력 유지).
|
||||
- **M5** 완화: M2 throttle 로 가입 열거 자동화 비용 상승(메시지는 UX 위해 유지).
|
||||
|
||||
**검증**: 백엔드 build/lint 0 · 20 tests pass. 프론트 변경 없음(전부 서버측).
|
||||
|
||||
## 적용 현황 (2026-06-19) — 배치 C (L2~L6) 완료, L1 보류
|
||||
|
||||
사용자 지시: 배치 C 진행하되 **에이전트 관련(L1)은 아직 미작업이라 제외**.
|
||||
|
||||
- **L1 ⏸ 보류**: AI 에이전트 터미널 v-html escape (에이전트 패널 자체가 미연동 영역이라 사용자 요청으로 제외).
|
||||
- **L2 ✅** DTO 길이 바운드 — task `content` `@ArrayMaxSize(200)`+요소 `@MaxLength(5000)`, `assigneeIds` `@ArrayMaxSize(50)`, `checklist` `@ArrayMaxSize(100)`(create/update 양쪽), `UpdateRepoDto.description` `@MaxLength(300)`.
|
||||
- **L3 ✅** 비밀번호 정책 — `@MaxLength(72)`(bcrypt 72바이트 한계) + `@Matches` 영문·숫자 포함, bcrypt rounds 10→12.
|
||||
- **L4 ✅** 그랜드페더링을 컷오프(`2026-06-20`) 이전 생성 계정으로 한정(`createdAt LessThan`) — 이후 가입은 토큰 상태 무관하게 정상 인증.
|
||||
- **L5 ✅** multer 오류(크기초과 등)를 전역 필터에서 400(VAL_001)로 매핑(기존 일반 500 → 명확한 검증 오류).
|
||||
- **L6 ✅** CORS `localhost:3000` 을 비운영 한정, Swagger(`/api-docs`)를 비운영 한정 노출.
|
||||
|
||||
**검증**: 백엔드 build/lint 0 · 20 tests. 프론트 변경 없음.
|
||||
|
||||
**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(~~토큰 완전회전~~ **완료** / 계정잠금·CAPTCHA / M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). access 토큰 즉시 폐기(denylist)는 여전히 미적용(15분 잔존, 수용). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요(특히 회전형 refresh의 동시성·재사용 탐지 동작).
|
||||
@@ -0,0 +1,7 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 env (development)
|
||||
# VITE_ 변수는 docker-compose 가 루트 .env.development 값을 build arg 로 주입한다:
|
||||
# VITE_API_BASE_URL ← BACKEND_API_URL
|
||||
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
|
||||
# 따라서 이 파일에 별도로 작성할 값은 없다.
|
||||
# ==========================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user