Compare commits

...

224 Commits

Author SHA1 Message Date
ttipo 01132d2ac3 Merge branch 'feat/task-coissuer-review-due-notify'
업무 공동 지시자·검토자 표기·마감 임박/초과 알림 및 드라이브 빈 상태 UI 통일 반영.
2026-07-10 00:41:18 +09:00
ttipo 5ff9bf0df8 feat: 업무 공동 지시자·검토자 표기·마감 임박/초과 알림 추가
- 공동 지시자(coIssuers): 업무에 공동 지시자를 지정해 원 지시자와 함께
  완료 검토(승인/수정요청) 권한을 부여. 생성/수정 UI, 검토 권한 체크,
  보고 열람, 승인요청·시작 알림 수신, '내가 지시한 업무' 목록에 포함.
- 검토자 표기(reviewedBy): 승인 완료/수정 요청 카드에 실제 검토를 수행한
  사람(원 지시자 또는 공동 지시자)을 표기하도록 추적·노출.
- 마감 알림: 매일 오전 9시(KST) cron으로 미완료 업무의 마감 임박(24h 이내)
  ·초과를 담당자에게 1회 알림(task.due_soon/task.overdue). 발송 플래그로
  중복 방지(다중 인스턴스 대비 원자적 선점), 마감 변경 시 초기화.
  웹푸시·인앱 문구 동시 지원(커버리지 스펙 갱신).

DB: task_co_issuers 조인테이블, tasks.reviewed_by,
    tasks.due_soon_notified_at/overdue_notified_at 마이그레이션 추가
    (dev synchronize 자동, 운영 migrationsRun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:40:04 +09:00
ttipo fb0669990d fix: 드라이브 빈 상태 안내를 상단 전체폭으로 통일
프로젝트·회의 화면과 달리 드라이브의 빈/검색없음 상태 안내문이 페이지
정중앙에 떠 있던 문제를 수정한다. 목록 컨테이너(.dr-listwrap)의 중앙
정렬을 제거해 안내 카드가 다른 화면과 동일하게 상단에 폭을 꽉 채워
배치되며, 남는 아래 영역은 그대로 드래그-드롭 업로드 영역으로 유지된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:39:32 +09:00
ttipo de92fc01b4 docs: env 예시 파일의 웹 푸시(VAPID) 섹션 주석 구조 통일
VAPID 섹션만 설명 주석을 배너 박스 안에 넣거나(dev) 배너 자체가
없어(prod) 다른 섹션과 어긋나던 것을, 다른 섹션과 동일하게
'배너(제목만) → 아래 설명 주석' 구조로 맞춘다. 값 변화 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:42:17 +09:00
ttipo a65b5aac05 fix: 멤버 초대 자동완성 드롭다운이 모달 밖에서 잘리는 문제 수정
드롭다운을 body 로 Teleport + fixed 위치로 띄워(DropdownSelect 와 동일 패턴)
모달의 overflow 에 잘리지 않게 한다. 입력창 기준 좌표를 계산하고 뷰포트
하단을 넘지 않도록 최대높이를 제한하며 스크롤/리사이즈 시 위치를 갱신한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:34 +09:00
ttipo 9a79e169c8 feat: 알림 신뢰성·내용 보강 (SSE 재동기화·웹푸시·전직원 일정·파일첨부)
[신뢰성]
- SSE 재연결 시 fetchInitial 로 재동기화: 끊긴 사이 놓친 알림 복구 +
  access 토큰 자동 refresh 로 만료 토큰 재연결 무한루프 해소
- 서비스워커 pushsubscriptionchange 처리로 구독 회전 시 자동 재등록
- 읽은 알림 90일 retention 정리 배치(@Cron)
- 웹푸시 다기기 병렬 발송(allSettled) + TTL/urgency 지정
- 알림 문구 SSOT 상호참조 주석 + web-push 커버리지 테스트 추가

[내용]
- 전 직원(allHands) 일정도 전 사용자에게 알림(생성/수정/삭제)
- task.file_attached 신규 타입: 파일 첨부 시 지시자·담당자 알림
- task.started 재개(수정요청 후) 문구 '재개/시작' 구분
- task.due_changed 푸시에 날짜/해제 상세 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:25 +09:00
ttipo 2d4f2e2c19 fix: 드라이브 검색바 너비·안내문을 프로젝트/회의 화면과 통일
검색바가 전체 너비로 늘어나던 오버라이드를 제거해 전역 고정폭(17.5rem)을
사용하고 버튼 그룹을 우측 정렬한다. 상단 안내문도 다른 목록 화면과 같은
한 줄 문구로 맞춘다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:07 +09:00
ttipo 3adb596511 feat: 데스크톱 알림을 포커스 중에도 항상 표시하도록 변경
기존엔 받는 사람이 앱 화면을 보고 있으면(포커스) OS 토스트를 띄우지
않고 종 알림만 갱신했으나, 화면을 보고 있어도 토스트가 뜨도록 변경한다.
서비스워커의 중복 방지(열린 탭 있으면 표시 생략)는 그대로라 토스트는
여전히 1개다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:13:17 +09:00
ttipo bd4f3e70bf fix: 운영 프론트 빌드에 VITE_VAPID_PUBLIC_KEY 누락 수정
프론트 Dockerfile이 VAPID 공개키 build arg 를 받지 못해 운영 빌드
번들에 키가 비어 "푸시 키 미설정" 으로 동작하던 문제를 수정한다.
다른 VITE_* 변수와 동일하게 ARG/ENV 를 선언해 빌드에 주입한다.
(dev 는 런타임 env 주입이라 영향 없었음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:05:55 +09:00
ttipo 8d00d71072 feat: PC 데스크톱 알림(웹 푸시) 추가 — 인앱/SSE에 OS 토스트 연동
카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.

- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
  type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
  함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
  web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
  안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:49:33 +09:00
ttipo 26cad39857 refactor: 드라이브 경로 표기를 전역 .crumb 시스템으로 통일
드라이브 브레드크럼이 커스텀 .dr-crumb/.dr-sep(› 구분자)로 따로
구현돼 프로젝트 화면(.crumb/.lnk/.sep, / 구분자)과 달랐던 것을
전역 시스템으로 맞춘다. 루트 링크도 goRoot 핸들러 대신 RouterLink 로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:49:05 +09:00
ttipo 5ee5ad6973 fix: 드라이브 드래그 드롭 영역을 파일 영역 전체로 확장
드롭 오버레이가 목록 래퍼 안에 갇혀 빈 폴더에서는 안내 카드 한 장
높이만큼만 드롭 영역으로 보이던 문제를 개선한다. 페이지를 세로
플렉스로 만들고 목록 영역이 남는 높이를 모두 채우게 해, 파일이 적거나
비어 있어도 파일 영역 전체에 끌어다 놓을 수 있게 한다.

- .page: 세로 플렉스 + min-height(뷰포트 - 상단바)
- .dr-listwrap: flex 1 로 남는 공간 차지(+ min-height 하한)
- 빈 폴더/검색 결과 없음 안내문은 영역 중앙 정렬

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:31:02 +09:00
ttipo f7b8a4c42d refactor: 활동 피드 공용 컴포넌트 추출 및 드라이브 기록 디자인 통일
프로젝트 활동 페이지에 인라인 중복 구현돼 있던 활동 피드(원형 아이콘+
아바타+본문+날짜그룹) 마크업·아이콘맵·스타일을 공용 ActivityFeed로
추출한다. 드라이브 '기록' 패널도 단색 점 목록에서 이 공용 컴포넌트로
교체해 다른 화면과 동일한 디자인 시스템을 쓰도록 통일한다.

- 신규 components/common/ActivityFeed.vue (모달 임베드용 bare 옵션 포함)
- ProjectActivityPage: 인라인 피드 제거 → ActivityFeed 사용
- drive.store: 감사 로그를 ProjectActivityDay[]로 변환(activityDays),
  tone setting·repo/pencil 아이콘, escapeHtml로 XSS 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:03:43 +09:00
ttipo e42ccf7139 feat: 드라이브를 전사 공유 드라이브로 전환하고 전역 감사 로그 추가
개인별 격리(owner 기준)를 제거해 로그인 사용자 전원이 하나의 공유
트리를 보고 관리하도록 전환한다. 수정/삭제는 전원 허용하며, 행위
추적을 위해 전역 감사 로그(drive_activities, GET /drive/activity)를
추가한다. 업로더 탈퇴 시 공유 파일이 사라지지 않도록 owner FK를
CASCADE에서 SET NULL로 변경한다.

- 백엔드: 접근 권한 해석 제거(단순 로더화), 업로드/이름변경/삭제
  6지점에 감사 적재, DriveAuditService 신설, 운영 마이그레이션
  1783200000000 추가
- 프론트: 목록 행에 업로더 표시, 툴바 '기록' 패널 추가
- 프로젝트 폴더 연동의 '본인 소유 폴더만' 제한 해제(공유 모델 일관성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:40:45 +09:00
ttipo ec54ba0e8d feat: 드라이브 업로드·삭제 행위자 추적 추가
연동 프로젝트 폴더의 파일 업로드/삭제와 폴더 삭제를 활동 피드에 기록하고,
파일 삭제를 소프트 삭제(status=deleted + deletedBy/deletedAt 묘비)로 전환해
누가·언제 지웠는지 추적할 수 있게 한다. 실제 객체는 즉시 영구 삭제(복구 제외).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:54:17 +09:00
ttipo 2cbd19e791 refactor: 프로젝트 상세 AI 검토 문서를 버튼+모달 리스트로 변경
인라인 wrap 나열 제거, 'AI 검토 문서' 버튼 클릭 시 모달에서 파일종류 배지·AI상태·크기를 세로 리스트로 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:56:01 +09:00
ttipo 65796a0893 fix: 드라이브 파일 배지를 확장자 기준으로 정확히 표기
kind(4종) 대신 확장자로 라벨 산출 — 엑셀 XLS, 파워포인트 PPT, CSV/JSON/TXT, 영상·오디오 등 구분 + 색상 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:41:59 +09:00
ttipo 5267b759be fix: 드라이브 전환 마이그레이션 멱등·견고화 (운영 적용 실패 방지)
ADD COLUMN IF NOT EXISTS, project_attachments 는 제약 이름 비의존 DROP TABLE IF EXISTS ... CASCADE 로 변경. 과거 synchronize 로 만들어진 운영 DB나 부분 적용 상태에서도 실패하지 않도록 함

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:43:23 +09:00
ttipo 524c6f6782 feat: 드라이브 연동 전환 운영 마이그레이션 추가
drive_files 에 extracted_text/extract_status 추가, project_attachments 폐기(완전 대체). dev 는 synchronize, 운영은 이 마이그레이션으로 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:15:02 +09:00
ttipo 5a92813a31 fix: AI 체크리스트 추천 응답 견고 파싱으로 502 방지
Claude가 깨진/잘린 JSON을 반환해도 정규식으로 카테고리·항목 복구(폴백), 프롬프트에 완결 JSON 규칙 추가, max_tokens 상향

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:37 +09:00
ttipo cf1f4db6af feat: 프로젝트 드라이브 폴더 연동 + AI 검토 문서 전환
프로젝트 직접 업로드(project_attachments) 폐기, AI 검토 문서를 드라이브 폴더 연동으로 전환. 폴더 연동 CRUD·연동 폴더 문서에서 텍스트 지연 추출(NCP 다운로드, xlsx 포함)·AI 추천 주입. 폴더 선택 피커 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:23 +09:00
ttipo f023bd0ed7 fix: 화상회의 로비 입장/링크복사 버튼 순서 변경
입장 버튼을 맨 왼쪽으로, 그다음 링크복사 순으로 배치

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:07 +09:00
ttipo 638d1265f6 feat: 드라이브(파일 저장소) 프론트엔드 UI 구현
폴더 탐색·드래그앤드롭 업로드(presigned 3단계)·다운로드·이름변경/삭제, 라우트(/drive)·사이드바 네비 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:51 +09:00
ttipo 9ce3158450 chore: 화상회의 네이티브 lk 시각/부하 테스트 러너 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:33 +09:00
ttipo 83d0cda163 Merge feature/drive: 드라이브(파일 저장소/폴더 관리) 기능 추가 2026-06-29 21:18:08 +09:00
ttipo 46f1875ca3 style: 화상회의 방 수정 삼항 연산자 포맷 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo 612c4fec4c docs: 프론트 env 에 VITE_LIVEKIT_URL 매핑 주석 추가
docker-compose 가 LIVEKIT_WS_URL → VITE_LIVEKIT_URL 로 주입함을 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo eebdbc34f5 chore: 메모리 디렉터리 git 추적 제외
Claude Code 작업 메모(/memory)는 저장소 산출물이 아니므로 .gitignore 에 추가한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo 22a02be937 feat: 드라이브(파일 저장소/폴더 관리) 기능 추가
NCP Object Storage(S3 호환) 기반 파일 업로드/폴더 관리 기능을 백엔드에 추가한다.

- 스토리지 계층(common/storage): presign 기반 업로드/다운로드 URL 발급 래퍼
- 드라이브 모듈: 폴더 CRUD, 파일 presign 업로드→complete→다운로드, 이름변경/삭제
- 엔티티 3종: drive_folders(폴더 트리), drive_files(객체 메타), project_folder_links(프로젝트 공유)
- 마이그레이션 AddDriveTables: 3개 테이블 + 인덱스/외래키 생성
- stale pending 파일 정리 @Cron(매시) 추가
- env/docker-compose: OBJECT_STORAGE_* 주입(비시크릿은 루트, ACCESS/SECRET 키는 backend env)
- 의존성: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:48 +09:00
ttipo 101b1553b0 Merge feature/meeting-room-code: 화상회의 방 고유 입장 코드 도입 2026-06-26 17:46:58 +09:00
ttipo 2d1026466f feat: 화상회의 방에 고유 입장 코드 도입(이름과 식별자 분리)
방 식별을 이름이 아닌 불변 코드로 전환 — 이름을 바꿔도 링크·세션이 유지되고
초대 링크의 안정적 기준이 된다.

- 백엔드: meeting_rooms.code(고유·불변) 추가, name 고유 해제(표시용 라벨, 중복 허용).
  마이그레이션 AddMeetingRoomCode(코드 백필+name 고유 인덱스 제거). 토큰 발급은
  code 로 방 조회(자동 생성 제거 — 명시 생성된 방만 입장, 없으면 404). LiveKit 방=code.
  createRoom 이 고유 코드 발급. MeetingRoomResponse 에 code 추가.
- 프론트: 입장/라우팅을 code 기준으로(/meeting/:code), 로비 입장은 방 객체 전달,
  '초대 링크 복사' 버튼(코드 기반 URL) 추가. 생성 폼 이름 중복 검사 제거.

검증: 백엔드 build, 프론트 type-check·lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:46:57 +09:00
ttipo 5ecc1d7be4 Merge feature/meeting-room-edit: 화상회의 방 편집 기능 2026-06-26 16:15:31 +09:00
ttipo bde4b561f3 feat: 화상회의 내가 만든 방 편집 기능
진행자(생성자)가 방의 이름·예약 시각·참여 제한(allow-list)을 수정할 수 있다.

- 백엔드: PATCH /meetings/rooms/:roomId (updateRoom, 진행자만, 이름 중복 검사).
  UpdateMeetingRoomDto(생성과 동일 필드). MeetingRoomResponse 에 allowedUsers 추가
  (진행자에게만 노출 — 편집 폼 사전 채움용). User repo 주입해 ID→PublicUser 해석.
- 프론트: useMeeting.updateRoom, 로비 각 방에 편집 버튼(진행자), MeetingCreateForm
  를 편집 모드로 재사용(현재 값 프리필·중복검사 자기제외·저장 분기), MeetingIcon edit 추가.

검증: 백엔드 build, 프론트 type-check·lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:15:31 +09:00
ttipo 5866d6ad55 Merge feature/meeting-stage-quality: 스테이지 기반 수신 화질 중앙 제어 2026-06-26 15:15:41 +09:00
ttipo 2015b794fc feat: 화상회의 수신 화질을 스테이지 역할 기반으로 중앙 제어
스테이지(featured) 유저만 HIGH, 나머지 원격 참여자는 LOW 로 구독한다.
스테이지가 바뀔 때마다 강등된 참여자가 LOW 로 내려가도록 보장한다.

- adaptiveStream 비활성화: 화질이 "엘리먼트 크기"가 아니라 "스테이지 역할"로만
  결정되도록(스테이지=HIGH 보장, 강등 반영 결정적). dynacast 는 유지.
- 화질 제어를 useLiveKitRoom 로 중앙화: featuredIdentity 변경·참여자/트랙 변동
  시마다 전원 재적용. 기존 타일 리마운트 의존(레이스로 강등자가 HIGH 유지되던 버그) 제거.
- ParticipantTile 의 개별 setVideoQuality 제거(중복·레이스 원인), 영상 연결만 담당.

검증: type-check, lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:15:40 +09:00
ttipo 04995ca91a chore: 루트 임시 스크린샷(image.png) gitignore 추가
작업 중 공유한 스크린샷이 반복적으로 스테이징되는 것을 방지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:57:28 +09:00
ttipo 2740205ed5 Merge feature/livekit-upgrade-1.13.1: LiveKit 서버 1.8.4→1.13.1 업그레이드(클라이언트 호환) 2026-06-25 23:57:14 +09:00
ttipo 4ad762881c Merge commit '6bd7fac875d9e9009915053d8a590abb372c5679' into feature/livekit-upgrade-1.13.1 2026-06-25 23:54:33 +09:00
ttipo 6bd7fac875 Squashed 'livekit-server/' changes from 154b4d26..46c43095
46c43095 fix goreleaser workflow, version 1.13.1 (#4577)
e0815be2 chore: improve docker test shutdown reliability (#4576)
bfd9deff expose TCPFallbackRTTThreshold and AllowUDPUnstableFallback via config (#4556)
b93c1e16 Release v1.13.0. (#4573)
fd452212 Update mediatransportutil to get ICE candidate timeout config (#4572)
8be8c74a Update github workflows (#4463)
c4e41872 Update go deps to v1.17.2 (#4462)
dc8e0310 Update go deps to v4 (#4482)
20fd1ad2 turn: allow for providing secret via file (#4564)
6590570d Pin pion/dtls to v3.1.2 (#4570)
cdbbee1f deps: bump protocol + psrpc to latest tips (#4565)
d290de81 Correct config comment (#4563)
77ecf920 rtc: report participant session end time on room move (#4561)
63be96f6 Prevent panic from nil(illegal) syncState.Subscriptions message (#4560)
835ef1b3 Metrics for participant active, i. e. fully established. (#4557)
5bd42534 Document of advertise_internal_ip and external_ip_only (#4554)
356ae211 Config documentation for advertise_internal_ip and skip_external_ip_validation (#4552)
7c319a67 rtc: prevent duration reporting for inactive participants (#4550)
2dd5e632 telemetry: split webhook-processed hook out of NewTelemetryService (#4548)
222177a9 service: prevent nil deref in validate with wrapped join request (#4547)
dd7580b4 Protect against nil clientInfo (#4546)
145689e6 Start tracking Twirp method request latency in prometheus too, not just in logs (#4545)
cde89627 rtc: emit per-data-track bytes via BytesTrackStats (#4540)
2e22911d Remove backwards compatibility support for TURN auth. (#4539)
062d1219 Use NACKQuueInterface type. (#4538)
7f08b04c Add IsIntentionalDisconnect helper (#4537)
1ab2bf04 Clean up packet size logging (#4536)
8ab92a80 Don't require media sections when joining (#4535)
019a6640 rtc: report participant kind code and details (#4534)
77595d38 TEL-336: fix sip error categorization (#4528)
f303f499 Always enable rtx codec (#4533)
e4a8a55c Check Less and LessEq in version compare. (#4532)
37eb7a32 Release v1.12.0 (#4529)
4a7b1e85 Create NACK tracker only once. (#4527)
89faaeba Apply ttl check only when authenticate allocation creating (#4526)
b32933b0 Log details of RTCP packets. (#4525)
8b79ec9e Support SIP auth realm for inbound. (#4522)
4b8db3cf Add integration test for TURN auth failures (#4524)
ef2e5efe Log large packets receive/send. (#4521)
d1236750 feat: auto create rooms for tokens with the RoomCreate grant (#4320)
7a3e595b apply room tags from JWT grant room configuration (#4518)
ab7fdeab add AssignmentHook to AssignJob; propagate websocket write errors (#4516)
cf20c9cd Add expiry to TURN password. (#4515)
20d4a3a1 Populate data track loggers with context (#4514)
12fff29a allow setting agent job assignment url (#4512)
ba366fc7 Fix SIP media config upgrade. (#4511)
8fbc5adf update protocol for protojson (#4510)
3de6f517 Add TURN permission handler. (#4505)
8ffcef93 Update protocol to support SIP media config. (#4509)
1ab1e072 test: verify upstream and downstream connection stats end-to-end (#4508)
c4fd71a5 Fix sense check in DeltaInfo gathering (#4507)
803999ef rename agent environment to deployment (#4506)
bacc21e6 add helper to check for agent worker endpoint (#4503)
253f977d add duration seconds reporting (#4500)
ffab3bd3 add agent environment (#4498)
ccdf23c8 Use mediatransportutil/codec package, no functional change (#4497)
680703f2 Include reception reoprts in receiver report callback. (#4496)
f51798bc Fix publish-only limitations being incorrectly applied to receivers (#4495)
a002337d Legacy TrackInfo.Simulcast flag. (#4493)
af1dcc88 Add CloseWithReason to agent SignalConn interface (#4492)
d7c2daf1 report all simulcast layers (#4491)
c1ad2b22 Misc optimisations. (#4490)
19b9e8c0 Additional data tracks logging (#4489)
743d9c8b add support for client capabilities (#4461)
fc47e478 Close peer connection unconditionally to unblock set local/remote (#4485)
639406eb Update module github.com/pion/ice/v4 to v4.2.3 (#4481)
dc6b7505 reduce some heap use in packet path (#4478)
f3b80b28 fix: wrap IPv6 addresses in brackets in UDP TURN URLs (RFC 3986) (#4476)
3a7f2628 Turn off transceiver re-use on Safari. (#4474)
d84f3d7a add more types to signum (#4473)
701a37c2 Convert sort.Slice -> slices.SortFunc (#4472)
85be9d70 Avoid stream allocator event data cast to interface and back. (#4471)
b43685e8 Keep a shadow copy of tracks for use by different stream allocator state (#4470)
27c2b149 Consolidate RTCP packets and do RTCP callback outside lock. (#4469)
31083307 do not log data track stats if not started (#4468)
9ee06635 feat(pion/ice): replace deprecated NAT1To1 with SetAddressRewriteRules (#4466)
8ccad68d Release v1.11.0 (#4459)
dbf5cf61 Store concrete ICE candidate for remote candidates. (#4458)
2a04bc3c fix publisher frame count reporting for simulcast streams (#4457)
1d804737 fix: limit join request and WHIP request body to http.DefaultMaxHeaderBytes (#4450)
3cfb71e7 Use Muted in TrackInfo to propagated published track muted. (#4453)
69aa9479 Some drive-by clean up (#4452)
6c81f678 Add subscriber stream start event notification (#4449)
ce1bf47b Revert "fix: ensure num_participants is accurate in webhook events (#4265) (#…" (#4448)
cdb0769c fix: ensure num_participants is accurate in webhook events (#4265) (#4422)
c91e79af Switch to stdlib maps, slices (#4445)
ea7b9c6f Update module github.com/livekit/protocol to v1.45.3 (#4435)
97378368 Update go deps (major) (#3179)
d6aef547 Update go deps (#3862)
afc9feae Update github workflows (#4331)
4b385612 chore: pin GH commits and switch to golangci-lint (#4444)
2974ba87 Unsubscribe from data track on close (#4443)
5dc4e90d Apply IPFilter when get local ip (#4440)
88c77dc6 compute agent dispatch affinity from target load (#4442)
8fe99377 Log join duration. (#4433)
0a503a57 Add `Close` method for UpDataTrackManager and call it on participant (#4432)
55912dff Add some simple data track stats (#4431)
050909e6 Enable data tracks by default. (#4429)
72c7e65c chore: log API key during worker registration (#4428)
8a67dd1b Do not close publisher peer connection to aid migration. (#4427)
91e90c10 Add some more logging around migration. (#4426)
c6ddc879 isExpectedToResume is based on whether flushing or not. (#4425)
7d06cfca Keep subscription synchronous when publisher is expected to resume. (#4424)
934f8598 Clean up data track observers on unsubscribe. (#4421)
9674ac48 Cleaning up some logs and standardising log frequency. (#4420)
7b925304 Drop time inverted packets in RED -> Opus conversion. (#4418)
4d8d232a ensure participant init is correctly serialized for logging (#4417)
4fe80877 Log time inversion between incoming packets (#4415)
248d7394 Guard against timestamp inversion in RED -> Opus conversion. (#4414)
9ab8c1d5 clear track notifier observers on subscription teardown (#4413)
397cd09a Embedded turn test (#4412)
56326654 Prepare release v1.10.1 (#4408)
e9b113c8 Make the TURN bind address configurable and allow for multiple addresses. (#4315)
4bc5e6bb Address malformed H264/H265 parsing issues. (#4407)
77a0a4fc AV1 parser overflow fix. (#4405)
ff7fd7ed feat(agent-dispatch): add job restart policy (#4401)
34bd1e08 do not log roll over for padding only packets (#4396)
13d02ee9 add deadline to dtls connect context (#4395)
9055a349 Path check helpers (#4392)
1f1eeb68 Fallback to servicestore if rpc is unavailable (#4391)
59e9bb41 Fix TURN server URL (#4389)
9e0a7e54 Close both peer connections to aid migration. (#4382)
9474c807 route participant reads through PSRPC instead of Redis (#4387)
a5333a86 add packet trailer stripping support (#4361)
0e3d765d Release v1.10.0 (#4380)
bc3aeaf3 Update grpc to address CVE-2026-33186 (#4381)
8cdd6f4c Replace deprecated io/ioutil with io in whipservice (#4375)
89410df7 handle AGENT_ERROR disconnect reason (#4339)
8f984c77 Fix repair stream ID reporting for RTX pairing. (#4369)
cdfaacfc Restart nacker on OOB sequence number restart. (#4368)
4a9e0045 Update protocol. (#4367)
750d5904 Add API to restart lite stats. (#4366)
c8bb2578 Rename log field "pID" to "participantID" for consistency (#4365)
77fc74a7 Do not block all ext ID determination on stream allocator listener (#4364)
90a46fab Do not kick off migration of closed participant (#4363)
5dc2e7b1 Switch data track extension to 1-byte ID/length. (#4362)
7323ad02 Sample data send error logging. (#4358)
0d34e455 Add option to not re-use transceiver in e2ee. (#4356)
95225ff2 don't require media section for dual peerconnection mode (#4354)
e9639538 Refine ipv6 support (#4352)
b34b0472 Add StopEgress function to the EgressLauncher interface (#4353)
69235ed2 update readme (#4340)
db1a8046 defensive check for peer connection instance (#4350)
cb7dc2d0 TEL-405: support originating calls from custom domains (#4349)
7eaaaada Mark last run of grow bucket outside goroutine. (#4348)
caa47522 Add option to require media sections when participant joining (#4347)
087050d1 Release v1.9.12 (#4346)
493e87df Fix SIP client timeout. (#4345)
52c28a93 Log a bit more details of out-of-order TWCC feedback report. (#4343)
516aeabf Use ParticipantTelemetryListener of LocalParticipant. (#4342)
b3510565 Exclude ice restart case from offer answer id mismatch warning (#4341)
303657bc feat: make INSTALL_PATH overridable in install script (#3954)
9d418689 Send participant left event after track unpublished for moved (#4334)
bab91868 do not discount packets lost on duplicate packets (#4333)
939794cf mark + restart (#4329)
75f9c462 Add debug for receiver restart. (#4328)
74891f30 Protect against incorrect temporal layer. (#4327)
f51b2732 Update pion/webrtc and deps to update dtls (#4326)
b81bac0e Key telemetry stats worker using combination of roomID, participantID  (#4323)
2d40449f Update self-hosting deployment documentation link (#4312)
03e90dd7 Fix for some CodeQL reported issues (#4314)
77c858f0 Log stats worker when it not closable. (#4313)
a6035212 ESP32 client info (#4267)
478e486a Copilot suggested improvement to Github Actions permissions (#4310)
cbd2f82d Copilot suggested improvement to Github Actions permissions (#4311)
a9b8d40d Publish is always on publisher peer connection. (#4307)
8ae56406 generate & log egressID for start egress request (#4303)
35d7ef88 Avoid alloc in RTPStatsReceiver.Update (#4302)
bb744916 More optimisation in RTPStats module. (#4298)
cefd5da9 Optimise some bits in rtpstats_receiver (#4297)
52a4b848 Read client protocol from query param (#4294)
195b17f6 Populate client_protocol field in ParticipantInfo (#4293)
370e0a4d Set up audio config in audio level module when config is updated. (#4290)
f3e9b688 Do not increase max expected layer on track info update. (#4285)
a9849340 Avoid logger data race. (#4284)
97016627 Do not hold lock when creating buffer (#4283)
6b68e3d5 Create buffer if needed when a PLI is requested. (#4282)
3cca7180 use separate allocation for signal stats telemetry guard (#4281)
d2bae34d refresh telemetry guard on participant move (#4280)
333f0349 clear reference guard when resetting signal stats (#4279)
d1bab17b Add session duration and participant kind to closing log. (#4277)
1e689e1a Reducing some info level logs. (#4274)
88facc02 adds a test to ensure agent worker errors cause disconnection (#4273)
76a41a7a Generate config flags (#4268)
700e1788 require participant broadcast when metadata/attributes are set in token (#4266)
b61799ec Ignore parse addr error when add remote candidate (#4264)
01bd966f Add silent frame for pcmu/a (#4258)
0c33b8c6 chore: move codecs/mime stuff to protocol (#4255)
165c1735 Update livekit protocol to v1.44.0 (#4254)
343f12b8 Wrapping SIP errors for invalid argument and not found (#4253)
aea044c5 Defer setting clock rate in RTPStats module till codec is bound. (#4250)
d9f716c1 FIx receiver restart race (#4248)
0508ee9f remove copy/paste left over line (#4246)
40408407 Release v1.9.11 (#4245)
f8675507 Do not remove participant from cache on disconnect. (#4241)
a35a6ae7 Add participant option for data track auto-subscribe. (#4240)
07572511 Wrapping the invalid request errors for CreateSipParticipant (#4239)
843d8c3e Update Pion transport package. (#4237)
a05690d2 Changing field naming of data track packet (#4235)
c4015008 Clear participant version cache on disconnect (#4234)
18db4ec1 Log modified timeout of API context. (#4232)
ac20ccda Log timeout in API. (#4231)
541a7d6c Change some logs to debugw (#4229)
52ab3374 Return on SDP fragment read error. (#4228)
7fae5ac9 Do not restart receiver on codec change mid-session. (#4225)
dafdc36e Add option to force simuclast codec. (#4226)
0a7dd40b Use only layer 0 for SVC codecs. (#4224)
4ec0f8f4 Support OpenTelemetry tracing. Add Jaeger support. (#4222)
80ba93fa Do NACK updates as soon as flow state is processed. (#4221)
4405afe2 Use atomic pointer and return interface from RED transformer constructors (#4220)
b649c2fe Remove method not needed from REDTransformer. (#4219)
f0080f35 Remove enable arrival time forwarding method. (#4217)
335f4c33 Swap result sink atomically rather than closing and setting. (#4216)
46651c19 Release v1.9.10 (#4214)
08ac4ecd Support preserving external supplied time. (#4212)
f6efccce report video size from media data for whip (#4211)
d92f6a79 return iceservers for whip (#4210)
1a4758ed Skip restart callback if external. (#4208)
dde4fb49 configurable dependency descriptor restart (#4207)
08793bea Use active at time to check for track not bound timeout. (#4206)
3606ce54 Do not warn about track not bound if participant is not ready. (#4205)
b8ddd0f9 Taking interface{} -> any modernize bits (#4204)
b91cd2e4 Rework receiver restart. (#4202)
bb00c864 Restart API on receiver. (#4200)
25ece1e9 Minor refactor in buffer base and audio level (#4198)
599002f8 ignore PLI requests for non-video (#4196)
2510b946 Taking a bunch of go modernize suggestions. (#4194)
ed8e6afc Handle repair SSRC of simulcast tracks during migration. (#4193)
c6bf7a27 Fix logging key and other clean up around stream restart. (#4192)
3cb9abb6 Update pion/webrtc to v4.2.1 (#4191)
8b0efb8c Resolve RTX pair via OnTrack also. (#4190)
381bce03 Return extended sequence number only and not packet. (#4189)
6bcbf54e Always instantiate nacker when using out-of-band sequence numbers. (#4187)
e71184de Store buffer after creating it.  (#4186)
7c8ea115 Refactor receiver and buffer into Base and higher layer.  (#4185)
4104b827 update protocol (#4183)
cd99fec2 Make new path for signalling v1.5 support. (#4180)
32cd0370 Flush the ext packets on restart/close and release packets. (#4179)
1df1316b Move OnDataTrackMessage to participant listener to support replay. (#4178)
e7601251 Make data message naming a bit more consistent. (#4177)
a04e566d Use published track for model access in data down track. (#4176)
47c86be1 Add support for TURN static auth secret credentials (#3796)
24559a28 chore(deps): update github workflows to v6 (#3866)
0a824386 add explicit room exists servicestore op (#4175)
39bd077d Release v1.9.9 (#4174)
cbb2c617 Publish/Unpublish counter match. (#4173)
fb849edc Minor clean up (#4172)
c28e5e45 fix(kindToProto): update protocol (#4171)
47324abd Drop run away receiver reports. (#4170)
462ec324 prevent uint overflow setting packet not found count (#4169)
5c841b8e Some logging changes. (#4168)
2f2d0a57 skip lost sequence number ranges in getIntervalStats (#4166)
898ebe05 clean up manual roomservice log redaction (#4165)
3e417253 move delete to oss service store (#4164)
5964efbb Ensure subscribe data track handles are unique (#4162)
a26c4830 Add support for RTP stream restart. (#4161)
386f0b38 fix typo in clearing index when removing track from room track manager (#4158)
0abfb251 deregister observability function when participant is closed (#4157)
97aba5e7 Consistently undo update to sequence number and timestamp when the (#4156)
2317c295 Fix panic while removing track from room track manager. (#4153)
a0a28ac5 Avoid duplicate track add to room track manager.  (#4152)
f01008f8 Revert telemetry stats worker wait configuration. (#4151)
ca4b56d2 Handle case of sequence number jump just after start. (#4150)
97099cae Configurable telemetry stats worker clean up wait. (#4148)
d7db7cb3 chore: fix a large number of spelling issues (#4147)
cadf2ad9 Release v1.9.8 (#4145)
498304cd defensive nil check (#4144)
20f6a497 Store ddParser in atomic.Pointer (#4143)
037cb906 release ext packet if patching fails (#4142)
dd598ef2 Release ExtPacket if dependency descriptor or other parsing fails (#4141)
35b0e2bc update webrtc to 4.1.8 to pick up DTLS fingerprint check during handshake (#4140)
1c1a836c Mark RTCP buffer Write as noinline. (#4138)
ea9b2177 protocol deps to get inactive file adjusted memory usage. (#4137)
64f3d1e9 switch participant callbacks to room to listener interface (#4136)
c6e6c021 add debug metric for tracking references (#4134)
a30c79fa Use isEnding to indicate if down track could be resumed. (#4132)
8c241ecf Fix RTCP reader leak in DownTrack. (#4131)
3eef869a Do not pause rid in SDP (#4129)
8e01e595 Release 1.9.7 (#4128)
7c1a0fab Fix concurrent map access. (#4127)
14446b1c Let participant close remove the published tracks. (#4125)
fa0633aa move utils.WrapAround to mediatransportutil (#4124)
f8706cd4 Update pion/ice to stop gather first on close (#4123)
7954748d Data tracks (#4089)
7158d983 log bucket growth (#4122)
04b35eb6 Release v1.9.6 (#4121)
ebdcead5 Update mediatransportutil to get bucket packet size limit. (#4120)
411b09f6 Release v1.9.5 (#4119)
8dcf235a Update pion/ice - attempt to address tcp packet conn close hang (#4116)
64c65143 Update mediatransportutil (#4115)
0a2943bb Clean up bits added to debug peer connection close hang. (#4114)
9c483a69 Use released version v1.8.41 of pion/sctp (#4113)
35c79a57 Update SCTP hash, had the wrong one in previous PR (#4111)
e0fbbef1 Update pion/sctp with RFC9260 revert (#4110)
f3c80917 Try SCTP with read deadline to unblock abort. (#4109)
bd5382da Splitting transport close timeout logs. (#4108)
6d4154b8 Update pion/ice. (#4107)
a6418ae2 Log more peer conenction state on close timeout. (#4105)
06d99974 Check for cancel on unsubscription/source track going away. (#4104)
7f10e18b Record join/publish/subscribe cancellations. (#4102)
40293632 Clear stereo=1 if stereo is not enabled. (#4101)
70f6def3 Add checks for participant and sub-components close. (#4100)
ffbabcc7 Switch forwarding latency log to Debugw (#4098)
27d82a72 Fix "address" typo in transport logs (addddress → address) (#4097)
37a06821 logger proto redaction. (#4090)
54cf7d46 Control latency of lossy data channel (#4088)
5175c1af Lock x/tools at 0.37.0 (#4085)
d510fff1 Downgrade x/tools to be able to make a release (#4084)
c3ea5890 Prepare release v1.9.4. (#4083)
3a128e61 protocol bump for SIP error mapping and validation (#4081)
c3964ba2 Use sync.Pool for objects in packet path. (#4066)
f8b994d4 Forwarding latency measurement tweaks. (#4080)
f4929f09 Revert "Revert pion/transpor to v3.0.8 (#4073)" (#4074)
a04d9c48 Revert pion/transpor to v3.0.8 (#4073)
2d5054ad kind details for connector (#4072)
a272e28a Log raeson for subscriber not being to determine codec. (#4071)
b9b4eec9 Update pion/transport to v3.1.1 (#4070)
b23d093c update protocol (#4069)
4ce07bed Higher resolution forwarding latency histogram. (#4067)
858db7ab fix(deps): update module github.com/livekit/protocol to v1.43.0 (#4015)
1dc9b8fc Use buffered indicator to exclude from forwarding latency.  (#4062)
f117ee51 Track start up delay.  (#4061)
4872f205 Return write count from WriteRTP. (#4059)
d0ba46b4 Log write count atomic. (#4057)
ae5fb7e8 Add packet to forwarding stats only if packet is forwarded. (#4056)
f6909192 Update PsRPC to get redis pipeliner implementation. (#4055)
ca3c507b Prevent invalid track access while peer connection is shutting down. (#4054)
9ca6ee00 Use replace so that x/tools does not get overridden (#4048)
b9323eab chore(deps): downgrade x/tools for counterfeiter (#4047)
2f1e6c36 Prep release v1.9.3 (#4046)
9d5c351d Fix prom units for forwarding latency/jitter. (#4045)
e183657c Add prom histogram for forwarding latency and jitter. (#4044)
1eefeb30 Enable AbsCaptureTimeURI in RTC configuration (#4043)
075a7576 Use simulcast codec as default policy for audio track (#4040)
c264b504 Don't warn 0 payload type for PCMU (#4039)
32fc3525 Broadcast cond var on RTX write. (#4038)
061eb8b4 AddDownTrack to regressed codec after restarting forwarder. (#4037)
c87eb8ed fix: add missing Unlock() in AddReceiver (#4036)
70444924 if RingingTimeout is provided, deadline should be set to that timeout. (#4018)
ab906d71 Prevent leakage of previous codec after codec regression. (#4035)
79b03f97 Log queueing latency when encountering high forwarding latency (#4034)
29117b14 set max layer in allocation (#4033)
15b19ccd Remove ~ from rid which indicates disabled layer to get the actual rid (#4032)
34e16a87 Check more conditions for opportunistic alloc. (#4031)
81fbd355 Use the optimal allocation function for opportunistic allocation. (#4030)
a2ce73e0 Do not bind buffer if codec is invalid. (#4028)
cef6fdb7 Correct direction for request/response for prom counters. (#4027)
5042c06c Use rtp converter from protocol/utils/rtputil (#4020)
5a426d15 Use rtp converter from protocol/utils (#4019)
35fb8877 feat: use env var for GOARCH (#4012)
c0397696 Issue #1 only: Fix spatial layer initialization in Forwarder (#4003)
2afbf0e8 Some golang modernisation bits. (#4016)
484f784a Prepare release v1.9.2 (#4011)
ad074ed2 counterfeiter needs an older version of x/tools (#4009)
e63e8b6f Include mid -> trackID in both SDP offer and answer. (#4007)
781dfede Do not call receiver methods under settings lock. (#4006)
69ff25a0 Use answer with mid -> trackID mapping when in single peer connection (#4005)
fe912acf Update pion/webrtc to prevent GetStats panic. (#4004)
7930dcde Do not try to read stats from peer connection after close. (#4002)
ca0d5ee9 Count request/response packets on both client and server side. (#4001)
dd62eb00 Resort to full search for requested quality is not available. (#4000)
f6ca82d1 Revert to using silence packets for audio dummy start. (#3999)
0e2c59c8 Sort codec layers when adding track (#3998)
100bb46a Adding ProviderInfo to GetSIPTrunkAuthenticationResponse (#3993)
a8d4df66 "Power of Two Random Choices" option for node selection  (#3785)
a20bbe34 Log RPC details. (#3991)
158496bc Increment RTP timestamp on padding when using dummy start. (#3989)
4f6ed65d Limit check to red + opus when looking for primary codec match. (#3988)
a87f6c4b Allow passing inline trunk for outbound calls. (#3987)
bf06596f Support Opus mixed with RED when encrypted. (#3986)
ea208a1c Add encryption datapacket type (#3869)
2a6adbe8 Use padding only packets for dummy start of audio. (#3984)
be018f97 Provide the InputVideo/AudioState to Ingress in WHIPRTCConnectionNotify (#3982)
146bd969 Do not panic of redis is not configured (#3981)
01337ba7 Do not start forawarding on out-of-order packet. (#3985)
c7f625d6 Do not force codec regression between opus and red. (#3980)
3bd20ddb Revert unintentional change to not handle transport fallback on (#3970)
89a2f46c Update deps to fix redis issue when 1 cluster address is provided (#3969)
060719d1 add config for user data recording (#3966)
b3ee219c fix stats worker closed condition (#3965)
3d737031 add idempotent reference count to telemetry stats worker (#3964)
735c663a Update protocol for EventKey helper. (#3963)
646b9de8 Add node_ip to config-sample.yaml (#3960)
0bf7b178 avoid logging on small values (#3958)
00ff2ab9 Adjust for hold time when fowarding RTCP report. (#3956)
e180be06 short circuit participant broadcast filter in livestream mode (#3955)
bfba6fee Adjust stream allocator ping interval based on state. (#3951)
3837006b Revert "Switch ops queue a singly linked list. (#3949)" (#3950)
990c5faf feat: server rpc apis (#3904)
80b11662 Switch ops queue a singly linked list. (#3949)
56ee2328 handle terminated job requests (#3948)
49f9b9c8 Flush stats when there are no packets. (#3947)
e6a3df1e ForwarStats.GetStats needs to be public (#3946)
824d116b Tweaks tresholds for logging high forwarding latency/jitter. (#3945)
408492e0 Log some information around high forwarding latency. (#3944)
6a41fae5 Use microseconds for forwarding stats. (#3943)
856e0871 mediatransportutil to log local address when validating external IP (#3942)
40101cf7 Update protocol for SipCreateParticipant (#3939)
b07e7a38 Use difference in key frame counter to stop seeder. (#3936)
d7f92878 Avoid matching on empty track id. (#3937)
56fb2885 Do DD restart only if DD structure is present. (#3935)
86facce9 More debugging of DD jump (#3934)
6058a3f6 Add debugging from DD frame number wrap around. (#3933)
dc6825c0 mediatransportutil crash fix for logging local address (#3930)
d6f0588f Update mediatransportutil to log external IP found via STUN. (#3929)
2c30a064 Fix dynacast subscriber node clearing on move participant. (#3926)
6489237e Simulcast audio fixes (#3925)
9f0ab870 Wait for `SetRemoteDescription` before configuring senders. (#3924)
df6c26db Subscrbed audio codecs - update from remote nodes. (#3921)
798fa761 Support simulcasting of audio (#3920)
f4a06cf0 Clean code as there is no oss sweeper for ingress (#3918)
5f561b4f Include agent_name as a participant attribute (#3914)
782a35e8 update protocol for psrpc (#3915)
eee2001a Set publisher codec preferences after setting remote description (#3913)
fc995533 add incoming request id to request response message (#3912)
76645fad Rpcs for ingress proxy WHIP (#3911)
991a4a4f Refactor subscribedTrack + mediaTrackSubscriptions. (#3908)
e16b3ba9 Use gzip reader pool (#3903)
17c34921 update protocol for sip api change (#3902)
2f43a575 Release candidate for v1.9.1 (#3899)
07c40cf3 Use `RequestResponse` to report protocol handling errors (#3895)
98352fd0 Prevent race in determining BWE type. (#3891)
f7291fda Do not send both asb-send-time and twcc. (#3890)
21b42fa6 Do not advertise NACK for RED. (#3889)
6633bf93 Use departure timeout from room preset. (#3888)
38f7906e Handle migration better in single peer connection case. (#3886)
5026de2b handle frame number wrap back in svc (#3885)
091e3c13 Revert to using answer for migration case. (#3884)
2aeadf14 init ua parser once (#3883)
998a9f94 Switch known rids from 012 -> 210, used by OBS. (#3882)
890fd942 Single peer connection mode (#3873)
bfe98eaa fix: ensure the participant kind is set on refresh tokens (#3881)
8d270e2a chunk room updates (#3880)
b4e146c5 update mediatransport util for ice port 3478 (#3877)
dc3a7753 Fix timeout handing in StopEgress (#3876)
d62336e1 Remove unnecessary check (#3870)
c58e5d23 Update golang Docker tag to v1.25 (#3864)
98d577ee Update module github.com/livekit/protocol to v1.40.0 (#3865)
afbf541e Update pion deps (#3863)
b660c3b5 Extract video size from media stream (#3856)
456b8709 Fix missed unlock (#3861)
d500806e Handle no codecs in track info. (#3859)
11b240d6 Log track settings more. (#3853)
1aa0f963 Log signal messages on media node. (#3852)
b182d07b Log signal messages as debug. (#3851)
a370bb20 Support G.711 A-law and U-law (#3849)
fa5f4ef3 Populate SDP cid in track info when available. (#3845)
eed27885 Send `participant_connection_aborted` when participant session is closed (#3848)
61e59346 Update go deps (#3439)
1b228913 Support video layer mode from client and make most of the code mime aware (#3843)
f2da4444 Support per simulcast codec layers. (#3840)
f275f592 handle SyncState in join request (#3839)
5d44cf6d Use wrapped join request to be able to support compressed and uncompressed. (#3838)
5ca16264 Support join request as proto + base64 encoded query param (#3836)
7dea1012 Clean up missed v2 pieces (#3837)
34a49130 Delete v2 signalling (#3835)
1fe33716 Fix: RingingTimeout was being skipped for transferParticipant (#3831)
5751692a deps (#3829)
db4bc127 Get to the point of connecting publisher PC and using it for async signalling (#3822)
5e483e75 update readme (#3809)
e3155b14 Get to the point of establishing subscriber peer connection. (#3821)
a7ce1382 HTTP DELETE of participant session (#3819)
01de0e36 Do not send leave if nil (to older clients) (#3817)
10103449 Add country label to edge prom stats. (#3816)
68387b41 Minor tweak to keep RPC type at service level. (#3815)
a75295fc More v2 signalling changes (#3814)
b20db94d Validation end point for v2 signalling. (#3811)
f2f595f4 update readme (#3808)
fffc2ac0 Use signalling utils from protocol (#3807)
f5fc82d3 Filling out messages unlikely to change in v2. (#3806)
1c99b9ad Split signal segmenter and reassembler. (#3805)
0a1bfd30 Signal handling interfaces and participant specific HTTP PATCH. (#3804)
7837c8e5 starting signaller interface (#3802)
18ce5244 Handle Metadata field from RoomConfig (#3798)
2a6a9b8a Grouping all signal messages into participant_signal. (#3801)
078c01fa Signal v2: envelope and fragments as wire message format. (#3800)
b9a44c3f Signalling V2 protocol implementation start (#3794)
ba702a53 forward agent id to job state (#3786)
1f31d430 Map ErrNoResponse to ErrRequestTimedOut in StopEgress to avoid returning 503 (#3788)
51bbe8c5 Set participant active when peerconnection connected (#3790)
40028dc3 Normalize known rids. (#3779)
ddd92329 Return default layer for invalid rid + track info combination. (#3778)
8c033ce9 Enable H265 by default (#3773)
7678e087 Set rids for all codecs. (#3772)
5d636acf Limit taking rids from SDP only in WHIP path. (#3771)
4d09e5b5 Log SDP rids to understand the mapping better. (#3770)
c69f1aae Revert "Temporary change: use pre-defined rids" (#3769)
8197438e bounds check layer index (#3768)
d11da5f5 Temporary change: use pre-defined rids (#3767)
cb4da533 fix(deps): update module github.com/livekit/protocol to v1.39.3 (#3733)
d6d2b6d8 feat(cli-flags): add option for cpu profiling (#3765)
9fc4ddbe ClearAllReceivers interface is used to pause relay tracks. (#3761)
1216113b Do not need to just clean up receivers. Remove that interface. (#3760)
ef6c38ce Log previous allocation to see changes. (#3759)
01bf9685 SVC with RID -> spatial layer mapping (#3754)
c481396f offer could be nil when migrating. (#3752)
8c2fc0bc Fix svc encoding for chrome mobile on iOS (#3751)
e467daa0 move egress roomID load to launcher (#3748)
3783ebb3 feat(cli): update to urfave/cli/v3 (#3745)
03d3fcab Fix data packet ParticipantIdentity override logic in participant.go (#3735)
068b4366 reuse compiled client config scripts (#3743)
e754a860 return error when moving egree/agent participant (#3741)
7542cf07 remove unused code (#3740)
9d569e2f Take ClientInfo from request. (#3738)
80774263 chore: set workerid on job creation (#3737)
5549ab55 Revert clearing RIDs. (#3732)
ae967313 Clear rids if not present in SDP. (#3731)
0e033907 Return highest available layer if requested quality is higher than (#3729)
9ce737db Add log for dropping out of order reliable message (#3728)
1b95e818 Don't check bindState on downtrack.Bind (#3726)
670f927f Set and use rid/spatial layer in TrackInfo. (#3724)
a9e29116 Add Id to SDP signalling messages. (#3722)
4ec828ce Fix bug with SDP rid, clear only overflow. (#3723)
8f6c3a9b Clear rids from default for layers not published. (#3721)
ce07740e Add simulcast support for WHIP. (#3719)
e98fb94f Create client config manager in room manager constructor. (#3718)
fdf9b852 e2e reliability for data channel (#3716)
35dda8ea swap pub/sub track metrics (#3717)
1d9a4366 Do not require create permission for WHIP participant. (#3715)
e0aea17a Flush stats on close (#3713)
630aa7d9 implement observability for room metrics (#3712)
e7f0294e remove unused ws signal read loop (#3709)
77f70b18 for real, pick up protocol change for webhooks queue length bucnkets (#3707)
7b180646 protocol dep for webhook stats buckets (#3706)
b0ab95ba warn about credentials when used in tokens (#3705)
a72ce30f Small changes to add/use helper functions for length checks. (#3704)
425f6bb3 Allow passing extra attributes to RTC endpoint. (#3693)
758e1762 Add a trend check before declaring joint queuing region. (#3701)
fe81e411 Adds Devin to readme so it auto updates DeepWiki weekly (#3699)
09dede35 version bump v1.9.0 (#3698)
fc867c5b Webhook prom stats (#3697)
0e17916f Do not use Redis pipeline for SIP delete. Fixes Redis clustering support. (#3694)
1b760393 WHIP support. (#3692)
e4f7d81b add client ip to agent worker registration (#3675)
6b849a87 update mediatransportutil for sctp congestion control (#3673)
83b189b0 Add ServerInfo to ReconnectResponse (#3671)
5f87a35b Prevent operating on swapped out map. (#3670)
13b55a80 move agent token (#3669)
c9385edd handle agent worker jwt (#3668)
3b359d8b Use logger resolver reset to reset contexts. (#3665)
dbb70e0f Fix dynacast quality for moving out tracks (#3664)
0a5f3c2a resolve new room name logger earlier when moving participant (#3662)
2df05517 Revert unbound transceiver stop. (#3661)
5172af15 ~Send initial participant update only after a participant becomes active.~ - General clean up (#3655)
5d4d86f8 protocol to pick resolver values replace (#3659)
7f8e6323 Send self participant update immediately. (#3656)
11630878 Use unordered for lossy data channel. (#3653)
aee34ffe log request for agent dispatch api (#3650)
793b383a Add Moving participant to another room (#3648)
2fff36cb Stub MoveParticipant so that cloud can include the latest protocol. (#3646)
d4ab1142 Redact address (#3643)
b83190a3 protocol update to fix memory stats path (#3642)
a1f4e88e Update protocol to latest, got bit by tag (#3641)
8d3902af Protocol to pick up cgroups v2 memory path fix (#3640)
2f002388 Use participant close reason in remove. (#3639)
58822c26 Include clientInfo in connectivity logs. (#3638)
6d6393a6 Take AudioFeatures from AddTrack. (#3635)
08670412 Limit buffer queue before Bind. (#3634)
9f5bc9b9 Avoid synthesising duplicate feature. (#3632)
847239c3 Disable vp9 for safari 18.4 (#3631)
f69ab680 Populate the sender identity when translating to user packet. (#3628)
e1490558 Forward data between WHIP client and non-WHIP client (#3627)
6739e7bc Broadcast inside lock (#3626)
f24152b4 Call Broadcast in lock scope. (#3625)
b760918a Use logger from request context. (#3623)
34a2e2c1 Check for multiple layers for managed track. (#3622)
4955ebe4 Forward transfer headers to internal request (#3615)
d9ee9214 Set up RTX for WHIP publish (#3619)
d8cf5439 Determine TURN connection type and no fallback for TURN/TLS. (#3612)
d0d212fd Fix WHIP ICE restart. (#3616)
5e7f8a12 Update mediatransportutil for max sctp message (65K) (#3611)
28dfac14 Use exported GetEgressNotifyOptions (#3604)
75236bef protocol update to fix IPv6 SDP fragment parsing (#3603)
2130980d Add basic video support to WHIP. (#3602)
e5cbb227 Allow specifying extra webhooks with egress requests (#3597)
7e16106a Add OnSubscirberReady callback on LocalParticipant. (#3600)
5c2d96b9 Check DestinationRoom of VideoGrant for participant forwarding (#3599)
2e236a19 Revert participant state ACTIVE change. (#3598)
35ac5f56 Add support for WHIP ICE Trickle/Restart. (#3596)
ec2dff96 Fix SIP updates when replacing slices. (#3592)
e24fe77b map PEER_CONNECTION_DISCONNECTED -> CONNECTION_TIMEOUT (#3591)
6ee6eb43 Do not drop audio codecs (#3590)
68357ba6 List audio codecs after video codecs. (#3589)
05a891ff Fix rule (had an extra bracket) (#3588)
d7c41091 Exclude RED from enabled codecs for Flutter + 2.4.2 + Android. (#3587)
ee08aede skip out of order participant state updates (#3583)
15a8d9a2 Break track published fuse when there are no tracks (#3581)
35f83c51 Replace Promise with Fuse. (#3580)
07fe9b72 Prevent migration race. (#3579)
ac8082ff Use older SDP module to accommodate bad SDP. (#3578)
1c8307c7 Use cgroup for memstats. (#3573)
e9be0fca log SDP offer on error (#3577)
3238ab8d Calculate rates for memory used and total. (#3570)
d08487bf Unlabeled (pass through) data channels. (#3567)
52ce18d5 fix: revert recent changes to determine simulcast from sdp (#3565)
cdfbb106 Audio uses signal SignalCid and SdpCid. (#3564)
ed5e2f16 Keep simulcast information tied to receiver. (#3563)
ad010cfc chore(logs): log VLS type for VP9/AV1 (#3561)
8cc17f8f Rework node stats a bit. (#3555)
15f56551 fix(video): determine svc/simulcast from SDP for advanced codecs (#3549)
2b6a46f4 Handle `prefer_regression` for backup codec (#3554)
b0abb0ae Add option to use different pacer with send side bwe. (#3552)
26822b6b ParseUsername utility for TURN user name. (#3547)
55909ed7 log the initial join response (#3546)
97fcb82a Fix: Return NotFoundErr instead of Unavailable when the participant does not exist in UpdateParticipant. (#3543)
75d0e18e Implement SIP update API. (#3141)
e118aff1 Fire track subscribed when the subscriber connected (#3540)
13417c01 Send mute event only on change (#3537)
7f4c4597 Stubs for SIP update API. (#3533)
fe673bb2 Send regressed codec upstream stats to analytics. (#3532)
8eb81388 Use a generation to counter to stop key frame seeder on codec change (#3531)
188470a2 Do not accept unsupported track type in AddTrack (#3530)
507fc9cf Do not instantiate 0 sized sequencer. (#3529)
20bddfea Clean up published track on participant removal. (#3527)
65d8aa28 Handle subscribe race with track close better. (#3526)
a6cb00b3 Reduce seeder duration to 30s and also do not force send PLI. (#3525)
c8233205 Add a key frame seeder in up track. (#3524)
0f61ff3a Remove redundant log (#3523)
7685cd25 Log ParticipantInit on signal start to get a picture of join params (#3522)
ac9e62ef add server agent load threshold config (#3520)
cd5d32f0 Add pID and connID to log context to make it easier to search using pID. (#3518)
2d9aa6dd Update api call info method (#3515)
b3779a90 WebHookConfig (#3517)
6121b9af Check ForwardParticipant room name (#3514)
9a7c9442 mediatransportutil update (#3511)
50ab47c1 Log packet drops/forward. (#3510)
139d1b13 Add ForwardParticipant method to room service (#3507)
6c04909f Use atomic to store codec. (#3505)
7f6afe05 Prevent bind lock deadlock on muted. (#3504)
48063df5 load mime type before calling writeBlankFrameRTP (#3502)
d2e6cd15 Do not bind lock across flush which could take time (#3501)
47896f50 Update protocol and IO service. (#3499)
1dc42eef Bump github.com/go-jose/go-jose/v3 from 3.0.3 to 3.0.4 (#3497)
3a35cbc4 Log migration complete only when coming from sync (#3496)
c2f17a10 refactor: using slices.Contains to simplify the code (#3495)
01e51dbd fix: fix the wrong error return value (#3493)
ff9115b2 Disable dd parser for vp8 if extension is not found (#3492)
c3e06f05 Do not attempt to create objects for URL ingresses as the ingress service will do so (#3491)
f0edfbba Fix receiver rtt/jitter. (#3487)
05dfd30d Take RTT and jitter from receiver view while reporting track stats for (#3483)
04ed5683 Don't issue TrackPublished/Unpublished event on migrated track (#3482)
1cffe30c Use a RED transformer to consolidate both RED -> Opus OR Opus -> RED (#3481)
591888f7 Fix missing RTCP sender report when forwarding RED as Opus. (#3480)

git-subtree-dir: livekit-server
git-subtree-split: 46c4309554d37d23ee8da88a8a7e02a68fba09c1
2026-06-25 23:54:33 +09:00
ttipo 7f6baff425 Merge fix/meeting-connect-error-log: 화상회의 연결 실패 콘솔 로깅 2026-06-25 23:35:57 +09:00
ttipo 8d78ba25a2 fix: 화상회의 연결 실패 원인을 콘솔에 로깅
connect 실패가 errorMsg(미렌더링 ref)로만 삼켜져 화면·콘솔 어디에도
드러나지 않아 디버깅이 불가능했다. catch 에서 console.error 로 원인을 남긴다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:35:57 +09:00
ttipo 2313418e9c Merge feature/livekit-source-build: LiveKit SFU 소스 빌드 전환(Phase 1) 2026-06-25 14:39:20 +09:00
ttipo 38a3ecf8a2 build: LiveKit 을 공식 이미지 대신 소스(livekit-server/)에서 직접 빌드
- SFU 코어(pkg/sfu) 커스터마이즈를 위해 image: 참조를 build: 로 전환.
- 소스는 git subtree 로 v1.8.4 를 livekit-server/ 에 편입(이 레포에서 관리).
- 검증: 소스 빌드 이미지 99.8MB(공식 99.1MB 와 동일 수준),
  `livekit-server version 1.8.4` 정상 출력 → 코드 수정 없이 동일 동작 확인.
- dev 는 프로파일로 제외돼 무거운 Go 빌드가 돌지 않음(개발은 Cloud 유지).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:39:19 +09:00
ttipo 0da97ebd21 Squashed 'livekit-server/' content from commit 154b4d26
git-subtree-dir: livekit-server
git-subtree-split: 154b4d26b769c68a03c096124094b97bf61a996f
2026-06-25 14:35:28 +09:00
ttipo 8f49774aff Merge commit '0da97ebd2149ec2a0412a273b17270f540431d81' as 'livekit-server' 2026-06-25 14:35:28 +09:00
ttipo 79194428fc chore: 실수로 커밋된 스크린샷(image.png) 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:41:00 +09:00
ttipo 954f4a6932 Merge feature/livekit-always-on: LiveKit 운영 기본 서비스 전환 2026-06-25 13:40:48 +09:00
ttipo 034afe2cf8 refactor: LiveKit 을 운영 기본 서비스로 전환(프로파일 게이트 제거)
- docker-compose.yml: livekit 의 selfhost-livekit 프로파일 제거 →
  운영(base)에서 db·redis 처럼 항상 함께 기동. Portainer 등 GUI 배포에서
  COMPOSE_PROFILES 없이도 컨테이너가 생성된다.
- docker-compose.dev.yml: livekit 에 selfhost-livekit 프로파일 추가 →
  개발에서는 가두어 미생성(개발은 LiveKit Cloud 사용 유지).

검증: prod(base) 서비스에 livekit 포함, dev(base+dev) 에는 제외 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:40:48 +09:00
ttipo 1a17540d91 Merge feature/livekit-selfhost: LiveKit 셀프호스팅(AWS EC2+도커 nginx) 구성 2026-06-25 12:59:30 +09:00
ttipo 3ec7e76f7c docs: LiveKit 셀프호스팅(AWS EC2+도커 nginx) 구성 완성
- livekit/nginx.livekit.conf.example: 도커 nginx용 wss 리버스 프록시 참조 설정 추가
  (docker DNS resolver+변수 proxy_pass로 부팅 순서 의존 제거, TLS 종단·WS 업그레이드)
- DEPLOY.md: AWS EC2+도커 nginx 4-1장 추가, 참조 설정 파일로 연결
- .env.production.example: LiveKit 섹션을 셀프호스팅 우선으로 정리, 키 생성 원라이너 추가

애플리케이션 코드 변경 없음(백엔드 토큰 오프라인 서명·프론트 env 주입뿐).
운영은 selfhost-livekit 프로파일 + EC2 보안그룹/EIP 구성으로 가동.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:30:32 +09:00
ttipo dfe2310661 Merge feature/video-meeting-host-avatar: 회의 목록 소유자 아바타+이름 표기 2026-06-24 21:18:21 +09:00
ttipo 9ad10afca6 feat: 화상회의 목록 소유자 표기를 아바타+이름으로 통일
회의방 목록의 진행자(소유자) 표기를 별 아이콘+이름에서 UserAvatar(프로필
이미지/실루엣)+이름으로 변경 — 프로젝트·업무 화면의 사람 표기 방식과 일치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:18:13 +09:00
ttipo 960ff1d420 Merge feature/video-meeting-fixes: 화상회의 후속 보정 2026-06-24 18:17:30 +09:00
ttipo d8d2ff21f9 fix: 화상회의 후속 보정(권한 헤더·빈 상태·한글 방이름 등)
- nginx Permissions-Policy: 운영에서 camera/microphone 을 self 에 허용
  (기존 microphone=()/camera=() 가 LiveKit 장치 접근을 차단하던 문제 수정)
- 회의 로비 빈 상태/검색결과 없음을 공용 EmptyState 로 통일(내 업무·프로젝트 화면과 동일)
- 미구현인 공유 링크 안내 문구 제거(lede)
- 프리조인 "카메라·마이크 권한이 없습니다" 문구 제거
- 회의실 상단 노란색 권한 안내 배너(room-err) 제거 + 관련 prop/이벤트 정리
- 회의방 이름에 한글 허용(백엔드 DTO 2종 + 프론트 검증 정규식/문구, 공백·특수문자는 계속 차단)
- 멤버 초대 등급 셀렉트(DropdownSelect block) 라벨 좌측 정렬(버튼 기본 가운데정렬 상속 수정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:17:22 +09:00
ttipo 30b4b87368 Merge feature/video-meeting-ui: 화상회의 UI 개편 및 코드·로직 검토 반영 2026-06-24 17:57:01 +09:00
ttipo 999a8730dd feat: 화상회의 UI 전면 개편 및 코드·로직 검토 반영
디자인 시안(프로토타입)을 실제 Vue 컴포넌트로 이식하고, 코드·로직·보안
검토에서 도출한 항목을 반영한다.

UI 개편
- 컴포넌트 분리: MeetingLobby/CreateForm/Prejoin/Room/SidePanel/PeopleList/
  Chat/ControlBar/ParticipantTile/QualityBars/MeetingIcon
- 로비: 검색 툴바 + "새 회의 만들기" 모달(BaseModal) + 방 목록(삭제=휴지통)
- 프리조인: 실제 카메라/마이크 미리보기 + 초기 on/off, 실제 프로필 아바타
- 회의실: 축소 스테이지 + 썸네일 그리드(한 페이지 50명) + 페이지네이션,
  말하는 사람 하이라이트/연결 품질 막대
- 채팅: 데이터 채널 실제 구현(Shift+Enter 줄바꿈, 실제 프로필 아바타)

디자인 시스템 정합
- rem 단위/토큰 정렬, 공용 .btn/.form-*/.search/BaseModal/UserAvatar 재사용
- 회의실 다크 캔버스 토큰(--d-*, --d-red/green/amber/accent-soft) 추가

화질/성능
- 스테이지=고화질, 나머지=저화질 구독(setVideoQuality)
- 타일 backdrop-filter 제거 + 발화/품질 이벤트 전체 리렌더 제거(버벅임 완화)

코드·로직·보안 검토 반영
- 채팅 표시 이름은 서명 토큰의 name/identity 만 신뢰(클라이언트 name 사칭 차단)
- promote 는 보낸 사람 metadata role=host 일 때만 적용(인가)
- 원격 오디오를 룸 레벨에서 전원 재생(페이지 밖 참여자 무음 수정)
- 타일 언마운트 시 트랙 detach(누수 방지)
- 마이크/카메라 토글 실패 시 상태 롤백
- 입장 토큰 TTL 1h → 12h(장시간 회의 재연결 안정성)
- 채팅 길이 상한(MAX_CHAT_LENGTH)
- 토큰 metadata 에 avatarUrl 포함(참여자/채팅 아바타)

기타
- 임시 명세/디자인 참고 파일 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:56:53 +09:00
ttipo 251830d421 Merge feature/video-meeting-phase6: 화상회의 단계 6(셀프호스팅 배포 구성) 2026-06-23 22:27:10 +09:00
ttipo c5a8fc8697 feat: 화상회의 단계 6 — 셀프호스팅 배포 구성/가이드
운영에서 LiveKit(SFU)을 직접 호스팅하기 위한 구성과 배포 가이드를 추가한다.
실제 배포는 서버/도메인/TLS 가 필요하므로, 클론 후 값만 채워 배포하는 템플릿 형태.

- docker-compose.yml: livekit 서비스를 opt-in 프로파일(selfhost-livekit)로 추가.
  기존 dev/운영 동작 불변(프로파일 미지정 시 미생성). 시그널링 7880 은 expose(프록시 TLS 라우팅),
  미디어 7881/tcp·7882/udp 는 호스트 공개. 키는 LIVEKIT_KEYS env 주입.
- livekit/livekit.prod.yaml: 단일 노드 운영 설정(use_external_ip, tcp/udp 포트).
  클러스터링용 redis 블록은 비밀값이라 주석으로 두고 배포 시 사본에 채움.
- .env.production.example: LIVEKIT_* 섹션(셀프호스팅/ Cloud 양쪽 안내).
- livekit/DEPLOY.md: 사전준비/키생성/기동/프록시(wss)/방화벽/검증(load-test)/
  멀티노드 클러스터링(Redis·캐스케이딩)/운영팁.
- .gitignore: livekit/*.local.yaml(클러스터 설정 사본) 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:27:10 +09:00
ttipo b40e795664 Merge feature/video-meeting-phase5: 화상회의 단계 5(부하 테스트 도구) 2026-06-23 21:05:37 +09:00
ttipo 88ef92628d feat: 화상회의 단계 5 — 부하 테스트 도구/방법론
livekit-cli(lk load-test) 기반 부하 테스트 러너와 가이드를 추가한다.
다수 publisher/subscriber 를 시뮬레이션해 SFU 부하·미디어 품질을 측정한다.

- livekit/load-test.sh: docker 로 lk load-test 실행. preset(smoke|medium|full)
  + 환경변수 조정(PUBLISHERS/SUBSCRIBERS/DURATION/RESOLUTION/LAYOUT/SIMULCAST).
  자격증명은 루트 .env 에서 읽는다.
- livekit/LOAD_TEST.md: 실행/결과 해석/합격 기준/실행 대상(Cloud=smoke만,
  셀프호스팅=full·클러스터 부하분산)/서버 모니터링/한계 정리.

검증: smoke(2 pub + 3 sub, 15s) 를 dev(LiveKit Cloud) 대상으로 실행 →
6/6 트랙 구독, 패킷 손실 0%, 정상 비트레이트 확인(도구 동작 검증 완료).
대규모(160명) 검증은 단계 6 셀프호스팅 환경에서 수행한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:05:36 +09:00
ttipo 423f2197ed Merge feature/video-meeting-phase4: 화상회의 단계 4(방 관리) 2026-06-23 20:48:52 +09:00
ttipo 54fe3d36e7 feat: 화상회의 단계 4 — 방 관리(목록·생성·예약·참여 제한)
암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI)

백엔드
- meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션
- GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함)
- POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409)
- DELETE /meetings/rooms/:id(진행자만, 아니면 403)
- 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403).
  방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지.

프론트
- useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts
- MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장
- 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트)

권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:51 +09:00
ttipo 2416f728ba Merge feature/video-meeting-phase3: 화상회의 단계 3(화면 구성, 임시 레이아웃) 2026-06-23 18:06:23 +09:00
ttipo 3c7aec86a2 feat: 화상회의 단계 3 — 화면 구성(임시 레이아웃)
진행자/승격 참여자 큰 화면 + 썸네일 + 페이지네이션 + 화면 승격을 구현한다.
(레이아웃은 임시 — UI 는 추후 개편 예정. 데이터/로직만 확정.)

- useLiveKitRoom: featuredIdentity 상태 + promote(identity) 추가.
  진행자가 데이터 채널로 승격을 브로드캐스트 → RoomEvent.DataReceived 로 전원 동기화.
- MeetingPage:
  · stage(featured: 승격>진행자>첫 참여자) 큰 화면 + 나머지 썸네일
  · 진행자에게만 썸네일 "크게"(승격) 버튼
  · 썸네일 페이지네이션(페이지당 12명) — 현재 페이지만 렌더 →
    adaptiveStream 이 안 보이는 참여자 미디어 자동 미구독(대역폭 절약)
- 화상회의_화면명세.md: 단계 2~3 반영(범위/화면 구성/데이터 계약 갱신)

백엔드 변경 없음(승격은 클라이언트 데이터 채널).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:06:23 +09:00
ttipo 9a4d287ffd Merge feature/video-meeting-phase2: 화상회의 단계 2(권한 분기) 2026-06-23 17:51:22 +09:00
ttipo f519723260 feat: 화상회의 단계 2 — 권한 분기(생성자=진행자)
방을 처음 만든 사용자를 진행자로 보고, 역할별로 LiveKit 권한을 차등 부여한다.

백엔드
- meeting_rooms 엔티티/마이그레이션: 방 이름(고유)→진행자(host) 보관
- 토큰 발급 시 역할 결정: 방이 없으면 생성자=진행자, 있으면 생성자 일치로 판정
  (동시 생성 경쟁은 name unique 충돌 후 재조회로 처리)
- grant 차등: 진행자 roomAdmin+발행/구독, 일반 발행/구독
- 역할을 토큰 metadata({"role":...})에 실어 다른 참여자도 진행자 식별
- 응답에 role 추가

프론트
- useMeeting: role 반환
- ParticipantTile: metadata 파싱 → 진행자 타일에 "진행자" 배지
- MeetingPage: 본인이 진행자면 헤더에 역할 칩 표시

방 목록/예약/참여 제한 UI 는 단계 4 로 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:51:21 +09:00
ttipo 85b6b65e24 Merge feature/video-meeting-phase1: 화상회의 단계 1(인프라 토대) 2026-06-23 17:40:18 +09:00
ttipo bf2321f2d5 feat: 화상회의 단계 1 — LiveKit 기반 인프라 토대
LiveKit(SFU)에 미디어 전송을 위임하고, 앱은 입장 토큰 발급 + 회의 화면만
담당하는 구조로 단계 1(기본 연결)을 구현한다.

백엔드
- meeting 모듈: POST /api/meetings/token (JWT 인증)
  livekit-server-sdk 로 입장 JWT 발급(identity=userId, name, grants:
  roomJoin/publish/subscribe/publishData, TTL 1h). 단계 2에서 역할별 분기 예정.

프론트
- useMeeting(토큰 발급), useLiveKitRoom(연결/참여자/컨트롤 상태)
- ParticipantTile(카메라/마이크 트랙 attach, 본인 거울·음소거, 이니셜 플레이스홀더)
- MeetingPage(로비 + 영상 그리드), 라우트 /meeting/:room?, 사이드바 네비 추가
- VITE_LIVEKIT_URL 주입(build arg + dev 런타임 env)

인프라/설정
- 개발 미디어 서버는 LiveKit Cloud 사용(로컬 self-host 는 WSL2/Docker Desktop
  WebRTC 네트워킹 제약으로 제외). env 만으로 운영 전환 가능.
- livekit/livekit.yaml 은 단계 6 셀프호스팅 참고용으로 보관.
- 화상회의_화면명세.md: 현재 화면 기능/구조/데이터 계약 문서(UI 개편 참고).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:40:18 +09:00
ttipo 145b4611d3 Merge tweak/detail-hide-ai-badge: 상세 화면 AI 배지 제거 2026-06-23 15:23:48 +09:00
ttipo c52630af27 refactor: 프로젝트 상세 화면에서 AI 반영 상태 배지 제거
반영 상태는 생성·설정 화면의 업로드 시점에 이미 확인 가능하므로,
조회 전용인 상세 화면 문서 목록에서는 배지를 노출하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:23:48 +09:00
ttipo 3f2a72ab31 Merge feature/attachment-ai-badge: 문서 AI 반영 상태 배지 2026-06-23 15:10:21 +09:00
ttipo e719829f0b feat: 프로젝트 문서의 AI 반영 상태 배지 표시
AI 추천이 문서 내용을 반영하지 못해도(추출 실패·미지원 형식) 추천은
정상 응답되어 사용자가 인지할 수 없던 문제를 해소한다. 문서 목록에
반영 상태 배지를 노출한다.

- ProjectAttachmentResponse.aiStatus 추가(ready/failed/unsupported)
  · ready: 추출 성공(반영 가능) · failed: 지원 형식이나 추출 실패
  · unsupported: 미지원 형식(이미지·xlsx·ppt·zip 등)
- isExtractableType + deriveAiStatus 로 상태 산출
  (추출 본문은 응답에 노출하지 않고 상태 판별에만 사용)
- listFiles 에서 extracted_text 를 명시적으로 선택(select:false 우회)
- 공용 AttachmentAiBadge 컴포넌트 추가 → 프로젝트 상세·설정 문서 목록에 적용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:10:20 +09:00
ttipo d964f9101a Merge feature/ai-read-project-docs: AI 추천에 프로젝트 문서 반영(DB 캐싱) 2026-06-23 14:54:44 +09:00
ttipo 739a5a70f3 feat: AI 업무 추천에 프로젝트 문서 내용 반영(DB 캐싱)
업무 작성 화면의 AI 추천이 프로젝트 제목·설명과 업무 제목·내용에 더해
프로젝트 첨부 문서의 내용까지 파악하도록 확장한다.

매 호출마다 문서를 재파싱하지 않도록, 업로드 시점에 1회 텍스트를
추출해 project_attachments.extracted_text 에 캐싱하고 AI 추천에서
꺼내 읽는 구조로 구현한다.

- ProjectAttachment.extractedText 컬럼 추가(text, nullable, select:false)
- extract-text.ts: txt/csv(직접)·pdf(pdf-parse)·docx(mammoth) 추출
  (문서당 8천 자 상한, 추출 불가/실패 시 null)
- addFile: 업로드 시 텍스트 추출·캐싱
- getAttachmentsTextForAi: 문서별 추출 본문을 합쳐 반환(총 1.6만 자 상한)
- suggestChecklist: [프로젝트 문서] 섹션을 프롬프트에 추가 + 시스템 규칙 보강
- 운영용 마이그레이션 추가(dev 는 synchronize 로 자동 반영)
- 추출 텍스트는 AI 내부용 — 프론트 응답 DTO 에는 노출하지 않음

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:54:29 +09:00
ttipo 87c9019446 Merge: 프로젝트 문서 첨부(생성/편집/상세) 2026-06-23 14:32:35 +09:00
ttipo 8fc9287747 feat: 프로젝트 문서 첨부(생성/편집/상세)
프로젝트에 문서 파일을 첨부할 수 있도록 추가(업무 첨부 인프라·업로드 설정 재사용).

백엔드:
- ProjectAttachment 엔티티 + 마이그레이션(project_attachments)
- 업로드(관리자)/삭제(관리자)/다운로드(멤버) 엔드포인트, 멀터 디스크 저장
- 프로젝트 상세 응답에 attachments 포함

프론트:
- 생성 화면: 파일 staged 후 생성 직후 업로드
- 편집(설정) 화면: 기존 문서 목록 + 추가/삭제(즉시), 폼 미저장 보존
- 프로젝트 상세: 문서 목록(다운로드 전용)
- useProject.uploadFile/removeFile, ApiProjectAttachment 타입

권한: 업로드/삭제=관리자, 조회/다운로드=멤버. 형식/용량은 업무 첨부와 동일(25MB).
런타임 검증: 업로드·목록·다운로드·삭제 + 비관리자 403 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:32:35 +09:00
ttipo 916dd49887 Merge: 일정 참석자 알림(지정/수정/삭제) 2026-06-23 13:56:04 +09:00
ttipo 42bf97636a feat: 일정 참석자 알림(지정/수정/삭제)
일정 CRUD 시 참석자에게 인앱 알림 발송(기존 NotificationService + SSE 재사용).

- 생성: 참석자로 지정된 사용자에게 schedule.invited (전 직원 일정은 개별 알림 생략)
- 수정: 신규 참석자 → schedule.invited, 기존 참석자 → schedule.updated
- 삭제: 참석자에게 schedule.removed (삭제 전 참석자 확보)
- 행위자 본인은 자동 제외, payload(actorName/eventTitle/start), 클릭 시 /schedule 이동
- NotificationType(백/프론트) 3종 추가 + notification.store 렌더 케이스

런타임 검증: 지정/수정/삭제 알림이 참석자에게만 정상 도달.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:56:03 +09:00
ttipo a344945e4e Merge: 사이드바 순서 변경 + 기본 진입 일정으로 2026-06-23 13:39:38 +09:00
ttipo 34b5b1d552 feat: 사이드바 순서 변경 + 기본 진입 경로 일정으로
- 사이드바 nav 순서: 일정 → 내 업무 → 프로젝트
- 기본 진입을 /schedule 로: 루트 redirect, 로그인 후 이동, 로그인 상태에서
  로그인/가입 진입 가드, 소셜 로그인(구글/카카오) 콜백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:39:38 +09:00
ttipo ad950e88ad Merge: 일정 주간/월간 뷰 + 내 일정 필터 + 부가 개선
feature/schedule-month-view 머지 — 주간/월간 전환, 월간 달력 그리드(+N 팝오버),
'내 일정' 필터(참석자 기준), 필수 입력 표기·참석자 필수, 월간 바 두께.
2026-06-23 13:34:03 +09:00
ttipo 511efec64d feat: 일정 — 내 일정 필터 + 월간 '+N' 팝오버 + 필수표기/참석자 필수
- '내 일정' 토글 추가(참석자 기준: 전 직원 또는 참석자에 본인 포함). 주간/월간 공통
- 월간 '+N' 클릭 시 그날 전체 일정 팝오버(Teleport, 바깥클릭/ESC 닫기, 클릭→상세)
- 일정/카테고리 모달 필수 입력 표기(*) — 제목·유형·시작일·종료일·시간·참석자, 카테고리 이름
  (.form-label .req 전역 스타일 추가)
- 참석자 필수화(폼 검증: 전 직원 또는 1명 이상)
- 월간 일정바 두께 소폭 증가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:33:52 +09:00
ttipo 4ff746638c feat: 일정 주간/월간 뷰 전환 추가
상단 토글(주간/월간)로 보기 전환. 월간은 전통 달력 그리드(주×요일).

- scheduleDate: firstOfMonth/addMonths/monthLabel/monthGridWeeks(+MonthDay) 추가
- schedule.store: viewMode + anchor 기반으로 주간/월간 범위·라벨·네비 일원화
  (goPrev/goNext 가 모드별로 주/월 이동, setViewMode 시 범위 재조회)
- ScheduleMonthGrid: 주 단위 다중일 막대(레인 패킹) + 초과 '+N',
  카테고리 색·유형 필터·오늘/주말/이전·다음달 표기, 막대 클릭 시 상세 패널
- SchedulePage: SegmentedTabs 토글 + 주간/월간 조건부 렌더

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:45:52 +09:00
ttipo 3f134ccca3 Merge: 일정 타임라인 빈 상태 표 영역 가득 채우기
tweak/schedule-empty-fill 머지.
2026-06-23 12:05:17 +09:00
ttipo cc06fd9816 style: 일정 타임라인 빈 상태가 표 영역을 가득 채우도록
카테고리가 없을 때 안내 문구를 상단에 작게 두지 않고, 본문(min-height:100%)과
.tl-empty(flex:1)로 표 영역 전체 높이를 채워 중앙 배치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:03:18 +09:00
ttipo f2ead261c8 Merge: 일정 기본(시드) 카테고리 제거
chore/schedule-remove-default-categories 머지 — 기본 카테고리 시드 삭제(사용자 추가 방식).
2026-06-23 11:49:19 +09:00
ttipo 1ad95ff7e8 chore: 일정 기본(시드) 카테고리 제거
기본 카테고리 12종 onModuleInit 시드를 삭제 — 카테고리는 사용자가 직접 추가한다.
(dev DB 의 기존 시드 카테고리/일정은 별도로 정리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:47:13 +09:00
ttipo b9d1990162 Merge: 토큰 갱신/로그인 유지 수정 + 일정/업무 UI 다듬기
fix/auth-token-refresh 머지:
- 401 자동 refresh 가 /auth/me 에서 동작하도록 수정(짧은 로그아웃 해결)
- keepLogin 구현(persistent 세션 플래그·세션/영속 쿠키)
- 일정 모달 placeholder·참석자 콤보 UI, AvatarStack 공용화(일정 참석자·업무 담당자)
2026-06-23 11:33:42 +09:00
ttipo a094283e62 style: 일정/업무 UI 다듬기 — placeholder·참석자 콤보·AvatarStack 공용화
- 새 일정 모달 placeholder 를 프로젝트 기존 톤("~을 입력하세요/선택하세요")으로 통일
- 참석자 등록을 업무 생성의 담당자 등록과 동일한 콤보(검색 입력+후보 드롭다운+선택 칩)로 개편
  ('전 직원'은 드롭다운 항목/단일 칩으로 처리)
- 아바타 스택+오버플로(+N)+팝오버 목록을 공용 AvatarStack 으로 추출,
  일정 상세 참석자 + 업무 상세 담당자 카드에 적용(담당자 팝오버에 역할 표시)
- 사용처가 사라진 ScheduleAvatar 삭제(UserAvatar/AvatarStack 으로 일원화)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:58 +09:00
ttipo b8756d34ab fix: 토큰 갱신/로그인 유지(keepLogin) 정상화
세션이 짧게 유지되고 '로그인 유지'가 동작하지 않던 문제 수정.

원인1(프론트): 401 자동 refresh 가 '/auth/' 포함 URL을 모두 제외해,
부트스트랩 /auth/me 가 access 만료(15분)로 401 이어도 refresh 를 시도하지 않아
새로고침/재방문 시 로그아웃됨.
 - useApi 인터셉터: refresh-재시도 제외 대상을 /auth/refresh·/auth/login 으로만 한정
   → /auth/me 등은 access 만료 시 refresh 후 재시도되어 세션 복원

원인2(백엔드): keepLogin 값이 무시되어 쿠키 영속 여부에 반영되지 않음.
 - RefreshSession 에 persistent 플래그 추가(마이그레이션, default true)
 - 로그인 시 keepLogin 저장, refresh 회전 시에도 그대로 유지
 - setAuthCookies: persistent=true 면 maxAge(영속), false 면 세션 쿠키
 - 소셜 로그인은 체크박스가 없어 영속(true) 기본

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:56:25 +09:00
ttipo 0cdd2bb98e Merge: 보고서 모달·일정 화면 다듬기 + 일정 컴포넌트 rem 통일
tweak/schedule-polish 머지 — 보고서 모달(전체탭 제거·상태버튼 축소·푸터 배경),
일정 화면 여백/마진 정리, 일정 하위 컴포넌트 px→rem 일괄 변환.
2026-06-23 10:36:50 +09:00
ttipo 7568637de2 style: 보고서 모달·일정 화면 다듬기 + 일정 컴포넌트 rem 통일
- 보고서 모달: '전체' 탭 제거(기본=항목 있는 첫 분류), 상태 드롭다운 크기 축소,
  푸터 배경을 다른 모달(.bm-foot)과 동일한 #fafbfc 로 통일
- 일정 화면: 콘텐츠 영역 상하좌우 여백 1.5rem 통일, 툴바 가로 여백 정렬 +
  전역 .toolbar 의 margin-bottom 상속 차단(상단 여분 마진 제거)
- 일정 하위 컴포넌트 px→rem 일괄 변환(보더 width·box-shadow 는 px 유지),
  ScheduleAvatar 도 size 를 rem 렌더 → '전 직원' 표식과 아바타 크기 정합 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:38:59 +09:00
ttipo 61d247de08 Merge: 프론트 공용화 리팩터 (Phase 0-4)
refactor/frontend-commonization 머지 — useClickOutside/useDropdownMenu 훅,
BaseModal/SegmentedTabs/LoadMore/EmptyState 공용 컴포넌트, .form-*/.empty-state
전역 클래스, 작업상태 SSOT(workStatus) + 업무상태 'todo' 라벨 '대기' 통일.
2026-06-23 00:10:16 +09:00
ttipo ddf94b188b Merge: 전사 공유 일정(주간 캘린더) 기능
feature/schedule-calendar 머지 — 백엔드 Schedule 모듈 + 프론트 일정 화면
(주간 타임라인·카테고리/일정 CRUD·상세 패널·모달) + UI 다듬기.
공용화 리팩터(refactor/frontend-commonization)는 별도 브랜치로 분리 유지.
2026-06-23 00:09:12 +09:00
ttipo daff61c85e refactor: 업무 상태 'todo' 라벨을 '대기' 로 통일 (Phase 4)
todo 표기가 화면마다 '할 일'(taskStatusLabel) vs '대기'(ProjectDetail 배지)로
갈리던 불일치 해소. SSOT(format.taskStatusLabel)를 '대기' 로 통일하고
ProjectDetailPage 의 자체 STATUS_LABEL 제거 → taskStatusLabel 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:42 +09:00
ttipo 3380bbdcff refactor: 작업자 진행상태(대기/진행중/완료) 라벨·색 SSOT 통합 (Phase 4)
StatusSelect 와 업무 상세에 각각 중복 정의되던 WORK_STATUS_META/ORDER 를
shared/constants/workStatus.ts 단일 소스로 추출(값 동일, 표시 변화 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:00:21 +09:00
ttipo 21cabbe20b refactor: ScheduleAvatar 를 UserAvatar 어댑터로 정리 (Phase 3-4)
ScheduleAvatar 가 복제하던 이미지/실루엣 렌더링을 제거하고, 공용 UserAvatar 를
size(px)로 감싸는 얇은 래퍼로 전환. 아바타 표시 로직을 UserAvatar 단일 소스로 통일
(.sa 클래스는 상세 패널 스택 CSS 훅으로 유지, 사용처 변경 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:57:22 +09:00
ttipo d3e2861440 refactor: 빈/상태 메시지 박스 EmptyState 로 통일 (Phase 3-3)
목록 로딩·빈 상태 박스(state-msg/feed-state/list-empty)를 전역 .empty-state
카드 스타일 + EmptyState 컴포넌트로 통일. 5개 페이지(프로젝트 목록/상세,
내 업무, 활동, 멤버)의 중복 박스 CSS 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:56:07 +09:00
ttipo e7760e9ddc refactor: 더보기 버튼 LoadMore 컴포넌트로 통일 (Phase 3-2)
목록 페이지네이션 '더보기' 버튼 마크업/문구를 LoadMore 로 추출(전역 .load-more 사용).
- 프로젝트 목록/상세, 내 업무, 프로젝트 활동 4곳 적용
- 활동 화면의 비표준 .more-row/.more-btn 제거하고 표준 .load-more 로 통일
  ('더 보기' → '더보기' 문구도 통일)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:51:45 +09:00
ttipo d3a916dbd7 refactor: 세그먼트 토글 SegmentedTabs 컴포넌트로 통일 (Phase 3-1)
3개 페이지(활동/프로젝트 상세/멤버)에 반복되던 .segmented 버튼 마크업을
options 기반 SegmentedTabs 로 추출(전역 .segmented 스타일은 그대로 사용).
수량(.n) 표기는 option.count 로 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:49:09 +09:00
ttipo 7425d57134 refactor: Teleport 드롭다운 위치/토글 로직 useDropdownMenu 로 통합 (Phase 2)
DropdownSelect·StatusSelect 에 중복되던 fixed 위치 계산·open 토글·
바깥클릭/ESC/스크롤 닫기 로직을 useDropdownMenu 훅으로 추출.

- composables/useDropdownMenu.ts 신설 (align/matchWidth/gap/esc 옵션)
- DropdownSelect, StatusSelect 가 훅을 사용하도록 정리
- 절대배치 메뉴(일정 멀티셀렉트/유형필터/참석자 스택)는 Phase 0 useClickOutside 로 이미 공유

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:48 +09:00
ttipo 0c4f0817e6 refactor: 멤버 초대 모달을 BaseModal 로 전환 (Phase 1b)
- 중복 모달 셸/헤더/푸터/필드 CSS(~150줄) 제거 → BaseModal + 전역 .form-*
- BaseModal 에 subtitle 슬롯 + 푸터 버튼 아이콘 정렬 추가
- 본문 폼은 display:contents 로 BaseModal 본문 간격 상속,
  고유 요소(자동완성/역할 힌트/푸터 노트)만 .invite-body 로 재스코프

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:41:41 +09:00
ttipo 5f8b2597bf refactor: 공용 폼 모달 BaseModal + 전역 .form-* 필드 클래스 (Phase 1a)
- components/common/BaseModal.vue 신설: backdrop+헤더(제목/닫기)+본문/푸터 슬롯,
  Teleport·ESC·바깥클릭 닫기 내장. 전역 .modal* 와 충돌 없게 bm- 접두 클래스 사용
- relay.css 에 .form-field/-label/-row/-input/-textarea/-select(+wrap/chev) 표준 추가
- 일정·카테고리 모달을 BaseModal + .form-* 로 전환(중복 모달/필드 CSS 제거,
  푸터 버튼은 전역 .mbtn 사용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:35:33 +09:00
ttipo 92ed6c332f refactor: 바깥클릭/ESC 닫기 공용 훅 useClickOutside 도입 (Phase 0)
11곳에 복붙되던 document 리스너 등록/해제·바깥클릭·ESC·스크롤 닫기 로직을
useClickOutside 훅으로 통합. 재사용 드롭다운 5종에 우선 적용.

- composables/useClickOutside.ts 신설 (event/esc/closeOnScroll 옵션)
- 적용: DropdownSelect, StatusSelect, ScheduleTypeFilter,
  ScheduleDetailPanel(참석자 스택), ScheduleEventModal(참석자 멀티셀렉트)
- 모달 ESC·페이지 레벨 오버레이 조정 로직은 후속 단계(BaseModal)에서 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:45 +09:00
ttipo d5b54c1afb style: 일정 화면 UI 다듬기 (기존 디자인 규격에 통일)
- 툴바 '오늘' 버튼 제거, 컨트롤 간격을 전역 .toolbar(0.625rem)와 통일
- 타임라인 레인 hover 편집/삭제 버튼 제거(카테고리 관리는 유형 필터에서)
- 유형 필터 버튼을 기존 DropdownSelect pill 디자인으로 통일 + 수량 뱃지 제거
- '새 일정' 버튼의 스코프 .btn 재정의 제거 → 전역 .btn primary 표준 사용
- 일정/카테고리 모달을 사이트 폼 모달 규격(rem + 멤버 초대 모달)으로 재정렬,
  라벨 좌측정렬, 취소/저장 버튼을 전역 .mbtn 으로 통일
- 참석자 아바타를 공용 UserAvatar 방식(이미지/실루엣)으로 교체,
  '전 직원' 표식 크기를 아바타와 동일(20px)하게 고정(루트 14px rem 보정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:24:33 +09:00
ttipo 346ec75553 feat: 전사 공유 일정(주간 캘린더) 기능 추가
상단 nav 독립 메뉴 '일정' — 주간 타임라인(간트형) 캘린더.
카테고리=레인, 일정=막대(다중일 지원), 우측 상세 패널, 일정/카테고리 CRUD.

백엔드(Schedule 모듈):
- ScheduleCategory/ScheduleEvent 엔티티 + 참석자(다대다) + 작성자
- 카테고리/일정 CRUD, 기간 겹침 조회, 참석자 풀(전사 사용자)
- 기본 카테고리 12종 onModuleInit 멱등 시드
- 인증 사용자 누구나 관리(전역 일정, 글로벌 관리자 개념 없음)
- 운영 마이그레이션(AddSchedule) 추가, @nestjs/schedule 임포트는 CronModule 로 alias

프론트:
- /schedule 라우트 + 사이드바 nav 항목
- 주간 네비(이전/다음/오늘), 유형 필터·카테고리 관리 드롭다운
- 타임라인/상세 패널/일정 모달/카테고리 모달(hue 슬라이더)/참석자 멀티셀렉트
- schedule.store + useSchedule + scheduleDate 유틸

런타임 검증: dev 부팅·시드(12건)·라우트 매핑 정상, 인증 게이트(401),
일정 생성/조회/수정/삭제 + 참석자/전직원/다중일 스모크 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:10 +09:00
ttipo 62df92e6eb chore: 디자인 시안 HTML(단일 파일) gitignore 패턴 추가
시안 네이밍이 '(단일 파일)' 로도 들어와 기존 '단독' 패턴에 안 걸리던 것 보완.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:23:45 +09:00
ttipo 2c02c4afc1 perf: 활동 피드 무제한 렌더링 방지(상한 + 페이지네이션)
활발한 프로젝트/업무에서 활동이 한 번에 전부 내려와 렌더링되던 문제 보완.

- 업무 상세 활동 탭: 최신 100건으로 조회 상한 추가(기존 무제한)
- 프로젝트 활동 피드: 200건 통째 조회 → 오프셋 페이지네이션 전환
  - 컨트롤러 page/size 쿼리 지원, 서비스 findAndCount
  - 프론트 스토어 누적 + hasMore/loadMore, 활동 페이지 "더 보기" 버튼

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:03:19 +09:00
ttipo 2b534743d6 feat: 중간 점검 보고 시 활동 피드 기록 추가
보고 제출 시 알림만 보내고 활동(activity)에는 적재되지 않아
프로젝트 활동 피드·업무 활동 탭에 표시되지 않던 누락을 보완.

- ActivityType/ApiActivityType 에 task.checkpoint_reported 추가
- submitCheckpointReport 에서 activity.record 호출
- 프론트 활동 렌더링 케이스(프로젝트 피드/업무 탭) 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:37 +09:00
ttipo af8b363f23 feat: 업무 상세 댓글·활동 빈 상태 UI 추가
댓글/활동 패널에 컨텐츠가 없을 때 아이콘 + 안내 문구의 빈 상태(thread-empty) 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:39:32 +09:00
ttipo 4efe5a7a9a style: 상세 화면 할 일 탭/항목 UI를 작성 화면과 동일하게 통일
상세 체크리스트 카테고리 탭을 작성 화면과 동일한 테두리 pill + 배지형 카운트로 변경(플랫 탭 제거),
항목 행 hover/텍스트 flex 도 작성 화면과 맞춤. 미사용 ck-tabs 클래스 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:36:02 +09:00
ttipo dd7d75310e feat: 보고 완료 배지 옆 보고서 아이콘으로 모달 열기
보고 완료 상태에서 배지 클릭으로만 열리던 것을 명확히 — 배지 오른쪽에 보고서 아이콘 버튼 추가, 아이콘 클릭 시 보고 모달 오픈(열람 권한자만). 배지는 상태 표시 전용으로 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:13 +09:00
ttipo 32c04db9fe fix: 지시자 보고 모달 보고자 메모 상하 여백 정리
pr-memo margin을 위 0.375rem·아래 0(비대칭)에서 상하 0.875rem 균등으로, 본문 하단 패딩 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:26:41 +09:00
ttipo b8bb046733 fix: 보고 모달에서 빈 카테고리 탭에 안내 텍스트 표시
전체가 비었을 때만 안내하던 것을 현재 탭 기준으로 변경 — 특정 카테고리에 항목이 없으면 ''X' 분류에 항목이 없습니다' 안내.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:24:00 +09:00
ttipo ea83895938 chore: 디자인 시안 단독 HTML 을 gitignore 에 추가
참고용 대용량 번들 HTML(받은 진행 보고/진행 상태 보고서 단독)을 커밋 대상에서 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:19:49 +09:00
ttipo 1f7d7dcebb feat: 진행 보고 작성/열람 모달 UI 개편(시안 반영)
시안(진행 상태 보고서/받은 진행 보고) 기준으로 두 모달 전면 개편:
- 전체 탭 + 카테고리 탭(색상 dot·건수), 전체 탭에서 카테고리별 그룹 헤더로 묶음
- 작성(작업자): 항목별 상태색 드롭다운(StatusSelect, body Teleport), 보고 메모, 하단 상태 요약(대기/진행/완료 N)
- 열람(지시자·담당자): '진행 보고 도착' 태그 + 보고자 아바타/이름/점검일·시각, 읽기전용 상태 배지, 보고자 메모 박스
- 신규 StatusSelect 컴포넌트 추가, 기존 cp-modal/ws-* 스타일 정리, 미사용 DropdownSelect import 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:19:08 +09:00
ttipo 1df0f2e4e2 feat: 업무 수정/편집을 지시자 전용으로 + 더보기 메뉴를 삭제만 남김
- 상세 우측 상단 액션(수정 버튼 + 더보기 메뉴) 전체를 isIssuer 일 때만 노출
- 더보기 메뉴에서 업무 복제·링크 복사 제거, 업무 삭제만 유지
- 편집 화면(/edit) 진입을 지시자 본인만 허용(그 외 상세로 리다이렉트). 생성 모드는 기존대로 관리자만

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:50:21 +09:00
ttipo 69a25d46ec docs: 중간 점검일 안내 문구 복원('지나면 누락')
직전 커밋에 휩쓸려 빠진 안내 문구 복원.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:44:29 +09:00
ttipo a8d068a76c refactor: 중간 점검을 상단 상태/담당/마감 카드에 통합 + 상태 항목 제거
별도 중간 점검 카드 제거 → 상단 카드의 마감기한 아래로 이동.
해당 카드에서 '상태' 항목 제거(상태는 상단 헤더 배지로 확인).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:43:05 +09:00
ttipo ce50c3c4f5 style: 보고서 모달 버튼을 공용 .btn 크기로 통일
보고서 작성 모달의 취소/보내기 버튼을 커스텀 소형 버튼 → 전역 .btn / .btn primary(height 2.25rem)로 교체해 다른 화면과 크기 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:36:09 +09:00
ttipo 2fd19e0bec style: 진행 상태 라벨 '미착수'를 '대기'로 변경
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:33:31 +09:00
ttipo 1a6321ed49 style: 보고 모달 항목 영역 최소 높이 확대 + 빈 상태 텍스트 중앙 정렬
cp-modal-items min-height 9rem→14rem, cp-modal-empty 를 margin:auto 로 상하/좌우 중앙 배치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:31:42 +09:00
ttipo bc99911b5f style: 보고 모달 항목 영역 최소 높이 지정 + 상하 패딩 통일
cp-modal-items: min-height 9rem(빈 상태/소수 항목에서도 답답하지 않게), 상하 패딩 1rem 으로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:29:54 +09:00
ttipo 69708b2b8b fix: 드롭다운 메뉴를 body Teleport(fixed)로 띄워 잘림 해결 + 보고 모달 높이 확대
DropdownSelect 메뉴를 absolute → body Teleport + fixed 위치 계산으로 변경.
스크롤/overflow 컨테이너(보고 모달 등) 안에서도 메뉴가 잘리지 않음(스크롤·리사이즈 시 닫힘).
보고 열람/작성 모달 max-height 를 calc(100vh - 3rem) 로 확대.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:26:57 +09:00
ttipo b00785d056 fix: 보고서 작성 상태 드롭다운 화살표를 오른쪽 끝으로 정렬
min-width 로 생긴 여백이 chevron 뒤에 남아 화살표가 가운데로 보이던 문제 수정 — 라벨 flex:1 로 화살표를 우측 끝에 고정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:22:03 +09:00
ttipo 4a31331ae1 feat: 보고서 작성 항목 상태를 내용 앞 드롭다운으로 변경
세그먼트 버튼 → 할 일 내용 앞쪽의 DropdownSelect(미착수/진행중/완료)로 선택하도록 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:19:49 +09:00
ttipo 8a52ad0d0a feat: 진행 보고서 작성을 모달 + 카테고리 탭 방식으로 변경
인라인 보고서 폼 제거 → '보고서 작성' 클릭 시 모달로 띄움.
모달 내 항목은 필수/관리/보안/부가 탭으로 구분해 항목별 상태를 선택하고 메모와 함께 전송.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:15:55 +09:00
ttipo 6577550937 feat: 중간 점검 보고 내용은 지시자·담당자만 열람(상태는 전원 노출)
보고 '내용' 열람을 지시자 + 해당 업무 담당자로 제한:
- 백엔드 buildDetail: viewerId 기준으로 권한 없으면 checkpointReports 를 빈 배열로 응답(findOne 에 현재 사용자 전달). 점검일에는 reported 플래그를 두어 상태는 전원 공유
- 프론트: checkpointState 는 reported 플래그 기준, '보고 완료' 클릭(모달)은 지시자·담당자만. 그 외는 비클릭 상태 배지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:01:50 +09:00
ttipo b717ecd9a5 fix: 보고된 중간 점검일 삭제 방지(보고 이력 보존)
수정 시 보고가 접수된 점검일을 지우면 CASCADE 로 보고서까지 삭제되던 문제 방지:
- 백엔드 syncCheckpoints: 보고가 있는 점검일은 삭제 대상에서 제외
- 프론트 수정 폼: 보고된 점검일은 '보고됨' 잠금 배지로 표시하고 삭제 버튼 비노출

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:49:51 +09:00
ttipo 814dd7bb8b feat: 업무 상세 해야 할 일을 카테고리 탭 구조로 변경
그룹 나열 → 필수/관리/보안/부가 탭으로 전환(생성 화면·보고 모달과 동일한 탭 UI 재사용).
검토 모달은 기존 그룹 구조 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:39:54 +09:00
ttipo 98a2308c4e feat: 중간 점검 보고를 모달 + 카테고리 탭으로 열람
하단 토글 목록 제거 → 상단 '보고 완료' 체크포인트 클릭 시 모달로 보고 내용 표시.
모달은 업무 생성의 카테고리 탭(필수/관리/보안/부가) 방식으로 항목을 구분해 보여준다.
보고 스냅샷에 category 추가(백엔드 엔티티/서비스 + 프론트 타입/뷰/스토어).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:33:19 +09:00
ttipo 31704cc95b style: 상세 체크리스트 진행상태 배지 제거 + 중간 점검 목록/배지/버튼 크기 조정
- 해야 할 일 항목의 진행상태 배지 제거(진행상태는 중간 점검 보고/기록에서만 확인)
- 중간 점검 점검일 목록 간격 확대, 상태 배지·보고서 작성 버튼 크기 소폭 확대

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:25:39 +09:00
ttipo 4ef07f65a7 refactor: 중간 점검을 점검일 당일 보고서 방식으로 변경
진행 상태(미착수/진행중/완료)를 상시 토글 대신 '보고서'로 전환:
- 보고는 점검일 당일(KST)에만 가능, 지나면 누락 — 마감 전 상시 전송 제거
- 작업자가 점검일 당일 보고서 폼에서 항목별 상태 + 메모를 작성해 전송(상태는 보고에 포함)
- 지시자는 상세 화면에서 보고 기록을 접힌 목록으로 보고 클릭해 펼쳐 열람
- 체크리스트 항목은 마지막 보고 상태를 읽기 전용 배지로 표시(인라인 편집 제거)
- 상시 진행상태 갱신 엔드포인트/DTO 제거(work-status)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:18:58 +09:00
ttipo 25ca63a925 feat: 중간 점검(체크포인트) 프론트 — 진행상태 토글·점검일 입력·보고
작성/수정 화면: 중간 점검일 여러 개 입력(추가/삭제, 편집 모드 로드).
상세 화면: 체크리스트 항목별 작업자 진행상태(미착수/진행중/완료) 토글(담당자) + 비담당자 배지,
중간 점검 카드(점검일 목록, 점검일~마감 구간 '보고 보내기', 보고 메모, 보고 이력 스냅샷).
타입/컴포저블/스토어/뷰모델 + 알림(task.checkpoint_reported) 렌더 연동.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:57:52 +09:00
ttipo 20664dab0f feat: 중간 점검(체크포인트) 백엔드 — 진행상태/점검일/보고
체크리스트에 작업자 진행상태(work_status: 미착수/진행중/완료) 필드 추가(지시자 검토와 분리).
업무당 중간 점검일(task_checkpoints) 여러 개 + 작업자 보고 스냅샷(checkpoint_reports) 도입.
- 생성/수정 DTO에 checkpoints(날짜 목록) 추가, 날짜 기준 동기화(보고 보존)
- PATCH .../work-status(담당자 진행상태 갱신), POST .../checkpoints/:id/report(점검일~마감 구간, 스냅샷+지시자 알림 task.checkpoint_reported)
- 상세 응답에 checkpoints/checkpointReports/항목 workStatus 포함
- 운영용 마이그레이션 추가(dev 는 synchronize 자동 반영)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:46:07 +09:00
ttipo c550d66826 fix: 업무 작성/수정 화면 진입을 프로젝트 관리자로 제한
비관리자가 URL 로 직접 진입해도 폼이 열리던 문제 수정 — 프로젝트 로드 후 canManage 가 아니면
경고 후 프로젝트 상세로 리다이렉트. 생성·수정 모두 백엔드 admin 전용과 일치시킨다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:13:53 +09:00
ttipo 48b8a1b613 fix: 업무 목록의 새 업무 버튼을 프로젝트 관리자에게만 노출
프로젝트 상세 업무 목록의 '새 업무' 버튼을 repo.canManage(관리자) 일 때만 표시.
업무 생성은 백엔드에서 이미 admin 전용이므로 UI 노출을 권한과 일치시킨다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:11:42 +09:00
ttipo 9e2326f939 style: 이메일 인증 완료 화면에서 상단 브랜드·이 창에서 로그인 버튼 제거
새 탭으로 뜨는 인증 완료 화면을 간결하게 정리 — 상단 Relay 브랜드 표기와
'이 창에서 로그인하기' 버튼을 제거하고, 미사용이 된 RelayMark import·관련 CSS 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:55:20 +09:00
ttipo 24031d93a9 fix: 미인증 로그인 시 에러 토스트 없이 발송 완료 화면으로 전환
로그인 호출에 silent 옵션을 추가해 전역 에러 토스트를 끄고, 로그인 화면에서 에러를 직접 처리.
미인증(403)은 토스트 없이 인증 메일 발송 후 발송 완료 화면으로 이동하고, 그 외 자격 오류만 다이얼로그로 안내.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:52:06 +09:00
ttipo 6c0f1778c8 fix: 미인증 로그인 시 인증 메일 자동 발송 후 발송 완료 화면으로 이동
로그인 화면 상단 인라인 재발송 배너를 제거하고, 미인증(403) 로그인 시도 시
인증 메일을 즉시 발송한 뒤 /signup?sent=<이메일> 의 '발송 완료' 화면으로 이동하도록 변경.
SignupPage 는 sent 쿼리로 진입하면 곧바로 발송 완료 단계를 표시한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:49:48 +09:00
ttipo 5e742e5c8b fix: 미인증 계정 재가입 허용 및 로그인 화면 인증 메일 재발송
인증 미완료 상태에서 재가입/로그인이 모두 막혀 데드락되던 문제 해결.
- signup: 인증 완료 계정만 409로 거부하고, 미인증 로컬 계정은 입력값으로 갱신 후 인증 메일 재발송(중복 계정 생성 안 함)
- 로그인 화면: 미인증(403) 응답 시 '인증 메일 다시 보내기' 인라인 액션 노출

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:42:34 +09:00
ttipo afed0d087a env 예제 수정 2026-06-22 12:35:11 +09:00
ttipo 944b8efbce feat: 이메일 인증·비밀번호 재설정 링크를 운영 환경에서도 로그 출력
SMTP 미구현 상태에서 운영 환경의 isProd 분기가 인증/재설정 링크를 가려 사실상 인증 불가였던 제한 해제.
개발·운영 공통으로 전체 링크를 로그로 남긴다. 실 메일러 도입 시 토큰 노출 방지를 위해 반드시 제거 필요(주석 경고 명시).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:34:53 +09:00
ttipo 433901312d feat: Relay 브랜드 로고·favicon 전면 적용
사이드바·인증 화면 'R' 텍스트 로고를 브랜드 더블 셰브론 마크(RelayMark)로 교체.
favicon을 SVG+PNG+ICO 세트와 웹 매니페스트로 정비(create-vue 기본 아이콘 대체), 가운데 셰브론을 키워 작은 크기 가독성 개선.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:31 +09:00
ttipo 6767316cdb feat: 정렬·필터 서버 측 동작 구현 및 커스텀 드롭다운 적용
프로젝트 목록·업무 목록·내 업무에 정렬/검색/프로젝트 필터를 백엔드 쿼리 파라미터로 추가(페이지네이션과 일관). 활동 멤버 필터는 전량 로드 피드 기준 클라이언트 처리.
모든 정렬/필터 및 멤버 초대 역할 선택을 네이티브 select → 공통 DropdownSelect 컴포넌트로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:17 +09:00
ttipo 71040f94c8 style: 전 화면 컨텐츠 최대 너비 통일
업무 작성 화면의 .page max-width(77.5rem) override 제거 → 전역 기본값 67.5rem 상속.
프로젝트 목록·내 업무·상세 등 다른 화면과 동일한 너비로 정렬.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:01 +09:00
ttipo 1e6acff4f1 feat: 업무 상세 화면을 첨부파일 보기 전용으로 변경
상세 화면에서 첨부 업로드·삭제 UI/로직을 제거하고 목록 표시만 유지.
업로드는 작성·편집 화면에서만 가능하도록 일원화(미사용 스토어 액션·CSS 정리).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:05:53 +09:00
ttipo fd2e3245b4 docs: command.md 에 기존 내용 제거 2026-06-22 02:09:25 +09:00
ttipo 791c74f388 feat: 초기 마이그레이션에 베이스라인 가드 추가(기존 스키마 DB 자동 처리)
이미 스키마가 존재하는 DB(과거 synchronize 로 생성·데이터 보존)에 배포해도
부팅이 실패하지 않도록, up() 진입 시 핵심 테이블(users) 존재를 확인해 DDL 을
건너뛰고 마이그레이션을 '적용됨'으로만 기록한다(데이터/스키마 보존). 빈 DB 는
기존대로 전체 스키마를 생성한다.

검증: 빈 DB 전체 생성 / 스키마 존재 DB(이력 없음) 건너뛰고 기록·데이터 보존 /
재실행 멱등(No migrations pending) 모두 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:06:50 +09:00
ttipo 034bcf9bc7 docs: command.md 에 DB 마이그레이션 명령어 가이드 추가
동작 방식(dev synchronize / 운영 migrationsRun 자동), 스크립트(generate/create/
run/revert), 새 마이그레이션 생성 절차(임시 DB diff), 기존 운영 DB 베이스라인 SQL 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:59:31 +09:00
ttipo 43254fc388 style: 백엔드 lint 포맷 정리 (eslint --fix)
마이그레이션 셋업 중 백엔드 lint 실행으로 정리된 포맷(줄바꿈/따옴표) 반영. 동작 변경 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:54:30 +09:00
ttipo 0d453fc426 chore: TypeORM 마이그레이션 셋업 + 초기 스키마 마이그레이션
운영(synchronize 비활성, migrationsRun 활성)에서 스키마를 구성할 마이그레이션이
전무해 신규 운영 DB 배포 시 테이블이 생성되지 않던 문제를 해결한다.

- data-source.ts(CLI 전용 DataSource) + package.json 마이그레이션 스크립트
  (typeorm/migration:generate/create/run/revert) 추가
- 초기 마이그레이션(CreateInitialSchema) 생성 — 전체 스키마(users·projects·
  project_members·tasks·task_assignees·checklist_items·comments·attachments·
  activities·notifications·refresh_sessions·ai_conversations·ai_messages,
  인덱스·FK 포함), uuid-ossp 확장 생성 보장
- 임시 빈 DB에 up/down 적용 검증, 현재 dev 스키마와 드리프트 없음(No changes) 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:54:15 +09:00
ttipo 9e29d0d0b0 refactor: 업무 삭제 모달을 공통 다이얼로그로 흡수
업무 삭제 모달은 공통 다이얼로그와 동일 구조(아이콘+제목+설명+취소/삭제)라
공통 시스템으로 통합한다. 구조가 다른 할 일 검토 모달은 그대로 유지.

- showDelete·confirmDelete·삭제 모달 마크업 제거
- askDelete() 에서 dialog.confirm(danger, 삭제)로 확인받아 삭제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:35:03 +09:00
ttipo b1afe5480c feat: alert/confirm을 공통 모달 다이얼로그로 통합
모든 window.alert/confirm 을 업무 삭제 모달과 동일한 구조/디자인의
공통 모달로 대체하고, 한 곳에서 관리하도록 다이얼로그 시스템을 도입한다.

- dialog.store: 알림/확인 큐 + Promise 기반 상태 관리
- useDialog(): alert/confirm 컴포저블 API
- AppDialog: App 루트 1회 마운트, 전역 .modal-*/.mbtn 클래스로 렌더
  (variant: info/success/warning/danger → 아이콘·버튼 색)
- relay.css: modal-ico.info/.warning, mbtn.primary 변형 추가
- 교체: API 에러 알림(useApi), 검증/성공 알림(Signup/ProjectCreate/
  ProjectSettings/TaskCreate), 삭제·제거 확인(ProjectSettings/
  ProjectMembers/AgentChatPanel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:46:28 +09:00
ttipo 118d12f829 style: 지시자 카드의 지시 시각을 카드 우측 상단으로 이동
업무 상세 지시자 카드 하단에 있던 '언제 지시했는지' 텍스트를 '지시자' 라벨과
같은 줄의 우측 상단으로 배치한다.

- side-head(라벨 + 우측 보조 텍스트) 한 줄 배치 추가, issued-at 우측 정렬
- 하단 .meta-line 제거(미사용 스타일 정리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:16:27 +09:00
ttipo 5c3b1e5ab4 feat: 겹친 담당자 아바타 스택을 최대 3개 + N 형태로 통일
가로로 겹쳐 표시되는 프로필 스택이 3개를 넘으면 마지막에 +N 으로 표기한다.

- 업무 목록(ProjectDetailPage): 담당자 스택 3개 초과 시 +N
- 내 업무(MyTasksPage): 미니 담당자 스택 3개 초과 시 +N(.mini-more 추가)
- 프로젝트 멤버 스택(목록/헤더)은 이미 백엔드 AVATAR_LIMIT=3 으로 동일 처리됨
- 담당자 세로 목록(상세)·칩(지시 작성)은 겹침 스택이 아니라 제외

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:12:57 +09:00
ttipo f6b01b01a8 fix: 프로젝트 헤더에서 설명 없을 때 제목·메타 사이 빈 여백 제거
ProjectHeader(상세·멤버·활동·설정 공용)의 .rh-desc 가 설명이 비어도 빈 줄로
렌더되어 제목과 프로필·업데이트 시간 사이 간격이 벌어지던 문제 수정 —
설명이 있을 때만 렌더. (프로젝트 목록과 동일 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:04:08 +09:00
ttipo d9a343e5e9 fix: 프로젝트 목록에서 설명 없을 때 제목·메타 사이 빈 여백 제거
설명(desc)이 비어도 .repo-desc 가 빈 줄로 렌더되어 제목과 프로필·업데이트
시간 영역 사이 간격이 과하게 벌어지던 문제 수정 — 설명이 있을 때만 렌더.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:48:09 +09:00
ttipo 32815a0627 fix: 로그아웃을 사이드바 하단 상시 노출 버튼으로 변경
기존 드롭다운 메뉴 안에 숨어 보이지 않던 로그아웃을 항상 노출되는
버튼으로 변경한다.

- 사용자 프로필 행은 표시 전용, 아래에 로그아웃 버튼 상시 노출(빨강 호버)
- 접힘 시 아이콘만 표시
- 미사용 드롭다운(menuOpen·me-menu·me-overlay) 로직/스타일 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:40:30 +09:00
ttipo f4a1253526 feat: 사이드바 접기 + 모바일 드로어 + 검색 아이콘 제거
- 데스크톱: 사이드바 접기 토글(아이콘 레일, 상태 localStorage 보존)
  접힘 시 브랜드/네비/사용자가 아이콘만 표시, 메뉴는 레일 우측으로 펼침
- 모바일(≤48rem): 사이드바를 오버레이 드로어로 전환, 툴바 햄버거로 열고
  배경/화면 전환 시 닫힘(접힘 상태는 모바일에서 무시)
- 전역 도구에서 미사용 검색 아이콘 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:33:39 +09:00
ttipo f60f705f56 refactor: 상단 헤더 네비게이션을 좌측 사이드바로 전환
추후 메뉴 확장을 고려해 AppShell 레이아웃을 사이드바 구조로 변경한다.

- 좌측 사이드바: 브랜드 + 세로 네비(내 업무/프로젝트, 아이콘) + 하단 사용자 프로필·메뉴(위로 펼침)
- 우측 본문 컬럼: 슬림 툴바(페이지 액션 슬롯 + AI/검색/알림) + 기존 페이지 슬롯
- 알림 드롭다운·AI 패널 동작은 그대로 유지(.page 본문은 컬럼 내 중앙 정렬)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:23:09 +09:00
ttipo 3b78574855 feat: 수정 요청 메시지를 선택 사항으로 변경
수정 요청 시 공용 메시지 입력을 필수에서 선택으로 완화한다.

- 백엔드: ApprovalChangesDto.note 를 선택값으로 변경, requestChanges 가
  빈 메시지면 changesNote 를 null 로 저장
- 프론트: 제출 버튼 비활성(메시지 필수) 해제, 빈 메시지는 미전송,
  안내/라벨 문구를 '선택'으로 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:42:37 +09:00
ttipo 70d75cf88d refactor: 승인 요청 흐름에서 메시지 입력·표시 제거
수정 요청(검토 모달)을 제외한 나머지 승인 흐름에서는 메시지를 사용하지 않는다.

- 진행 중 → 승인 요청 카드의 메시지 입력창 제거
- 승인 요청 도착 카드의 승인 메시지 표시 제거
- approvalNote 상태 제거, sendApproval 은 메시지 없이 요청
- 미사용이 된 .ap-note 입력 스타일 정리(수정 요청 메모 .ap-note-box 는 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:28:16 +09:00
ttipo 8b3cebbc62 style: 승인 요청 카드의 설명과 버튼·입력 간격 확대
설명 텍스트와 아래 버튼/입력창이 너무 붙어 보여 간격을 넓힌다.

- 액션 버튼 영역 상단 여백 0.8125rem → 1.125rem
- 승인 메시지 입력창 상단 여백 추가(0.875rem)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:16:07 +09:00
ttipo b387b7e378 refactor: 수정 요청 후 다시 승인 요청 시 메시지 입력 제거
작업자가 수정 요청을 받은 뒤 다시 승인을 요청하는 화면에서
보완 메시지 입력창을 제거한다. (지시자의 수정 요청 내용은 그대로 표시)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:03:55 +09:00
ttipo 9d004dd64e feat: 할 일 항목별 보완 내용 개별 입력·표시 추가
공용 수정 요청 메시지는 그대로 두고, 검토 모달에서 각 보완 항목 아래에
항목별 보완 내용을 개별 등록할 수 있게 하고 상세 화면에 함께 표시한다.

- 백엔드: ChecklistItem.reviewNote 컬럼 추가, ChecklistReviewDto.note 수용,
  requestChanges 가 미완성 항목에 보완 내용 보관(완료/승인 시 비움), 상세 응답 동봉
- 프론트: 검토 모달 보완 항목 아래 보완 내용 textarea(개별), 상세 체크리스트
  보완 항목 아래 보완 내용 표시. 공용 메시지 입력은 유지
- 테스트: 미완성 항목 보완 내용 보관·완료 항목 비움 검증(9 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:26:48 +09:00
ttipo c32df3cf39 feat: 수정 요청 상태에서 보완 항목을 체크박스에 X로 표기
업무가 '수정 요청됨(changes)' 상태일 때 상세 화면 체크리스트의
미완료 항목을 빨강 X(보완 필요)로 표시해 담당자가 보완 대상을 명확히 보게 한다.

- 완료 항목은 초록 체크, 보완 항목은 빨강 X + '보완 필요' 태그
- changes 상태가 아닐 때는 기존대로 체크/빈 박스 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:10:19 +09:00
ttipo 5f0b80593f refactor: 승인/수정 요청을 '할 일 검토' 모달로 단일화
지시자 검토 카드의 '승인하기' 버튼과 별도 승인 모달을 제거하고,
단일 '할 일 검토하기' 버튼이 검토 모달을 열도록 통합한다.

- 검토 카드 버튼을 '할 일 검토하기' 하나로 정리(승인하기 제거)
- 미사용이 된 승인 확인 모달·approveTask·showApprove 상태 제거
- 승인은 검토 모달에서 전 항목 완료 시 '승인 완료' 버튼으로 수행

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:06:59 +09:00
ttipo 20f593bd5a feat: 수정 요청 모달을 '할 일 완료 검토' 디자인으로 개편
시안(수정 요청 모달.html)을 반영해 모달 UI를 재구성한다.

- 헤더(그라데이션 배지·안내)·스크롤 본문·고정 푸터 구조로 변경
- 항목별 완료/보완을 세그먼트 토글(완료=초록 / 보완=빨강 좌측 바)로 표시,
  카테고리 그룹·진행률 바·완료 카운트 동봉
- 보완 항목 수에 따라 안내 배너(warn/good)와 메시지 필수 여부가 전환
- 모든 항목 완료 시 버튼이 '승인 완료'(초록)로 바뀌어 곧바로 승인,
  보완 항목이 있으면 '수정 요청 보내기 (N건)'(빨강)으로 메시지와 함께 반려
- 모달 진입 시 전 항목을 '완료'로 초기화(담당자가 완료를 주장하며 요청)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:18:53 +09:00
ttipo 79e0c820ba feat: 수정 요청 시 할 일 항목별 완료 체크/X 검토 추가
지시자가 승인을 검토하며 수정 요청할 때, 단순 텍스트 입력에 더해
할 일 목록에서 어떤 항목이 완성/미완성인지 체크(✓)·X로 표시할 수 있게 한다.

- 백엔드: ApprovalChangesDto 에 항목별 검토 결과(checklist) 추가,
  requestChanges 가 전달된 항목만 done 상태로 반영(미전달 항목은 보존)
- 프론트: 수정 요청 모달에 카테고리별 체크/X 토글 UI 추가, 검토 결과를
  note 와 함께 전송
- 테스트: 전달된 항목만 완료 체크가 반영되는지 검증(9 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:46:03 +09:00
ttipo c86c04f99e feat: 체크리스트를 필수·관리·보안·부가 카테고리 탭으로 구조화
업무 지시(생성/편집)·상세 화면의 '해야 할 일'을 AI 추천과 동일한
4개 영역(필수/관리/보안/부가)으로 분류한다.

- 백엔드: ChecklistItem 에 category 컬럼(기본 '필수') 추가, 생성/동기화
  DTO·서비스·응답에 category 반영
- 프론트: 생성/편집은 카테고리 탭 + 탭별 추가 입력, AI 추천 항목은
  소속 영역으로 매핑해 일괄 추가, 상세는 카테고리별 그룹 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:18:37 +09:00
ttipo 93c8ae2a22 docs: 단독 실행(non-Docker) 관련 내용 전부 제거
Docker 만 사용하므로 standalone 안내를 정리:
- 프론트 env(.example): '단독 실행 시 주석 해제' 안내 + 주석 처리된 VITE 라인 제거
  (VITE_ 값은 compose build arg 로만 주입됨)
- CLAUDE.md: '단독 실행 시 주석 예시 활성화' 규칙 삭제
- README: 사전요구사항/cp 명령/‘단독 실행’ 섹션/env 표의 standalone 문구 제거
  (모바일은 compose 비대상이라 별도 실행 안내만 한 줄 유지)
- integration-progress: Redis 폴백 설명의 '단독 실행' 표현을 'REDIS_HOST 미설정 시'로 교정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:53:31 +09:00
ttipo 1f2a93b01a chore: 모든 env 파일 구조·주석 정리 및 env↔example 규격 통일
- 죽은 설정 제거: GITEA_*/REPO_IMPORT_ADMIN_EMAIL (저장소·Gitea 기능 삭제됨)
- 섹션 헤더 스타일을 # === 블록으로 전 파일 통일(루트/백엔드/프론트)
- 루트: AI 섹션(AI_TIMEOUT_MS) 형식 통일 + production 파일에 누락됐던 AI 섹션 추가
- 백엔드: JWT/앱URL/OAuth/AI 4섹션으로 일관 정렬, production 에 누락됐던 URL/OAuth/AI 추가
- 프론트: 빌드 주입 변수 안내(VITE_API_BASE_URL·VITE_AI_TIMEOUT_MS) 명시
- 각 .env 와 .example 의 키·순서·주석을 동일하게(값만 실값/placeholder 차이)

(실제 .env.* 는 gitignore 라 미커밋 — 로컬에선 동일 구조로 함께 정리함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:21:40 +09:00
ttipo 042d724b3a refactor: AI 타임아웃을 백엔드·프론트 동일 값 + 환경변수(AI_TIMEOUT_MS)로 통일
기존: 백엔드 30초(하드코딩 const)·프론트 60초(하드코딩)로 불일치
변경: 루트 .env 의 AI_TIMEOUT_MS(기본 60000)를 단일 소스로
- 백엔드 ai.service: REQUEST_TIMEOUT_MS const → ConfigService(AI_TIMEOUT_MS) 주입, 기본 60s
- 프론트 useAi: import.meta.env.VITE_AI_TIMEOUT_MS 사용, 기본 60s
- docker-compose: 백엔드 environment 에 AI_TIMEOUT_MS, 프론트 build arg 에 VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
- 프론트 Dockerfile ARG/ENV, env.d.ts 타입, 루트 .env.example 추가
미설정/비정상 값이면 양쪽 모두 기본 60초로 폴백.

검증: 백엔드 build/lint 0, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:01:51 +09:00
ttipo ff33fb2e6c fix: AI 추천 프론트 타임아웃(10초) → 60초, 응답 잘림 없이 수신
근본 원인: useApi 기본 axios timeout 이 10초인데 suggestChecklist 가 이를
덮어쓰지 않아, AI 생성이 10초를 넘으면(항목 제한 해제 후 ~10.5초로 일관 초과)
백엔드가 200 으로 완료해도 프론트가 먼저 끊어 '추천을 가져오지 못했습니다' 발생.
(백엔드 에러 로그가 없던 이유 — 백엔드는 성공했음)
suggestChecklist 호출에 timeout 60000(백엔드 abort 30초보다 길게) 지정.

런타임 측정: 백엔드 suggest 응답 10.48초/HTTP 200 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:54:40 +09:00
ttipo 0d47e989ba feat: AI 추천 카테고리당 항목 수 제한 해제
- 프롬프트의 '3~6개씩' 제한 제거 → 카테고리별로 필요한 만큼 제안
- parseSuggestions 의 카테고리당 15개 슬라이스 캡 제거(응답 길이는 max_tokens 가 자연 제한)
- 항목이 늘어 잘리지 않도록 suggest max_tokens 3072 → 4096

런타임 재현: 동일 입력에서 필수8·관리6·보안8·부가6(총 28개)로 더 풍부하게 반환.
백엔드 build/lint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:32:35 +09:00
ttipo f8ae813c05 fix: AI 추천 토큰 한도 부족으로 응답 잘림 → 추천 실패 수정
4개 카테고리(필수/관리/보안/부가) 구조화 JSON 응답이 max_tokens=1024 를
넘으면 중간에 잘려 JSON 파싱이 실패하고 '추천을 가져오지 못했습니다' 로 떴음.
- callClaude 에 maxTokens 파라미터 추가, suggestChecklist 는 3072 사용
- parseSuggestions 파싱 실패/빈 결과 시 원본 일부를 warn 로그로 남겨 진단 가능

런타임 재현: 동일 입력으로 필수5·관리5·보안5·부가4(총 19개) 정상 반환 확인.
백엔드 build/lint 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:27:31 +09:00
ttipo 3379c54033 feat: AI 추천 모달 디자인 개편 + 4개 고정 카테고리(필수/관리/보안/부가)
백엔드:
- SUGGEST_SYSTEM_PROMPT 를 정확히 4개 카테고리(필수/관리/보안/부가 기능)로 고정
  (parseSuggestions 가 빈 카테고리는 자동 제외)

프론트(시안 'AI 추천 모달.html' 반영):
- 그라데이션 헤더(배지 아이콘 + '자동 생성' 뱃지 + 분석 요약 서브타이틀)
- 카테고리별 색상 아이콘 dot + 개수 pill + 전체 추가/해제 토글
- 항목을 체크 토글로 '선택' 후 푸터의 'N개 업무에 추가'로 일괄 반영(기존 즉시추가 → 선택→커밋 모델)
- 이미 체크리스트에 있는 항목은 잠금(added) 표시
- 푸터: 선택 개수 + 취소/추가 버튼(.ai-btn — 페이지 .btn 과 분리)

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:19:03 +09:00
ttipo bd21557e39 fix: AI 추천 검증을 제목·내용 둘 다로 확장
AI 추천은 제목과 내용을 함께 분석하므로, 둘 중 하나라도 비면 해당 필드에
인라인 에러(빨강 테두리+메시지)를 표시하고 요청을 중단하도록 수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:55:20 +09:00
ttipo 867f47ba8f feat: AI 추천 버튼도 비활성화 제거 + 인라인 검증으로 통일
기존: 제목 미입력 시 'AI 추천' 버튼 비활성화(canRequestAi)
변경: 제출 버튼과 동일 로직 — 버튼 상시 활성(분석 중 in-flight 만 disabled),
클릭 시 제목이 비어 있으면 titleError 로 인라인 안내(빨강 테두리+메시지) 후 중단.
사용 안 하게 된 canRequestAi computed 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:46:07 +09:00
ttipo 116560b549 style: 업무 제목 인풋도 에러 시 빨간 테두리 표시
borderless 제목 인풋에 has-error 시 빨간 테두리(+가로 패딩) 부여 —
에디터·담당자 필드와 일관. box-sizing:border-box 라 overflow 없음,
세로 패딩 유지로 레이아웃 점프 최소화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:41:10 +09:00
ttipo 612d6c7de8 fix: 업무 내용 필수 검증 추가 + 제목 에러 표기 정리
- 업무 내용(*)이 표기상 필수인데 검증이 없던 문제 → 필수로 강제
  · 프론트: 인라인 검증(에디터 빨강 테두리 + '업무 내용을 입력해 주세요.'),
    입력 시 즉시 해제
  · 백엔드: CreateTaskDto.content 를 @ArrayNotEmpty 필수로(데이터 계약 일치)
- 제목 에러 표기 정리: 1.5rem 큰 placeholder 가 빨갛게 변하며 아래 메시지와
  이중으로 강조되던 것을 제거 → 작은 인라인 메시지만 표시(차분하게)

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:36:58 +09:00
ttipo 9f66c6d0ec feat: 업무 지시 필드별 인라인 검증 + 제출 버튼 상시 활성화
기존: 제목·담당자 미입력 시 '지시 보내기' 버튼이 비활성화(이유 안내 없음)
변경:
- 버튼 비활성화 해제(제출 중 중복 클릭 방지용 submitting 만 유지)
- 제출 시 validateForm 으로 검증 → 미입력 필드에 인라인 에러 표시
  · 제목: 빨강 placeholder + '업무 제목을 입력해 주세요.'
  · 담당자: combo 빨강 테두리 + '담당자를 1명 이상 지정해 주세요.'
- 해당 필드를 채우면 watch 로 에러 즉시 해제

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:29:36 +09:00
ttipo ae6af518dd style: 업무 생성 AI 추천을 하단 인라인 패널 → 모달로 변경
기존: 체크리스트 아래에 인라인으로 펼쳐지던 AI 추천 패널
변경: Teleport 기반 중앙 모달(반투명 배경, 헤더 고정 + 본문 스크롤).
배경 클릭/× 로 닫기(분석 중에는 닫기 비활성). 추천 항목 추가 동작은 동일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:50:41 +09:00
ttipo 544d49aa97 style: 업무 생성 첨부파일 선택을 강조 없는 일반 버튼으로 변경
기존: 점선 테두리 + 강조색(accent) 굵은 '클릭하여 파일 선택' 드롭존(강조 스타일)
변경: 클립 아이콘 + '파일 선택' 의 차분한 보조 버튼(.attach-btn). 미사용
.dropzone.is-disabled 잔여 스타일도 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:46:35 +09:00
ttipo 73eb53625c fix: 멤버 초대/역할변경/제거 알림에 actorName 누락 → '누군가' 표기 수정
ProjectMemberService 의 notify payload 에 actorName 이 빠져 있어, 프론트
notification.store 가 actorName 부재 시 '누군가'로 폴백하던 문제.
세 member.* 알림(invited/role_changed/removed) payload 에 actorName 추가
(actingUserId 로 사용자명 조회하는 actorName 헬퍼 신설). 활동 피드는 actor
관계로 별도 해결되므로 영향 없음.

런타임 검증: 초대 시 수신자 알림 payload.actorName = 초대자 이름으로 채워짐.
백엔드 build/lint 0·8 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:31:03 +09:00
ttipo 32a87d8f62 chore: 잔존 '저장소' 주석·문구를 '프로젝트'로 정리
저장소 기능 제거 후 남은 '저장소' 표현을 프로젝트로 통일:
- 백엔드: activity/notification/task 주석·ApiResponse 문구, AI 시스템 프롬프트
  (SUGGEST_SYSTEM_PROMPT 의 저장소→프로젝트, 무의미해진 'slug=영문이름' 지시 제거),
  CONTEXT_REPO_LIMIT→CONTEXT_PROJECT_LIMIT(프로젝트 컨텍스트 한도)
- 프론트: 페이지/스토어 주석 및 TaskCreate/TaskDetail 의 사용자 노출 문구('저장소'→'프로젝트')
- 단, auth OAuth state 'store' 의미의 '저장소'(cookie-state/strategies)는 정당하여 유지

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:57:06 +09:00
ttipo be0a1bc9d1 refactor: 'Repo' 접두 타입·헬퍼 식별자를 'Project' 로 일괄 rename
저장소 기능 제거 후 남은 레거시 'Repo' 명칭을 프로젝트 의미에 맞게 정리(동작 변화 없음):
- 타입: ApiRepoMember→ApiProjectMember, ApiRepoTask→ApiProjectTask,
  ApiRepoTaskListResult→ApiProjectTaskListResult, RepoTask(View)→ProjectTask(View),
  RepoMember(Response)→ProjectMember(Response), RepoActivityItem/Day→ProjectActivityItem/Day,
  RepoTaskResponse/ListResult→ProjectTaskResponse/ListResult (백엔드 task.service 포함)
- 헬퍼: toRepoTaskView→toProjectTaskView, toRepoActivityItem→toProjectActivityItem,
  loadRepo→loadProject, filteredRepos→filteredProjects, removeRepo→removeProject
- 백엔드 spec mock repoRepo→projectRepo, 잔존 RepoDetailPage/저장소(Repo) 주석 정리

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0 (식별자 충돌 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:52:23 +09:00
ttipo ef1757181d refactor: 저장소(Repo)·Gitea 연동 기능 완전 제거 — 프로젝트 단독 구조
복잡도를 줄이기 위해 저장소 연동 기능을 전부 제거하고 프로젝트를 단독 단위로 운영한다.

백엔드:
- modules/repo, modules/gitea 모듈 전체 삭제(엔티티/서비스/컨트롤러/DTO/역방향 동기화)
- app.module 에서 RepoModule 제거
- ProjectService: repoCount 집계·repoRepo·"미분류" 시스템 프로젝트(ensureUnassigned/
  onApplicationBootstrap)·isSystem 게이트 전부 제거. ProjectResponse 에서 repoCount/isSystem 제거
- Project 엔티티 is_system 컬럼 제거(dev synchronize 가 DROP)
- ProjectModule forFeature 에서 Repo 제거
- Activity 타입 repo.linked/unlinked 제거
- env example 에서 Gitea/REPO_IMPORT 섹션 제거

프론트:
- ProjectReposPage, useRepo 삭제. ProjectTabs 의 '저장소' 탭·repoCount prop 제거
- 4개 페이지의 :repo-count 제거, 라우트 /projects/:id/repos 제거
- types/project·project.store·mock 에서 repoCount/isSystem·Repo 뷰 제거
- ProjectListPage 저장소수/시스템 배지·gitea CSS 제거, activity 의 repo.* 케이스 제거
- types/repo 는 공용 ApiMember 만 유지
- 잔여 '저장소'/Gitea 사용자 문구를 프로젝트로 정리

검증: 백엔드 build/lint 0·8 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:44:15 +09:00
ttipo 861c8efa6b chore: 테스트 편의를 위해 rate limit 을 분당 1000회로 완화
세 겹의 제한을 모두 분당 1000회로 상향(테스트 중 429 회피):
- main.ts express-rate-limit: 15분 150회 → 1분 1000회
- 전역 ThrottlerModule: 분당 100회 → 1000회
- 라우트별 @Throttle(login/signup/resend/forgot/reset/ai): 3~20회 → 1000회

각 라우트 주석에 운영 권장값을 남겨 추후 복원/ env 화 용이.
운영 배포 전 적정값으로 되돌릴 것.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:27:41 +09:00
ttipo d0599787f7 style: 저장소 탭 breadcrumb 을 다른 탭과 동일하게 통일
ProjectReposPage 가 .crumb .lnk(accent색)·.sep(여백) 를 로컬로 덮어써 다른 탭과
달라 보이던 것을 제거 → 전역 relay.css 의 crumb 스타일(text-2 링크·opacity 구분자)
을 따르도록 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:19:42 +09:00
ttipo 647336b14d style: 저장소 탭 가이드 문구를 멤버 탭과 동일한 형식(tab-note)으로 변경
기존 .lede 단락 → 멤버 탭의 안내(정보 아이콘 + 작은 회색 텍스트) 형식으로 통일

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:12:15 +09:00
ttipo 3667f48897 fix: 저장소 연동 추가 폼이 열려 있을 때 기존 저장소 목록 숨김
연동 추가 화면(showForm)에서는 하단의 연동 저장소 목록 섹션을 표시하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 01:06:43 +09:00
ttipo 4d943e8d0d refactor: 저장소 공개 범위(visibility) UI 제거 + 기본 공개(public) 생성
사용자 입력/표시에서 저장소 공개 범위를 제거하고 새 저장소는 공개로 생성한다.
(Gitea 역방향 동기화에 쓰이는 Repo 엔티티 visibility 컬럼은 유지)

백엔드:
- CreateRepoDto/UpdateRepoDto 에서 visibility 제거
- RepoService.create 가 visibility 를 'public' 으로 고정
- update 의 visibility 변경·Gitea private 푸시 로직 제거(공개범위는 Gitea 기준)
- RepoResponse/toResponse 에서 visibility 제거

프론트:
- types/repo ApiRepo/Create/Update 페이로드에서 visibility 제거
- ProjectReposPage 연동 폼의 공개 범위 입력 + 목록 배지 + 관련 CSS 제거
- mock Visibility 타입·orphan Repo 뷰의 visibility 제거

런타임 검증: 저장소 응답에 visibility 없음, repos.visibility 컬럼 유지(Gitea 동기화).
백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:51:21 +09:00
ttipo 135cc0759f refactor: 저장소 연동 폼 — 표시 제목 최상단 + 템플릿 셀렉트 복원
- 필드 순서 변경: 표시 제목(name)을 최상단으로(기존 slug 가 최상단)
- 템플릿을 체크박스 → 기존 셀렉트 구조로 복원
  (없음[빈 저장소] / {템플릿slug} 템플릿, 가용 시에만 옵션 노출)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:36:48 +09:00
ttipo 58c0b9510d refactor: 프로젝트에서 공개 범위(visibility) 제거
불필요한 프로젝트 공개 범위 데이터를 백엔드·프론트에서 일괄 제거(저장소 Repo 의 visibility 는 Gitea 연동용이라 유지).

백엔드:
- Project 엔티티 visibility 컬럼·ProjectVisibility 타입 제거(dev synchronize 가 컬럼 DROP)
- create/update DTO, ProjectResponse, create/update/ensureUnassigned/toResponse 에서 제거
- ai.service 프로젝트 컨텍스트 문자열에서 공개범위 표기 제거
- activity 타입 project.visibility_changed 제거

프론트:
- types/project·project.store·mock Project 에서 visibility 제거
- ProjectCreatePage·ProjectSettingsPage 공개 범위 입력 폼+CSS 제거
- ProjectListPage 공개여부 세그먼트 필터+배지 제거, ProjectHeader 배지 제거
- activity 타입/스토어 project.visibility_changed 케이스 제거

런타임 검증: DB projects.visibility 컬럼 DROP, 생성/목록 응답에 visibility 없음, CRUD 정상.
백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:39:48 +09:00
ttipo 9fdcecace8 feat: 이메일 인증 링크를 결과 페이지(창 닫기)로 분리 + 가입 대기 화면에 로그인 버튼
- 인증 링크(verify-email)의 리다이렉트 대상을 /login?verified → /email-verified?ok=1|0 으로 변경
- 신규 EmailVerifiedPage(/email-verified): 인증 완료/실패 표시 후 창 자동 닫기 시도.
  브라우저가 사용자가 연 탭의 close 를 막을 수 있어 안내 문구 + 수동 로그인/가입 링크 병행
- SignupPage 인증 대기 화면에 '인증을 완료했어요 · 로그인하러 가기' 기본 버튼 추가
  (다른 탭에서 인증을 마친 뒤 기존 화면에서 로그인 진입)

런타임 검증: 유효 토큰→/email-verified?ok=1·무효→ok=0, 인증 후 로그인 200, SPA 라우트 제공.
백엔드 build/lint 0, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:22:23 +09:00
ttipo 72b05f1b69 feat: 비밀번호 찾기 화면을 로그인 2단 레이아웃으로 변경
- 시안(비밀번호 찾기.html) 적용 — 좌측 브랜드 패널 + 우측 폼(로그인 화면과 동일 구조)
- 1단계 이메일 입력(뒤로가기 링크·재설정 링크 보내기) → 2단계 전송 완료
  (메일 아이콘·수신 이메일 칩·스팸함 안내 박스·다시 보내기·로그인 복귀)
- 다시 보내기 시 동일 이메일 재요청 + '전송됨 ✓' 일시 피드백
- 시안의 '30분 유효'는 실제 백엔드 토큰 TTL(1시간)에 맞춰 표기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:15:22 +09:00
ttipo 52b70363d9 feat: 비밀번호 재설정 플로우 (이메일 링크 기반)
백엔드:
- User 엔티티에 passwordResetTokenHash/passwordResetExpiresAt(select:false) 추가
- UserService: setPasswordResetToken/findByPasswordResetTokenHash/updatePassword
- MailService: sendPasswordResetEmail (dev=로그 출력, prod=수신자만)
- AuthService: requestPasswordReset(존재 비노출·로컬계정만·1시간 토큰),
  resetPassword(토큰 해시 검증→새 비번 저장→전 세션 폐기)
- POST /auth/forgot-password(3/분)·/auth/reset-password(5/분), 가입과 동일 비번 규칙
- OAuth 콜백의 잔여 /repos 리다이렉트를 /projects 로 교정(리팩터 누락분)

프론트:
- useAuth: forgotPassword/resetPassword
- ForgotPasswordPage(/forgot-password), ResetPasswordPage(/reset-password, 토큰=쿼리)
- LoginPage '비밀번호 찾기' 링크 연결 + 재설정 완료(?reset=1) 배너

런타임 검증: 재설정→새 비번 로그인 200·구 비번 거부·토큰 재사용 거부·약한 비번 VAL_001·
없는 이메일 동일응답(비노출). 백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:50:34 +09:00
ttipo cb6aa07ebc docs: 프로젝트 우선 리팩터 런타임 점검 완료 기록 (Phase 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:12:19 +09:00
ttipo 9aa22f092d fix: 백엔드·프론트 헬스체크 오탐 수정 (런타임 점검 발견)
- 백엔드: 앱이 전역 프리픽스 /api 하위라 루트(/) 프로브가 404 → /api/health 프로브로 교정
- 공통: localhost 가 IPv6(::1)로 풀려 IPv4 전용 바인딩에 연결 실패하던 것을 127.0.0.1 고정
- 두 컨테이너가 정상 동작에도 unhealthy 로 표시되던 선존 버그(리팩터 무관)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:11:40 +09:00
ttipo 8a2e34d617 feat: 프로젝트 저장소 연동 UI + 화면 문구 정리 (Phase 2b)
- useRepo 복구(프로젝트 종속 /projects/:projectId/repos): list/meta/checkName/create/remove
- types/repo ApiRepo 를 새 RepoResponse 와 정합(projectId 추가, members/counts 제거)
- ProjectTabs 에 '저장소' 탭(repoCount) 추가, 4개 페이지 repo-count 전달
- ProjectReposPage 신설(/projects/:id/repos): 연동 저장소 목록 + 연동(생성) 폼
  (slug 가용성 디바운스 체크·템플릿 옵션) + Gitea 열기 + 연동 해제(admin)
- 화면 잔여 '저장소' 문구를 '프로젝트'로 정리(crumb/설정/멤버 모달/주석/not-found)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:52:23 +09:00
ttipo e028449312 feat: 프로젝트 카운트·멤버 활동/알림 배선 (Phase 1 마무리)
- ProjectService: 프로젝트별 연동 저장소 수·업무 완료/전체 수 집계
  (Repo/Task 엔티티 forFeature 등록, 그룹 쿼리로 N+1 회피, findOne 멤버 로딩)
- ProjectMemberService: 멤버 목록 taskCount(담당 업무 수) 집계
- ProjectMemberService: 초대/역할변경/제거 시 활동 적재 + 대상자 알림 발송
  (ActivityModule/NotificationModule 주입, member.* 페이로드 projectId/projectName/targetName)
- TaskService: 다른 모듈로 이관되어 미사용된 countsByProject/assigneeCounts 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:43:52 +09:00
ttipo 7cd6676ef9 feat: 프론트엔드 프로젝트 우선 구조 재구성 (Phase 2-a)
- 데이터 계층 신설: types/project·useProject·project.store (프로젝트 CRUD·페이지네이션)
- useTask/useMember/useActivity 경로를 /projects/:projectId/... 로 전환
- task·my-task·activity 타입/스토어 repoId/repoName → projectId/projectName
- 알림 store 라우팅·문구 프로젝트화, 활동 타입 repo.linked/unlinked·project.* 반영
- 라우팅 /projects/... 전면 교체, / → /projects 리다이렉트
- 페이지 rename Repo*→Project* (List/Create/Detail/Settings/Members/Activity)
- ProjectCreatePage: Gitea 저장소 폼 → 단순 프로젝트 생성 폼으로 재작성
- RepoHeader→ProjectHeader, RepoTabs→ProjectTabs, AppShell 네비 프로젝트화
- 죽은 파일 삭제: repo.store·useRepo·GiteaRepoBar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:37:51 +09:00
ttipo 6ba148314c refactor: 백엔드를 프로젝트 우선 구조로 이전 (업무·멤버·활동·저장소)
저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 이전(2단계).

- Activity: repo FK → project FK, listForProject, /projects/:projectId/activity
- Repo: project FK 추가, RepoMember 폐지(멤버십은 프로젝트), /projects/:projectId/repos 로 이동
- Task: project 종속(seq=프로젝트 내 순번 @Unique[project,seq]), 권한=ProjectMember, /projects/:projectId/tasks
- RepoService: 프로젝트 종속(멤버·업무카운트·캐시 제거), 권한은 ProjectService 위임
- RepoSyncService: Gitea import → "미분류" 시스템 프로젝트로 연결, import-admin 로직 제거
- AiService: 컨텍스트·추천을 프로젝트 기준으로
- 삭제: repo-member 엔티티/멤버 서비스·컨트롤러/repo-cache
- 알림 payload repoId/repoName → projectId/projectName

검증: build/lint 0, 14 tests. 잔여(후속): ProjectResponse 카운트 집계·멤버 활동/알림 배선.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:03:09 +09:00
ttipo 2f66cdda8a feat: 프로젝트(Project) 모듈 기반 추가 — 프로젝트 우선 구조 1단계
저장소=프로젝트 1:1 구조를 프로젝트 우선 + 프로젝트 1:N 저장소로 전환하는 리팩터의 기반.
기존 코드는 건드리지 않고 추가만 함(기존 저장소 기반 동작 유지).

- Project / ProjectMember 엔티티 (isSystem '미분류' 버킷, admin/member/owner)
- ProjectService: CRUD + 권한 헬퍼(assertProjectMember/Admin) + 부팅 시 '미분류' 시스템 프로젝트 보장
- ProjectMemberService: 초대/역할/제거 (활동·알림 배선은 Activity 이관 단계 TODO)
- /projects, /projects/:id/members 라우트, app.module 등록
- docs/project-refactor-plan.md (리팩터 SSOT)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:41:58 +09:00
ttipo 9bcbc1564f revert: AI 코드 검토 기능 제거 (85ae82f 되돌림)
통짜 코드 전송 방식이 토큰 비효율·부정확해, 검색 기반(grep으로 좁힌 뒤 관련
스니펫만 판정) 방식으로 재설계하기 위해 일괄 제거. 추후 개선안으로 재작업 예정.

This reverts commit 85ae82f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:56:43 +09:00
ttipo e72e6aee83 chore: 디자인 시안 HTML 파일 제거
Vue 이식이 완료된 초기 디자인 시안(Relay 화면 전체/*.html) 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:20:13 +09:00
ttipo 85ae82f3ce feat: AI 코드 검토 — 연결된 Gitea 코드로 체크리스트 구현 여부 판정
- ChecklistItem.reviewVerdict('done'|'missing'|null) 컬럼 추가 + 응답/프론트 타입 전파
- GiteaService.fetchRepoCode: 기본 브랜치 텍스트 파일 스냅샷(의존성/빌드/바이너리 제외, ~120KB 예산)
- AiService.reviewChecklist: 코드+체크리스트를 Claude 에 보내 항목별 done/missing 판정 후 체크리스트에 반영
- POST /ai/review-checklist(throttle 6/분, Claude 타임아웃 60s), Gitea 미연동/코드없음/항목없음 400
- TaskService.getTaskForReview/applyReviewVerdicts
- 프론트: TaskDetailPage 승인 요청 카드(prog/changes)에 "AI 검토" 버튼 → 체크박스 ✓(초록)/✗(빨강), 요약 배너

베스트에포트 휴리스틱(코드 일부만 검토), Gitea 연동 저장소 한정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:19:01 +09:00
ttipo 57a88ff741 feat: 업무 생성 화면 첨부파일 활성화 (제출 시 업로드)
업로드 API 가 기존 taskId 종속이라 막혀 있던 생성 화면 첨부를 2단계 제출로 해결.
선택한 파일을 메모리에 보유하다 업무 생성/수정 후 그 taskId 로 순차 업로드(베스트에포트,
실패 개수 안내). 수정 모드는 기존 첨부 표시·즉시 삭제 + 새 파일 업로드. 25MB 사전 필터.
백엔드 변경 없음(기존 업로드 엔드포인트 재사용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:18:35 +09:00
ttipo df97719e0f fix: 첨부 다운로드 링크를 백엔드 절대 URL 로 변환
백엔드가 내려준 루트 상대경로(/api/.../download)를 <a href> 가 프론트 오리진으로
해석해 404 페이지가 뜨던 문제 수정. useApi 에 toAbsoluteApiUrl 추가, 첨부 매핑에 적용.
(dev 프론트 5273 / 백엔드 3100 오리진 분리 환경)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:18:14 +09:00
ttipo 406e13da1c fix: 업무 상세 활동 탭 내용·시간 간격 축소
활동 항목의 내용과 아래 시간 텍스트 사이 간격(.act-time margin-top)을 0.375rem→0.1875rem 으로 줄임.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:17:44 +09:00
ttipo 0976a5057b feat: 업무 작성 AI 할 일 추천
- POST /ai/suggest-checklist — 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내
  카테고리별(필수/추가/보안) 할 일 항목을 JSON 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502)
- AiSuggestController + SuggestChecklistDto + AiService.suggestChecklist/parseSuggestions
- 프론트: TaskCreatePage 액션바 "취소 / AI 추천 / 지시 보내기" + 체크리스트 추천 패널
  (카테고리별 항목 클릭 추가·전체 추가·중복 스킵), useAi.suggestChecklist(silent)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:34 +09:00
ttipo 51b608efeb feat: AI 채팅 다듬기 — 저장소 컨텍스트·표시제목·마크다운 렌더·말투
- 컨텍스트에 저장소 목록 추가(RepoService.findAll), slug 빼고 표시 제목만 노출
- AI 응답 마크다운 렌더링: shared/utils/markdown.ts (escapeHtml 후 신뢰 태그만 조립, 의존성 0, XSS 안전 — 굵게/목록/코드/제목/링크)
- 시스템 프롬프트: 간결·이모지/사과 남발 금지·과도한 존칭 지양·마크다운 사용·저장소는 표시제목으로 안내

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:03:59 +09:00
ttipo b8e8095170 fix: 내 업무·AI 컨텍스트 orderBy 컬럼명 버그(databaseName) 수정
listAssigned/listIssued 가 to-many 조인 + 페이지네이션 정렬에서 DB 컬럼명
'task.due_date' 를 사용해, TypeORM distinct 경로가 컬럼 메타데이터를 못 찾아
"Cannot read properties of undefined (reading 'databaseName')" 로 크래시.
정렬 키를 엔티티 속성명 'task.dueDate' 로 변경 — 내 업무 화면 + AI 컨텍스트 동시 복구.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:59:45 +09:00
ttipo 2f7f28dd7b feat: AI 에이전트 주제 한정(가드레일) + 사용자 업무 컨텍스트 주입
- 시스템 프롬프트를 업무/Relay 한정으로 재작성 — 무관한 주제는 정중히 거절
- 매 요청 시 사용자 컨텍스트 주입: 오늘 날짜(KST) + 담당 업무 최대 30건(제목/상태/마감/체크리스트) + 지시 업무 수
- TaskService.listAssigned/listIssued 활용(AiModule→TaskModule 단방향), 조회 실패해도 채팅 진행
- callClaude(messages, system)로 시그니처 변경(매 메시지 최신 컨텍스트)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:03:45 +09:00
ttipo e432832c92 feat: AI 대화 DB 영속 + 다중 대화/기록 목록
- AiConversation·AiMessage 엔티티(user/conversation CASCADE), 제목 자동 생성
- 대화 스코프 API: GET/POST /ai/conversations, GET/:id, POST/:id/messages, DELETE/:id (전부 본인 소유 스코프 → IDOR 차단)
- 클라는 새 메시지 1건만 전송, 서버가 DB 이력으로 Claude 호출(무상태 /ai/chat 폐기)
- Claude 실패 시 사용자 메시지·빈 대화 롤백(고아 데이터 방지)
- 프론트: AgentChatPanel 채팅↔지난 대화 목록 뷰(열기/삭제), ai.store 재작성(목록·현재 대화)
- 다른 기기·재로그인·새로고침에도 기록 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:58:40 +09:00
ttipo 5da3ec8818 feat: AI 채팅 패널 (Claude API)
- 백엔드 modules/ai: POST /ai/chat — Anthropic Messages API 프록시(키 서버 env 한정, 30s 타임아웃, throttle 20/분, 미설정 503/외부오류 502)
- ChatDto 검증(role enum, content 8000자, 메시지 50개 상한), 모델 CLAUDE_MODEL(기본 claude-sonnet-4-6)
- 프론트 AgentChatPanel: 우측 슬라이드 채팅(말풍선/타이핑/에러/새 대화), plain text 렌더로 XSS 안전
- AppShell 헤더에 AI 버튼 상시 노출 — 모든 화면에서 열림
- 대화 이력은 stores/ai.store(싱글톤)에 보관 → 화면 이동에도 유지, '새 대화'로만 초기화
- TaskDetailPage 의 기존 목업 에이전트 패널 전부 제거(보안 L1 해소)
- env.example 에 CLAUDE_MODEL 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:04:48 +09:00
ttipo db6a80a6f4 feat: refresh 토큰 완전 회전 + 재사용 탐지
- RefreshSession(패밀리) 테이블 도입 — 한 로그인=1행(currentJti/prevJti/expiresAt)
- /auth/refresh 회전형으로 교체: 현재 jti→새 jti 회전, 옛 토큰 재제출 시 재사용 탐지→패밀리 폐기(401)
- 동시 갱신 오탐 방지: 직전 jti 15초 유예 + 프론트 useApi single-flight refresh
- 로그아웃=해당 세션만 폐기, revokeAllSessions(전 기기) 준비
- tokenVersion·jwt-refresh.strategy·JwtRefreshGuard 제거(refresh는 가드 없이 서비스 직접 처리)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:57:01 +09:00
ttipo df268d3b5e chore: env.example 파일 현행화 (development 기준)
- backend/.env.development.example: CLAUDE_API_KEY 추가(dev 실제와 키 셋 일치)
- backend/.env.production.example: 소셜 OAuth·APP_API_URL/FRONTEND_URL·CLAUDE_API_KEY 추가(운영 placeholder)
- frontend/.env.*.example: 고유 키 없음 + VITE_API_BASE_URL docker 주입 설명 헤더 추가

실제 시크릿/인프라 값은 미포함(전부 placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:01:59 +09:00
ttipo b9e2b0e67d fix: 보안 LOW 하드닝 (L2~L6)
- DTO 길이 바운드: 업무 content/assigneeIds/checklist ArrayMaxSize·요소 MaxLength, UpdateRepoDto.description MaxLength(300)
- 비밀번호 정책: 8~72자 + 영문·숫자 포함 강제, bcrypt rounds 10→12
- 그랜드페더링을 컷오프(2026-06-20) 이전 생성 계정으로 한정
- multer 업로드 오류를 400(VAL_001)로 매핑
- 운영 환경에서 CORS localhost:3000·Swagger /api-docs 노출 제외

(L1 에이전트 터미널 v-html 은 에이전트 작업 시 함께 처리 — 보류)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:46:08 +09:00
ttipo 8efcacb6bb fix: 보안 점검 보완 — 소셜 로그인·세션·업로드 강화 (HIGH+MEDIUM)
- OAuth 제공자 이메일 검증 미확인 차단(email_verified/is_email_verified 미검증 시 거부)
- OAuth state CSRF 방지: 세션 없는 CookieStateStore(httpOnly 1회용 state 쿠키) 두 전략에 주입
- refresh 토큰 폐기: User.tokenVersion + refresh payload ver 검증, 로그아웃 시 일괄 무효화
- 파일 업로드 MIME 화이트리스트(html/svg 차단) + 다운로드 attachment·nosniff(저장형 XSS 차단)
- 인증 라우트 throttle(login 10·signup 5·resend 3 /분)
- 첨부 삭제 업로더/admin 한정
- 인증 토큰 로그 운영 환경 게이트(원문 토큰 미기록)
- docs/security-review.md(감사 보고서) 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:52:36 +09:00
ttipo 8766a4f5b6 feat: 소셜 로그인(Google/Kakao) 및 회원가입 이메일 인증 추가
- User 엔티티 확장: passwordHash nullable, provider, emailVerified, 인증 토큰 해시/만료(select:false)
- 회원가입: 자동 로그인 폐지 → 미인증 계정 생성 + 인증 메일 발송(MailService 플레이스홀더, 백엔드 로그로 링크 출력)
- 이메일 인증: GET /auth/verify-email(해시 매칭+만료 검사) → 프론트로 리다이렉트, 재발송 엔드포인트
- 로그인 게이트: 미인증 로컬 403 차단, 소셜 전용 계정은 소셜 로그인 안내
- 소셜 OAuth: passport google/kakao 전략(키 미설정 시 자동 비활성), 이메일로 계정 매칭/생성
- 기존 로컬 계정 그랜드페더링(부팅 시 인증 완료 보정)
- 프론트: 가입 후 인증 메일 안내 화면, 로그인 소셜 버튼 실연동 + 인증 결과 배너
- env(APP_API_URL/FRONTEND_URL/GOOGLE_*/KAKAO_*) 문서화, 문서·계약 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:04:30 +09:00
ttipo 7a2c70a260 feat: 저장소 생성 이름 실시간 가용성 체크 + 소유자 아바타 제거
- GET /repos/name-available?slug= 추가(:repoId 앞 선언, Relay slug 중복만 검사)
- 생성 폼: 형식 즉시 검사 + 350ms 디바운스 서버 조회로 상태별(빈칸/형식위반/확인중/사용가능/중복) 색·아이콘 표기
- 항상 떠 있던 정적 "사용 가능한 이름입니다" 표시 제거
- 조직 이니셜 소유자 아바타 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:03:59 +09:00
688 changed files with 168766 additions and 14018 deletions
+57 -5
View File
@@ -1,5 +1,11 @@
# ==========================================
# 프로젝트 전역 설정
# 루트 .env (development) — 모든 설정의 단일 진실 공급원(SSOT)
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# ==========================================
# 프로젝트 전역
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=development
@@ -8,7 +14,7 @@ FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# 포트
# ==========================================
FRONTEND_PORT=5273
BACKEND_PORT=3100
@@ -17,7 +23,7 @@ DB_PORT=5532
REDIS_PORT=6479
# ==========================================
# Database 설정
# Database (PostgreSQL)
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=dev_ttipo
@@ -26,14 +32,60 @@ DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# Redis
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로
# ==========================================
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL)
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진
BACKEND_API_URL=http://localhost:3100/api
CORS_ORIGIN=http://localhost:5273
# ==========================================
# AI (백엔드·프론트 공통)
# ==========================================
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit Cloud)
# ==========================================
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
LIVEKIT_API_KEY=APIxxxxxxxxxxxx
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
LIVEKIT_URL=wss://your-project.livekit.cloud
# ==========================================
# 드라이브 (NCP Object Storage / S3 호환)
# ==========================================
# 파일 업로드/저장용 오브젝트 스토리지. NCP 콘솔에서 버킷 생성 후 값을 채운다.
# 비시크릿(endpoint/region/bucket)만 루트에 두고, ACCESS/SECRET 키는 backend/.env 에 둔다.
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
# OBJECT_STORAGE_BUCKET : 버킷명(환경별로 분리 권장, 예: comrelay-dev)
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
OBJECT_STORAGE_REGION=kr-standard
OBJECT_STORAGE_BUCKET=comrelay-dev
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID)
# ==========================================
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+61 -6
View File
@@ -1,5 +1,11 @@
# ==========================================
# 프로젝트 전역 설정
# 루트 .env (production) — 모든 설정의 단일 진실 공급원(SSOT)
# docker-compose 가 이 값을 backend/frontend/db/redis 에 주입한다.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# ==========================================
# 프로젝트 전역
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=production
@@ -8,16 +14,16 @@ FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# 포트
# ==========================================
FRONTEND_PORT=5173
FRONTEND_PORT=80
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# Database 설정
# Database (PostgreSQL)
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=prd_ttipo
@@ -26,14 +32,63 @@ DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# Redis
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# API 경로
# ==========================================
# BACKEND_API_URL: 프론트 빌드에 주입(VITE_API_BASE_URL) — 운영 도메인으로 교체
# CORS_ORIGIN : 백엔드가 허용할 프론트 오리진 — 운영 도메인으로 교체
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
# ==========================================
# AI (백엔드·프론트 공통)
# ==========================================
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit) — 셀프호스팅(기본) / Cloud(대안)
# ==========================================
# [셀프호스팅] 운영 권장. 절차: livekit/DEPLOY.md (단일노드 3장, AWS EC2+도커 nginx 4-1장).
# - selfhost-livekit 프로파일로 컨테이너 기동:
# docker compose --profile selfhost-livekit --env-file .env.production up -d --build
# - 리버스 프록시(wss 종단)는 livekit/nginx.livekit.conf.example 참고.
# - 키 생성(절대 Cloud 키 재사용 금지):
# LIVEKIT_API_KEY=$(openssl rand -hex 8)
# LIVEKIT_API_SECRET=$(openssl rand -hex 32) # 최소 32자
# - LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
# - LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
# [Cloud] 대안. LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로
# 채우고 selfhost-livekit 프로파일은 켜지 않는다.
LIVEKIT_API_KEY=change-me-livekit-key
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
LIVEKIT_WS_URL=wss://livekit.example.com
LIVEKIT_URL=http://livekit:7880
# ==========================================
# 드라이브 (NCP Object Storage / S3 호환)
# ==========================================
# 운영 버킷은 개발과 분리한다(예: comrelay-prod). 비시크릿만 루트에 두고
# ACCESS/SECRET 키는 backend/.env.production 에 둔다.
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
# OBJECT_STORAGE_BUCKET : 운영 버킷명
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
OBJECT_STORAGE_REGION=kr-standard
OBJECT_STORAGE_BUCKET=comrelay-prod
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID)
# ==========================================
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+13
View File
@@ -50,3 +50,16 @@ Thumbs.db
.cache/
.eslintcache
.npm/
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
frontend/*단독*.html
frontend/*단일 파일*.html
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
livekit/*.local.yaml
# 작업 중 공유용 임시 스크린샷(루트) 커밋 방지
/image.png
# Claude Code 메모리 디렉터리(작업 메모) — 커밋 제외
/memory/
-1
View File
@@ -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` 재포함 규칙)
+5 -19
View File
@@ -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>
-314
View File
@@ -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>
-187
View File
@@ -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
-482
View File
@@ -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>
-430
View File
@@ -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>
-301
View File
@@ -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>
-350
View File
@@ -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>
-289
View File
@@ -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>
-275
View File
@@ -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>
-286
View File
@@ -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>
-222
View File
@@ -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>
+51 -23
View File
@@ -1,31 +1,59 @@
# ==========================================
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.development 에서 주입한다.
# .example 을 복사해 .env.development 를 만들고 실제 값으로 채운다.
# 백엔드 고유 설정 (development)
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.development 에서 주입.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# ==========================================
# JWT / 인증 토큰
# ==========================================
# 시크릿은 강력한 랜덤 문자열로 교체:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
# ==========================================
# 앱 URL (리다이렉트)
# ==========================================
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용. 미설정 시 localhost:3100/api 폴백.
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트. 미설정 시 CORS_ORIGIN→localhost:5273 폴백.
APP_API_URL=http://localhost:3100/api
FRONTEND_URL=http://localhost:5273
# ==========================================
# 소셜 로그인 (OAuth)
# ==========================================
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
KAKAO_CALLBACK_URL=http://localhost:3100/api/auth/kakao/callback
GOOGLE_CLIENT_ID=change-me-google-client-id
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
# ==========================================
# AI 에이전트 (Claude)
# ==========================================
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
# ==========================================
# 드라이브 (NCP Object Storage) — 시크릿
# ==========================================
# NCP 콘솔 → 마이페이지 → 인증키 관리(또는 서브계정)에서 발급한 키.
# endpoint/region/bucket 은 루트 .env 에서 docker-compose 가 주입한다.
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
+50 -23
View File
@@ -1,31 +1,58 @@
# ==========================================
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.production 에서 주입한다.
# .example 을 복사해 .env.production 를 만들고 실제 값으로 채운다.
# 백엔드 고유 설정 (production)
# 루트 .env 에서 주입하지 않는 값만 여기에 둔다.
# DB/Redis/CORS/포트/AI_TIMEOUT_MS 는 docker-compose 가 루트 .env.production 에서 주입.
# .example 을 복사해 만들고 실제 값으로 채운다. (커밋 금지)
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# ==========================================
# JWT / 인증 토큰
# ==========================================
# 시크릿은 강력한 랜덤 문자열로 교체:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_ACCESS_SECRET=change-me-access-secret
JWT_REFRESH_SECRET=change-me-refresh-secret
# 토큰 만료 시간
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
# GITEA_TOKEN: 조직 권한을 가진 액세스 토큰 (Gitea Settings → Applications → Generate Token)
GITEA_TOKEN=change-me-gitea-token
# GITEA_ORG: 저장소가 생성될 조직 이름 (Relay 의 owner 로도 사용)
GITEA_ORG=marketing-team
# 템플릿 저장소(생성 시 복제) — 미설정 시 기본 TtiPo/project-base 사용. Gitea 에서 template 설정 필요
GITEA_TEMPLATE_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
# ==========================================
# 앱 URL (리다이렉트) — 운영 도메인으로 교체
# ==========================================
# APP_API_URL : 백엔드 공개 주소(/api 포함). 인증 메일 링크 생성용.
# FRONTEND_URL: 프론트 공개 주소. 인증 완료·소셜 로그인 후 리다이렉트.
APP_API_URL=https://your-domain.com/api
FRONTEND_URL=https://your-domain.com
# ==========================================
# 소셜 로그인 (OAuth) — 운영 키/도메인으로 교체
# ==========================================
# 각 제공자 콘솔 발급 키. CLIENT_ID 가 비면 해당 소셜 로그인 비활성.
# CALLBACK_URL 은 콘솔 등록값과 정확히 일치해야 함(/api 포함).
KAKAO_CLIENT_ID=change-me-kakao-rest-api-key
KAKAO_CLIENT_SECRET=change-me-kakao-client-secret
KAKAO_CALLBACK_URL=https://your-domain.com/api/auth/kakao/callback
GOOGLE_CLIENT_ID=change-me-google-client-id
GOOGLE_CLIENT_SECRET=change-me-google-client-secret
GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
# ==========================================
# AI 에이전트 (Claude)
# ==========================================
# CLAUDE_API_KEY: Anthropic API 키. 미설정 시 AI 기능 비활성.
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
# ==========================================
# 드라이브 (NCP Object Storage) — 시크릿
# ==========================================
# NCP 콘솔에서 발급한 운영용 키. endpoint/region/bucket 은 루트 .env 가 주입.
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
+2009 -122
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -17,9 +17,16 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs -d ./src/data-source.ts",
"migration:generate": "typeorm-ts-node-commonjs migration:generate -d ./src/data-source.ts",
"migration:create": "typeorm-ts-node-commonjs migration:create",
"migration:run": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
@@ -33,21 +40,30 @@
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"@types/web-push": "^3.6.4",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"cookie-parser": "^1.4.7",
"exceljs": "^4.4.0",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"livekit-server-sdk": "^2.15.5",
"mammoth": "^1.12.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-kakao": "^1.0.1",
"pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"web-push": "^3.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
+16 -5
View File
@@ -1,7 +1,7 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleModule as CronModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
@@ -10,17 +10,22 @@ import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { StorageModule } from './common/storage/storage.module';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
import { TaskModule } from './modules/task/task.module';
import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
import { MeetingModule } from './modules/meeting/meeting.module';
import { DriveModule } from './modules/drive/drive.module';
@Module({
imports: [
ScheduleModule.forRoot(),
CronModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -55,10 +60,11 @@ import { NotificationModule } from './modules/notification/notification.module';
},
}),
// 전역 Rate Limiter — main.ts 의 express-rate-limit 와 별개로 라우트 단위 제어 가능
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌릴 것.
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
limit: 1000,
},
]),
TypeOrmModule.forRootAsync({
@@ -88,13 +94,18 @@ import { NotificationModule } from './modules/notification/notification.module';
};
},
}),
StorageModule,
HealthModule,
UserModule,
AuthModule,
RepoModule,
TaskModule,
ActivityModule,
NotificationModule,
AiModule,
ProjectModule,
ScheduleModule,
MeetingModule,
DriveModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,14 @@
import type { ValueTransformer } from 'typeorm';
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
export const numericColumnTransformer: ValueTransformer = {
// 저장: number → DB(bigint). null/undefined 는 그대로.
to(value: number | null | undefined): number | null | undefined {
return value;
},
// 조회: DB 문자열 → number. null 은 그대로.
from(value: string | null): number | null {
return value === null ? null : Number(value);
},
};
@@ -22,6 +22,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
let message =
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
// multer 업로드 오류(크기 초과 등)는 HttpException 이 아니므로 별도 400(VAL_001) 매핑
if (this.isMulterError(exception)) {
const muMessage =
exception.code === 'LIMIT_FILE_SIZE'
? '파일 크기가 허용 한도(25MB)를 초과했습니다.'
: '파일 업로드에 실패했습니다. 파일을 확인해 주세요.';
response.status(HttpStatus.BAD_REQUEST).json({
success: false,
error: { code: 'VAL_001', message: muMessage },
});
return;
}
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
@@ -83,6 +96,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
});
}
// multer 업로드 오류 판별 (name === 'MulterError', code 보유)
private isMulterError(e: unknown): e is { name: string; code?: string } {
return (
typeof e === 'object' &&
e !== null &&
(e as { name?: unknown }).name === 'MulterError'
);
}
// 예외 응답에서 명시적 { code, message } 추출 (둘 다 문자열일 때만)
private extractExplicitError(
res: string | object,
@@ -0,0 +1,187 @@
import { Injectable, Logger } from '@nestjs/common';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { StorageService } from './storage.service';
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// presigned URL 기본 유효 시간(초)
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
// OBJECT_STORAGE_REGION 예) kr-standard
// OBJECT_STORAGE_BUCKET 버킷명
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
@Injectable()
export class NcpObjectStorageService extends StorageService {
private readonly logger = new Logger(NcpObjectStorageService.name);
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
private readonly client: S3Client | null;
private readonly bucketName: string;
private readonly missing: string[];
constructor() {
super();
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
const bucket = process.env.OBJECT_STORAGE_BUCKET;
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
this.bucketName = bucket || '';
this.missing = [
['OBJECT_STORAGE_ENDPOINT', endpoint],
['OBJECT_STORAGE_BUCKET', bucket],
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
]
.filter(([, v]) => !v)
.map(([k]) => k as string);
if (this.missing.length > 0) {
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
this.client = null;
this.logger.warn(
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
);
return;
}
this.client = new S3Client({
endpoint,
region,
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
forcePathStyle: true,
credentials: {
accessKeyId: accessKeyId as string,
secretAccessKey: secretAccessKey as string,
},
});
this.logger.log(
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
);
}
get bucket(): string {
return this.bucketName;
}
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
private ensureConfigured(): S3Client {
if (!this.client) {
throw new Error(
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
);
}
return this.client;
}
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
ContentType: input.contentType,
});
const url = await getSignedUrl(client, command, { expiresIn });
return {
url,
method: 'PUT',
headers: { 'Content-Type': input.contentType },
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
async presignDownload(input: PresignDownloadInput): Promise<string> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
input.filename,
)}`,
ResponseContentType: input.contentType,
});
return getSignedUrl(client, command, { expiresIn });
}
async getObjectBuffer(key: string): Promise<Buffer | null> {
const client = this.ensureConfigured();
try {
const res = await client.send(
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
);
const body = res.Body as
| { transformToByteArray?: () => Promise<Uint8Array> }
| undefined;
// AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집
if (!body?.transformToByteArray) return null;
const bytes = await body.transformToByteArray();
return Buffer.from(bytes);
} catch (err: unknown) {
if (this.isNotFound(err)) return null;
throw err;
}
}
async headObject(key: string): Promise<ObjectHead | null> {
const client = this.ensureConfigured();
try {
const res = await client.send(
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
);
return {
size: res.ContentLength ?? 0,
contentType: res.ContentType ?? null,
};
} catch (err: unknown) {
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
if (this.isNotFound(err)) return null;
throw err;
}
}
async deleteObject(key: string): Promise<void> {
const client = this.ensureConfigured();
try {
await client.send(
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
);
} catch (err: unknown) {
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
if (this.isNotFound(err)) return;
throw err;
}
}
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
private isNotFound(err: unknown): boolean {
const e = err as {
name?: string;
$metadata?: { httpStatusCode?: number };
};
return (
e?.$metadata?.httpStatusCode === 404 ||
e?.name === 'NotFound' ||
e?.name === 'NoSuchKey'
);
}
}
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { NcpObjectStorageService } from './ncp-object-storage.service';
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
@Global()
@Module({
providers: [
{
provide: StorageService,
useClass: NcpObjectStorageService,
},
],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,29 @@
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// 오브젝트 스토리지 추상 인터페이스.
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
export abstract class StorageService {
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
abstract get bucket(): string;
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
abstract headObject(key: string): Promise<ObjectHead | null>;
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
abstract deleteObject(key: string): Promise<void>;
}
@@ -0,0 +1,42 @@
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
// Presigned 업로드 발급 입력
export interface PresignUploadInput {
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
key: string;
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// Presigned 업로드 발급 결과
export interface PresignedUpload {
// 클라이언트가 직접 PUT 할 URL
url: string;
// HTTP 메서드 (PUT 고정)
method: 'PUT';
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
headers: Record<string, string>;
// 만료 시각(클라이언트 표시/검증용)
expiresAt: Date;
}
// Presigned 다운로드 발급 입력
export interface PresignDownloadInput {
key: string;
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
filename: string;
// 응답 Content-Type
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
export interface ObjectHead {
// 실제 저장된 바이트 크기
size: number;
// 실제 저장된 MIME 타입(없을 수 있음)
contentType: string | null;
}
+19
View File
@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
// TypeORM CLI 전용 DataSource — 마이그레이션 생성/실행/되돌리기(generate/run/revert)에 사용한다.
// 런타임(앱)은 app.module 의 TypeOrmModule 설정을 사용하며, 운영에서는 migrationsRun=true 로
// 부팅 시 이 폴더(dist/migrations)의 마이그레이션을 자동 실행한다.
// DB 접속값은 환경변수에서 읽는다(dev/CI/운영에서 주입). app.module 과 동일한 키를 사용.
export default new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
// CLI 는 절대 스키마 자동 동기화를 하지 않는다(마이그레이션만 사용)
synchronize: false,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
});
+24 -15
View File
@@ -43,8 +43,15 @@ async function bootstrap() {
app.use(helmet());
// 2. 화이트리스트 기반 CORS 정책
const isProduction = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
const corsOriginEnv = process.env.CORS_ORIGIN;
// 운영에서는 로컬 개발 오리진(localhost:3000)을 허용 목록에서 제외
const corsWhitelist = [
frontendUrl,
...(isProduction ? [] : ['http://localhost:3000']),
...(corsOriginEnv ? [corsOriginEnv] : []),
];
app.enableCors({
origin: (
@@ -52,10 +59,7 @@ async function bootstrap() {
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
const whitelist = [frontendUrl, 'http://localhost:3000'];
if (corsOriginEnv) whitelist.push(corsOriginEnv);
if (!origin || whitelist.indexOf(origin) !== -1) {
if (!origin || corsWhitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
// 클라이언트에는 403 금지 에러로 리턴됨
@@ -66,10 +70,11 @@ async function bootstrap() {
});
// 3. 글로벌 Rate Limiting 적용 (DDoS, 브루트포스 예방)
// NOTE: 개발/테스트 편의를 위해 분당 1000회로 완화함. 운영 배포 전 적정값으로 되돌리거나 env 화 필요.
app.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15
max: 150, // 15분 IP당 최대 150개 요청
windowMs: 60 * 1000, // 1분
max: 1000, // 1분 IP당 최대 1000개 요청
message: {
success: false,
error: {
@@ -94,15 +99,19 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// 6. Swagger API 문서 설정
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 6. Swagger API 문서 설정 — 운영 환경에서는 노출하지 않는다(공격 표면 축소)
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription(
'The API documentation conforming to the global guidelines',
)
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
}
// 종료 시그널(SIGTERM/SIGINT) 에 lifecycle 훅 실행 — onModuleDestroy 로 Redis pub/sub 등
// 외부 연결을 정상 종료(graceful shutdown). 미설정 시 정리 훅이 호출되지 않는다.
@@ -0,0 +1,228 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateInitialSchema1782060613058 implements MigrationInterface {
name = 'CreateInitialSchema1782060613058';
public async up(queryRunner: QueryRunner): Promise<void> {
// 베이스라인 가드 — 이미 스키마가 존재하는 기존 DB(과거 synchronize 로 생성·데이터 보존)면
// 초기 마이그레이션을 '베이스라인'으로 간주하고 DDL 을 건너뛴다. 마이그레이션은 '적용됨'으로
// 기록되어 다음 변경 마이그레이션부터 정상 진행되며, 기존 데이터/스키마는 보존된다.
// (빈 DB 면 이 가드를 통과해 아래 DDL 로 전체 스키마를 생성한다.)
if (await queryRunner.hasTable('users')) {
return;
}
// uuid_generate_v4() 기본값에 필요한 확장 — 신규(빈) 운영 DB 에서도 보장
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password_hash" character varying, "provider" character varying NOT NULL DEFAULT 'local', "email_verified" boolean NOT NULL DEFAULT false, "email_verify_token_hash" character varying, "email_verify_expires_at" TIMESTAMP WITH TIME ZONE, "password_reset_token_hash" character varying, "password_reset_expires_at" TIMESTAMP WITH TIME ZONE, "name" character varying NOT NULL, "role" character varying, "avatar_url" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "project_members" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role_type" character varying NOT NULL DEFAULT 'member', "is_owner" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "projectId" uuid, "userId" uuid, CONSTRAINT "UQ_326b2a901eb18ac24eabc9b0581" UNIQUE ("projectId", "userId"), CONSTRAINT "PK_0b2f46f804be4aea9234c78bcc9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "checklist_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" character varying NOT NULL, "category" character varying NOT NULL DEFAULT '필수', "done" boolean NOT NULL DEFAULT false, "review_note" text, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "taskId" uuid, CONSTRAINT "PK_bae00945a1d4789bd648e583e29" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "seq" integer NOT NULL, "title" character varying NOT NULL, "content" jsonb NOT NULL DEFAULT '[]', "status" character varying NOT NULL DEFAULT 'todo', "due_date" TIMESTAMP WITH TIME ZONE, "approval_note" text, "approval_requested_at" TIMESTAMP WITH TIME ZONE, "changes_note" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "issuerId" uuid, "approval_requested_by" uuid, CONSTRAINT "UQ_011566614ffa01ec7281ee29754" UNIQUE ("project_id", "seq"), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_73f95adb41a77c1bfd7e19dca7" ON "tasks" ("seq") `,
);
await queryRunner.query(
`CREATE TABLE "attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_5e1f050bcff31e3084a1d662412" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "author_id" uuid, "parent_id" uuid, "attachment_id" uuid, CONSTRAINT "PK_8bf68bc960f2b69e818bdb90dcb" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "read" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" uuid, CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_92be92313f09987bc6ad70ab41" ON "notifications" ("recipient_id", "created_at") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "current_jti" uuid NOT NULL, "prev_jti" uuid, "prev_at" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_9190032f6967b7971dca07d69f3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "activities" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "task_seq" integer, "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "actor_id" uuid, CONSTRAINT "PK_7f4004429f731ffb9c88eb486a8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7297db78eb572fa29db5aaf815" ON "activities" ("project_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_8291258c0c575bdab03443ef47" ON "activities" ("task_seq") `,
);
await queryRunner.query(
`CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "task_assignees" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_7ae8012667c1cc4ca8266002afc" PRIMARY KEY ("task_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0141288f2306f20da9a60ec8d6" ON "task_assignees" ("task_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bb8051e376a2b083e074678cb6" ON "task_assignees" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_d19892d8f03928e5bfc7313780c" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_950b234a262e85a895b9f9e634e" FOREIGN KEY ("issuerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd" FOREIGN KEY ("approval_requested_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_e62fd181b97caa6b150b09220b1" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_18c2493067c11f44efb35ca0e03" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_e6d38899c31997c45d128a8973b" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_d6f93329801a93536da4241e386" FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351" FOREIGN KEY ("attachment_id") REFERENCES "attachments"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "notifications" ADD CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d" FOREIGN KEY ("recipient_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_7297db78eb572fa29db5aaf8158" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_0141288f2306f20da9a60ec8d69" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_bb8051e376a2b083e074678cb60" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_bb8051e376a2b083e074678cb60"`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_0141288f2306f20da9a60ec8d69"`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_7297db78eb572fa29db5aaf8158"`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a"`,
);
await queryRunner.query(
`ALTER TABLE "notifications" DROP CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_d6f93329801a93536da4241e386"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_e6d38899c31997c45d128a8973b"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_18c2493067c11f44efb35ca0e03"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_e62fd181b97caa6b150b09220b1"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_950b234a262e85a895b9f9e634e"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_d19892d8f03928e5bfc7313780c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bb8051e376a2b083e074678cb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0141288f2306f20da9a60ec8d6"`,
);
await queryRunner.query(`DROP TABLE "task_assignees"`);
await queryRunner.query(`DROP TABLE "ai_messages"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_8291258c0c575bdab03443ef47"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7297db78eb572fa29db5aaf815"`,
);
await queryRunner.query(`DROP TABLE "activities"`);
await queryRunner.query(`DROP TABLE "ai_conversations"`);
await queryRunner.query(`DROP TABLE "refresh_sessions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_92be92313f09987bc6ad70ab41"`,
);
await queryRunner.query(`DROP TABLE "notifications"`);
await queryRunner.query(`DROP TABLE "comments"`);
await queryRunner.query(`DROP TABLE "attachments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_73f95adb41a77c1bfd7e19dca7"`,
);
await queryRunner.query(`DROP TABLE "tasks"`);
await queryRunner.query(`DROP TABLE "checklist_items"`);
await queryRunner.query(`DROP TABLE "projects"`);
await queryRunner.query(`DROP TABLE "project_members"`);
await queryRunner.query(`DROP TABLE "users"`);
}
}
@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 중간 점검(체크포인트) 기능 — 체크리스트 작업자 진행상태 + 점검일/보고 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCheckpoints1782200000000 implements MigrationInterface {
name = 'AddTaskCheckpoints1782200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 체크리스트 작업자 진행상태(자가보고) — 미착수/진행중/완료
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD "work_status" character varying NOT NULL DEFAULT 'todo'`,
);
// 중간 점검일
await queryRunner.query(
`CREATE TABLE "task_checkpoints" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "check_at" TIMESTAMP WITH TIME ZONE NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, CONSTRAINT "PK_task_checkpoints" PRIMARY KEY ("id"))`,
);
// 중간 점검 보고(스냅샷)
await queryRunner.query(
`CREATE TABLE "checkpoint_reports" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "note" text, "items_snapshot" jsonb NOT NULL DEFAULT '[]', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "checkpoint_id" uuid, "reporter_id" uuid, CONSTRAINT "PK_checkpoint_reports" PRIMARY KEY ("id"))`,
);
// FK
await queryRunner.query(
`ALTER TABLE "task_checkpoints" ADD CONSTRAINT "FK_task_checkpoints_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_checkpoint" FOREIGN KEY ("checkpoint_id") REFERENCES "task_checkpoints"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_reporter" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_reporter"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_checkpoint"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_task"`,
);
await queryRunner.query(
`ALTER TABLE "task_checkpoints" DROP CONSTRAINT "FK_task_checkpoints_task"`,
);
await queryRunner.query(`DROP TABLE "checkpoint_reports"`);
await queryRunner.query(`DROP TABLE "task_checkpoints"`);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP COLUMN "work_status"`,
);
}
}
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 일정(캘린더) — 카테고리/일정/참석자 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// (기본 카테고리 시드는 ScheduleService.onModuleInit 가 멱등 처리하므로 여기서 넣지 않는다.)
export class AddSchedule1782300000000 implements MigrationInterface {
name = 'AddSchedule1782300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 카테고리(레인)
await queryRunner.query(
`CREATE TABLE "schedule_categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "label" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_schedule_categories" PRIMARY KEY ("id"))`,
);
// 일정(이벤트)
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "time" character varying NOT NULL DEFAULT '', "place" character varying, "description" text, "all_hands" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category_id" uuid, "created_by" uuid, CONSTRAINT "PK_schedule_events" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_dates" ON "schedule_events" ("start_date", "end_date")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_category" ON "schedule_events" ("category_id")`,
);
// 참석자(다대다 조인)
await queryRunner.query(
`CREATE TABLE "schedule_event_attendees" ("event_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_schedule_event_attendees" PRIMARY KEY ("event_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_event" ON "schedule_event_attendees" ("event_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_user" ON "schedule_event_attendees" ("user_id")`,
);
// FK
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_category" FOREIGN KEY ("category_id") REFERENCES "schedule_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_event" FOREIGN KEY ("event_id") REFERENCES "schedule_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_user"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_event"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_created_by"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_category"`,
);
await queryRunner.query(`DROP TABLE "schedule_event_attendees"`);
await queryRunner.query(`DROP TABLE "schedule_events"`);
await queryRunner.query(`DROP TABLE "schedule_categories"`);
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 로그인 유지(keepLogin) — refresh 세션에 persistent 플래그 추가.
// true(기본): 영속 쿠키(refresh TTL), false: 세션 쿠키(브라우저 종료 시 만료).
// 기존 세션은 기존 동작(영속) 유지를 위해 default true.
export class AddRefreshSessionPersistent1782400000000 implements MigrationInterface {
name = 'AddRefreshSessionPersistent1782400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`,
);
}
}
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
// (업무 첨부 attachments 와 동일 구조, project_id FK)
export class AddProjectAttachments1782500000000 implements MigrationInterface {
name = 'AddProjectAttachments1782500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
);
await queryRunner.query(`DROP TABLE "project_attachments"`);
}
}
@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
export class AddDriveTables1782600000000 implements MigrationInterface {
name = 'AddDriveTables1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ===== drive_folders =====
await queryRunner.query(
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
);
// ===== drive_files =====
await queryRunner.query(
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
);
// ===== project_folder_links =====
await queryRunner.query(
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
);
// 한 폴더는 한 프로젝트에 한 번만 연동
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
);
// ===== 외래키 =====
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 외래키 → 테이블 순서 역순 정리
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
);
await queryRunner.query(`DROP TABLE "project_folder_links"`);
await queryRunner.query(`DROP TABLE "drive_files"`);
await queryRunner.query(`DROP TABLE "drive_folders"`);
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 첨부 문서의 추출 본문 캐시 컬럼 추가.
// 업로드 시 1회 추출해 보관하고, AI 업무 추천에서 꺼내 읽는다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddProjectAttachmentExtractedText1782600000000 implements MigrationInterface {
name = 'AddProjectAttachmentExtractedText1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD "extracted_text" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "project_attachments" DROP COLUMN "extracted_text"`,
);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRooms1782700000000 implements MigrationInterface {
name = 'AddMeetingRooms1782700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
);
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
}
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
name = 'AddMeetingRoomFields1782800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
);
}
}
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 입장 코드(code) 추가 — 이름과 식별자를 분리한다.
// code = 입장 URL·LiveKit 방·초대 링크의 안정적 키(불변). 이름은 표시용 라벨로 강등(고유 해제).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomCode1782900000000 implements MigrationInterface {
name = 'AddMeetingRoomCode1782900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1) 코드 컬럼 추가(우선 nullable) → 기존 행 백필 → NOT NULL 승격
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "code" character varying`,
);
// 기존 방에 고유 코드 백필(md5(random) 12자리 16진수 — 충돌 사실상 없음)
await queryRunner.query(
`UPDATE "meeting_rooms" SET "code" = substr(md5(random()::text || "id"::text), 1, 12) WHERE "code" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ALTER COLUMN "code" SET NOT NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_code" ON "meeting_rooms" ("code")`,
);
// 2) 이름은 더 이상 고유가 아니다(표시용 라벨) → 고유 인덱스 제거
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다.
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
);
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_code"`);
await queryRunner.query(`ALTER TABLE "meeting_rooms" DROP COLUMN "code"`);
}
}
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 AI 검토 문서를 "드라이브 폴더 연동"으로 전환.
// - drive_files 에 AI 추출 캐시 컬럼(extracted_text, extract_status) 추가
// → 연동 폴더 문서를 NCP 에서 받아 텍스트 추출·캐싱(AI 업무 추천 주입)
// - 프로젝트 직접 업로드(project_attachments) 폐기 → 테이블 제거(완전 대체)
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
//
// 멱등·견고하게 작성: 과거 synchronize 로 만들어진 운영 DB(제약 이름 상이)나
// 부분 적용 상태에서도 실패하지 않도록 IF [NOT] EXISTS / CASCADE 를 사용한다.
export class AddDriveFileExtractDropProjectAttachments1783000000000 implements MigrationInterface {
name = 'AddDriveFileExtractDropProjectAttachments1783000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1) drive_files AI 추출 캐시 컬럼 (이미 있으면 건너뜀)
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extracted_text" text`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extract_status" character varying NOT NULL DEFAULT 'pending'`,
);
// 2) project_attachments 폐기 (드라이브 연동으로 대체)
// 제약 이름에 의존하지 않도록 CASCADE 로 테이블·FK 를 한 번에 제거.
await queryRunner.query(
`DROP TABLE IF EXISTS "project_attachments" CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 1) project_attachments 복원 (AddProjectAttachments + ExtractedText 와 동일 스키마)
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "extracted_text" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
// 2) drive_files 추출 캐시 컬럼 제거
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extract_status"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extracted_text"`,
);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가.
// - deleted_at: 삭제 시각(null 이면 미삭제)
// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL)
// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해
// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface {
name = 'AddDriveFileSoftDeleteAudit1783100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`,
);
// FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비)
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by'
) THEN
ALTER TABLE "drive_files"
ADD CONSTRAINT "FK_drive_files_deleted_by"
FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`,
);
}
}
@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 드라이브 전환 + 전역 감사 로그.
// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관).
// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경
// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비).
export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface {
name = 'AddDriveSharedAndAudit1783200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ===== 감사 로그 테이블 =====
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "drive_activities" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"type" character varying NOT NULL,
"payload" jsonb NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"actor_id" uuid,
CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`,
);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor'
) THEN
ALTER TABLE "drive_activities"
ADD CONSTRAINT "FK_drive_activities_actor"
FOREIGN KEY ("actor_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END IF;
END $$;
`);
// ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) =====
for (const table of ['drive_files', 'drive_folders']) {
// 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능)
await queryRunner.query(
`ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`,
);
// owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성
await queryRunner.query(`
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE rel.relname = '${table}'
AND con.contype = 'f'
AND att.attname = 'owner_id'
LOOP
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
END LOOP;
ALTER TABLE "${table}"
ADD CONSTRAINT "FK_${table}_owner"
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END $$;
`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
// owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성)
for (const table of ['drive_files', 'drive_folders']) {
await queryRunner.query(`
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE rel.relname = '${table}'
AND con.contype = 'f'
AND att.attname = 'owner_id'
LOOP
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
END LOOP;
ALTER TABLE "${table}"
ADD CONSTRAINT "FK_${table}_owner"
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
END $$;
`);
}
await queryRunner.query(
`ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`);
}
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
name = 'AddPushSubscriptions1783300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"user_id" uuid,
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
) THEN
ALTER TABLE "push_subscriptions"
ADD CONSTRAINT "FK_push_subscriptions_user"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
}
}
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
name = 'AddTaskCoIssuers1783400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
await queryRunner.query(
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
);
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
await queryRunner.query(
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
);
await queryRunner.query(
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
);
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
name = 'AddTaskReviewedBy1783500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
);
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
}
}
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
name = 'AddTaskDueNotifyFlags1783600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
);
}
}
@@ -1,20 +1,33 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
Controller,
DefaultValuePipe,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ActivityService, type ActivityResponse } from './activity.service';
// 활동 피드 컨트롤러 — 읽기 전용. 조직 모델상 인증 사용자면 열람 가능
@ApiTags('Activity')
@Controller('repos/:repoId/activity')
@Controller('projects/:projectId/activity')
@UseGuards(JwtAuthGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
@ApiOperation({ summary: '저장소 활동 피드 (최신순)' })
@ApiOperation({ summary: '프로젝트 활동 피드 (최신순, 페이지네이션)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<ActivityResponse[]> {
return this.activityService.listForRepo(repoId);
list(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<ActivityResponse>> {
return this.activityService.listForProject(projectId, page, size);
}
}
@@ -1,14 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Activity } from './entities/activity.entity';
import { Repo } from '../repo/entities/repo.entity';
import { ActivityService } from './activity.service';
import { ActivityController } from './activity.controller';
// 활동 피드 모듈 — 다른 도메인 모듈(repo/task)이 import 해 ActivityService 로 이벤트를 적재한다.
// (Activity 는 taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
// 활동 피드 모듈 — 다른 도메인 모듈(project/task)이 import 해 ActivityService 로 이벤트를 적재한다.
// (Activity 는 projectId + taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
@Module({
imports: [TypeOrmModule.forFeature([Activity, Repo])],
imports: [TypeOrmModule.forFeature([Activity])],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
@@ -1,10 +1,18 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { UserService, type PublicUser } from '../user/user.service';
import { Repo } from '../repo/entities/repo.entity';
import { Project } from '../project/entities/project.entity';
import { Activity, type ActivityType } from './entities/activity.entity';
// 업무 상세 활동 탭 조회 상한 — 매우 활발한 업무에서 전량 렌더링을 방지(최신 N건)
const TASK_ACTIVITY_LIMIT = 100;
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
export interface ActivityResponse {
id: string;
@@ -17,7 +25,7 @@ export interface ActivityResponse {
// 활동 적재 파라미터
export interface RecordActivityParams {
repoId: string; // Repo uuid
projectId: string; // Project uuid
actorId: string | null;
type: ActivityType;
payload?: Record<string, unknown>;
@@ -25,7 +33,7 @@ export interface RecordActivityParams {
}
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
// 프로젝트/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
@@ -33,15 +41,13 @@ export class ActivityService {
constructor(
@InjectRepository(Activity)
private readonly activityRepo: Repository<Activity>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
) {}
// 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
async record(params: RecordActivityParams): Promise<void> {
try {
const activity = this.activityRepo.create({
repo: { id: params.repoId } as Repo,
project: { id: params.projectId } as Project,
actor: params.actorId ? { id: params.actorId } : null,
type: params.type,
payload: params.payload ?? {},
@@ -56,45 +62,38 @@ export class ActivityService {
}
}
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
async listForRepo(slugName: string): Promise<ActivityResponse[]> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
const rows = await this.activityRepo.find({
where: { repo: { id: repo.id } },
// 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 오프셋 페이지네이션). 날짜 그룹핑은 프론트.
async listForProject(
projectId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ActivityResponse>> {
const pg = resolvePage(page, size);
const [rows, total] = await this.activityRepo.findAndCount({
where: { project: { id: projectId } },
relations: ['actor'],
order: { createdAt: 'DESC' },
take: 200,
skip: pg.skip,
take: pg.take,
});
return rows.map((a) => this.toResponse(a));
return paginated(
rows.map((a) => this.toResponse(a)),
total,
pg,
);
}
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
// 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
// 전량 렌더링 방지를 위해 최신 TASK_ACTIVITY_LIMIT 건으로 제한한다.
async listForTask(
repoId: string,
projectId: string,
taskSeq: number,
): Promise<ActivityResponse[]> {
const rows = await this.activityRepo.find({
where: { repo: { id: repoId }, taskSeq },
where: { project: { id: projectId }, taskSeq },
relations: ['actor'],
order: { createdAt: 'DESC' },
});
return rows.map((a) => this.toResponse(a));
}
// 저장소 단위(업무 무관) 활동만 — 필요 시 사용. taskSeq IS NULL
async listRepoOnly(slugName: string): Promise<ActivityResponse[]> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
const rows = await this.activityRepo.find({
where: { repo: { id: repo.id }, taskSeq: IsNull() },
relations: ['actor'],
order: { createdAt: 'DESC' },
take: 200,
take: TASK_ACTIVITY_LIMIT,
});
return rows.map((a) => this.toResponse(a));
}
@@ -9,14 +9,13 @@ import {
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { Project } from '../../project/entities/project.entity';
// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
// 활동 이벤트 타입 — 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
export type ActivityType =
// 저장소(설정)
| 'repo.created'
| 'repo.renamed'
| 'repo.visibility_changed'
// 프로젝트(설정)
| 'project.created'
| 'project.renamed'
// 멤버
| 'member.invited'
| 'member.role_changed'
@@ -29,8 +28,13 @@ export type ActivityType =
| 'task.assignees_changed'
| 'task.due_changed'
| 'task.edited'
| 'task.checkpoint_reported'
| 'task.file_removed'
| 'task.removed';
| 'task.removed'
// 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적)
| 'drive.file_uploaded'
| 'drive.file_deleted'
| 'drive.folder_deleted';
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
@@ -39,14 +43,14 @@ export class Activity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 프로젝트 활동이면 null
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
@Index()
@Column({ name: 'task_seq', type: 'int', nullable: true })
@@ -0,0 +1,40 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
@ApiTags('AI')
@Controller('ai')
@UseGuards(JwtAuthGuard)
export class AiSuggestController {
constructor(private readonly aiService: AiService) {}
@Post('suggest-checklist')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '할 일(체크리스트) 추천 — 프로젝트·업무 기반' })
@ApiResponse({ status: 200, description: '카테고리별 추천 항목' })
@ApiResponse({ status: 404, description: '프로젝트 없음 (RES_001)' })
suggestChecklist(
@CurrentUser() user: PublicUser,
@Body() dto: SuggestChecklistDto,
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
return this.aiService.suggestChecklist(user.id, {
projectId: dto.projectId,
title: dto.title,
content: dto.content,
});
}
}
+97
View File
@@ -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;
}
}
+22
View File
@@ -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 {}
+538
View File
@@ -0,0 +1,538 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { TaskService } from '../task/task.service';
import type { TaskStatus } from '../task/entities/task.entity';
import { ProjectService } from '../project/project.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// Anthropic Messages API 응답(필요 필드만)
interface AnthropicResponse {
content?: { type: string; text?: string }[];
}
// 외부 노출 형태
export interface ConversationSummary {
id: string;
title: string;
updatedAt: Date;
}
export interface ConversationDetail {
id: string;
title: string;
messages: { role: 'user' | 'assistant'; content: string }[];
}
export interface SendResult {
conversationId: string;
title: string;
reply: string;
}
// 할 일 추천 — 카테고리별 항목
export interface ChecklistSuggestionGroup {
category: string;
items: string[];
}
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
// AI 요청 타임아웃 기본값(ms) — env AI_TIMEOUT_MS 로 재정의. 프론트와 동일 값을 사용한다.
const DEFAULT_AI_TIMEOUT_MS = 60_000;
const MAX_TOKENS = 1024;
const TITLE_MAX = 60;
// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약)
const CONTEXT_TASK_LIMIT = 30;
// 컨텍스트로 주입할 프로젝트 최대 개수
const CONTEXT_PROJECT_LIMIT = 30;
// 주제 한정(가드레일) + 데이터 근거 지시
const SYSTEM_PROMPT = [
'당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.',
'',
'[역할]',
"- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.",
'- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.',
'',
'[규칙]',
'- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.',
'- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.',
"- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.",
"- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.",
'- 프로젝트는 한글 표시 제목으로 설명하세요.',
].join('\n');
// 업무 상태 → 한국어 라벨(컨텍스트 표기용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '검토 대기',
changes: '수정 요청',
done: '완료',
};
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
const SUGGEST_SYSTEM_PROMPT = [
'당신은 소프트웨어/업무 기획 보조자입니다.',
"주어진 프로젝트와 업무 정보를 바탕으로, 이 업무(기능)를 완성하기 위해 필요한 '할 일(체크리스트)' 항목을 제안합니다.",
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
'- [프로젝트 문서] 섹션이 주어지면(첨부 문서에서 추출한 내용) 그 내용을 적극 반영해 더 구체적으로 제안하세요.',
'',
'[형식]',
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly apiKey: string;
private readonly model: string;
private readonly timeoutMs: number;
constructor(
config: ConfigService,
private readonly taskService: TaskService,
private readonly projectService: ProjectService,
@InjectRepository(AiConversation)
private readonly convRepo: Repository<AiConversation>,
@InjectRepository(AiMessage)
private readonly msgRepo: Repository<AiMessage>,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
config.get<string>('CLAUDE_MODEL') ?? 'claude-sonnet-4-6'
).trim();
// AI 타임아웃 — env AI_TIMEOUT_MS(ms), 미설정/비정상이면 기본값
const t = Number(config.get<string>('AI_TIMEOUT_MS'));
this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_AI_TIMEOUT_MS;
if (!this.apiKey) {
this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화');
}
}
get enabled(): boolean {
return this.apiKey.length > 0;
}
// 내 대화 목록(최근순, 페이지네이션)
async listConversations(
userId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ConversationSummary>> {
const params = resolvePage(page, size);
const [rows, total] = await this.convRepo.findAndCount({
where: { user: { id: userId } },
order: { updatedAt: 'DESC' },
skip: params.skip,
take: params.take,
});
return paginated(
rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })),
total,
params,
);
}
// 대화 상세(본인 소유만) — 메시지 시간순
async getConversation(
userId: string,
conversationId: string,
): Promise<ConversationDetail> {
const conv = await this.getOwnedConversation(userId, conversationId);
const messages = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
return {
id: conv.id,
title: conv.title,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
}
// 새 대화 시작 — 첫 메시지로 대화 생성 후 응답
async startConversation(
userId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.convRepo.save(
this.convRepo.create({
user: { id: userId },
title: this.makeTitle(content),
}),
);
return this.appendAndReply(conv, content, userId);
}
// 기존 대화에 메시지 추가 후 응답(본인 소유만)
async addMessage(
userId: string,
conversationId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.getOwnedConversation(userId, conversationId);
return this.appendAndReply(conv, content, userId);
}
// 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
async suggestChecklist(
userId: string,
input: { projectId: string; title: string; content?: string[] },
): Promise<{ groups: ChecklistSuggestionGroup[] }> {
this.ensureEnabled();
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
const project = await this.projectService.findOne(input.projectId, userId);
const content = (input.content ?? []).join('\n').trim() || '(없음)';
// 프로젝트 첨부 문서의 캐싱된 추출 본문(업로드 시 보관) — 있으면 추천에 반영
const docsText = await this.projectService.getAttachmentsTextForAi(
input.projectId,
);
const userMsg = [
`[프로젝트] 표시 제목: ${project.name}`,
`설명: ${project.description ?? '(없음)'}`,
...(docsText
? ['', '[프로젝트 문서] (첨부 문서에서 추출한 내용)', docsText]
: []),
'',
`[업무] 제목: ${input.title}`,
'내용:',
content,
].join('\n');
// 4개 카테고리 × 항목 수 무제한의 구조화 JSON 이라 토큰 여유를 넉넉히 준다
// (부족하면 응답이 중간에 잘려 JSON 파싱이 실패할 수 있음)
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
SUGGEST_SYSTEM_PROMPT,
8000,
);
return { groups: this.parseSuggestions(raw) };
}
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
const text = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/, '')
.trim();
// 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse
let groups: unknown[] = [];
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
const parsed = JSON.parse(text.slice(start, end + 1)) as {
groups?: unknown;
};
if (Array.isArray(parsed.groups)) groups = parsed.groups;
} catch {
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
}
}
// 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도
// "category"/"items" 패턴을 정규식으로 직접 복구한다.
if (groups.length === 0) {
const recovered = this.recoverGroups(text);
if (recovered.length > 0) {
groups = recovered;
this.logger.warn(
`추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}`,
);
}
}
const result: ChecklistSuggestionGroup[] = [];
for (const g of groups.slice(0, 8)) {
const obj = g as { category?: unknown; items?: unknown };
const category =
typeof obj.category === 'string' ? obj.category.trim() : '';
if (!category || !Array.isArray(obj.items)) continue;
// 카테고리당 항목 수 제한 없음 — 응답 길이는 max_tokens 가 자연 제한
const items = obj.items
.filter((x): x is string => typeof x === 'string')
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (items.length > 0) result.push({ category, items });
}
if (result.length === 0) {
this.logger.warn(
`추천 결과가 비어 있음 — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
);
throw this.suggestFailed();
}
return result;
}
// 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어).
// 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다.
private recoverGroups(text: string): { category: string; items: string[] }[] {
const out: { category: string; items: string[] }[] = [];
const groupRe =
/"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g;
const itemRe = /"(?:[^"\\]|\\.)*"/g;
let gm: RegExpExecArray | null;
while ((gm = groupRe.exec(text)) !== null) {
let category: string;
try {
category = JSON.parse(gm[1]) as string;
} catch {
continue;
}
const items: string[] = [];
let im: RegExpExecArray | null;
while ((im = itemRe.exec(gm[2])) !== null) {
try {
const s = JSON.parse(im[0]) as unknown;
if (typeof s === 'string') items.push(s);
} catch {
// 깨진 토큰은 건너뛴다
}
}
out.push({ category, items });
}
return out;
}
private suggestFailed(): BusinessException {
return new BusinessException(
'BIZ_001',
'추천을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
conversationId: string,
): Promise<void> {
const conv = await this.getOwnedConversation(userId, conversationId);
await this.convRepo.remove(conv);
}
// 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신.
// Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지.
private async appendAndReply(
conv: AiConversation,
content: string,
userId: string,
): Promise<SendResult> {
const userMsg = await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'user',
content,
}),
);
const history = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
let reply: string;
try {
const system = await this.buildSystemPrompt(userId);
reply = await this.callClaude(
history.map((m) => ({ role: m.role, content: m.content })),
system,
);
} catch (e) {
// 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제
await this.msgRepo.delete(userMsg.id);
const remaining = await this.msgRepo.count({
where: { conversation: { id: conv.id } },
});
if (remaining === 0) {
await this.convRepo.delete(conv.id);
}
throw e;
}
await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'assistant',
content: reply,
}),
);
// 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn)
await this.convRepo.save(conv);
return { conversationId: conv.id, title: conv.title, reply };
}
// 소유권 확인 — 본인 대화가 아니면 404(존재 비노출)
private async getOwnedConversation(
userId: string,
conversationId: string,
): Promise<AiConversation> {
const conv = await this.convRepo.findOne({
where: { id: conversationId, user: { id: userId } },
});
if (!conv) {
throw new BusinessException(
'RES_001',
'대화를 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
return conv;
}
private makeTitle(content: string): string {
const firstLine = content.trim().split('\n')[0].trim();
if (!firstLine) return '새 대화';
return firstLine.length > TITLE_MAX
? `${firstLine.slice(0, TITLE_MAX)}`
: firstLine;
}
private ensureEnabled(): void {
if (!this.enabled) {
throw new BusinessException(
'BIZ_001',
'AI 기능이 설정되지 않았습니다. 관리자에게 문의해 주세요.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
// 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트
private async buildSystemPrompt(userId: string): Promise<string> {
const context = await this.buildUserContext(userId);
return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`;
}
// 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행
private async buildUserContext(userId: string): Promise<string> {
const today = new Date(Date.now() + 9 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10); // KST 기준 오늘
try {
const assigned = await this.taskService.listAssigned(
userId,
1,
CONTEXT_TASK_LIMIT,
);
const issued = await this.taskService.listIssued(userId, 1, 1);
const projects = await this.projectService.findAll(
1,
CONTEXT_PROJECT_LIMIT,
);
const lines = [`오늘 날짜: ${today} (KST)`, ''];
// 담당 업무
lines.push(`내가 담당한 업무 (${assigned.total}건):`);
if (assigned.items.length === 0) {
lines.push('- (없음)');
} else {
for (const t of assigned.items) {
const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음';
const [done, totalC] = t.checklist;
lines.push(
`- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`,
);
}
if (assigned.total > assigned.items.length) {
lines.push(`- … 외 ${assigned.total - assigned.items.length}`);
}
}
lines.push('', `내가 지시한 업무: ${issued.total}`);
// 프로젝트 목록(인증 사용자는 전체 열람 가능)
lines.push('', `프로젝트 목록 (${projects.total}개):`);
if (projects.items.length === 0) {
lines.push('- (없음)');
} else {
for (const p of projects.items) {
lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}`);
}
if (projects.total > projects.items.length) {
lines.push(`- … 외 ${projects.total - projects.items.length}`);
}
}
return lines.join('\n');
} catch (e) {
this.logger.warn(
`사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
);
return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`;
}
}
// Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구
private async callClaude(
messages: { role: 'user' | 'assistant'; content: string }[],
system: string,
maxTokens: number = MAX_TOKENS,
): Promise<string> {
let response: Response;
try {
response = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: this.model,
max_tokens: maxTokens,
system,
messages,
}),
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (e) {
this.logger.error(
`Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`,
);
throw new BusinessException(
'BIZ_001',
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
if (!response.ok) {
const detail = await response.text().catch(() => '');
this.logger.error(
`Anthropic API 오류 ${response.status}: ${detail.slice(0, 500)}`,
);
throw new BusinessException(
'BIZ_001',
'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.BAD_GATEWAY,
);
}
const data = (await response.json()) as AnthropicResponse;
const text = (data.content ?? [])
.filter((b) => b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('')
.trim();
return text || '(빈 응답을 받았습니다.)';
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다.
export class SendMessageDto {
@ApiProperty({ description: '사용자 메시지 내용' })
@IsString()
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
content!: string;
}
@@ -0,0 +1,35 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 할 일(체크리스트) 추천 요청 — 프로젝트·업무 정보를 바탕으로 AI 가 항목 제안
export class SuggestChecklistDto {
@ApiProperty({ description: '프로젝트 식별자(slugName)' })
@IsString()
@IsNotEmpty({ message: '프로젝트가 필요합니다.' })
projectId!: string;
@ApiProperty({ description: '업무 제목' })
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '업무 내용(문단 배열)',
required: false,
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(5000, { each: true })
content?: string[];
}
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N).
@Entity('ai_conversations')
export class AiConversation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 첫 사용자 메시지로 자동 생성되는 제목
@Column({ type: 'varchar', length: 120 })
title!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// 새 메시지 추가 시 갱신(최근 대화 정렬용)
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { AiConversation } from './ai-conversation.entity';
// AI 대화 메시지 1건 — 대화에 종속(CASCADE).
@Entity('ai_messages')
export class AiMessage {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'conversation_id' })
conversation!: Relation<AiConversation>;
@Column({ type: 'varchar' })
role!: 'user' | 'assistant';
@Column({ type: 'text' })
content!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+179 -28
View File
@@ -5,18 +5,25 @@ import {
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import type { CookieOptions, Response } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import type { CookieOptions, Request, Response } from 'express';
import { AuthService, type SignupResult } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtRefreshGuard } from './guards/jwt-refresh.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
import type { PublicUser } from '../user/user.service';
// 쿠키 만료 시간(ms)
@@ -34,6 +41,15 @@ export class AuthController {
private readonly config: ConfigService,
) {}
// 프론트엔드(웹) 기준 URL — 소셜 로그인/이메일 인증 후 리다이렉트 대상
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// HttpOnly·Secure 쿠키 공통 옵션
private cookieBase(): CookieOptions {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
@@ -44,21 +60,23 @@ export class AuthController {
};
}
// access/refresh 토큰을 쿠키로 설정
// access/refresh 토큰을 쿠키로 설정.
// persistent=true 면 maxAge 부여(영속 쿠키), false 면 maxAge 생략(세션 쿠키 — 브라우저 종료 시 만료).
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string,
persistent: boolean,
): void {
res.cookie('access_token', accessToken, {
...this.cookieBase(),
path: '/',
maxAge: ACCESS_MAX_AGE,
...(persistent ? { maxAge: ACCESS_MAX_AGE } : {}),
});
res.cookie('refresh_token', refreshToken, {
...this.cookieBase(),
path: REFRESH_COOKIE_PATH,
maxAge: REFRESH_MAX_AGE,
...(persistent ? { maxAge: REFRESH_MAX_AGE } : {}),
});
}
@@ -71,23 +89,92 @@ export class AuthController {
});
}
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
// keepLogin: 로그인 유지 여부(영속 쿠키 vs 세션 쿠키)
private async loginWith(
res: Response,
user: PublicUser,
keepLogin: boolean,
): Promise<void> {
const tokens = await this.authService.issueTokensForNewSession(
user,
keepLogin,
);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
}
@Post('signup')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입 (가입 후 자동 로그인)' })
@ApiResponse({ status: 201, description: '가입 성공, 인증 쿠키 발급' })
// 가입 남용/메일 폭탄 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '회원가입 (인증 메일 발송, 자동 로그인 안 함)' })
@ApiResponse({ status: 201, description: '가입 접수 — 인증 메일 발송됨' })
@ApiResponse({ status: 409, description: '이미 가입된 이메일 (BIZ_001)' })
async signup(
@Body() dto: SignupDto,
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.signup(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return user;
signup(@Body() dto: SignupDto): Promise<SignupResult> {
// 이메일 인증 완료 전까지 로그인 불가 — 쿠키를 발급하지 않는다
return this.authService.signup(dto);
}
@Post('resend-verification')
@HttpCode(HttpStatus.OK)
// 메일 폭탄 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '인증 메일 재발송' })
@ApiResponse({ status: 200, description: '재발송 접수(존재 여부 비노출)' })
async resend(@Body() dto: ResendVerificationDto): Promise<null> {
await this.authService.resendVerification(dto.email);
return null;
}
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
// 메일 폭탄/계정 탐색 방지 — 운영 권장 3회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 요청 (재설정 메일 발송)' })
@ApiResponse({ status: 200, description: '요청 접수(존재 여부 비노출)' })
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<null> {
await this.authService.requestPasswordReset(dto.email);
return null;
}
@Post('reset-password')
@HttpCode(HttpStatus.OK)
// 토큰 추측 방지 — 운영 권장 5회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 (토큰 + 새 비밀번호)' })
@ApiResponse({ status: 200, description: '비밀번호 변경 성공' })
@ApiResponse({ status: 400, description: '토큰 만료/무효 (BIZ_001)' })
async resetPassword(@Body() dto: ResetPasswordDto): Promise<null> {
await this.authService.resetPassword(dto.token, dto.password);
return null;
}
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입(보통 새 탭).
// 검증 후 결과 페이지로 리다이렉트하며, 그 페이지는 완료를 표시하고 창을 닫는다.
@Get('verify-email')
@SkipTransform()
@ApiOperation({ summary: '이메일 인증 (메일 링크)' })
async verifyEmail(
@Query('token') token: string,
@Res() res: Response,
): Promise<void> {
try {
await this.authService.verifyEmail(token);
res.redirect(`${this.webUrl()}/email-verified?ok=1`);
} catch {
res.redirect(`${this.webUrl()}/email-verified?ok=0`);
}
}
@Post('login')
@HttpCode(HttpStatus.OK)
// 무차별 대입 방지 — 운영 권장 10회/분 (현재 테스트용 1000회로 완화)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '로그인' })
@ApiResponse({ status: 200, description: '로그인 성공, 인증 쿠키 발급' })
@ApiResponse({
@@ -99,36 +186,100 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
): Promise<PublicUser> {
const user = await this.authService.validateUser(dto);
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
// keepLogin 미지정(API 직접 호출 등)이면 세션 쿠키(false)로 안전하게 처리
await this.loginWith(res, user, dto.keepLogin ?? false);
return user;
}
// ── 구글 OAuth ──
@Get('google')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 시작 (구글 동의 화면으로 이동)' })
googleAuth(): void {
// 가드(AuthGuard)가 구글 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('google/callback')
@SkipTransform()
@UseGuards(AuthGuard('google'))
@ApiOperation({ summary: '구글 로그인 콜백' })
async googleCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/schedule`);
}
// ── 카카오 OAuth ──
@Get('kakao')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 시작 (카카오 동의 화면으로 이동)' })
kakaoAuth(): void {
// 가드(AuthGuard)가 카카오 인증 페이지로 리다이렉트 — 본문 없음
}
@Get('kakao/callback')
@SkipTransform()
@UseGuards(AuthGuard('kakao'))
@ApiOperation({ summary: '카카오 로그인 콜백' })
async kakaoCallback(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
await this.loginWith(res, req.user as PublicUser, true);
res.redirect(`${this.webUrl()}/schedule`);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '로그아웃 (인증 쿠키 제거)' })
@ApiOperation({ summary: '로그아웃 (세션 폐기 + 인증 쿠키 제거)' })
@ApiResponse({ status: 200, description: '로그아웃 성공' })
logout(@Res({ passthrough: true }) res: Response): null {
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
// 이 세션(패밀리)을 서버측에서 폐기 — 이후 해당 refresh 토큰 재사용 차단
const cookies = req.cookies as Record<string, string> | undefined;
await this.authService.revokeSession(cookies?.refresh_token);
this.clearAuthCookies(res);
return null;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtRefreshGuard)
@ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' })
@ApiResponse({ status: 200, description: '토큰 재발급 성공' })
@ApiOperation({ summary: 'access/refresh 토큰 회전 (refresh 쿠키 필요)' })
@ApiResponse({ status: 200, description: '토큰 회전 성공' })
@ApiResponse({
status: 401,
description: 'refresh 토큰 만료/누락 (AUTH_001)',
description: 'refresh 토큰 만료/누락/재사용 (AUTH_001)',
})
async refresh(
@CurrentUser() user: PublicUser,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<null> {
const tokens = await this.authService.issueTokens(user);
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return null;
const cookies = req.cookies as Record<string, string> | undefined;
try {
// 회전형 refresh — 재사용 탐지 시 401(AUTH_001), 정상 시 새 토큰 쌍으로 교체
const tokens = await this.authService.rotateTokens(
cookies?.refresh_token,
);
this.setAuthCookies(
res,
tokens.accessToken,
tokens.refreshToken,
tokens.persistent,
);
return null;
} catch (e) {
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
this.clearAuthCookies(res);
throw e;
}
}
@Get('me')
+46 -4
View File
@@ -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 {}
+356 -19
View File
@@ -1,14 +1,29 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService, type JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash, randomBytes, randomUUID } from 'crypto';
import * as bcrypt from 'bcryptjs';
import { UserService, type PublicUser } from '../user/user.service';
import type { User } from '../user/entities/user.entity';
import { MailService } from '../mail/mail.service';
import { RefreshSession } from './entities/refresh-session.entity';
import { BusinessException } from '../../common/exceptions/business.exception';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import type { JwtPayload } from './types/jwt-payload';
// bcrypt 해시 라운드
const BCRYPT_ROUNDS = 10;
const BCRYPT_ROUNDS = 12;
// 이메일 인증 토큰 유효 시간 (24시간)
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
// 비밀번호 재설정 토큰 유효 시간 (1시간 — 보안상 짧게)
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다.
const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음
const REFRESH_GRACE_MS = 15 * 1000;
// 발급된 토큰 쌍
export interface TokenPair {
@@ -16,20 +31,46 @@ export interface TokenPair {
refreshToken: string;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 토큰 발급
// 세션 토큰 + 쿠키 영속 여부(keepLogin) — 컨트롤러가 쿠키 maxAge 결정에 사용
export interface SessionTokens extends TokenPair {
persistent: boolean;
}
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string;
verificationPending: true;
}
// 소셜 로그인 프로필 — 각 전략(google/kakao)이 공통 형태로 정규화해 전달
export interface OAuthProfile {
provider: string;
email: string | null;
// 제공자가 이메일 소유를 검증했는지 — 미검증이면 계정 매칭/생성을 거부(탈취/선점 방지)
emailVerified: boolean;
name: string;
avatarUrl?: string | null;
}
// 인증 비즈니스 로직 — 가입, 로그인 검증, 이메일 인증, 소셜 로그인, 토큰 발급
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly mailService: MailService,
@InjectRepository(RefreshSession)
private readonly refreshRepo: Repository<RefreshSession>,
) {}
// 회원가입 — 이메일 중복 검사 후 비밀번호 해시하여 저장
async signup(dto: SignupDto): Promise<PublicUser> {
const exists = await this.userService.findByEmail(dto.email);
if (exists) {
// 409 + 비즈니스 코드/문구 명시 → 필터가 그대로 노출
// 회원가입 — 인증 완료 계정만 중복 거부. 미인증(가입 대기) 계정은 재가입을 허용한다.
// (인증 메일을 받기 전 사이트를 닫아 가입을 못 끝낸 경우, 같은 이메일로 다시 가입해
// 새 인증 메일을 받아 가입을 완료할 수 있게 한다. 자동 로그인은 하지 않음)
async signup(dto: SignupDto): Promise<SignupResult> {
const existing = await this.userService.findByEmail(dto.email);
// 이미 인증을 마친 계정(로컬·소셜)은 재가입 불가
if (existing && (existing.emailVerified || existing.provider !== 'local')) {
throw new BusinessException(
'BIZ_001',
'이미 가입된 이메일입니다.',
@@ -37,33 +78,283 @@ export class AuthService {
);
}
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
// 미인증 로컬 계정이 있으면 입력값으로 갱신 후 인증 메일만 재발송(중복 계정 생성 X)
if (existing) {
await this.userService.updateRegistration(existing.id, {
passwordHash,
name: dto.name,
role: dto.role ?? null,
});
existing.name = dto.name; // 메일 인사말에 최신 이름 반영
await this.sendVerification(existing);
return { email: existing.email, verificationPending: true };
}
const user = await this.userService.create({
email: dto.email,
passwordHash,
name: dto.name,
role: dto.role ?? null,
});
return UserService.toPublic(user);
await this.sendVerification(user);
return { email: user.email, verificationPending: true };
}
// 로그인 검증 — 이메일/비밀번호 일치 확인
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
// 401 + 비즈니스 코드/문구 명시 → 자격 불일치를 명확히 안내
// 인증 메일 재발송 — 사용자 존재/상태를 노출하지 않기 위해 항상 동일하게 응답(미인증 로컬만 실제 발송)
async resendVerification(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (user && user.provider === 'local' && !user.emailVerified) {
await this.sendVerification(user);
}
}
// 이메일 인증 — 토큰(원문) 해시로 사용자 조회 후 만료 검사하여 인증 완료 처리
async verifyEmail(token: string): Promise<void> {
if (!token) {
throw new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
'잘못된 인증 링크입니다.',
HttpStatus.BAD_REQUEST,
);
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByEmailVerifyTokenHash(tokenHash);
if (
!user ||
!user.emailVerifyExpiresAt ||
user.emailVerifyExpiresAt.getTime() < Date.now()
) {
throw new BusinessException(
'BIZ_001',
'인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
HttpStatus.BAD_REQUEST,
);
}
await this.userService.markEmailVerified(user.id);
}
// 비밀번호 재설정 요청 — 존재/상태를 노출하지 않기 위해 항상 동일하게 응답.
// 로컬(이메일) 계정만 실제 재설정 메일 발송(소셜 전용은 비밀번호가 없음).
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || user.provider !== 'local') {
return;
}
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
await this.userService.setPasswordResetToken(user.id, tokenHash, expiresAt);
const resetUrl = `${this.webUrl()}/reset-password?token=${rawToken}`;
this.mailService.sendPasswordResetEmail(user.email, user.name, resetUrl);
}
// 비밀번호 재설정 — 토큰(원문) 해시로 조회 + 만료 검사 후 새 비밀번호 저장.
// 보안상 비밀번호 변경 시 해당 사용자의 모든 세션을 폐기(전 기기 강제 로그아웃).
async resetPassword(token: string, newPassword: string): Promise<void> {
if (!token) {
throw this.invalidResetLink();
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByPasswordResetTokenHash(tokenHash);
if (
!user ||
!user.passwordResetExpiresAt ||
user.passwordResetExpiresAt.getTime() < Date.now()
) {
throw this.invalidResetLink();
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await this.userService.updatePassword(user.id, passwordHash);
await this.revokeAllSessions(user.id);
}
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
if (!user) {
throw this.invalidCredentials();
}
// 비밀번호 미설정 = 소셜 전용 계정 → 소셜 로그인 안내
if (user.passwordHash == null) {
throw new BusinessException(
'BIZ_001',
'소셜(구글/카카오) 계정으로 가입된 이메일입니다. 소셜 로그인을 이용해 주세요.',
HttpStatus.UNAUTHORIZED,
);
}
if (!(await bcrypt.compare(dto.password, user.passwordHash))) {
throw this.invalidCredentials();
}
// 이메일 미인증 → 가입 미완료로 로그인 차단
if (!user.emailVerified) {
throw new BusinessException(
'BIZ_001',
'이메일 인증이 완료되지 않았습니다. 받은 인증 메일의 링크를 클릭해 가입을 완료해 주세요.',
HttpStatus.FORBIDDEN,
);
}
return UserService.toPublic(user);
}
// access/refresh 토큰 발급
async issueTokens(user: PublicUser): Promise<TokenPair> {
// 소셜 로그인 — 이메일로 기존 계정 매칭(있으면 로그인/인증보정), 없으면 신규 생성
async validateOrCreateOAuthUser(profile: OAuthProfile): Promise<PublicUser> {
if (!profile.email) {
throw new BusinessException(
'BIZ_001',
'소셜 계정에서 이메일을 가져오지 못했습니다. 이메일 제공에 동의한 뒤 다시 시도해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 제공자가 이메일을 검증하지 않았으면 거부 — 미검증 이메일로 타인 계정 탈취/선점 방지
if (!profile.emailVerified) {
throw new BusinessException(
'BIZ_001',
'소셜 계정의 이메일이 인증되지 않았습니다. 제공자에서 이메일 인증을 완료한 뒤 다시 시도해 주세요.',
HttpStatus.FORBIDDEN,
);
}
let user = await this.userService.findByEmail(profile.email);
if (!user) {
user = await this.userService.createOAuthUser({
email: profile.email,
name: profile.name,
provider: profile.provider,
avatarUrl: profile.avatarUrl ?? null,
});
} else if (!user.emailVerified) {
// 기존(미인증 로컬) 계정과 이메일이 같으면 소셜 인증으로 인증 완료 처리하고 로그인
await this.userService.markEmailVerified(user.id);
user.emailVerified = true;
}
return UserService.toPublic(user);
}
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급.
// keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록.
async issueTokensForNewSession(
user: PublicUser,
keepLogin: boolean,
): Promise<SessionTokens> {
// 만료된 이 사용자의 세션 정리(누적 방지)
await this.refreshRepo
.createQueryBuilder()
.delete()
.where('user_id = :userId', { userId: user.id })
.andWhere('expires_at < :now', { now: new Date() })
.execute();
const jti = randomUUID();
const session = await this.refreshRepo.save(
this.refreshRepo.create({
user: { id: user.id },
currentJti: jti,
prevJti: null,
prevAt: null,
persistent: keepLogin,
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
}),
);
const tokens = await this.signTokens(user.id, user.email, session.id, jti);
return { ...tokens, persistent: keepLogin };
}
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
async rotateTokens(refreshToken: string | undefined): Promise<SessionTokens> {
if (!refreshToken) throw new UnauthorizedException();
let payload: JwtPayload;
try {
payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.fid || !payload.jti) {
throw new UnauthorizedException();
}
const session = await this.refreshRepo.findOne({
where: { id: payload.fid },
relations: ['user'],
});
if (!session || session.user?.id !== payload.sub) {
throw new UnauthorizedException();
}
// 1) 정상 회전 — 현재 jti 일치 → 새 jti 발급, 직전 jti 기록
if (payload.jti === session.currentJti) {
const newJti = randomUUID();
session.prevJti = session.currentJti;
session.prevAt = new Date();
session.currentJti = newJti;
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
await this.refreshRepo.save(session);
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
newJti,
);
return { ...tokens, persistent: session.persistent };
}
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
if (
payload.jti === session.prevJti &&
session.prevAt &&
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
) {
const tokens = await this.signTokens(
session.user.id,
session.user.email,
session.id,
session.currentJti,
);
return { ...tokens, persistent: session.persistent };
}
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
await this.refreshRepo.delete({ id: session.id });
throw new UnauthorizedException();
}
// 로그아웃 — refresh 토큰의 세션(패밀리)만 폐기. 무효 토큰이면 조용히 무시.
async revokeSession(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(
refreshToken,
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') },
);
if (payload?.fid) {
await this.refreshRepo.delete({ id: payload.fid });
}
} catch {
// 무효/만료 refresh 토큰 — 무시
}
}
// 사용자의 모든 세션 폐기 — 비밀번호 변경/계정 보안 시 호출(전 기기 강제 로그아웃). 현재 미사용.
async revokeAllSessions(userId: string): Promise<void> {
await this.refreshRepo
.createQueryBuilder()
.delete()
.where('user_id = :userId', { userId })
.execute();
}
// access/refresh 토큰 서명 — refresh 에는 세션 fid + 현재 jti 를 담는다
private async signTokens(
sub: string,
email: string | undefined,
fid: string,
jti: string,
): Promise<TokenPair> {
const accessToken = await this.jwtService.signAsync(
{ sub: user.id, email: user.email },
{ sub, email },
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
@@ -71,7 +362,7 @@ export class AuthService {
},
);
const refreshToken = await this.jwtService.signAsync(
{ sub: user.id },
{ sub, fid, jti },
{
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
@@ -80,4 +371,50 @@ export class AuthService {
);
return { accessToken, refreshToken };
}
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
private async sendVerification(user: User): Promise<void> {
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + VERIFY_TTL_MS);
await this.userService.setEmailVerifyToken(user.id, tokenHash, expiresAt);
const apiUrl = (
this.config.get<string>('APP_API_URL') ?? 'http://localhost:3100/api'
).replace(/\/$/, '');
const verifyUrl = `${apiUrl}/auth/verify-email?token=${rawToken}`;
this.mailService.sendVerificationEmail(user.email, user.name, verifyUrl);
}
// 토큰 원문 → SHA-256 해시(원문은 메일 링크에만, DB엔 해시만 저장)
private hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
// 프론트엔드(웹) 기준 URL — 비밀번호 재설정 폼 링크 대상(컨트롤러 webUrl 과 동일 정책)
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// 재설정 링크 무효/만료 공통 예외
private invalidResetLink(): BusinessException {
return new BusinessException(
'BIZ_001',
'비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다. 다시 요청해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
private invalidCredentials(): BusinessException {
return new BusinessException(
'BIZ_001',
'이메일 또는 비밀번호가 올바르지 않습니다.',
HttpStatus.UNAUTHORIZED,
);
}
}
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 요청 DTO — 대상 이메일
export class ForgotPasswordDto {
@ApiProperty({
description: '비밀번호 재설정 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 인증 메일 재발송 DTO — 대상 이메일
export class ResendVerificationDto {
@ApiProperty({
description: '인증 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,29 @@
import {
IsNotEmpty,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 DTO — 메일 링크의 토큰 + 새 비밀번호(가입과 동일 규칙)
export class ResetPasswordDto {
@ApiProperty({ description: '재설정 토큰(메일 링크의 token 값)' })
@IsString()
@IsNotEmpty({ message: '재설정 토큰이 필요합니다.' })
token!: string;
@ApiProperty({
description: '새 비밀번호 (8~72자, 영문·숫자 포함)',
example: 'newpass123',
})
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
})
password!: string;
}
+11 -1
View File
@@ -3,6 +3,8 @@ import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
@@ -14,9 +16,17 @@ export class SignupDto {
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@ApiProperty({
description: '비밀번호 (8~72자, 영문·숫자 포함)',
example: 'password123',
})
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
})
password!: string;
@ApiProperty({ description: '사용자 이름', example: '김서연' })
@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// refresh 토큰 세션(패밀리) — 회전형 refresh 토큰 + 재사용 탐지의 단위.
// 한 로그인(기기)당 1행. id=패밀리 식별자(fid), currentJti=현재 유효한 refresh 토큰 표식.
// 갱신 시 currentJti 를 새로 발급하고, 이미 회전된(옛) 토큰이 다시 제출되면 탈취로 간주해
// 행을 삭제(패밀리 폐기)한다. 동시 갱신(재시도) 오탐 방지를 위해 직전 jti 를 짧은 유예 동안 허용.
@Entity('refresh_sessions')
export class RefreshSession {
// 패밀리 식별자(fid) — refresh JWT payload 에 담긴다
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 현재 유효한 refresh 토큰의 jti
@Column({ name: 'current_jti', type: 'uuid' })
currentJti!: string;
// 직전 jti + 회전 시각 — 동시 갱신(재시도) 유예 판정용
@Column({ name: 'prev_jti', type: 'uuid', nullable: true })
prevJti!: string | null;
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
prevAt!: Date | null;
// 로그인 유지(keepLogin) 여부 — true 면 영속 쿠키(refresh TTL 만큼), false 면
// 세션 쿠키(브라우저 종료 시 만료). refresh 회전 시에도 이 값을 따라 쿠키를 재발급한다.
@Column({ type: 'boolean', default: true })
persistent!: boolean;
// 세션 만료(refresh TTL) — 만료분 정리용
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,6 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
// refresh 토큰 기반 가드 — /auth/refresh 전용
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
@@ -0,0 +1,50 @@
import { randomBytes } from 'crypto';
import type { Request } from 'express';
// OAuth CSRF 방지용 state 저장소 — 세션을 쓰지 않는 무상태(JWT) 구조라
// passport-oauth2 의 기본 SessionStore 대신 httpOnly 쿠키에 1회용 난수 state 를 보관한다.
// 인가 시작 시 store() 로 쿠키 발급 + state 반환(인가 URL 에 부착), 콜백에서 verify() 로
// 쿠키와 제공자 반환 state 일치를 확인한다(불일치 시 인증 거부).
const STATE_COOKIE = 'oauth_state';
const STATE_TTL_MS = 10 * 60 * 1000; // 10분
type StoreCallback = (err: Error | null, state?: string) => void;
type VerifyCallback = (
err: Error | null,
ok?: boolean,
info?: { message: string },
) => void;
function cookieOptions() {
const isProd = process.env.NODE_ENV === 'production';
return {
httpOnly: true,
secure: isProd,
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: STATE_TTL_MS,
};
}
export class CookieStateStore {
// passport 버전에 따라 (req, cb) 또는 (req, meta, cb) 로 호출되므로 마지막 인자를 콜백으로 사용
store(req: Request, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as StoreCallback;
const state = randomBytes(16).toString('hex');
req.res?.cookie(STATE_COOKIE, state, cookieOptions());
cb(null, state);
}
verify(req: Request, providedState: string, ...rest: unknown[]): void {
const cb = rest[rest.length - 1] as VerifyCallback;
const cookies = req.cookies as Record<string, string> | undefined;
const expected = cookies?.[STATE_COOKIE];
// 1회용 — 검증 직후 쿠키 제거
req.res?.clearCookie(STATE_COOKIE, { path: '/api/auth' });
if (!expected || expected !== providedState) {
cb(null, false, { message: 'OAuth state 불일치' });
return;
}
cb(null, true);
}
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import {
Strategy,
type Profile,
type StrategyOptions,
} from 'passport-google-oauth20';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 구글 OAuth 전략 — 콜백에서 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
const options: StrategyOptions & { store: CookieStateStore } = {
clientID: config.getOrThrow<string>('GOOGLE_CLIENT_ID'),
clientSecret: config.getOrThrow<string>('GOOGLE_CLIENT_SECRET'),
callbackURL: config.getOrThrow<string>('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
store: new CookieStateStore(),
};
super(options);
}
// 성공 시 PublicUser 반환 → @nestjs/passport 가 req.user 에 주입
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
): Promise<PublicUser> {
const email = profile.emails?.[0]?.value ?? null;
const name = profile.displayName || email?.split('@')[0] || '구글 사용자';
const avatarUrl = profile.photos?.[0]?.value ?? null;
// 구글이 이메일 소유를 검증했는지(email_verified)
const json = profile._json as { email_verified?: boolean } | undefined;
const emailVerified = json?.email_verified === true;
return this.authService.validateOrCreateOAuthUser({
provider: 'google',
email,
emailVerified,
name,
avatarUrl,
});
}
}
@@ -1,35 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UserService, type PublicUser } from '../../user/user.service';
import type { JwtPayload } from '../types/jwt-payload';
// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh',
) {
constructor(
config: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: (req: Request): string | null =>
(req?.cookies as Record<string, string> | undefined)?.refresh_token ??
null,
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<PublicUser> {
const user = await this.userService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return UserService.toPublic(user);
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, type KakaoProfile } from 'passport-kakao';
import { AuthService } from '../auth.service';
import { CookieStateStore } from './cookie-state.store';
import type { PublicUser } from '../../user/user.service';
// 카카오 OAuth 전략 — 콜백 프로필을 정규화해 AuthService 로 위임.
// 키 미설정 환경에서는 모듈이 이 전략을 등록하지 않는다(AuthModule 팩토리 가드).
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(
config: ConfigService,
private readonly authService: AuthService,
) {
super({
clientID: config.getOrThrow<string>('KAKAO_CLIENT_ID'),
clientSecret: config.get<string>('KAKAO_CLIENT_SECRET') ?? '',
callbackURL: config.getOrThrow<string>('KAKAO_CALLBACK_URL'),
// store: 쿠키 기반 state 저장소(OAuth CSRF 방지)
store: new CookieStateStore(),
});
}
// 카카오 프로필(_json)에서 이메일/닉네임/프로필이미지 정규화 후 위임
async validate(
_accessToken: string,
_refreshToken: string,
profile: KakaoProfile,
): Promise<PublicUser> {
const account = profile._json?.kakao_account;
const email = account?.email ?? null;
// 카카오가 이메일 보유+검증을 확인했는지(is_email_verified)
const emailVerified = account?.is_email_verified === true;
const name =
account?.profile?.nickname ||
profile._json?.properties?.nickname ||
profile.displayName ||
email?.split('@')[0] ||
'카카오 사용자';
const avatarUrl =
account?.profile?.profile_image_url ??
profile._json?.properties?.profile_image ??
null;
return this.authService.validateOrCreateOAuthUser({
provider: 'kakao',
email,
emailVerified,
name,
avatarUrl,
});
}
}
@@ -4,4 +4,8 @@ export interface JwtPayload {
sub: string;
// 이메일 (access 토큰에만 포함)
email?: string;
// 세션(패밀리) id (refresh 토큰에만 포함)
fid?: string;
// 현재 토큰 표식 jti (refresh 토큰에만 포함) — RefreshSession.currentJti 와 일치해야 유효
jti?: string;
}
+55
View File
@@ -0,0 +1,55 @@
// passport-kakao 공식 타입 미제공 — 사용 범위에 맞춘 최소 선언.
declare module 'passport-kakao' {
export interface KakaoProfile {
id: number | string;
username?: string;
displayName?: string;
_json?: {
id: number;
kakao_account?: {
email?: string;
// 카카오 이메일 보유/검증 플래그 — 미검증 이메일은 신뢰하지 않는다
has_email?: boolean;
is_email_verified?: boolean;
profile?: {
nickname?: string;
profile_image_url?: string;
thumbnail_image_url?: string;
};
};
properties?: {
nickname?: string;
profile_image?: string;
thumbnail_image?: string;
};
};
}
export interface StrategyOptions {
clientID: string;
clientSecret?: string;
callbackURL: string;
// passport-oauth2 호환 state 저장소(OAuth CSRF 방지)
store?: unknown;
}
export type VerifyCallback = (
error: unknown,
user?: unknown,
info?: unknown,
) => void;
export class Strategy {
constructor(
options: StrategyOptions,
verify: (
accessToken: string,
refreshToken: string,
profile: KakaoProfile,
done: VerifyCallback,
) => void,
);
name: string;
authenticate(req: unknown, options?: unknown): void;
}
}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { UserService, type PublicUser } from '../user/user.service';
import {
DriveActivity,
type DriveActivityType,
} from './entities/drive-activity.entity';
// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립
export interface DriveActivityResponse {
id: string;
type: DriveActivityType;
actor: PublicUser | null;
payload: Record<string, unknown>;
createdAt: string;
}
// 감사 로그 적재 파라미터
export interface RecordDriveActivityParams {
actorId: string | null;
type: DriveActivityType;
payload?: Record<string, unknown>;
}
// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고,
// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
@Injectable()
export class DriveAuditService {
private readonly logger = new Logger(DriveAuditService.name);
constructor(
@InjectRepository(DriveActivity)
private readonly activityRepo: Repository<DriveActivity>,
) {}
// 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
async record(params: RecordDriveActivityParams): Promise<void> {
try {
const activity = this.activityRepo.create({
actor: params.actorId ? { id: params.actorId } : null,
type: params.type,
payload: params.payload ?? {},
});
await this.activityRepo.save(activity);
} catch (err) {
this.logger.error(
`드라이브 감사 로그 적재 실패: ${params.type}`,
err instanceof Error ? err.stack : String(err),
);
}
}
// 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트.
async list(
page?: number,
size?: number,
): Promise<PaginatedResult<DriveActivityResponse>> {
const pg = resolvePage(page, size);
const [rows, total] = await this.activityRepo.findAndCount({
relations: ['actor'],
order: { createdAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
return paginated(
rows.map((a) => this.toResponse(a)),
total,
pg,
);
}
// 엔티티 → 응답 매핑
private toResponse(a: DriveActivity): DriveActivityResponse {
return {
id: a.id,
type: a.type,
actor: a.actor ? UserService.toPublic(a.actor) : null,
payload: a.payload ?? {},
createdAt: a.createdAt.toISOString(),
};
}
}
+72
View File
@@ -0,0 +1,72 @@
import { randomUUID } from 'crypto';
import { extname } from 'path';
import type { AttachmentKind } from '../task/entities/attachment.entity';
// 드라이브 파일 1건 최대 크기(기본 200MB) — env 로 조정 가능.
// presigned PUT 은 크기를 서명으로 강제하지 못하므로, 최종 검증은 complete 단계의 HEAD 로 한다.
export const DRIVE_MAX_FILE_SIZE = (() => {
const mb = Number(process.env.DRIVE_MAX_FILE_MB);
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 200 * 1024 * 1024;
})();
// 허용 MIME 화이트리스트 — 실행/스크립트(text/html, image/svg+xml 등)는 차단(심층 방어).
// 문서 첨부(upload.config) 기준에 드라이브 용도의 미디어 타입을 추가했다.
export const DRIVE_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
// 이미지
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
// 문서
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/json',
// 압축
'application/zip',
'application/x-zip-compressed',
'application/x-7z-compressed',
'application/x-rar-compressed',
// 미디어(드라이브 용도)
'video/mp4',
'video/webm',
'video/quicktime',
'audio/mpeg',
'audio/wav',
'audio/ogg',
]);
// 허용 MIME 여부
export function isAllowedMime(mime: string): boolean {
return DRIVE_ALLOWED_MIME_TYPES.has(mime);
}
// MIME/확장자로 아이콘 종류 파생(첨부와 동일 분류 재사용)
export function deriveKind(mime: string, name: string): AttachmentKind {
const lower = name.toLowerCase();
if (mime.startsWith('image/')) return 'img';
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
if (
mime.includes('zip') ||
mime.includes('compressed') ||
lower.endsWith('.zip')
) {
return 'zip';
}
return 'doc';
}
// 버킷 객체 키 생성 — {APP_ENV}/drive/{userId}/{uuid}{ext}
// 원본 파일명은 DB(name)에만 보관하고, 키는 충돌/인코딩 안전을 위해 uuid 기반으로 만든다.
export function buildObjectKey(userId: string, filename: string): string {
const env = process.env.APP_ENV || process.env.NODE_ENV || 'development';
const ext = extname(filename).toLowerCase().slice(0, 16); // 비정상적으로 긴 확장자 방어
return `${env}/drive/${userId}/${randomUUID()}${ext}`;
}
@@ -0,0 +1,165 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
DriveService,
type DriveFileResponse,
type DriveFolderResponse,
type DriveListResponse,
type PresignUploadResponse,
} from './drive.service';
import {
DriveAuditService,
type DriveActivityResponse,
} from './drive-audit.service';
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';
import { RenameDto } from './dto/rename.dto';
// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요.
@ApiTags('Drive')
@Controller('drive')
@UseGuards(JwtAuthGuard)
export class DriveController {
constructor(
private readonly driveService: DriveService,
private readonly auditService: DriveAuditService,
) {}
@Get()
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
list(
@CurrentUser() user: PublicUser,
@Query('folderId') folderId?: string,
): Promise<DriveListResponse> {
return this.driveService.list(user.id, folderId);
}
@Get('activity')
@ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' })
@ApiResponse({ status: 200, description: '조회 성공' })
activity(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<DriveActivityResponse>> {
return this.auditService.list(page, size);
}
/* ===================== 폴더 ===================== */
@Post('folders')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '폴더 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createFolder(
@CurrentUser() user: PublicUser,
@Body() dto: CreateFolderDto,
): Promise<DriveFolderResponse> {
return this.driveService.createFolder(user.id, dto);
}
@Patch('folders/:folderId')
@ApiOperation({ summary: '폴더 이름 변경' })
@ApiResponse({ status: 200, description: '변경 성공' })
renameFolder(
@CurrentUser() user: PublicUser,
@Param('folderId', ParseUUIDPipe) folderId: string,
@Body() dto: RenameDto,
): Promise<DriveFolderResponse> {
return this.driveService.renameFolder(user.id, folderId, dto.name);
}
@Delete('folders/:folderId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '폴더 삭제(하위 폴더·파일 포함)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteFolder(
@CurrentUser() user: PublicUser,
@Param('folderId', ParseUUIDPipe) folderId: string,
): Promise<null> {
await this.driveService.deleteFolder(user.id, folderId);
return null;
}
/* ===================== 파일 ===================== */
@Post('files/presign')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '업로드용 presigned URL 발급(+pending 메타 생성)' })
@ApiResponse({ status: 201, description: '발급 성공' })
@ApiResponse({
status: 400,
description: '허용되지 않는 형식/크기 (VAL_001)',
})
presignUpload(
@CurrentUser() user: PublicUser,
@Body() dto: PresignUploadDto,
): Promise<PresignUploadResponse> {
return this.driveService.presignUpload(user.id, dto);
}
@Post('files/:fileId/complete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업로드 확정(HEAD 검증 후 active 전환)' })
@ApiResponse({ status: 200, description: '확정 성공' })
@ApiResponse({ status: 400, description: '업로드 미완료/검증 실패' })
completeUpload(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<DriveFileResponse> {
return this.driveService.completeUpload(user.id, fileId);
}
@Get('files/:fileId/download-url')
@ApiOperation({ summary: '다운로드용 단기 presigned URL 발급' })
@ApiResponse({ status: 200, description: '발급 성공' })
@ApiResponse({ status: 404, description: '파일 없음/미확정 (RES_001)' })
getDownloadUrl(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<{ url: string }> {
return this.driveService.getDownloadUrl(user.id, fileId);
}
@Patch('files/:fileId')
@ApiOperation({ summary: '파일 이름 변경' })
@ApiResponse({ status: 200, description: '변경 성공' })
renameFile(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
@Body() dto: RenameDto,
): Promise<DriveFileResponse> {
return this.driveService.renameFile(user.id, fileId, dto.name);
}
@Delete('files/:fileId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '파일 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteFile(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<null> {
await this.driveService.deleteFile(user.id, fileId);
return null;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { ActivityModule } from '../activity/activity.module';
import { DriveFolder } from './entities/drive-folder.entity';
import { DriveFile } from './entities/drive-file.entity';
import { ProjectFolderLink } from './entities/project-folder-link.entity';
import { DriveActivity } from './entities/drive-activity.entity';
import { DriveService } from './drive.service';
import { DriveAuditService } from './drive-audit.service';
import { DriveController } from './drive.controller';
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
@Module({
imports: [
TypeOrmModule.forFeature([
DriveFolder,
DriveFile,
ProjectFolderLink,
DriveActivity,
]),
UserModule,
ActivityModule,
],
controllers: [DriveController],
providers: [DriveService, DriveAuditService],
exports: [DriveService],
})
export class DriveModule {}
+538
View File
@@ -0,0 +1,538 @@
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { HttpStatus } from '@nestjs/common';
import { In, IsNull, LessThan, Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import { StorageService } from '../../common/storage/storage.service';
import type { PresignedUpload } from '../../common/storage/storage.types';
import { UserService, type PublicUser } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { ActivityService } from '../activity/activity.service';
import type { ActivityType } from '../activity/entities/activity.entity';
import { DriveAuditService } from './drive-audit.service';
import { DriveFolder } from './entities/drive-folder.entity';
import { DriveFile } from './entities/drive-file.entity';
import { ProjectFolderLink } from './entities/project-folder-link.entity';
import type { AttachmentKind } from '../task/entities/attachment.entity';
import type { DriveFileStatus } from './entities/drive-file.entity';
import {
DRIVE_MAX_FILE_SIZE,
buildObjectKey,
deriveKind,
isAllowedMime,
} from './drive.config';
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';
// --- 응답 형태 ---
export interface DriveFolderResponse {
id: string;
name: string;
parentId: string | null;
owner: PublicUser | null;
createdAt: string;
updatedAt: string;
}
export interface DriveFileResponse {
id: string;
name: string;
size: number;
mime: string;
kind: AttachmentKind;
status: DriveFileStatus;
folderId: string | null;
owner: PublicUser | null;
createdAt: string;
updatedAt: string;
}
export interface DriveListResponse {
// 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
current: DriveFolderResponse | null;
breadcrumb: { id: string; name: string }[];
folders: DriveFolderResponse[];
files: DriveFileResponse[];
}
export interface PresignUploadResponse {
fileId: string;
file: DriveFileResponse;
upload: PresignedUpload;
}
@Injectable()
export class DriveService {
private readonly logger = new Logger(DriveService.name);
constructor(
@InjectRepository(DriveFolder)
private readonly folderRepo: Repository<DriveFolder>,
@InjectRepository(DriveFile)
private readonly fileRepo: Repository<DriveFile>,
@InjectRepository(ProjectFolderLink)
private readonly linkRepo: Repository<ProjectFolderLink>,
private readonly storage: StorageService,
private readonly userService: UserService,
private readonly activity: ActivityService,
private readonly audit: DriveAuditService,
) {}
/* ===================== 조회 ===================== */
// 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더.
// 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음).
async list(_userId: string, folderId?: string): Promise<DriveListResponse> {
if (!folderId) {
// 루트 — 공유 최상위 폴더/파일 전체
const [folders, files] = await Promise.all([
this.folderRepo.find({
where: { parent: IsNull() },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.fileRepo.find({
where: { folder: IsNull(), status: 'active' },
relations: { owner: true },
order: { name: 'ASC' },
}),
]);
return {
current: null,
breadcrumb: [],
folders: folders.map((f) => this.toFolderResponse(f, null)),
files: files.map((f) => this.toFileResponse(f, null)),
};
}
const folder = await this.loadFolder(folderId);
const [folders, files, breadcrumb] = await Promise.all([
this.folderRepo.find({
where: { parent: { id: folder.id } },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.fileRepo.find({
where: { folder: { id: folder.id }, status: 'active' },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.buildBreadcrumb(folder),
]);
return {
current: this.toFolderResponse(folder, folder.parent?.id ?? null),
breadcrumb,
folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
files: files.map((f) => this.toFileResponse(f, folder.id)),
};
}
/* ===================== 폴더 CRUD ===================== */
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
async createFolder(
userId: string,
dto: CreateFolderDto,
): Promise<DriveFolderResponse> {
let parent: DriveFolder | null = null;
if (dto.parentId) {
parent = await this.loadFolder(dto.parentId);
}
const owner = await this.getUserOrThrow(userId);
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
const created = await this.folderRepo.save(
this.folderRepo.create({
owner,
parent: parent ? { id: parent.id } : null,
name: dto.name,
path: '',
}),
);
created.path = `${parent ? parent.path : ''}${created.id}/`;
await this.folderRepo.save(created);
await this.audit.record({
actorId: userId,
type: 'folder_created',
payload: { name: created.name },
});
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
}
// 폴더 이름 변경 — 로그인 사용자 전원 가능.
async renameFolder(
userId: string,
folderId: string,
name: string,
): Promise<DriveFolderResponse> {
const folder = await this.loadFolder(folderId);
const fromName = folder.name;
folder.name = name;
await this.folderRepo.save(folder);
await this.audit.record({
actorId: userId,
type: 'folder_renamed',
payload: { fromName, toName: name },
});
return this.toFolderResponse(folder, folder.parent?.id ?? null);
}
// 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리.
async deleteFolder(userId: string, folderId: string): Promise<void> {
const folder = await this.loadFolder(folderId);
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
const descendantFiles = await this.fileRepo
.createQueryBuilder('f')
.innerJoin('f.folder', 'fd')
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
.getMany();
// 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다.
const projectIds = await this.resolveLinkedProjectIds(
this.parseFolderPath(folder.path),
);
await this.folderRepo.remove(folder);
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
await this.audit.record({
actorId: userId,
type: 'folder_deleted',
payload: { name: folder.name, fileCount: descendantFiles.length },
});
for (const projectId of projectIds) {
await this.activity.record({
projectId,
actorId: userId,
type: 'drive.folder_deleted',
payload: { folderName: folder.name, fileCount: descendantFiles.length },
});
}
}
/* ===================== 파일 업로드(Presigned) ===================== */
// ① 업로드용 presigned URL 발급 + pending 메타 생성.
async presignUpload(
userId: string,
dto: PresignUploadDto,
): Promise<PresignUploadResponse> {
if (!isAllowedMime(dto.contentType)) {
throw new BusinessException(
'VAL_001',
'업로드할 수 없는 파일 형식입니다.',
HttpStatus.BAD_REQUEST,
);
}
let folder: DriveFolder | null = null;
if (dto.folderId) {
folder = await this.loadFolder(dto.folderId);
}
const owner = await this.getUserOrThrow(userId);
const objectKey = buildObjectKey(userId, dto.filename);
const saved = await this.fileRepo.save(
this.fileRepo.create({
owner,
folder: folder ? { id: folder.id } : null,
name: dto.filename,
bucket: this.storage.bucket,
objectKey,
size: dto.size,
mime: dto.contentType,
kind: deriveKind(dto.contentType, dto.filename),
status: 'pending',
}),
);
const upload = await this.storage.presignUpload({
key: objectKey,
contentType: dto.contentType,
});
return {
fileId: saved.id,
file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
upload,
};
}
// ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
async completeUpload(
userId: string,
fileId: string,
): Promise<DriveFileResponse> {
const file = await this.fileRepo.findOne({
where: { id: fileId },
relations: { owner: true, folder: true },
});
if (!file) {
throw new NotFoundException();
}
// 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관)
if (file.owner?.id !== userId) {
throw new ForbiddenException();
}
if (file.status === 'deleted') {
// 이미 삭제된 묘비 행 — 확정 불가
throw new NotFoundException();
}
if (file.status === 'active') {
// 멱등 — 이미 확정된 경우 현재 상태 반환
return this.toFileResponse(file, file.folder?.id ?? null);
}
const head = await this.storage.headObject(file.objectKey);
if (!head) {
throw new BusinessException(
'BIZ_001',
'업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
if (head.size > DRIVE_MAX_FILE_SIZE) {
await this.discardFile(file);
throw new BusinessException(
'VAL_001',
'허용 최대 파일 크기를 초과했습니다.',
HttpStatus.BAD_REQUEST,
);
}
if (head.contentType && !isAllowedMime(head.contentType)) {
await this.discardFile(file);
throw new BusinessException(
'VAL_001',
'업로드할 수 없는 파일 형식입니다.',
HttpStatus.BAD_REQUEST,
);
}
file.size = head.size;
file.status = 'active';
await this.fileRepo.save(file);
await this.audit.record({
actorId: userId,
type: 'file_uploaded',
payload: { name: file.name },
});
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록
await this.recordFileActivity(file, userId, 'drive.file_uploaded');
return this.toFileResponse(file, file.folder?.id ?? null);
}
/* ===================== 파일 CRUD ===================== */
// 파일 이름 변경 — 로그인 사용자 전원 가능.
async renameFile(
userId: string,
fileId: string,
name: string,
): Promise<DriveFileResponse> {
const file = await this.loadFile(fileId);
const fromName = file.name;
file.name = name;
await this.fileRepo.save(file);
await this.audit.record({
actorId: userId,
type: 'file_renamed',
payload: { fromName, toName: name },
});
return this.toFileResponse(file, file.folder?.id ?? null);
}
// 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고,
// 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다.
async deleteFile(userId: string, fileId: string): Promise<void> {
const file = await this.loadFile(fileId);
file.status = 'deleted';
file.deletedAt = new Date();
file.deletedBy = { id: userId } as User;
await this.fileRepo.save(file);
await this.deleteObjects([file.objectKey]);
await this.audit.record({
actorId: userId,
type: 'file_deleted',
payload: { name: file.name },
});
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록
await this.recordFileActivity(file, userId, 'drive.file_deleted');
}
// 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능.
async getDownloadUrl(
_userId: string,
fileId: string,
): Promise<{ url: string }> {
const file = await this.loadFile(fileId);
if (file.status !== 'active') {
// 아직 업로드 확정 전 — 다운로드 불가
throw new NotFoundException();
}
const url = await this.storage.presignDownload({
key: file.objectKey,
filename: file.name,
contentType: file.mime,
});
return { url };
}
/* ===================== 로딩 ===================== */
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
private async loadFolder(folderId: string): Promise<DriveFolder> {
const folder = await this.folderRepo.findOne({
where: { id: folderId },
relations: { owner: true, parent: true },
});
if (!folder) {
throw new NotFoundException();
}
return folder;
}
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
private async loadFile(fileId: string): Promise<DriveFile> {
const file = await this.fileRepo.findOne({
where: { id: fileId },
relations: { owner: true, folder: true },
});
if (!file || file.status === 'deleted') {
throw new NotFoundException();
}
return file;
}
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
private parseFolderPath(path: string): string[] {
return path.split('/').filter(Boolean);
}
// 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
private async buildBreadcrumb(
folder: DriveFolder,
): Promise<{ id: string; name: string }[]> {
const ids = this.parseFolderPath(folder.path);
if (ids.length === 0) return [];
const rows = await this.folderRepo.find({
where: { id: In(ids) },
select: { id: true, name: true },
});
const byId = new Map(rows.map((r) => [r.id, r.name]));
return ids
.filter((id) => byId.has(id))
.map((id) => ({ id, name: byId.get(id) as string }));
}
/* ===================== 내부 유틸 ===================== */
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
private async recordFileActivity(
file: DriveFile,
actorId: string,
type: ActivityType,
): Promise<void> {
const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : [];
const projectIds = await this.resolveLinkedProjectIds(folderIds);
for (const projectId of projectIds) {
await this.activity.record({
projectId,
actorId,
type,
payload: { fileName: file.name },
});
}
}
// 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환.
private async resolveLinkedProjectIds(
folderIds: string[],
): Promise<string[]> {
if (folderIds.length === 0) return [];
const links = await this.linkRepo.find({
where: { folder: { id: In(folderIds) } },
relations: { project: true },
});
return [...new Set(links.map((l) => l.project.id))];
}
private async getUserOrThrow(userId: string): Promise<User> {
const user = await this.userService.findById(userId);
if (!user) {
throw new NotFoundException();
}
return user;
}
// 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
private async discardFile(file: DriveFile): Promise<void> {
await this.fileRepo.remove(file);
await this.deleteObjects([file.objectKey]);
}
// 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
private async deleteObjects(keys: string[]): Promise<void> {
for (const key of keys) {
try {
await this.storage.deleteObject(key);
} catch (err: unknown) {
this.logger.warn(
`객체 삭제 실패(무시): ${key}${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
@Cron(CronExpression.EVERY_HOUR)
async cleanupStalePending(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await this.fileRepo.find({
where: { status: 'pending', createdAt: LessThan(cutoff) },
});
if (stale.length === 0) return;
await this.deleteObjects(stale.map((f) => f.objectKey));
await this.fileRepo.remove(stale);
this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
} catch (err: unknown) {
this.logger.warn(
`미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/* ===================== 매핑 ===================== */
private toFolderResponse(
folder: DriveFolder,
parentId: string | null,
): DriveFolderResponse {
return {
id: folder.id,
name: folder.name,
parentId,
owner: folder.owner ? UserService.toPublic(folder.owner) : null,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
};
}
private toFileResponse(
file: DriveFile,
folderId: string | null,
): DriveFileResponse {
return {
id: file.id,
name: file.name,
size: file.size,
mime: file.mime,
kind: file.kind,
status: file.status,
folderId,
owner: file.owner ? UserService.toPublic(file.owner) : null,
createdAt: file.createdAt.toISOString(),
updatedAt: file.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,18 @@
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// 폴더 생성 요청
export class CreateFolderDto {
@ApiProperty({ description: '폴더명', example: '디자인 시안' })
@IsString()
@Length(1, 255, { message: '폴더명은 1~255자로 입력해 주세요.' })
name!: string;
@ApiPropertyOptional({
description: '상위 폴더 ID (생략 시 드라이브 루트)',
format: 'uuid',
})
@IsOptional()
@IsUUID('4', { message: '올바른 상위 폴더 ID가 아닙니다.' })
parentId?: string;
}
@@ -0,0 +1,45 @@
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DRIVE_MAX_FILE_SIZE } from '../drive.config';
// 업로드용 presigned URL 발급 요청
export class PresignUploadDto {
@ApiProperty({ description: '원본 파일명', example: '기획서.pdf' })
@IsString()
@Length(1, 255, { message: '파일명은 1~255자로 입력해 주세요.' })
filename!: string;
@ApiProperty({
description: 'MIME 타입(업로드 시 동일 Content-Type 으로 전송해야 함)',
example: 'application/pdf',
})
@IsString()
@Length(1, 255)
contentType!: string;
@ApiProperty({
description: '파일 크기(바이트)',
example: 1048576,
maximum: DRIVE_MAX_FILE_SIZE,
})
@IsInt({ message: '파일 크기가 올바르지 않습니다.' })
@Min(1, { message: '빈 파일은 업로드할 수 없습니다.' })
@Max(DRIVE_MAX_FILE_SIZE, { message: '허용 최대 파일 크기를 초과했습니다.' })
size!: number;
@ApiPropertyOptional({
description: '업로드 대상 폴더 ID (생략 시 드라이브 루트)',
format: 'uuid',
})
@IsOptional()
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
folderId?: string;
}
@@ -0,0 +1,10 @@
import { IsString, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 폴더/파일 이름 변경 요청 (공용)
export class RenameDto {
@ApiProperty({ description: '변경할 이름', example: '최종_시안.pdf' })
@IsString()
@Length(1, 255, { message: '이름은 1~255자로 입력해 주세요.' })
name!: string;
}
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용)
export type DriveActivityType =
| 'file_uploaded'
| 'file_renamed'
| 'file_deleted'
| 'folder_created'
| 'folder_renamed'
| 'folder_deleted';
// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적.
// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다.
// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙).
@Entity('drive_activities')
export class DriveActivity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL)
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'actor_id' })
actor!: User | null;
// 이벤트 타입
@Column({ type: 'varchar' })
type!: DriveActivityType;
// 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제)
@Column({ type: 'jsonb', default: () => "'{}'" })
payload!: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,102 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { DriveFolder } from './drive-folder.entity';
import { numericColumnTransformer } from '../../../common/database/numeric-column.transformer';
import type { AttachmentKind } from '../../task/entities/attachment.entity';
// 드라이브 파일 업로드 상태
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
@Entity('drive_files')
export class DriveFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User | null;
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'folder_id' })
folder!: Relation<DriveFolder> | null;
// 원본 파일명(다운로드 시 표시)
@Column()
name!: string;
// 저장 버킷 — 추후 버킷 이전 대비해 메타에 함께 보관
@Column()
bucket!: string;
// 버킷 내 객체 키(고유). 예: development/drive/{userId}/{uuid}-name.pdf
@Index({ unique: true })
@Column({ name: 'object_key' })
objectKey!: string;
// 파일 크기(바이트) — 대용량 대비 bigint(문자열) → number 변환
@Column({ type: 'bigint', transformer: numericColumnTransformer })
size!: number;
// MIME 타입
@Column()
mime!: string;
// 아이콘 종류(MIME/확장자에서 파생) — 업무/프로젝트 첨부와 동일 분류 재사용
@Column({ type: 'varchar' })
kind!: AttachmentKind;
// 업로드 상태 — pending 은 미완료(목록/다운로드 비노출), 정리 크론 대상
@Index()
@Column({ type: 'varchar', default: 'pending' })
status!: DriveFileStatus;
// AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음
@Column({
name: 'extracted_text',
type: 'text',
nullable: true,
select: false,
})
extractedText!: string | null;
// AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용
@Column({ name: 'extract_status', type: 'varchar', default: 'pending' })
extractStatus!: DriveExtractStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다.
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
deletedAt!: Date | null;
// 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'deleted_by_id' })
deletedBy!: User | null;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
@Entity('drive_folders')
export class DriveFolder {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User | null;
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
// Relation<> 래퍼: 자기참조 순환 회피
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'parent_id' })
parent!: Relation<DriveFolder> | null;
// 폴더명
@Column()
name!: string;
// 머터리얼라이즈드 패스 — 루트부터 자기 자신까지의 폴더 id 를 '/' 로 이어 저장(자기 포함, 끝에 '/').
// 예: "rootId/childId/" . 조상 연동 여부 판정과 하위 트리 조회(LIKE)에 사용한다.
// (parent_id 가 트리의 SSOT 이고, path 는 빠른 조회를 위한 파생값)
@Index()
@Column({ type: 'text' })
path!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Project } from '../../project/entities/project.entity';
import { DriveFolder } from './drive-folder.entity';
// 연동 권한 — read: 보기/다운로드만, read-write: 프로젝트 멤버도 업로드 가능
export type DriveLinkPermission = 'read' | 'read-write';
// 프로젝트-폴더 연동 — 드라이브 폴더를 프로젝트에 공유한다.
// 연동된 폴더와 그 하위 트리 전체가 해당 프로젝트 멤버에게 노출된다.
@Entity('project_folder_links')
@Unique('UQ_project_folder_link', ['project', 'folder'])
export class ProjectFolderLink {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 대상 프로젝트 (프로젝트 삭제 시 연동 해제)
@Index()
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제)
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'folder_id' })
folder!: Relation<DriveFolder>;
// 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'linked_by_id' })
linkedBy!: User | null;
// 연동 권한 — 기본 read(안전)
@Column({ type: 'varchar', default: 'read' })
permission!: DriveLinkPermission;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { GiteaService } from './gitea.service';
// Gitea 연동 모듈 — 외부 Gitea 인스턴스의 조직 저장소를 생성/수정/삭제하는 클라이언트 제공
@Module({
providers: [GiteaService],
exports: [GiteaService],
})
export class GiteaModule {}
@@ -1,178 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { GiteaApiError, GiteaService } from './gitea.service';
// ConfigService 간이 스텁 — 주어진 맵에서만 값을 반환
function configStub(values: Record<string, string>): ConfigService {
return {
get: (key: string): string | undefined => values[key],
} as unknown as ConfigService;
}
// fetch 응답 간이 모킹
function mockResponse(status: number, body: unknown): Promise<Response> {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : ''),
} as Response);
}
describe('GiteaService', () => {
const fullConfig = {
GITEA_BASE_URL: 'https://gitea.example.com/', // 끝 슬래시 포함 — 제거되어야 함
GITEA_TOKEN: 'secret-token',
GITEA_ORG: 'marketing-team',
};
afterEach(() => {
jest.restoreAllMocks();
});
describe('enabled', () => {
it('필수 env 가 모두 있으면 활성화', () => {
const svc = new GiteaService(configStub(fullConfig));
expect(svc.enabled).toBe(true);
expect(svc.org).toBe('marketing-team');
});
it('하나라도 비면 비활성화', () => {
const svc = new GiteaService(
configStub({ GITEA_BASE_URL: 'https://gitea.example.com' }),
);
expect(svc.enabled).toBe(false);
});
});
describe('createRepo', () => {
it('조직 경로로 POST 하고 응답을 매핑한다(베이스 URL 끝 슬래시 제거)', async () => {
const res = mockResponse(201, {
id: 42,
name: 'q3-launch',
full_name: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
clone_url: 'https://gitea.example.com/marketing-team/q3-launch.git',
html_url: 'https://gitea.example.com/marketing-team/q3-launch',
default_branch: 'main',
});
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
const svc = new GiteaService(configStub(fullConfig));
const result = await svc.createRepo({
name: 'q3-launch',
description: '런칭',
private: true,
defaultBranch: 'main',
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(
'https://gitea.example.com/api/v1/orgs/marketing-team/repos',
);
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toMatchObject({
name: 'q3-launch',
description: '런칭',
private: true,
default_branch: 'main',
auto_init: true,
});
expect(result).toEqual({
id: 42,
name: 'q3-launch',
fullName: 'marketing-team/q3-launch',
description: 'q3 — 런칭',
private: true,
cloneUrl: 'https://gitea.example.com/marketing-team/q3-launch.git',
htmlUrl: 'https://gitea.example.com/marketing-team/q3-launch',
defaultBranch: 'main',
});
});
it('실패 응답은 상태코드를 보존한 GiteaApiError 로 던진다', async () => {
jest
.spyOn(global, 'fetch')
.mockReturnValue(mockResponse(409, 'conflict'));
const svc = new GiteaService(configStub(fullConfig));
const call = () =>
svc.createRepo({
name: 'dup',
description: '',
private: false,
defaultBranch: 'main',
});
await expect(call()).rejects.toBeInstanceOf(GiteaApiError);
await expect(call()).rejects.toMatchObject({ status: 409 });
});
});
describe('listOrgRepos', () => {
it('조직 저장소를 페이지네이션 순회하며 매핑한다', async () => {
const page1 = Array.from({ length: 50 }, (_, i) => ({
id: i + 1,
name: `repo-${i + 1}`,
full_name: `marketing-team/repo-${i + 1}`,
description: '',
private: false,
clone_url: `https://gitea.example.com/marketing-team/repo-${i + 1}.git`,
html_url: `https://gitea.example.com/marketing-team/repo-${i + 1}`,
default_branch: 'main',
}));
const page2 = [
{
id: 51,
name: 'repo-51',
full_name: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
clone_url: 'https://gitea.example.com/marketing-team/repo-51.git',
html_url: 'https://gitea.example.com/marketing-team/repo-51',
default_branch: 'develop',
},
];
const fetchMock = jest
.spyOn(global, 'fetch')
.mockReturnValueOnce(mockResponse(200, page1))
.mockReturnValueOnce(mockResponse(200, page2));
const svc = new GiteaService(configStub(fullConfig));
const result = await svc.listOrgRepos();
// 첫 페이지가 한도(50)면 다음 페이지까지 요청
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(
'https://gitea.example.com/api/v1/orgs/marketing-team/repos?page=1&limit=50',
);
expect(result).toHaveLength(51);
expect(result[50]).toEqual({
id: 51,
name: 'repo-51',
fullName: 'marketing-team/repo-51',
description: '표시명 — 설명',
private: true,
cloneUrl: 'https://gitea.example.com/marketing-team/repo-51.git',
htmlUrl: 'https://gitea.example.com/marketing-team/repo-51',
defaultBranch: 'develop',
});
});
});
describe('deleteRepo', () => {
it('repo 경로로 DELETE 한다', async () => {
const res = mockResponse(204, '');
const fetchMock = jest.spyOn(global, 'fetch').mockReturnValue(res);
const svc = new GiteaService(configStub(fullConfig));
await svc.deleteRepo('q3-launch');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(
'https://gitea.example.com/api/v1/repos/marketing-team/q3-launch',
);
expect(init?.method).toBe('DELETE');
});
});
});
-220
View File
@@ -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,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
// 메일 모듈 — 현재는 로그 기반 플레이스홀더(MailService) 제공.
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+44
View File
@@ -0,0 +1,44 @@
import { Injectable, Logger } from '@nestjs/common';
// 메일 발송 서비스 (플레이스홀더) —
// 현재는 실제 SMTP 전송 대신 백엔드 로그로 대체한다(개발/운영 공통).
// 실 메일러(nodemailer 등) 도입 전까지는 인증/재설정 링크를 로그에서 확인한다.
// ⚠️ 보안: 링크에는 원문 토큰이 포함된다. 실 메일러 도입 시 반드시 이 로그 출력을 제거하고
// 실제 이메일 전송으로 교체할 것(운영 로그에 토큰을 남기지 않기 위함).
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
// 가입 인증 메일 — 실 메일러 전까지 인증 링크(원문 토큰 포함)를 로그로 출력한다(개발/운영 공통).
sendVerificationEmail(to: string, name: string, verifyUrl: string): void {
this.logger.log(
[
'',
'──────────────── [메일 발송 - 가입 인증] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 이메일 인증을 완료해 주세요',
'본문 : 아래 링크를 클릭하면 회원가입이 완료됩니다.',
`인증 링크 : ${verifyUrl}`,
'──────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
// 비밀번호 재설정 메일 — 실 메일러 전까지 재설정 링크(원문 토큰 포함)를 로그로 출력한다(개발/운영 공통).
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
this.logger.log(
[
'',
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 비밀번호 재설정 안내',
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
`재설정 링크 : ${resetUrl}`,
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
'────────────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
}
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
} from 'class-validator';
// 화상회의 방 생성 요청
export class CreateMeetingRoomDto {
@ApiProperty({
description: '방 이름(한글/영문/숫자/-/_ 허용, 공백 불가, 고유)',
example: '주간-회의',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, {
message:
'방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)',
})
name!: string;
@ApiPropertyOptional({
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
example: '2026-07-01T10:00:00.000Z',
})
@IsOptional()
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
scheduledAt?: string;
@ApiPropertyOptional({
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
allowedUserIds?: string[];
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
export class CreateMeetingTokenDto {
@ApiProperty({
description: '참여할 회의 방 입장 코드(영문/숫자)',
example: 'a1b2c3d4e5',
})
@IsString()
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
@Matches(/^[A-Za-z0-9]+$/, {
message: '입장 코드 형식이 올바르지 않습니다.',
})
code!: string;
}
@@ -0,0 +1,5 @@
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
@Entity('meeting_rooms')
export class MeetingRoom {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
@Index({ unique: true })
@Column()
code!: string;
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
@Column()
name!: string;
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'host_id' })
host!: Relation<User> | null;
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
scheduledAt!: Date | null;
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
allowedUserIds!: string[] | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
MeetingService,
type MeetingTokenResult,
type MeetingRoomResponse,
} from './meeting.service';
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
@ApiTags('화상회의')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('meetings')
export class MeetingController {
constructor(private readonly meetingService: MeetingService) {}
@Get('rooms')
@ApiOperation({ summary: '회의 방 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
return this.meetingService.listRooms(user.id);
}
@Post('rooms')
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
@ApiResponse({ status: 201, description: '생성 성공' })
createRoom(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
return this.meetingService.createRoom(user.id, dto);
}
@Patch('rooms/:roomId')
@ApiOperation({ summary: '회의 방 수정(진행자만)' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
@Body() dto: UpdateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
return this.meetingService.updateRoom(user.id, roomId, dto);
}
@Delete('rooms/:roomId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
@ApiResponse({ status: 204, description: '삭제 성공' })
async deleteRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
): Promise<void> {
await this.meetingService.deleteRoom(user.id, roomId);
}
@Post('token')
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
issueToken(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingTokenDto,
): Promise<MeetingTokenResult> {
return this.meetingService.issueToken(user, dto.code);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MeetingController } from './meeting.controller';
import { MeetingService } from './meeting.service';
import { MeetingRoom } from './entities/meeting-room.entity';
import { User } from '../user/entities/user.entity';
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
// User repo 는 참여 제한(allow-list) 멤버를 진행자 편집 폼용으로 조회하는 데 사용한다.
@Module({
imports: [TypeOrmModule.forFeature([MeetingRoom, User])],
controllers: [MeetingController],
providers: [MeetingService],
exports: [MeetingService],
})
export class MeetingModule {}

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