Compare commits

...

6 Commits

Author SHA1 Message Date
ttipo 875eddf659 feat: 업무(Task) 모듈 4단계 — CRUD·상태머신·진행률 집계·역할 기반 상세 UI
백엔드
- Task/ChecklistItem 엔티티(저장소 내 순번 seq, jsonb content, assignees ManyToMany, checklist)
- GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../status
- 상태 머신(todo→prog→review→done, review→changes, changes→{review,prog}) + 전이별 역할 검증
- 담당자=저장소 멤버만 지정, 승인 완료(→done) 시 체크리스트 일괄 완료(영속)
- 저장소 doneCount/totalCount·멤버 taskCount 실집계(RepoModule→TaskModule)
- Task.repo FK 컬럼명 snake_case 고정(@JoinColumn), task.service 단위 테스트

프론트
- types/task·useTask·task.store, format.ts 라벨 파생(상태/마감/dday)
- RepoDetailPage 업무 목록, TaskCreatePage 생성/수정 겸용(편집 라우트)
- TaskDetailPage 역할 기반 액션 분기(지시자 승인/수정요청, 담당자 시작/승인요청)
- 액션 카드·상태 뱃지 디자인 시안 정렬, 작성폼/목록 UI 정리

문서: api-contract·integration-progress 4단계 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:12:11 +09:00
ttipo c01f8e535c feat: 멤버 관리 연동 + 사용자 검색 API (3단계)
- 멤버 목록/초대/역할변경/제거 프론트 연동(RepoMembersPage), 초대 자동완성
- GET /users 사용자 검색 API + UserController
- RepoMembersPage 역할 변경 드롭다운 잘림(overflow) 수정
- InviteMember/UpdateMember DTO, RepoMember 엔티티 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:11:46 +09:00
ttipo e2fe089321 feat: Gitea 프론트 연동(클론바·원격없음 배지)·import-admin 인프라 마무리
- GiteaRepoBar 컴포넌트(클론 주소 복사 + Gitea 열기)를 RepoSettingsPage 에 적용
- repo.store 뷰에 cloneUrl/htmlUrl/giteaMissing 매핑
- repo-sync: import 저장소 owner-admin 자동 지정(REPO_IMPORT_ADMIN_EMAIL)
- env example·docker-compose.dev 에 Gitea/import-admin 키 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:11:16 +09:00
ttipo b626bbdf6c chore: 브랜드 로고 에셋 추가 (logo png/svg)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:10:28 +09:00
ttipo ae385355b8 feat: Gitea 조직 저장소 역방향 동기화 (기동 시·매시간 import)
- GiteaService.listOrgRepos(): 조직 저장소 페이지네이션 조회
- RepoSyncService: OnApplicationBootstrap(비차단) + @Cron(매시간) 동기화
  - 추가형 import(멤버 0명), 연동정보 backfill, Gitea 누락 시 giteaMissing 표기
- Repo.giteaMissing 컬럼 + RepoResponse/프론트 ApiRepo 노출
- GET /repos 를 '조직 저장소 전체'(findAll)로, 단건은 인증 사용자 열람 허용
- docs(api-contract·integration-progress) 2.6단계 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:16:20 +09:00
ttipo 2b38e21188 chore: env example 파일 정비 (루트·프론트 추가, 단독 실행 예시 제거)
- 루트 .env.{development,production}.example 신규 (비밀값 placeholder 마스킹)
- frontend .env.{development,production}.example 신규 (docker build arg 주입 설명)
- backend example 의 단독 실행(non-Docker) 예시 블록 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:16:03 +09:00
74 changed files with 3914 additions and 600 deletions
+39
View File
@@ -0,0 +1,39 @@
# ==========================================
# 프로젝트 전역 설정
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=development
BUILD_MODE=development
FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# ==========================================
FRONTEND_PORT=5273
BACKEND_PORT=3100
WEBSOCKET_PORT=3101
DB_PORT=5532
REDIS_PORT=6479
# ==========================================
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=dev_ttipo
DB_PASSWORD=change-me-db-password
DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# ==========================================
BACKEND_API_URL=http://localhost:3100/api
CORS_ORIGIN=http://localhost:5273
+39
View File
@@ -0,0 +1,39 @@
# ==========================================
# 프로젝트 전역 설정
# ==========================================
PROJECT_NAME=comrelay
APP_ENV=production
BUILD_MODE=production
FRONTEND_DIR=frontend
BACKEND_DIR=backend
# ==========================================
# 포트 설정
# ==========================================
FRONTEND_PORT=5173
BACKEND_PORT=3000
WEBSOCKET_PORT=3001
DB_PORT=5432
REDIS_PORT=6379
# ==========================================
# Database 설정
# ==========================================
DB_IMAGE=postgres:15-alpine
DB_USER=prd_ttipo
DB_PASSWORD=change-me-db-password
DB_NAME=comrelay_db
DB_DATA_DIR=./data/postgres
# ==========================================
# Redis 설정
# ==========================================
REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# API 경로 설정
# ==========================================
BACKEND_API_URL=http://localhost:3000/api
CORS_ORIGIN=http://localhost:5173
+9 -14
View File
@@ -1,6 +1,7 @@
# ==========================================
# 백엔드 고유 설정 예시 — 복사해 .env.development 생성
# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등)
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.development 에서 주입한다.
# .example 을 복사해 .env.development 를 만들고 실제 값으로 채운다.
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
@@ -13,7 +14,7 @@ JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다.
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
@@ -22,15 +23,9 @@ 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_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
# DB_HOST=localhost
# DB_PORT=5532
# DB_USER=dev_ttipo
# DB_PASSWORD=dev_960426
# DB_NAME=comrelay_db
# FRONTEND_URL=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
+9 -14
View File
@@ -1,6 +1,7 @@
# ==========================================
# 백엔드 고유 설정 예시 — 복사해 .env.development 생성
# 루트 .env 에서 내려주지 않는 값만 작성한다 (JWT 등)
# 백엔드 고유 설정 — 루트 .env 에서 내려주지 않는 값만 작성 (JWT/Gitea 등)
# DB/Redis/CORS/포트 등은 docker-compose 가 루트 .env.production 에서 주입한다.
# .example 을 복사해 .env.production 를 만들고 실제 값으로 채운다.
# ==========================================
# JWT 시크릿 (access/refresh 분리) — 임의의 강력한 랜덤 문자열로 교체
@@ -13,7 +14,7 @@ JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=14d
# --- Gitea 연동 (외부 Gitea 인스턴스) ---
# 세 값이 모두 채워져야 연동 활성화. 비우면 저장소는 Gitea 동기화 없이 동작한다.
# 세 값(BASE_URL/TOKEN/ORG)이 모두 채워져야 연동 활성화. 비우면 Gitea 동기화 없이 동작한다.
# 저장소 생성/수정/삭제 시 GITEA_ORG 조직 아래 repo 가 자동 동기화된다.
# GITEA_BASE_URL: 끝 슬래시 없이 (/api/v1 는 코드가 붙임)
GITEA_BASE_URL=https://gitea.example.com
@@ -22,15 +23,9 @@ 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_OWNER=ttipo
GITEA_TEMPLATE_REPO=project-base
# --- 단독 실행(non-Docker) 시 주석 해제 ---
# NODE_ENV=development
# DB_HOST=localhost
# DB_PORT=5532
# DB_USER=dev_ttipo
# DB_PASSWORD=dev_960426
# DB_NAME=comrelay_db
# FRONTEND_URL=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173
# import(역방향 동기화로 가져온, 멤버 0명) 저장소의 기본 owner-admin 으로 지정할 가입 사용자 이메일.
# 동기화는 시스템 작업이라 실행 주체가 없으므로 여기서 지정한다.
# 비우거나 미가입 이메일이면 import 저장소는 멤버 0명으로 남고(초대 불가), 가입 후 다음 동기화에서 지정된다.
REPO_IMPORT_ADMIN_EMAIL=admin@example.com
+2
View File
@@ -12,6 +12,7 @@ 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';
@Module({
imports: [
@@ -63,6 +64,7 @@ import { RepoModule } from './modules/repo/repo.module';
UserModule,
AuthModule,
RepoModule,
TaskModule,
],
controllers: [AppController],
providers: [
@@ -48,7 +48,10 @@ describe('GiteaService', () => {
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',
@@ -77,7 +80,10 @@ describe('GiteaService', () => {
});
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',
@@ -103,6 +109,57 @@ describe('GiteaService', () => {
});
});
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, '');
@@ -4,7 +4,10 @@ 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;
@@ -24,7 +27,10 @@ export class GiteaApiError extends Error {
// 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;
@@ -146,6 +152,23 @@ export class GiteaService {
);
}
// 조직 아래 모든 저장소 조회(페이지네이션 순회) — 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
@@ -183,7 +206,10 @@ export class GiteaService {
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,
@@ -1,11 +1,4 @@
import {
IsEmail,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { IsEmail, IsIn, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
@@ -26,14 +19,4 @@ export class InviteMemberDto {
})
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType!: MemberRoleType;
@ApiProperty({
description: '저장소 내 직무(선택)',
example: '콘텐츠 디자인',
required: false,
})
@IsOptional()
@IsString()
@MaxLength(20)
subRole?: string;
}
@@ -1,8 +1,8 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsIn, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { MemberRoleType } from '../entities/repo-member.entity';
// 멤버 수정 DTO — 권한 역할/저장소 내 직무 (owner 는 변경 불가)
// 멤버 수정 DTO — 권한 역할 (owner 는 변경 불가)
export class UpdateMemberDto {
@ApiProperty({
description: '권한 역할',
@@ -12,10 +12,4 @@ export class UpdateMemberDto {
@IsOptional()
@IsIn(['admin', 'member'], { message: '권한 역할을 선택해 주세요.' })
roleType?: MemberRoleType;
@ApiProperty({ description: '저장소 내 직무', required: false })
@IsOptional()
@IsString()
@MaxLength(20)
subRole?: string;
}
@@ -37,10 +37,6 @@ export class RepoMember {
@Column({ name: 'is_owner', default: false })
isOwner!: boolean;
// 저장소 내 직무 표기(예: '리드', '콘텐츠 디자인') — 선택
@Column({ name: 'sub_role', type: 'varchar', nullable: true })
subRole!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -61,6 +61,11 @@ export class Repo {
@Column({ name: 'html_url', type: 'varchar', nullable: true })
htmlUrl!: string | null;
// Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신).
// 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지.
@Column({ name: 'gitea_missing', default: false })
giteaMissing!: boolean;
// 멤버 목록
@OneToMany(() => RepoMember, (member) => member.repo)
members!: Relation<RepoMember>[];
@@ -26,14 +26,11 @@ export class MemberController {
constructor(private readonly memberService: MemberService) {}
@Get()
@ApiOperation({ summary: '저장소 멤버 목록 (멤버 또는 공개)' })
@ApiOperation({ summary: '저장소 멤버 목록 (인증 사용자 열람 가능)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
list(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoMemberResponse[]> {
return this.memberService.list(repoId, user.id);
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(@Param('repoId') repoId: string): Promise<RepoMemberResponse[]> {
return this.memberService.list(repoId);
}
@Post()
@@ -2,11 +2,12 @@ import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { MemberService } from './member.service';
import { UserService } from '../user/user.service';
import { TaskService } from '../task/task.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// 저장소/멤버 리포지토리 + UserService 간이 모킹
// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹
function makeService() {
const repoRepo = { findOne: jest.fn() };
const memberRepo = {
@@ -17,12 +18,17 @@ function makeService() {
remove: jest.fn(),
};
const userService = { findByEmail: jest.fn() };
// 담당 업무 수 집계는 빈 Map(0건)으로 모킹
const taskService = {
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
};
const svc = new MemberService(
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
userService as unknown as UserService,
taskService as unknown as TaskService,
);
return { svc, repoRepo, memberRepo, userService };
return { svc, repoRepo, memberRepo, userService, taskService };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
@@ -30,17 +36,22 @@ const ADMIN = { roleType: 'admin' };
describe('MemberService', () => {
describe('list', () => {
it('멤버가 아니고 비공개면 접근 거부', async () => {
it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 목록에 현재 사용자(other-user)가 없음
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
memberRepo.find.mockResolvedValue([
{ user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null } },
{
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
email: 'a@b.c',
roleType: 'member',
isOwner: false,
},
]);
await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf(
ForbiddenException,
);
const result = await svc.list('q3-launch');
expect(result).toHaveLength(1);
expect(result[0].user.id).toBe('someone-else');
});
});
+11 -14
View File
@@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { TaskService } from '../task/task.service';
import { Repo } from './entities/repo.entity';
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
import { InviteMemberDto } from './dto/invite-member.dto';
@@ -17,7 +18,6 @@ import { UpdateMemberDto } from './dto/update-member.dto';
export interface RepoMemberResponse {
user: PublicUser;
email: string;
subRole: string | null;
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
roleType: MemberRoleType;
owner: boolean; // 소유자 여부(isOwner)
@@ -32,21 +32,21 @@ export class MemberService {
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly taskService: TaskService,
) {}
// 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능
async list(slugName: string, userId: string): Promise<RepoMemberResponse[]> {
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책).
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
async list(slugName: string): Promise<RepoMemberResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const members = await this.memberRepo.find({
where: { repo: { id: repo.id } },
relations: ['user'],
order: { createdAt: 'ASC' },
});
const isMember = members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return members.map((m) => this.toResponse(m));
// 사용자별 담당 업무 수 집계(taskCount)
const counts = await this.taskService.assigneeCounts(repo.id);
return members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0));
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
@@ -83,7 +83,6 @@ export class MemberService {
user,
roleType: dto.roleType,
isOwner: false,
subRole: dto.subRole?.trim() || null,
});
const saved = await this.memberRepo.save(member);
saved.user = user; // 관계 보장(응답 매핑용)
@@ -109,7 +108,6 @@ export class MemberService {
}
if (dto.roleType !== undefined) member.roleType = dto.roleType;
if (dto.subRole !== undefined) member.subRole = dto.subRole.trim() || null;
await this.memberRepo.save(member);
return this.toResponse(member);
@@ -170,13 +168,12 @@ export class MemberService {
}
}
// 엔티티 → 응답 매핑
private toResponse(member: RepoMember): RepoMemberResponse {
// 엔티티 → 응답 매핑 (taskCount 미제공 시 0)
private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse {
return {
user: UserService.toPublic(member.user),
email: member.user.email,
subRole: member.subRole,
taskCount: 0,
taskCount,
roleType: member.roleType,
owner: member.isOwner,
};
@@ -0,0 +1,240 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService } from '../user/user.service';
import type { User } from '../user/entities/user.entity';
import { GiteaService } from '../gitea/gitea.service';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// Gitea description 에서 분리한 표시명/설명
interface ParsedDescription {
name: string;
desc: string | null;
}
// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스.
// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다.
// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill.
// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다.
// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다.
@Injectable()
export class RepoSyncService implements OnApplicationBootstrap {
private readonly logger = new Logger(RepoSyncService.name);
// 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우)
private running = false;
// import(멤버 0명) 저장소에 기본 owner-admin 으로 지정할 사용자 이메일.
// 동기화는 시스템 작업이라 실행 주체가 없으므로 env 로 지정한다. 비어 있으면 멤버 0명으로 둔다.
private readonly importAdminEmail: string;
constructor(
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
config: ConfigService,
) {
this.importAdminEmail = (
config.get<string>('REPO_IMPORT_ADMIN_EMAIL') ?? ''
).trim();
}
// 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행
onApplicationBootstrap(): void {
void this.syncFromGitea('부트스트랩');
}
// 매시간 정각 동기화
@Cron(CronExpression.EVERY_HOUR)
async handleHourlySync(): Promise<void> {
await this.syncFromGitea('스케줄(매시간)');
}
// 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다.
async syncFromGitea(trigger: string): Promise<void> {
if (!this.gitea.enabled) {
this.logger.debug(`Gitea 미연동 — 동기화 건너뜀 (${trigger})`);
return;
}
if (this.running) {
this.logger.warn(`이전 동기화가 진행 중 — 건너뜀 (${trigger})`);
return;
}
this.running = true;
try {
const remote = await this.gitea.listOrgRepos();
const existing = await this.repoRepo.find();
// 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭
const byGiteaId = new Map<number, Repo>();
const bySlug = new Map<string, Repo>();
for (const r of existing) {
if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r);
bySlug.set(r.slugName, r);
}
const seenGiteaIds = new Set<number>();
let imported = 0;
let relinked = 0;
for (const gr of remote) {
seenGiteaIds.add(gr.id);
const match = byGiteaId.get(gr.id) ?? bySlug.get(gr.name);
if (match) {
// 기존 Relay 저장소 — 연동정보 backfill + 누락표시 해제.
// 표시명/설명/공개범위는 Relay 가 SSOT 이므로 덮어쓰지 않는다.
if (this.backfillLinkage(match, gr)) {
await this.repoRepo.save(match);
relinked++;
}
} else {
// Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음)
const { name, desc } = this.parseDescription(gr.description, gr.name);
const repo = this.repoRepo.create({
slugName: gr.name,
owner: this.gitea.org,
name,
description: desc,
visibility: gr.private ? 'private' : 'public',
branch: gr.defaultBranch || 'main',
giteaRepoId: gr.id,
giteaFullName: gr.fullName,
cloneUrl: gr.cloneUrl,
htmlUrl: gr.htmlUrl,
giteaMissing: false,
});
await this.repoRepo.save(repo);
imported++;
}
}
// Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 표기만 한다.
let missing = 0;
for (const r of existing) {
if (
r.giteaRepoId !== null &&
!seenGiteaIds.has(r.giteaRepoId) &&
!r.giteaMissing
) {
r.giteaMissing = true;
await this.repoRepo.save(r);
missing++;
}
}
// import(멤버 0명) 저장소에 기본 owner-admin 지정 — 멤버 할당 데드락 방지.
// (env REPO_IMPORT_ADMIN_EMAIL 미설정/미가입 시 건너뜀)
const adminAssigned = await this.ensureImportAdmin();
this.logger.log(
`Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing} · admin지정 ${adminAssigned}`,
);
} catch (e) {
this.logger.error(
`Gitea 동기화 실패 (${trigger})`,
e instanceof Error ? e.stack : String(e),
);
} finally {
this.running = false;
}
}
// import(멤버 0명) Gitea 연동 저장소에 기본 owner-admin 을 지정한다.
// env REPO_IMPORT_ADMIN_EMAIL 의 가입 사용자를 owner-admin 으로 등록(신규 import + 기존 멤버 0명 모두 대상).
// 반환: 이번 실행에서 admin 을 새로 붙인 저장소 수.
private async ensureImportAdmin(): Promise<number> {
if (!this.importAdminEmail) return 0;
const admin = await this.userService.findByEmail(this.importAdminEmail);
if (!admin) {
this.logger.warn(
`REPO_IMPORT_ADMIN_EMAIL(${this.importAdminEmail}) 에 해당하는 가입 사용자가 없어 import 저장소 admin 지정을 건너뜁니다. (가입 후 다음 동기화에서 지정됨)`,
);
return 0;
}
// Gitea 연동(import) 저장소 중 멤버가 0명인 것만 대상.
// Relay 생성 저장소는 생성자(owner)가 항상 멤버이므로 제외된다.
const linked = await this.repoRepo.find({ relations: ['members'] });
let assigned = 0;
for (const repo of linked) {
if (repo.giteaRepoId === null) continue;
if ((repo.members?.length ?? 0) > 0) continue;
await this.assignOwnerAdmin(repo, admin);
assigned++;
this.logger.log(
`import 저장소 owner-admin 지정: ${repo.slugName}${admin.email}`,
);
}
return assigned;
}
// 저장소에 사용자를 owner-admin 멤버로 등록(Relay 생성 시 owner 등록과 동일 규약)
private async assignOwnerAdmin(repo: Repo, user: User): Promise<void> {
const member = this.memberRepo.create({
repo,
user,
roleType: 'admin',
isOwner: true,
});
await this.memberRepo.save(member);
}
// 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true
private backfillLinkage(
repo: Repo,
gr: {
id: number;
fullName: string;
cloneUrl: string;
htmlUrl: string;
},
): boolean {
let changed = false;
if (repo.giteaRepoId !== gr.id) {
repo.giteaRepoId = gr.id;
changed = true;
}
if (repo.giteaFullName !== gr.fullName) {
repo.giteaFullName = gr.fullName;
changed = true;
}
if (repo.cloneUrl !== gr.cloneUrl) {
repo.cloneUrl = gr.cloneUrl;
changed = true;
}
if (repo.htmlUrl !== gr.htmlUrl) {
repo.htmlUrl = gr.htmlUrl;
changed = true;
}
if (repo.giteaMissing) {
repo.giteaMissing = false;
changed = true;
}
return changed;
}
// Gitea description("표시제목 — 설명") 에서 표시명/설명 분리.
// Relay 생성 저장소는 buildGiteaDescription 규칙을 따르고,
// 구분자가 없는 Gitea-native 저장소는 slug 를 표시명으로 사용한다.
private parseDescription(
description: string,
slug: string,
): ParsedDescription {
const sep = ' — ';
const d = (description ?? '').trim();
if (!d) return { name: slug, desc: null };
const idx = d.indexOf(sep);
if (idx > 0) {
return {
name: d.slice(0, idx).trim(),
desc: d.slice(idx + sep.length).trim() || null,
};
}
return { name: slug, desc: d };
}
}
+5 -9
View File
@@ -26,10 +26,10 @@ export class RepoController {
constructor(private readonly repoService: RepoService) {}
@Get()
@ApiOperation({ summary: '내가 멤버인 저장소 목록' })
@ApiOperation({ summary: '조직 저장소 전체 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
findAll(@CurrentUser() user: PublicUser): Promise<RepoResponse[]> {
return this.repoService.findAllForUser(user.id);
findAll(): Promise<RepoResponse[]> {
return this.repoService.findAll();
}
@Post()
@@ -61,13 +61,9 @@ export class RepoController {
@Get(':repoId')
@ApiOperation({ summary: '저장소 단건 조회' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@CurrentUser() user: PublicUser,
): Promise<RepoResponse> {
return this.repoService.findOneForUser(repoId, user.id);
findOne(@Param('repoId') repoId: string): Promise<RepoResponse> {
return this.repoService.findOne(repoId);
}
@Patch(':repoId')
+5 -2
View File
@@ -2,22 +2,25 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { GiteaModule } from '../gitea/gitea.module';
import { TaskModule } from '../task/task.module';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { RepoService } from './repo.service';
import { RepoController } from './repo.controller';
import { MemberService } from './member.service';
import { MemberController } from './member.controller';
import { RepoSyncService } from './repo-sync.service';
// 저장소 모듈 (저장소 + 멤버 관리)
// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화)
@Module({
imports: [
TypeOrmModule.forFeature([Repo, RepoMember]),
UserModule,
GiteaModule,
TaskModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService],
providers: [RepoService, MemberService, RepoSyncService],
exports: [RepoService, MemberService],
})
export class RepoModule {}
+34 -29
View File
@@ -6,7 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
@@ -14,6 +14,7 @@ import {
GiteaService,
type GiteaRepoResult,
} from '../gitea/gitea.service';
import { TaskService } from '../task/task.service';
import { Repo, type RepoVisibility } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
import { CreateRepoDto } from './dto/create-repo.dto';
@@ -40,6 +41,7 @@ export interface RepoResponse {
totalCount: number;
cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null)
htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null)
giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
updatedAt: string;
}
@@ -55,6 +57,7 @@ export class RepoService {
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly taskService: TaskService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
@@ -73,34 +76,22 @@ export class RepoService {
};
}
// 내가 멤버인 저장소 목록
async findAllForUser(userId: string): Promise<RepoResponse[]> {
const memberships = await this.memberRepo.find({
where: { user: { id: userId } },
relations: ['repo'],
});
const repoIds = memberships.map((m) => m.repo.id);
if (repoIds.length === 0) return [];
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분.
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
async findAll(): Promise<RepoResponse[]> {
const repos = await this.repoRepo.find({
where: { id: In(repoIds) },
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
});
return repos.map((repo) => this.toResponse(repo));
// 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영
const counts = await this.taskService.countsByRepo(repos.map((r) => r.id));
return repos.map((repo) => this.toResponse(repo, counts.get(repo.id)));
}
// 저장소 단건 — 접근 권한 확인(멤버이거나 공개)
async findOneForUser(
slugName: string,
userId: string,
): Promise<RepoResponse> {
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
async findOne(slugName: string): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
const isMember = repo.members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return this.toResponse(repo);
return this.toResponse(repo, await this.taskCountsOf(repo.id));
}
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
@@ -163,10 +154,10 @@ export class RepoService {
user,
roleType: 'admin',
isOwner: true,
subRole: '리드',
});
await this.memberRepo.save(ownerMember);
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
} catch (err) {
// Relay 저장 실패 시 방금 만든 Gitea 저장소를 정리(고아 방지, 베스트 에포트)
@@ -224,7 +215,10 @@ export class RepoService {
});
}
return this.toResponse(await this.getEntityOrThrow(slugName));
return this.toResponse(
await this.getEntityOrThrow(slugName),
await this.taskCountsOf(repo.id),
);
}
// 저장소 삭제 — 소유자만 가능
@@ -330,8 +324,18 @@ export class RepoService {
}
}
// 엔티티 → 응답 매핑
private toResponse(repo: Repo): RepoResponse {
// 단일 저장소의 업무 완료/전체 수 집계
private async taskCountsOf(
repoId: string,
): Promise<{ done: number; total: number } | undefined> {
return (await this.taskService.countsByRepo([repoId])).get(repoId);
}
// 엔티티 → 응답 매핑 (counts 미제공 시 0/0)
private toResponse(
repo: Repo,
counts?: { done: number; total: number },
): RepoResponse {
const members = (repo.members ?? []).map((m) =>
UserService.toPublic(m.user),
);
@@ -345,11 +349,12 @@ export class RepoService {
branch: repo.branch,
members,
memberCount: members.length,
// TODO(4단계): 업무 모듈 도입 시 실제 완료/전체 수 집계
doneCount: 0,
totalCount: 0,
// 업무 모듈 집계(없으면 0/0)
doneCount: counts?.done ?? 0,
totalCount: counts?.total ?? 0,
cloneUrl: repo.cloneUrl,
htmlUrl: repo.htmlUrl,
giteaMissing: repo.giteaMissing,
updatedAt: repo.updatedAt.toISOString(),
};
}
@@ -0,0 +1,22 @@
import { IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { TaskStatus } from '../entities/task.entity';
// 업무 상태 변경 요청 — 상태 머신 전이 규칙은 서비스에서 검증
const TASK_STATUSES: TaskStatus[] = [
'todo',
'prog',
'review',
'done',
'changes',
];
export class ChangeStatusDto {
@ApiProperty({
description: '변경할 상태',
enum: TASK_STATUSES,
example: 'prog',
})
@IsIn(TASK_STATUSES, { message: '올바른 업무 상태가 아닙니다.' })
status!: TaskStatus;
}
@@ -0,0 +1,78 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 체크리스트 입력 항목 (생성 시 함께 등록)
export class ChecklistInputDto {
@ApiProperty({
description: '체크리스트 항목 내용',
example: '가로형 배너 디자인',
})
@IsString()
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
}
// 업무 생성 요청
export class CreateTaskDto {
@ApiProperty({
description: '업무 제목',
example: '메인 배너 이미지 2종 디자인',
})
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '본문 문단 배열(선택)',
type: [String],
required: false,
example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
example: ['a1b2c3d4-...'],
})
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds!: string[];
@ApiProperty({
description: '마감 기한(ISO8601, 선택)',
required: false,
example: '2026-06-18T00:00:00Z',
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string;
@ApiProperty({
description: '체크리스트(선택)',
type: [ChecklistInputDto],
required: false,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
}
@@ -0,0 +1,51 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트)
export class UpdateTaskDto {
@ApiProperty({ description: '업무 제목', required: false })
@IsOptional()
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title?: string;
@ApiProperty({
description: '본문 문단 배열',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds?: string[];
@ApiProperty({
description: '마감 기한(ISO8601). null 이면 마감 해제',
required: false,
nullable: true,
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string | null;
}
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Task } from './task.entity';
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@ManyToOne(() => Task, (task) => task.checklist, { onDelete: 'CASCADE' })
task!: Relation<Task>;
// 항목 내용
@Column()
text!: string;
// 완료 여부
@Column({ default: false })
done!: boolean;
// 표시 순서(생성 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { ChecklistItem } from './checklist-item.entity';
// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review)
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes';
// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9)
@Entity('tasks')
@Unique(['repo', 'seq'])
export class Task {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가
@Index()
@Column({ type: 'int' })
seq!: number;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치)
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
// 업무 제목
@Column()
title!: string;
// 본문 문단 배열(멘션은 @로 시작) — jsonb 로 보관
@Column({ type: 'jsonb', default: () => "'[]'" })
content!: string[];
// 진행 상태
@Column({ type: 'varchar', default: 'todo' })
status!: TaskStatus;
// 마감 기한(선택)
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate!: Date | null;
// 지시자(업무 생성자) — 사용자 삭제 시에도 업무는 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
name: 'task_assignees',
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
assignees!: User[];
// 체크리스트 항목
@OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true })
checklist!: Relation<ChecklistItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+120
View File
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
TaskService,
type RepoTaskResponse,
type TaskDetailResponse,
} from './task.service';
import type { TaskStatus } from './entities/task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { ChangeStatusDto } from './dto/change-status.dto';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@Controller('repos/:repoId/tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
@ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(
@Param('repoId') repoId: string,
@Query('status') status?: TaskStatus,
@Query('assignee') assignee?: string,
): Promise<RepoTaskResponse[]> {
return this.taskService.list(repoId, { status, assignee });
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '업무 생성 (admin)' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 400,
description: '담당자가 저장소 멤버가 아님 (BIZ_001)',
})
create(
@Param('repoId') repoId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.create(repoId, user.id, dto);
}
@Get(':taskId')
@ApiOperation({ summary: '업무 상세' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
): Promise<TaskDetailResponse> {
return this.taskService.findOne(repoId, taskId);
}
@Patch(':taskId')
@ApiOperation({ summary: '업무 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.update(repoId, user.id, taskId, dto);
}
@Delete(':taskId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 삭제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.taskService.remove(repoId, user.id, taskId);
return null;
}
@Post(':taskId/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 상태 변경 (상태 머신)' })
@ApiResponse({ status: 200, description: '변경 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 409,
description: '허용되지 않는 상태 전이 (BIZ_001)',
})
changeStatus(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ChangeStatusDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
// 업무 모듈 (업무 CRUD + 상태 전이)
@Module({
imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])],
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService],
})
export class TaskModule {}
@@ -0,0 +1,132 @@
import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { TaskService } from './task.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
// 리포지토리 간이 모킹
function makeService() {
const taskRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
const checklistRepo = { create: jest.fn(), save: jest.fn() };
const repoRepo = { findOne: jest.fn() };
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
const svc = new TaskService(
taskRepo as unknown as Repository<Task>,
checklistRepo as unknown as Repository<ChecklistItem>,
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
);
return { svc, taskRepo, checklistRepo, repoRepo, memberRepo };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
describe('TaskService', () => {
describe('changeStatus — 상태 머신', () => {
it('허용되지 않는 전이(todo→done)는 BusinessException', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' });
taskRepo.findOne.mockResolvedValue({
seq: 1,
status: 'todo',
issuer: { id: 'admin' },
assignees: [],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'admin', 1, 'done'),
).rejects.toBeInstanceOf(BusinessException);
});
it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 요청자는 일반 멤버(담당자)
memberRepo.findOne.mockResolvedValue({ roleType: 'member' });
taskRepo.findOne.mockResolvedValue({
seq: 9,
status: 'review',
issuer: { id: 'someone-else' },
assignees: [{ id: 'assignee' }],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'assignee', 9, 'done'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('비멤버는 상태 변경 불가(Forbidden)', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음
await expect(
svc.changeStatus('q3-launch', 'outsider', 1, 'prog'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => {
const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } =
makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); // 지시자/admin
const task = {
seq: 9,
status: 'review',
title: '배너',
content: [],
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
assignees: [],
checklist: [
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
{ id: 'c3', text: '모바일', done: false, sortOrder: 2 },
],
dueDate: null,
createdAt: new Date('2026-06-08T00:00:00Z'),
};
taskRepo.findOne.mockResolvedValue(task);
taskRepo.save.mockResolvedValue(task);
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.changeStatus('q3-launch', 'admin', 9, 'done');
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
expect(task.checklist.every((c) => c.done)).toBe(true);
expect(result.checklist.every((c) => c.done)).toBe(true);
});
});
describe('create — 담당자 멤버 검증', () => {
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// assertAdmin 통과
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
user: { id: 'admin' },
});
// 요청한 담당자 2명 중 매칭 멤버십 1건만 → 비멤버 포함
memberRepo.find.mockResolvedValue([{ user: { id: 'u1' } }]);
await expect(
svc.create('q3-launch', 'admin', {
title: '배너 디자인',
assigneeIds: ['u1', 'u2'],
}),
).rejects.toBeInstanceOf(BusinessException);
});
});
});
+404
View File
@@ -0,0 +1,404 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task, type TaskStatus } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
// 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등)
export interface RepoTaskResponse {
id: number; // 저장소 내 순번(seq)
title: string;
status: TaskStatus;
dueDate: string | null;
checklist: [number, number]; // [완료, 전체]
commentCount: number; // 5단계 전까지 0
assignees: PublicUser[];
}
// 체크리스트 항목 응답
export interface ChecklistItemResponse {
id: string;
text: string;
done: boolean;
}
// 업무 상세 응답 — comments/activities/files/approval 은 5단계에서 채운다(현재 빈 값)
export interface TaskDetailResponse {
id: number;
repoId: string; // slugName
repoName: string;
title: string;
status: TaskStatus;
content: string[];
checklist: ChecklistItemResponse[];
assignees: PublicUser[];
issuer: PublicUser | null;
dueDate: string | null;
createdAt: string;
comments: never[];
activities: never[];
files: never[];
}
// 상태 머신 전이 규칙(허용 목적지)
const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
todo: ['prog'],
prog: ['review'],
review: ['done', 'changes'],
changes: ['review', 'prog'],
done: [],
};
// 상태 한국어 라벨(에러 문구용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '승인 대기',
done: '완료',
changes: '수정 요청',
};
// 업무 비즈니스 로직
@Injectable()
export class TaskService {
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(ChecklistItem)
private readonly checklistRepo: Repository<ChecklistItem>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
) {}
// 업무 목록 — 상태/담당자 필터(선택). seq 내림차순(최신 우선)
async list(
slugName: string,
filters: { status?: TaskStatus; assignee?: string },
): Promise<RepoTaskResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const qb = this.taskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.assignees', 'assignee')
.leftJoinAndSelect('task.checklist', 'checklist')
.where('task.repo = :repoId', { repoId: repo.id })
.orderBy('task.seq', 'DESC')
.addOrderBy('checklist.sort_order', 'ASC');
if (filters.status) {
qb.andWhere('task.status = :status', { status: filters.status });
}
// 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림)
if (filters.assignee) {
qb.andWhere(
'task.id IN ' +
qb
.subQuery()
.select('ta.task_id')
.from('task_assignees', 'ta')
.where('ta.user_id = :assignee')
.getQuery(),
{ assignee: filters.assignee },
);
}
const tasks = await qb.getMany();
return tasks.map((t) => this.toListResponse(t));
}
// 업무 단건 상세 — repo 내 순번(seq)으로 조회
async findOne(slugName: string, seq: number): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const task = await this.getTaskOrThrow(repo.id, seq);
return this.toDetailResponse(task, repo);
}
// 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능
async create(
slugName: string,
actingUserId: string,
dto: CreateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const issuer = await this.assertAdmin(repo.id, actingUserId);
const assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
// 저장소 내 다음 순번 채번
const last = await this.taskRepo.findOne({
where: { repo: { id: repo.id } },
order: { seq: 'DESC' },
});
const seq = (last?.seq ?? 0) + 1;
const task = this.taskRepo.create({
seq,
repo,
title: dto.title,
content: dto.content ?? [],
status: 'todo',
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
issuer: issuer.user,
assignees,
checklist: (dto.checklist ?? []).map((c, idx) =>
this.checklistRepo.create({
text: c.text,
done: false,
sortOrder: idx,
}),
),
});
const saved = await this.taskRepo.save(task);
return this.toDetailResponse(
await this.getTaskOrThrow(repo.id, saved.seq),
repo,
);
}
// 업무 수정 — admin 권한. 제공된 필드만 갱신
async update(
slugName: string,
actingUserId: string,
seq: number,
dto: UpdateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
if (dto.title !== undefined) task.title = dto.title;
if (dto.content !== undefined) task.content = dto.content;
if (dto.dueDate !== undefined) {
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
}
if (dto.assigneeIds !== undefined) {
task.assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
}
await this.taskRepo.save(task);
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// 업무 삭제 — admin 권한
async remove(
slugName: string,
actingUserId: string,
seq: number,
): Promise<void> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
await this.taskRepo.remove(task);
}
// 상태 변경 — 상태 머신 전이 규칙 + 역할 검증
async changeStatus(
slugName: string,
actingUserId: string,
seq: number,
next: TaskStatus,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: actingUserId } },
});
if (!membership) {
throw new ForbiddenException();
}
const task = await this.getTaskOrThrow(repo.id, seq);
const from = task.status;
if (from === next) {
return this.toDetailResponse(task, repo);
}
const allowed = TRANSITIONS[from] ?? [];
if (!allowed.includes(next)) {
throw new BusinessException(
'BIZ_001',
`'${STATUS_LABEL[from]}' 상태에서 '${STATUS_LABEL[next]}'(으)로 변경할 수 없습니다.`,
HttpStatus.CONFLICT,
);
}
const isAdmin = membership.roleType === 'admin';
const isIssuer = task.issuer?.id === actingUserId;
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
const permitted =
from === 'review'
? isAdmin || isIssuer
: isAdmin || isIssuer || isAssignee;
if (!permitted) {
throw new ForbiddenException();
}
task.status = next;
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속)
if (next === 'done' && task.checklist?.length) {
for (const item of task.checklist) item.done = true;
await this.checklistRepo.save(task.checklist);
}
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// --- 집계(다른 모듈에서 사용) ---
// 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용
async countsByRepo(
repoIds: string[],
): Promise<Map<string, { done: number; total: number }>> {
const result = new Map<string, { done: number; total: number }>();
if (repoIds.length === 0) return result;
const rows = await this.taskRepo
.createQueryBuilder('task')
.select('task.repo_id', 'repoId')
.addSelect('COUNT(*)', 'total')
.addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done')
.where('task.repo_id IN (:...repoIds)', { repoIds })
.groupBy('task.repo_id')
.getRawMany<{ repoId: string; total: string; done: string }>();
for (const r of rows) {
result.set(r.repoId, {
done: Number(r.done),
total: Number(r.total),
});
}
return result;
}
// 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용
async assigneeCounts(repoId: string): Promise<Map<string, number>> {
const rows = await this.taskRepo
.createQueryBuilder('task')
.innerJoin('task_assignees', 'ta', 'ta.task_id = task.id')
.select('ta.user_id', 'userId')
.addSelect('COUNT(*)', 'count')
.where('task.repo_id = :repoId', { repoId })
.groupBy('ta.user_id')
.getRawMany<{ userId: string; count: string }>();
const map = new Map<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
// --- 내부 헬퍼 ---
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// repo 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404
private async getTaskOrThrow(repoId: string, seq: number): Promise<Task> {
const task = await this.taskRepo.findOne({
where: { repo: { id: repoId }, seq },
relations: ['assignees', 'issuer', 'checklist'],
});
if (!task) {
throw new NotFoundException();
}
// 체크리스트 표시 순서 보정
task.checklist?.sort((a, b) => a.sortOrder - b.sortOrder);
return task;
}
// admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403
private async assertAdmin(
repoId: string,
userId: string,
): Promise<RepoMember> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
relations: ['user'],
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
return membership;
}
// 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증
private async resolveMemberAssignees(
repoId: string,
assigneeIds: string[],
): Promise<RepoMember['user'][]> {
const uniqueIds = [...new Set(assigneeIds)];
const memberships = await this.memberRepo.find({
where: { repo: { id: repoId }, user: { id: In(uniqueIds) } },
relations: ['user'],
});
if (memberships.length !== uniqueIds.length) {
throw new BusinessException(
'BIZ_001',
'담당자는 저장소 멤버만 지정할 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
return memberships.map((m) => m.user);
}
// 엔티티 → 목록 행 응답
private toListResponse(task: Task): RepoTaskResponse {
const checklist = task.checklist ?? [];
const done = checklist.filter((c) => c.done).length;
return {
id: task.seq,
title: task.title,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
checklist: [done, checklist.length],
commentCount: 0,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
};
}
// 엔티티 → 상세 응답
private toDetailResponse(task: Task, repo: Repo): TaskDetailResponse {
const checklist = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ id: c.id, text: c.text, done: c.done }));
return {
id: task.seq,
repoId: repo.slugName,
repoName: repo.name,
title: task.title,
status: task.status,
content: task.content ?? [],
checklist,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
createdAt: task.createdAt.toISOString(),
comments: [],
activities: [],
files: [],
};
}
}
@@ -0,0 +1,24 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserService, type PublicUser } from './user.service';
// 사용자 컨트롤러 — 멤버 초대 자동완성을 위한 검색/목록 제공 (인증 필요)
@ApiTags('User')
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({
summary: '사용자 검색/목록 (멤버 초대 자동완성)',
description: 'q 가 있으면 이메일/이름 부분일치, 없으면 전체 목록(이름순).',
})
@ApiQuery({ name: 'q', required: false, description: '검색어(이메일/이름)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
async list(@Query('q') q?: string): Promise<PublicUser[]> {
const users = await this.userService.search(q);
return users.map((u) => UserService.toPublic(u));
}
}
+2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { User } from './entities/user.entity';
// 사용자 데이터 계층 모듈 — UserService 를 다른 모듈(Auth 등)에 제공
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
+16 -1
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ILike, Repository } from 'typeorm';
import { User } from './entities/user.entity';
// 외부 노출용 사용자 형태 (passwordHash 등 민감정보 제외)
@@ -29,6 +29,21 @@ export class UserService {
return this.userRepository.findOne({ where: { email } });
}
// 사용자 검색/목록 (멤버 초대 자동완성용) — 이메일/이름 부분일치(대소문자 무시).
// 검색어가 없으면 전체 목록(이름순, limit 개)을 반환한다.
search(query: string | undefined, limit = 50): Promise<User[]> {
const q = (query ?? '').trim();
if (!q) {
return this.userRepository.find({ order: { name: 'ASC' }, take: limit });
}
const like = ILike(`%${q}%`);
return this.userRepository.find({
where: [{ email: like }, { name: like }],
order: { name: 'ASC' },
take: limit,
});
}
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
findByEmailWithPassword(email: string): Promise<User | null> {
return this.userRepository
+8
View File
@@ -1,5 +1,7 @@
services:
frontend:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
target: build-stage
command: npm run dev -- --host 0.0.0.0 --port 80
@@ -16,6 +18,8 @@ services:
- app-network
backend:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행)
restart: "no"
build:
target: builder
command: npm run start:dev
@@ -33,10 +37,14 @@ services:
- app-network
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
ports:
- "${DB_PORT}:5432"
redis:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
ports:
- "${REDIS_PORT}:6379"
+68 -27
View File
@@ -79,7 +79,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 |
| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) |
| GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) |
| POST | `/repos` | `CreateRepoDto` | `Repo` |
| GET | `/repos/:repoId` | — | `Repo`(상세) |
@@ -108,10 +108,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
"totalCount": 12,
"cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null
"htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null
"giteaMissing": false, // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
"updatedAt": "2026-06-16T11:00:00Z"
}
```
> `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산.
> `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다.
> 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage.
#### Gitea 연동 (외부 인스턴스)
@@ -132,50 +134,88 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
- Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502).
- 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김).
##### Gitea → Relay 역방향 동기화 (주기적 import)
조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`).
- **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음).
- **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로).
- **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구.
- Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함.
---
## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계**
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| GET | `.../members` | — | `RepoMember[]` | member+ |
| POST | `.../members` | `{ email, roleType, subRole? }` | `RepoMember` | admin |
| PATCH | `.../members/:userId` | `{ roleType?, subRole? }` | `RepoMember` | admin |
| GET | `.../members` | — | `RepoMember[]` | 인증(조직 모델상 누구나 열람) |
| POST | `.../members` | `{ email, roleType }` | `RepoMember` | admin |
| PATCH | `.../members/:userId` | `{ roleType? }` | `RepoMember` | admin |
| DELETE | `.../members/:userId` | — | `null` | admin (owner 제거 불가) |
`RepoMember`: `{ user: User, email, subRole, taskCount, roleType('admin'|'member'), owner }`
(`isYou` 는 프론트가 현재 사용자와 비교해 산출)
`RepoMember`: `{ user: User, email, taskCount, roleType('admin'|'member'), owner }`
(`isYou` 는 프론트가 현재 사용자와 비교해 산출. `taskCount` 는 4단계에서 사용자별 담당 업무 수로 실집계)
> **가시성 정책**: 멤버십은 **쓰기/권한과 업무 담당 자격**에만 쓰이고, 저장소·멤버·업무의 **조회는 인증 사용자면 누구나 가능**(단일 조직 모델). 비멤버를 가리는 가시성 가드는 도입하지 않음.
> 매핑 페이지: RepoMembersPage(초대 모달, 역할 변경, 제거).
#### 사용자 검색 (`/api/users`) — 멤버 초대 자동완성
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| GET | `/users` | `?q=<검색어>` | `User[]`(PublicUser) | 인증 |
- `q` 있으면 **이메일/이름 부분일치(대소문자 무시)**, 없으면 전체 목록(이름순, 최대 50). 응답은 `PublicUser`(`{ id, email, name, role }`).
- 초대 모달의 이메일 입력에서 호출: 포커스 시 전체, 입력 시 부분일치 목록을 띄우고 선택하면 이메일이 채워진다. **이미 저장소 멤버인 사용자는 프론트가 목록에서 제외**(현재 멤버 목록과 id 비교).
---
## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계**
## 4. 업무 (`/api/repos/:repoId/tasks`) — **4단계 ✅ 구현 완료**
| 메서드 | 경로 | 요청 | 응답 |
|---|---|---|---|
| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` |
| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` |
| GET | `.../tasks/:taskId` | — | `TaskDetail` |
| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` |
| DELETE | `.../tasks/:taskId` | — | `null` |
| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` |
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| GET | `.../tasks` | `?status&assignee` | `RepoTask[]` | 인증 |
| POST | `.../tasks` | `CreateTaskDto` | `TaskDetail` | admin |
| GET | `.../tasks/:taskId` | — | `TaskDetail` | 인증 |
| PATCH | `.../tasks/:taskId` | `UpdateTaskDto` | `TaskDetail` | admin |
| DELETE | `.../tasks/:taskId` | — | `null` | admin |
| POST | `.../tasks/:taskId/status` | `{ status }` | `TaskDetail` | 멤버(전이별 역할 검증) |
- `:taskId`**저장소 내 순번(seq)** — 정수(`ParseIntPipe`). 전역 PK 는 별도 uuid.
- 목록 정렬: seq 내림차순(최신 우선). `?status`/`?assignee`(userId) 필터.
상태(`TaskStatus`): `todo | prog | review | done | changes`. 전이 규칙(상태 머신):
```
todo ─시작→ prog ─승인요청→ review ─승인→ done
▲ └─수정요청→ changes ─재요청→ review
└──────────────────────────────────┘
└──────────(changes→prog)───────────┘
```
- `POST .../status` 는 일반 상태 변경.
- **승인 워크플로우**(5단계와 함께): 담당자 "승인 요청"(prog→review), 지시자 "승인"(review→done) / "수정 요청"(review→changes).
전이 허용 맵: `todo→[prog]`, `prog→[review]`, `review→[done,changes]`, `changes→[review,prog]`, `done→[]`.
허용 외 전이는 `BIZ_001`(409).
`CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[], dueDate?: ISO, checklist?: {text}[] }`
`RepoTask`(목록 행): `{ id, title, status, dueDate, checklist: [done,total], commentCount, assignees: User[] }`
`TaskDetail`: mock 의 `TaskDetail` 인터페이스와 동일 — 단 라벨류는 원시값으로:
`{ id, repoId, repoName, title, status, content[], checklist[], comments[], activities[], assignees[], issuer, dueDate, files[], approval?, createdAt }`
> 매핑 페이지: RepoDetailPage(목록), TaskCreatePage, TaskDetailPage(지시자), TaskAssigneePage(담당자, state 분기).
**역할 검증**(`POST .../status`):
- 승인/수정요청(**review 출발**: review→done, review→changes) = **지시자(issuer) 또는 admin**.
- 그 외 작업 전이(시작 todo→prog, 승인요청 prog→review, 재요청 changes→review/prog) = **담당자(assignee) 또는 지시자/admin**.
- 비멤버는 전이 불가(403). `from === next` 면 no-op(현재 상태 반환).
**부수효과**:
- **담당자 지정은 저장소 멤버만 가능** — `assigneeIds` 에 비멤버 포함 시 `BIZ_001`(400, "담당자는 저장소 멤버만 지정할 수 있습니다.").
- **승인 완료(→done) 시 체크리스트 항목 done 을 모두 true 로 영속 처리**(승인=완료의 부수효과).
- 생성 시 status 는 항상 `todo`. 저장소 `doneCount/totalCount` 는 업무 status 집계(`done` 수/전체)로 실시간 반영, 멤버 `taskCount` 는 사용자별 담당 업무 수로 집계.
`CreateTaskDto`: `{ title, content?: string[], assigneeIds: uuid[](≥1), dueDate?: ISO, checklist?: {text}[] }`
`UpdateTaskDto`: `{ title?, content?: string[], assigneeIds?: uuid[], dueDate?: ISO|null }` — 제공된 필드만 갱신(체크리스트 항목 편집은 5단계).
`RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, assignees: User[] }`
`TaskDetail`: `{ id(seq), repoId, repoName, title, status, content[], checklist: {id,text,done}[], assignees[], issuer: User|null, dueDate: ISO|null, createdAt, comments[], activities[], files[] }`
> **comments/activities/files 는 5·6단계 영역** — 4단계 응답에서는 빈 배열로 내려간다. `approval` 객체도 5단계(현재 미포함).
> **프론트 매핑/동작**:
> - 목록 진입은 항상 `…/tasks/:taskId`(**TaskDetailPage**) — 이 화면이 **현재 사용자 역할로 액션을 분기**한다(지시자: 승인/수정요청, 담당자: 업무 시작/승인 요청/재요청·승인 대기 중). `isIssuer`/`isAssignee` 로 판별.
> - **수정**: 생성 폼(TaskCreatePage)을 편집 모드로 재사용 — 프론트 라우트 `…/tasks/:taskId/edit`(API 는 동일 PATCH). 기존 값 미리 채움, 체크리스트는 읽기 전용.
> - 라벨류(dueLabel/dday/statusLabel)는 `format.ts`(`taskStatusLabel`/`taskDueInfo`)에서 원시값으로 파생.
> - `TaskAssigneePage`(`/view`)는 mock '내 업무' 전용 잔존(7단계 정리). 댓글/AI 패널/첨부 업로드는 로컬 데모(5단계 실연동).
---
@@ -247,10 +287,11 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
```
src/modules/
auth/ (1) — User 엔티티, JWT 전략, Guard, 쿠키
repo/ (2) — Repo 엔티티
member/ (3) — RepoMember 조인 엔티티 (repo 모듈에 흡수 가능)
task/ (4,5) — Task / ChecklistItem / Comment / Reply / Attachment
auth/ (1) — User 엔티티, JWT 전략, Guard, 쿠키
repo/ (2) — Repo 엔티티 (+ member 흡수, Gitea 동기화)
member/ (3) — RepoMember 조인 엔티티 (repo 모듈에 흡수)
task/ (4) ✅ — Task / ChecklistItem (Comment / Reply / Attachment 는 5단계)
activity/ (6) — Activity 이벤트 (다른 모듈에서 emit)
my-tasks/ (7) — 집계 전용 (task 모듈에 흡수 가능)
```
> 실제 배치: `member` 는 `repo` 모듈에 흡수(`MemberService`/`MemberController` + `RepoMember` 재사용). `task` 모듈은 4단계 완료(엔티티·CRUD·상태머신·집계), 5단계에서 Comment/Reply/Attachment 추가 예정. `RepoModule` 이 진행률·taskCount 집계를 위해 `TaskModule` 을 import(엔티티만 참조해 순환 없음).
+71 -22
View File
@@ -2,8 +2,8 @@
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기**
> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시(2.5단계). 그 외 4~8단계 미착수.
> 최종 갱신: 2026-06-17 · 현재 위치: **0~4단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강·멤버 프론트 연동·멤버 초대 자동완성·import 멤버 데드락 해소·cloneUrl/htmlUrl·giteaMissing 배지·업무 CRUD/상태머신/진행률 집계), 5단계(업무 상세 부속) 대기**
> 남은 프론트 연동: 업무 상세의 댓글/체크리스트 토글/첨부/승인 노트(5단계). 그 외 6~8단계 미착수.
---
@@ -17,8 +17,8 @@
### 단계 순서
```
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드)
→ 4. 업무 → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트)
→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
```
---
@@ -55,9 +55,9 @@
**백엔드** `backend/src/modules/repo/`
- `Repo` 엔티티: `slugName`(영문, 라우팅 id, unique), `owner`('marketing-team' 고정 상수), `name`(한글 표시제목), `description`, `visibility`, `branch`.
- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `subRole`, `@Unique([repo, user])`.
- `RepoMember` 엔티티: repo↔user 조인, `roleType`(admin/member), `isOwner`, `@Unique([repo, user])`.
- `RepoService`: 내 멤버십 저장소 목록 / 단건(멤버·공개 접근 체크) / 생성(생성자를 owner·admin 등록) / 수정(admin) / 삭제(owner).
- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount`=0, 4단계에서 집계).
- `GET/POST/PATCH/DELETE /repos[/:repoId]` — 전부 `JwtAuthGuard`. 응답 `RepoResponse`(원시값; `doneCount/totalCount` 는 당시 0 하드코딩 → **4단계에서 업무 status 집계로 교체됨**).
**프론트**
- `shared/utils/format.ts`: `avatarColor`(id 해시)·`initialOf`·`relativeTimeKo`·`progressOf` 추가.
@@ -76,20 +76,65 @@
- **삭제**: Gitea repo 삭제 동기화. 베스트 에포트(실패해도 Relay 삭제 유지, 에러 로그).
- 인프라 변경 없음(외부 Gitea). 토큰은 JWT 처럼 `backend/.env.{env}` 의 백엔드 고유 시크릿. 커밋용 `backend/.env.{env}.example` 에 키 문서화.
> **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함.
> **프론트**: 공용 컴포넌트 `components/GiteaRepoBar.vue`(클론 주소 표시·복사 + 'Gitea에서 열기', 미연동 시 렌더 안 함)를 **RepoSettingsPage**(일반 카드)·**RepoDetailPage**(헤더 아래)에 적용. `Repo` 뷰 타입/`repo.store` 매핑에 `cloneUrl`/`htmlUrl`/`giteaMissing` 추가.
### 3단계 — 멤버 관리 ✅ (백엔드)
### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드)
**백엔드** `backend/src/modules/gitea/`, `modules/repo/repo-sync.service.ts`
- 문제: 동기화가 단방향(Relay→Gitea)이라 **Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음**.
- `GiteaService.listOrgRepos()`: 조직 저장소 페이지네이션 순회(`GET /orgs/{org}/repos`). 결과에 `name`/`description`/`private` 추가.
- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 매시간(`@Cron(EVERY_HOUR)`)** 동기화. `ScheduleModule.forRoot()``app.module` 에 이미 등록됨.
- **추가형**: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT.
- **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출.
- 미연동 시 건너뜀 + 중복 실행 가드.
- **import 저장소 멤버 데드락 해소(2026-06-17)**: import 저장소는 멤버 0명이라 admin 이 없어 아무도 멤버를 초대할 수 없는 막힘이 있었음. 동기화 끝에 **`REPO_IMPORT_ADMIN_EMAIL`(env) 의 가입 사용자를 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정**(신규 import + 기존 멤버 0명 모두 대상, Relay 생성분은 생성자가 이미 멤버라 제외). env 미설정/미가입이면 건너뜀(가입 후 다음 동기화에서 지정). 키 문서화: `backend/.env.{env}.example`.
- **목록 모델 변경**: `GET /repos` 가 '내 멤버 저장소' → **'조직 저장소 전체'**(`findAll`). 단건(`findOne`)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용.
> **프론트 ✅**: RepoListPage 에 `giteaMissing` '원격 없음' 배지(저장소 행 제목 옆) 표시. 헤더 'Gitea 연동됨' 배지는 실제 연동 여부(`htmlUrl` 보유)에 따라 노출되도록 보정. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.
### 3단계 — 멤버 관리 ✅ (백엔드 + 프론트)
**백엔드** `backend/src/modules/repo/`
- `MemberService` + `MemberController`(repo 모듈에 흡수, `RepoMember` 엔티티 재사용). 엔드포인트: `GET/POST/PATCH/DELETE /repos/:repoId/members[/:userId]`.
- **목록**: 멤버이거나 공개 저장소만 조회(생성순 정렬). 응답 `RepoMemberResponse { user, email, subRole, taskCount, roleType, owner }`.
- **목록**: 조직 모델상 **인증 사용자면 누구나 열람**(생성순 정렬, `RepoService.findOne` 과 동일 정책). 응답 `RepoMemberResponse { user, email, taskCount, roleType, owner }`. (※ 2.6단계 조직 모델 전환 후에도 list 가 옛 '멤버 or 공개' 체크를 유지해 import/타인 생성 저장소 멤버 탭이 403 나던 버그 수정 — 2026-06-17)
- **초대**: admin 권한. **이미 가입된 사용자만 이메일로 초대**(별도 초대 메일/계정 생성 없음). 미가입 이메일→`BIZ_001`(404), 이미 멤버→`BIZ_001`(409).
- **역할/직무 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403).
- `InviteMemberDto`(email/roleType/subRole?), `UpdateMemberDto`(roleType?/subRole?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드).
- **역할 변경·제거**: admin 권한. **소유자(owner)는 역할 변경·제거 불가**(`BIZ_001` 403).
- `InviteMemberDto`(email/roleType), `UpdateMemberDto`(roleType?). 단위 테스트 `member.service.spec.ts`(소유자 보호·중복·권한 가드).
- `taskCount` 는 4단계 업무 모듈 도입 전까지 0.
- **Gitea 미동기화**: 인증 모델이 조직 토큰 단일 사용이라 멤버↔Gitea collaborator 동기화는 하지 않음(사용자별 Gitea 계정 매핑은 8단계 후보).
> **TODO(프론트)**: `useMember`/`member.store`, **RepoMembersPage**(초대 모달·역할 변경·제거) 연동.
**사용자 검색 API** `backend/src/modules/user/` ✅ (멤버 초대 자동완성용)
- `GET /users?q=<검색어>`(`JwtAuthGuard`) — `q` 있으면 이메일/이름 부분일치(`ILike`, 대소문자 무시), 없으면 전체(이름순, 최대 50). 응답 `PublicUser[]`.
- `UserController` 신규 + `UserService.search()` 추가, `UserModule` 에 컨트롤러 등록. (1단계에서 삭제했던 샘플 user.controller 와 무관한 신규 검색 전용)
**프론트** `frontend/src/`
- `types/member.ts`(`ApiRepoMember`/`InviteMemberPayload`/`UpdateMemberPayload`), `composables/useMember.ts`(list/invite/update/remove), `stores/member.store.ts`(API→화면용 `RepoMember` 매핑: 이니셜·색상 파생, `isYou`=현재 사용자 비교).
- **RepoMembersPage** 목업 제거 → 실제 연동: 멤버 목록(로딩/빈 상태), 헤더·탭 카운트 실데이터, **초대 모달**(이메일+역할, 가입 사용자만, **사용자 자동완성** — 포커스 시 전체·입력 시 이메일/이름 부분일치, 기존 멤버 제외), **역할 변경 드롭다운**(멤버↔관리자), **멤버 제거**(confirm). 초대/역할변경/제거 컨트롤은 **현재 사용자가 admin 일 때만 노출**(`isAdmin`), 소유자(owner) 행은 변경·제거 불가.
- 미가입/중복 초대 등은 인터셉터가 서버 `BIZ_001` 문구를 그대로 노출. `taskCount` 는 4단계까지 0.
### 4단계 — 업무(Task) CRUD + 상태 전이 ✅ (백엔드 + 프론트)
**백엔드** `backend/src/modules/task/`
- `Task` 엔티티: 저장소 종속(`@ManyToOne Repo`, `Relation<>`), `seq`(저장소 내 순번 #N, `@Unique([repo, seq])`), `title`, `content`(jsonb 문단 배열), `status`, `dueDate`, `issuer`(User, SET NULL), `assignees`(`@ManyToMany``task_assignees`), `checklist`(`@OneToMany ChecklistItem`, cascade). `ChecklistItem`: `text`/`done`/`sortOrder`.
- `TaskService`: 목록(상태/담당자 필터, seq 내림차순) / 상세 / 생성 / 수정 / 삭제 / `changeStatus`.
- **담당자 = 저장소 멤버만**: `resolveMemberAssignees``assigneeIds` 를 멤버십과 대조, 비멤버 포함 시 `BIZ_001`(400).
- **순번 채번**: 저장소 내 `max(seq)+1`.
- **상태 머신**: `todo→prog→review→done`, `review→changes`, `changes→{review,prog}`. 허용 외 전이는 `BIZ_001`(409). 역할: 승인/수정요청(review 출발)은 지시자/admin, 그 외 작업 전이는 담당자/지시자/admin. 비멤버는 전이 불가(403).
- **승인 완료(→done) 부수효과**: 해당 업무의 체크리스트 항목 `done` 을 모두 true 로 **영속 처리**(승인=완료. `done` 은 출구 전이 없어 안전). 디자인 시안의 '승인 완료=체크리스트 100%' 동작 일치.
- `TaskController`: `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]` + `POST .../:taskId/status`(`taskId`=seq, `ParseIntPipe`). 생성/수정/삭제는 admin.
- **저장소 진행률 실집계**: `RepoService``TaskService.countsByRepo``doneCount/totalCount` 를 채운다(기존 0 하드코딩 제거). **멤버 `taskCount`**: `MemberService``TaskService.assigneeCounts` 로 사용자별 담당 수 집계(`RepoModule``TaskModule` import). 순환 모듈 없음(TaskModule 은 엔티티만 참조).
- 단위 테스트 `task.service.spec.ts`(상태머신 위반·승인 권한·비멤버 차단·담당자 멤버 검증·**승인 시 체크리스트 일괄 완료**). 백엔드 18 tests pass.
- `comments`/`activities`/`files`/`approval`**5단계 영역** — 응답에 빈 배열로 내려간다.
**프론트** `frontend/src/`
- `shared/utils/format.ts`: `taskStatusLabel`·`taskDueInfo`(dueDate+완료여부→ dateLabel/dday/listLabel/overdue)·`monthDayKo` 추가.
- `types/task.ts`, `composables/useTask.ts`(list/get/create/update/remove/changeStatus), `stores/task.store.ts`(API→mock `RepoTask`/`TaskDetail` 뷰 매핑: 마감/dday/상태 라벨 파생, 지시자 null 폴백).
- 페이지 연동: **RepoDetailPage**(업무 목록 실데이터+로딩/빈상태, 툴바 ‘새 업무’ 버튼), **TaskCreatePage**(제목/내용 textarea/체크리스트/마감일/담당자=멤버 선택 드롭다운 → POST → 생성 상세로 이동), **TaskDetailPage**(상세 fetch, 삭제=DELETE, 승인=review→done·수정요청=review→changes), **TaskAssigneePage**(상세 fetch, 실제 status 로 카드 분기: 시작 todo→prog·승인요청 prog→review·재요청 changes→review, 삭제).
- **업무 수정 UI**: `TaskCreatePage` 를 생성/수정 겸용으로 확장. 라우트 `/repos/:repoId/tasks/:taskId/edit`(`task-edit`) 추가 → `taskId` 있으면 편집 모드(기존 값 미리 채움 + `useTask().get` 로 원시값 로드 + 제출 시 PATCH `taskStore.update`). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 `…/edit` 로 연결(기존엔 빈 생성 폼으로 오연결). **체크리스트 항목 편집은 5단계** — 수정 폼에선 표시·읽기전용(제목/내용/담당자/마감만 저장).
- 댓글/AI 에이전트 패널/첨부 업로드는 **로컬 데모 유지(5단계 실연동)**. 담당자 페이지의 `?state=` 쿼리 의존 제거 → `task.status` 기반 분기.
- **역할 기반 액션 통합(2026-06-17)**: 목록은 항상 `TaskDetailPage`(`…/tasks/:taskId`)로 진입하고, 이 페이지가 **현재 사용자 역할로 액션을 분기**한다. `isIssuer`(task.issuer===me) / `isAssignee`(me ∈ assignees) 계산 → 지시자는 승인 대기(review)에서 승인/수정요청, 담당자는 `업무 시작`(todo→prog)·`승인 요청`(prog→review)·`다시 승인 요청`(changes→review)·승인 대기 중(review) 표시. 담당자가 "업무 시작"에 도달할 경로가 없던 문제 해소(기존 시작 버튼은 `/view`(TaskAssigneePage)에만 있었고 거기로 가는 링크가 mock MyTasksPage뿐이었음). `TaskAssigneePage`/`/view` 는 mock MyTasks 전용으로 잔존(7단계 정리). **한계**: 지시자가 아닌 admin은 화면상 승인 버튼 미노출(편집/삭제는 가능, 백엔드는 admin 승인 허용).
- **UI 정리(2026-06-17)**: 공용 `RepoHeader` 기본 ‘새 업무’ 버튼 제거(`#actions` 슬롯 제공 시에만 렌더) → 상세 헤더 중복 버튼 해소. 작성 폼 ‘임시저장’ 제거, 체크박스 토글 비활성(어느 화면도 사용자 체크 불가), 담당자 칩을 입력칸 아래로 분리, 지시자 직무 줄 제거. 저장소 목록의 진행률 라벨(‘시작 전/67% 완료’) 제거(업무 수·바는 유지).
- **디자인 시안 정렬(2026-06-17)**: 담당자 액션 카드를 시안의 `.ap-card` 색상 체계로 정렬 — 승인요청(prog)=accent(`request`)·수정요청(changes)=빨강·승인완료(done)=초록·승인대기(review)=앰버. ‘업무 시작’도 `request` 톤(인디고)으로 통일. 사이드 상태 뱃지(`.status-badge`)에 `todo/changes/done` 변형 추가(기존엔 review 변형만 있어 나머지가 파란색으로 떨어지던 버그 수정). 카드 본문의 행위자·시각·승인노트는 5·6단계 데이터라 지시자 이름만 넣어 근사.
### 저장소 생성 폼 보강 ✅ (소유자/설명/템플릿 — 백엔드+프론트)
@@ -110,13 +155,9 @@
## 3. 남은 단계
### 4단계 — 업무(Task) CRUD + 상태 전이
- 백엔드: `Task`(+`ChecklistItem`) 엔티티, `GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId]`, `POST .../status`. 상태 머신 todo→prog→review→done/changes.
- 저장소 `doneCount/totalCount` 실제 집계로 교체(현재 0 하드코딩).
- 프론트: **RepoDetailPage 업무 목록**, **TaskCreatePage**, **TaskDetailPage**(지시자), **TaskAssigneePage**(담당자) 연동.
### 5단계 — 업무 상세 부속
- 체크리스트 토글, 댓글/답글, **파일 첨부**, 승인 워크플로우(승인 요청/승인/수정 요청).
- 체크리스트 **개별 항목 토글/추가/삭제 영속**(현재 항목은 읽기 전용, 단 *승인 완료 시 일괄 완료*는 4단계에서 구현), 댓글/답글, **파일 첨부**, 승인 노트(승인 요청/수정 요청 시 메시지).
- 승인 워크플로우 전용 엔드포인트(`approval/request|approve|changes`)는 상태 머신(4단계 `POST .../status`)에 노트·첨부를 더하는 형태로 확장.
- **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO).
### 6단계 — 활동 피드
@@ -126,6 +167,7 @@
### 7단계 — 내 업무 집계
- `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭).
- **정리 대상**: 현재 mock `MyTasksPage` 가 유일하게 `…/tasks/:taskId/view`(`TaskAssigneePage`)로 링크 중. 4단계에서 상세(`TaskDetailPage`)가 역할 기반으로 통합됐으므로, 이 단계에서 MyTasks 를 실데이터로 연동하며 `/view` 링크를 `…/tasks/:taskId` 로 바꾸고 `TaskAssigneePage`/`task-assignee-view` 라우트를 제거한다.
### 8단계 — 마무리(선택)
- 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
@@ -134,15 +176,16 @@
## 4. 전환기 한계 (현재 시점)
- **RepoMembersPage** 는 백엔드 API(3단계)는 준비됐으나 **프론트 연동 대기** → 연동 전까지 mock 사용. **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소).
- **RepoDetail 업무 목록**도 mock(4단계). 업무 모듈이 없어 모든 저장소 **진행률은 `0% / 시작 전`**.
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus` 등) + 미연동 화면 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
- **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). (RepoMembersPage 는 3단계, RepoDetail 업무 목록은 4단계에서 프론트 연동 완료)
- **업무 상세의 댓글·체크리스트 토글·첨부·승인 노트**는 5단계 영역 — 현재 댓글/에이전트는 로컬 데모, 첨부/승인노트 UI 는 자리만 표시. 진행률은 4단계 집계로 실데이터.
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail` 등) + 미연동 화면(활동/내업무) 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
---
## 5. 알아둘 사항 / 결정 기록
- **엔티티 순환참조**: 서로 참조하는 관계(예: `Repo``RepoMember`)의 **단일 클래스 관계 속성에는 TypeORM `Relation<>` 래퍼**를 사용한다. 안 그러면 SWC 환경에서 `Cannot access 'X' before initialization` 크래시. → 4·5단계(Task↔Repo, Comment↔Task 등)에도 동일 적용.
- **raw 집계 쿼리용 FK 컬럼명**: `createQueryBuilder().select('task.repo_id', …)` 처럼 **raw SQL 로 FK 컬럼을 직접 참조**하면, `@ManyToOne` 의 기본 FK 컬럼명이 camelCase(`repoId`)라 `task.repo_id`(snake) 와 불일치해 `42703 column does not exist` 가 난다. → 관계에 **`@JoinColumn({ name: 'repo_id' })` 를 명시**해 컬럼명을 snake_case 로 고정(프로젝트 컬럼 네이밍과도 일치). `Task.repo` 에 적용. (`task.repo = :id` 같은 관계 쿼리는 자동 매핑되어 무관)
- **bcrypt → bcryptjs**: Docker 베이스가 `node:20-alpine`(빌드 도구 없음)이라 네이티브 모듈 `bcrypt` 대신 순수 JS `bcryptjs` 사용.
- **dev 도커 익명 볼륨**: `/app/node_modules` 가 익명 볼륨이라 **의존성 변경 시 `up -d --build -V`(익명 볼륨 갱신)** 필요. `--build` 만으로는 기존 node_modules 볼륨이 재사용되어 새 패키지가 반영되지 않음.
- **owner 고정**: 조직 모델이 없어 `owner='marketing-team'` 상수. **Gitea 연동 시 owner=`GITEA_ORG`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체.
@@ -150,6 +193,11 @@
- **Gitea 연동 방식**: HTTP 클라이언트는 새 의존성(@nestjs/axios) 없이 Node 20 내장 `fetch` 사용(베이스 이미지 alpine 호환). 인증은 조직 토큰 1개(사용자별 Gitea 계정 매핑은 8단계 후보). `slug`↔Gitea repo name 1:1, 한글 표시제목은 Gitea description 에 합쳐 저장(Gitea 에 표시명 필드 없음).
- **연동 비활성 폴백**: `GITEA_*` 미설정 시 `GiteaService.enabled=false` → 저장소 CRUD 가 Gitea 없이 동작. 로컬 개발/테스트에서 Gitea 없이도 기존 흐름 유지.
- **저장소 생성 폼 보강**: ① **프로젝트 설명 필수화**(`CreateRepoDto.description` required, 프론트 검증 추가). ② **템플릿 복제**`useTemplate=true` 면 Gitea generate API 로 `GITEA_TEMPLATE_OWNER/REPO`(기본 `TtiPo/project-base`, *template* 설정 필요) 저장소를 복제 생성(브랜치는 템플릿을 따름). 프론트는 `GET /repos/meta`(owner+template) 로 owner 고정표시·템플릿 옵션을 받아 하드코딩 제거.
- **import 저장소 멤버 데드락 / `REPO_IMPORT_ADMIN_EMAIL`**: import 저장소는 멤버 0명 → admin 부재 → 초대 불가의 데드락. env(`REPO_IMPORT_ADMIN_EMAIL`)로 지정한 가입 사용자를 동기화 시 멤버 0명인 Gitea-연동 저장소에 owner-admin 으로 자동 지정해 해소. (선택지 중 "env 지정 이메일" 채택 — 동기화는 시스템 작업이라 실행 주체가 없기 때문)
- **`subRole`(저장소 내 직무) 제거**: 표시/권한 어디에도 관여하지 않는 장식 라벨이라 백엔드 엔티티·DTO·응답 + 프론트 타입·표시·초대입력에서 **완전 제거**(2026-06-17). owner 에 박던 `'리드'` 기본값도 제거. dev `synchronize``repo_members.sub_role` 컬럼을 드롭함. (권한은 `roleType`(admin/member)만 사용. 전사 직무 `User.role` 은 별개로 유지)
- **env 변경 시 컨테이너 재생성 필요**: `docker-compose``env_file`(루트·`backend/.env.*`)은 **컨테이너 시작 시 1회만** 주입된다. `nest start --watch` 의 핫리로드는 **코드만** 다시 읽고 `process.env` 는 갱신하지 않으므로, env 값을 바꾸면 `up -d`(설정 변경 감지 시 재생성) 또는 `up -d --force-recreate <서비스>` 로 컨테이너를 다시 만들어야 한다. `docker compose restart` 는 env_file 을 다시 읽지 않음. (코드 변경만이면 watch 가 자동 반영)
- **env 파일 구조 정규화**: 각 실제 `.env.{env}` 와 그 `.env.{env}.example` 은 **주석·키 순서·빈 줄까지 동일**하게 맞추고 차이는 값(실제 secret ↔ placeholder)만 두도록 정리. frontend 는 고유 키가 없어 헤더 주석만 동일하게 둠.
- **전역 `relay.css` 모달 규칙 상속 주의**: 공통 `.modal-body`(text-align:center)·`.mbtn`(flex:1)·`.modal-backdrop` 가 전역이라, 다른 레이아웃의 커스텀 모달(예: `.invite-modal`)은 **스코프 클래스로 `text-align`/`flex` 를 명시적으로 리셋**해야 한다. (초대 모달 본문 좌측정렬·푸터 버튼 폭 깨짐의 원인이었음)
---
@@ -165,7 +213,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
- 백엔드: `npm run build` / `npm run lint` (현재 0 error)
- 프론트: `npm run type-check` / `npm run lint` / `npm run build-only` (현재 0 error)
- dev `synchronize=true``users`·`repos`·`repo_members` 테이블 자동 생성(Gitea 연동 컬럼 포함). DB가 비어 있으므로 **회원가입 후 로그인**.
- dev `synchronize=true``users`·`repos`·`repo_members`·`tasks`·`checklist_items`·`task_assignees` 테이블 자동 생성(Gitea 연동 컬럼 포함)·스키마 변경 자동 반영(예: `sub_role` 컬럼 드롭, `tasks.repo_id` FK 컬럼명). DB가 비어 있으므로 **회원가입 후 로그인**. 엔티티 신규 추가(4단계) 시 백엔드 컨테이너 재기동으로 스키마 반영.
- 포트: 프론트 5273, 백엔드 3100(`/api`), Swagger `http://localhost:3100/api-docs`.
### Gitea 연동 설정/검증
@@ -176,3 +224,4 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
2. 부팅 로그에서 `Gitea 연동 활성화 — <url> (org: <org>)` 확인(미설정 시 비활성 경고).
3. Relay 에서 저장소 생성 → Gitea `<org>` 조직에 동일 slug repo 가 생기는지 확인. 응답 `cloneUrl`/`htmlUrl` 채워짐.
4. 수정(설명/공개범위)·삭제 시 Gitea 측 반영 확인.
5. (선택) `REPO_IMPORT_ADMIN_EMAIL`**가입된** 사용자 이메일을 넣으면, 동기화 시 멤버 0명 import 저장소에 그 사용자가 owner-admin 으로 지정된다. **env 변경이므로 컨테이너 재생성 필요**(`up -d --force-recreate backend`). 부팅 로그의 `import 저장소 owner-admin 지정: <slug> ← <email>` 로 확인.
View File
View File
@@ -0,0 +1,34 @@
Relay 로고 — A안 (더블 셰브론)
================================
브랜드 컬러
· 그라데이션 #5B54F0 → #4338CA (140°)
· 단색 인디고 #4F46E5
· 워드마크 텍스트 #16181D (라이트) / #FFFFFF (다크)
· 폰트 Pretendard, Bold(700), letter-spacing -1
파일 구성
svg/ ← 벡터 원본 (무한 확대, 편집 가능 — 권장)
relay-app-icon.svg 앱 아이콘 (그라데이션 타일 + 흰 셰브론)
relay-mark.svg 마크만 (인디고, 투명 배경)
relay-mark-white.svg 마크만 (흰색, 어두운 배경용)
relay-lockup.svg 가로 로고 (아이콘 + Relay, 라이트)
relay-lockup-dark.svg 가로 로고 (다크 배경용)
png/ ← 즉시 사용 (래스터)
relay-icon-1024 / 512 / 256.png 앱 아이콘
apple-touch-icon-180.png iOS 홈 화면
favicon-48 / 32 / 16.png 브라우저 파비콘
relay-mark-512 / 256 / 128.png 마크 (투명 배경)
relay-lockup.png 가로 로고 (흰 배경)
relay-lockup-transparent.png 가로 로고 (투명 배경)
사용 가이드
· 최소 여백은 셰브론 높이의 1/2 이상 확보하세요.
· 가장 작은 사용 크기: 마크 16px / 가로 로고 높이 20px.
· 셰브론 비율·각도·라운드는 변경하지 마세요.
· 단색이 필요한 경우 #4F46E5, 어두운 배경은 흰색을 사용하세요.
가로 로고 SVG의 'Relay' 글자는 Pretendard 폰트를 참조합니다.
폰트가 없는 환경에서 정확히 보이게 하려면 png/relay-lockup.png 를
사용하거나, 디자인 툴에서 글자를 윤곽선(outline)으로 변환하세요.
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Relay app icon">
<defs>
<linearGradient id="relayGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b54f0"></stop>
<stop offset="1" stop-color="#4338ca"></stop>
</linearGradient>
</defs>
<rect width="512" height="512" rx="143" fill="url(#relayGrad)"></rect>
<svg x="160" y="160" width="192" height="192" viewBox="0 0 32 32">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 765 B

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="356" height="96" viewBox="0 0 356 96" role="img" aria-label="Relay">
<defs>
<linearGradient id="relayGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b54f0"></stop>
<stop offset="1" stop-color="#4338ca"></stop>
</linearGradient>
</defs>
<rect width="96" height="96" rx="27" fill="url(#relayGrad)"></rect>
<svg x="22" y="22" width="52" height="52" viewBox="0 0 32 32">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<text x="120" y="63" font-family="Pretendard, -apple-system, system-ui, sans-serif" font-size="48" font-weight="700" letter-spacing="-1.1" fill="#ffffff">Relay</text>
</svg>

After

Width:  |  Height:  |  Size: 916 B

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="356" height="96" viewBox="0 0 356 96" role="img" aria-label="Relay">
<defs>
<linearGradient id="relayGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b54f0"></stop>
<stop offset="1" stop-color="#4338ca"></stop>
</linearGradient>
</defs>
<rect width="96" height="96" rx="27" fill="url(#relayGrad)"></rect>
<svg x="22" y="22" width="52" height="52" viewBox="0 0 32 32">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<text x="120" y="63" font-family="Pretendard, -apple-system, system-ui, sans-serif" font-size="48" font-weight="700" letter-spacing="-1.1" fill="#16181d">Relay</text>
</svg>

After

Width:  |  Height:  |  Size: 916 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="2 2 28 28" role="img" aria-label="Relay mark">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="2 2 28 28" role="img" aria-label="Relay mark">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#4f46e5" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#4f46e5" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 395 B

+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
// Gitea 저장소 바 — 클론 주소 복사 + Gitea 웹에서 열기 (미연동 시 렌더 안 함)
import { computed, ref } from 'vue'
interface Props {
cloneUrl?: string | null
htmlUrl?: string | null
}
const props = defineProps<Props>()
// 둘 다 없으면 Gitea 미연동 — 표시하지 않음
const connected = computed(() => !!(props.cloneUrl || props.htmlUrl))
const copied = ref(false)
let copyTimer: ReturnType<typeof setTimeout> | null = null
// 클론 주소 클립보드 복사 — 성공 시 잠시 "복사됨" 표시
async function copyClone() {
if (!props.cloneUrl) return
try {
await navigator.clipboard.writeText(props.cloneUrl)
copied.value = true
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => (copied.value = false), 1500)
} catch {
// 클립보드 권한 없음 등 — 무시(주소는 화면에 노출되어 있음)
}
}
</script>
<template>
<div
v-if="connected"
class="gitea-bar"
>
<span class="gb-ico">
<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>
</span>
<code
v-if="cloneUrl"
class="gb-url"
:title="cloneUrl"
>{{ cloneUrl }}</code>
<span
v-else
class="gb-url muted"
>클론 주소 없음</span>
<button
v-if="cloneUrl"
class="gb-btn"
type="button"
:aria-label="copied ? '복사됨' : '클론 주소 복사'"
@click="copyClone"
>
<svg
v-if="!copied"
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>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{{ copied ? '복사됨' : '복사' }}
</button>
<a
v-if="htmlUrl"
class="gb-btn open"
:href="htmlUrl"
target="_blank"
rel="noopener noreferrer"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<path d="M15 3h6v6M10 14 21 3" />
</svg>
Gitea에서 열기
</a>
</div>
</template>
<style scoped>
.gitea-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.gb-ico {
color: #1b7a3d;
display: grid;
place-items: center;
flex-shrink: 0;
}
.gb-ico svg {
width: 1rem;
height: 1rem;
}
.gb-url {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.781rem;
color: var(--text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gb-url.muted {
font-family: inherit;
color: var(--text-3);
}
.gb-btn {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
height: 2rem;
padding: 0 0.6875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
color: var(--text-2);
font-family: inherit;
font-size: 0.781rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
text-decoration: none;
}
.gb-btn:hover {
background: #f7f8fa;
color: var(--text);
}
.gb-btn svg {
width: 0.875rem;
height: 0.875rem;
}
.gb-btn.open {
border-color: var(--accent-border);
color: var(--accent);
background: var(--accent-weak);
}
.gb-btn.open:hover {
background: var(--accent-weak);
color: var(--accent);
}
</style>
+6 -20
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { Repo, User } from '@/mock/relay.mock'
interface Props {
@@ -122,25 +121,12 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
<!-- 진행률 스트립 추가 영역 -->
<slot name="extra" />
</div>
<div class="rh-actions">
<slot name="actions">
<RouterLink
class="btn primary"
:to="`/repos/${repo.id}/tasks/new`"
>
<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>
업무
</RouterLink>
</slot>
<!-- 액션 영역 페이지가 #actions 슬롯을 제공할 때만 렌더(기본 '새 업무' 버튼 제거) -->
<div
v-if="$slots.actions"
class="rh-actions"
>
<slot name="actions" />
</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { useApi } from './useApi'
import type {
ApiRepoMember,
InviteMemberPayload,
UpdateMemberPayload,
} from '@/types/member'
// 저장소 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/members
export function useMember() {
const api = useApi()
// 멤버 목록 (멤버 또는 공개 저장소)
async function list(repoId: string): Promise<ApiRepoMember[]> {
return (await api.get(`/repos/${repoId}/members`)) as unknown as ApiRepoMember[]
}
// 멤버 초대 (admin) — 가입된 사용자만 이메일로 초대
async function invite(
repoId: string,
payload: InviteMemberPayload,
): Promise<ApiRepoMember> {
return (await api.post(
`/repos/${repoId}/members`,
payload,
)) as unknown as ApiRepoMember
}
// 멤버 역할/직무 변경 (admin, owner 제외)
async function update(
repoId: string,
userId: string,
payload: UpdateMemberPayload,
): Promise<ApiRepoMember> {
return (await api.patch(
`/repos/${repoId}/members/${userId}`,
payload,
)) as unknown as ApiRepoMember
}
// 멤버 제거 (admin, owner 제외)
async function remove(repoId: string, userId: string): Promise<void> {
await api.delete(`/repos/${repoId}/members/${userId}`)
}
return { list, invite, update, remove }
}
+62
View File
@@ -0,0 +1,62 @@
import { useApi } from './useApi'
import type {
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type { TaskStatus } from '@/mock/relay.mock'
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
export function useTask() {
const api = useApi()
// 업무 목록 (상태/담당자 필터 선택)
async function list(repoId: string, query: TaskListQuery = {}): Promise<ApiRepoTask[]> {
return (await api.get(`/repos/${repoId}/tasks`, {
params: query,
})) as unknown as ApiRepoTask[]
}
// 업무 단건 상세 (taskId = 저장소 내 순번)
async function get(repoId: string, taskId: number): Promise<ApiTaskDetail> {
return (await api.get(`/repos/${repoId}/tasks/${taskId}`)) as unknown as ApiTaskDetail
}
// 업무 생성 (admin)
async function create(repoId: string, payload: CreateTaskPayload): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks`, payload)) as unknown as ApiTaskDetail
}
// 업무 수정 (admin)
async function update(
repoId: string,
taskId: number,
payload: UpdateTaskPayload,
): Promise<ApiTaskDetail> {
return (await api.patch(
`/repos/${repoId}/tasks/${taskId}`,
payload,
)) as unknown as ApiTaskDetail
}
// 업무 삭제 (admin)
async function remove(repoId: string, taskId: number): Promise<void> {
await api.delete(`/repos/${repoId}/tasks/${taskId}`)
}
// 상태 변경 (상태 머신)
async function changeStatus(
repoId: string,
taskId: number,
status: TaskStatus,
): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks/${taskId}/status`, {
status,
})) as unknown as ApiTaskDetail
}
return { list, get, create, update, remove, changeStatus }
}
+16
View File
@@ -0,0 +1,16 @@
import { useApi } from './useApi'
import type { ApiMember } from '@/types/repo'
// 사용자 도메인 API Composable — 멤버 초대 자동완성용 검색/목록
// 응답은 PublicUser(= ApiMember: { id, email, name, role }) 배열
export function useUser() {
const api = useApi()
// 사용자 검색 — q 가 있으면 이메일/이름 부분일치, 없으면 전체 목록
async function search(q?: string): Promise<ApiMember[]> {
const params = q && q.trim() ? { q: q.trim() } : {}
return (await api.get('/users', { params })) as unknown as ApiMember[]
}
return { search }
}
+11 -7
View File
@@ -56,6 +56,12 @@ export interface Repo {
progressLevel: 'done' | 'mid' | 'low'
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
progressLabel: string
/** Gitea HTTPS clone URL (미연동 시 null/undefined) */
cloneUrl?: string | null
/** Gitea 웹 UI URL (미연동 시 null/undefined) */
htmlUrl?: string | null
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
giteaMissing?: boolean
}
/** 저장소 상세의 업무 목록 행 */
@@ -744,8 +750,6 @@ export function countActive(groups: MyTaskGroup[]): number {
export interface RepoMember {
user: User
email: string
/** 저장소 내 직무 표기(예: 리드, 콘텐츠 디자인) */
subRole: string
/** 담당 업무 수 */
taskCount: number
/** 권한 역할 */
@@ -758,11 +762,11 @@ export interface RepoMember {
export const REPO_MEMBERS: Record<string, RepoMember[]> = {
'q3-launch-campaign': [
{ user: USERS.jung, email: 'tkjung@acme.co', subRole: '리드', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
{ user: USERS.kim, email: 'sykim@acme.co', subRole: '콘텐츠 디자인', taskCount: 2, roleType: 'member' },
{ user: USERS.park, email: 'jhpark@acme.co', subRole: '퍼포먼스 마케팅', taskCount: 2, roleType: 'member' },
{ user: USERS.lee, email: 'dylee@acme.co', subRole: '카피라이터', taskCount: 2, roleType: 'member' },
{ user: USERS.choi, email: 'yjchoi@acme.co', subRole: 'UX 디자인', taskCount: 1, roleType: 'member' },
{ user: USERS.jung, email: 'tkjung@acme.co', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
{ user: USERS.kim, email: 'sykim@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.park, email: 'jhpark@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.lee, email: 'dylee@acme.co', taskCount: 2, roleType: 'member' },
{ user: USERS.choi, email: 'yjchoi@acme.co', taskCount: 1, roleType: 'member' },
],
}
+83 -5
View File
@@ -5,14 +5,17 @@ import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { Repo, TaskStatus } from '@/mock/relay.mock'
type Segment = 'all' | 'prog' | 'todo' | 'done'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const taskStore = useTaskStore()
// 저장소 헤더/진행률 — API 연동
const repo = ref<Repo | null>(null)
@@ -23,10 +26,25 @@ async function loadRepo() {
repo.value = null // 404/403 등은 인터셉터가 알림 처리
}
}
watch(repoId, loadRepo, { immediate: true })
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
const tasks = computed(() => findRepoTasks(repoId.value))
// 업무 목록 — API 연동
async function loadTasks() {
try {
await taskStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
watch(
repoId,
() => {
loadRepo()
loadTasks()
},
{ immediate: true },
)
const tasks = computed(() => taskStore.tasks)
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
)
@@ -121,6 +139,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</template>
</RepoHeader>
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
<GiteaRepoBar
:clone-url="repo.cloneUrl"
:html-url="repo.htmlUrl"
class="gitea-bar-spacing"
/>
<!-- 서브탭(공통) -->
<RepoTabs
:repo-id="repo.id"
@@ -189,10 +214,41 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</svg>
마감 임박순
</div>
<RouterLink
class="btn primary new-task"
:to="`/repos/${repo.id}/tasks/new`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
업무
</RouterLink>
</div>
<!-- 업무 목록 -->
<div class="list">
<div
v-if="taskStore.loading && tasks.length === 0"
class="state-msg"
>
업무를 불러오는
</div>
<div
v-else-if="filteredTasks.length === 0"
class="state-msg"
>
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
</div>
<div
v-else
class="list"
>
<RouterLink
v-for="task in filteredTasks"
:key="task.id"
@@ -334,6 +390,9 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.crumb {
padding: 1.125rem 0 0.75rem;
}
.gitea-bar-spacing {
margin-top: 0.75rem;
}
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
.prog-strip {
@@ -376,6 +435,16 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.toolbar .search {
width: 15rem;
}
.new-task {
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
border: none;
text-decoration: none;
}
.new-task svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 업무 행 */
.task-row {
@@ -472,4 +541,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
text-align: center;
color: var(--text-2);
}
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
</style>
+46 -9
View File
@@ -20,6 +20,9 @@ onMounted(() => {
void repoStore.fetchCreateMeta()
})
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
// 검색어 + 공개여부 필터 적용
const filteredRepos = computed(() =>
repos.value.filter((repo) => {
@@ -43,7 +46,10 @@ const filteredRepos = computed(() =>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">{{ repos.length }}</span>
<span class="gitea-badge">
<span
v-if="giteaConnected"
class="gitea-badge"
>
<svg
viewBox="0 0 24 24"
fill="none"
@@ -201,6 +207,25 @@ const filteredRepos = computed(() =>
</svg>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
<span
v-if="repo.giteaMissing"
class="missing-badge"
title="Gitea 조직에서 원격 저장소를 찾을 수 없습니다."
>
<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="m2 2 20 20" />
</svg>
원격 없음
</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
@@ -261,9 +286,6 @@ const filteredRepos = computed(() =>
:style="{ width: repo.progressPct + '%' }"
/>
</div>
<div class="stat-pct">
{{ repo.progressLabel }}
</div>
</div>
<span class="chev">
@@ -447,11 +469,6 @@ const filteredRepos = computed(() =>
.repo-stat .pbar {
width: 9.75rem;
}
.stat-pct {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.chev {
color: var(--text-3);
flex-shrink: 0;
@@ -468,4 +485,24 @@ const filteredRepos = computed(() =>
color: var(--text-3);
font-size: 0.844rem;
}
/* 원격(Gitea) 없음 배지 */
.missing-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
padding: 0.125rem 0.4375rem;
border-radius: 20px;
line-height: 1.4;
white-space: nowrap;
}
.missing-badge svg {
width: 0.75rem;
height: 0.75rem;
}
</style>
+457 -154
View File
@@ -1,18 +1,59 @@
<script setup lang="ts">
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달 (백엔드 연동)
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import { findRepo, findRepoMembers } from '@/mock/relay.mock'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { MemberRoleType } from '@/types/member'
type RoleFilter = 'all' | 'admin' | 'member'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
const members = computed(() => findRepoMembers(repoId.value))
const repoStore = useRepoStore()
const memberStore = useMemberStore()
// 저장소 헤더 — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null // 404/403 은 인터셉터가 알림 처리
}
}
// 멤버 목록 — API 연동
const members = computed(() => memberStore.members)
const loading = computed(() => memberStore.loading)
async function loadMembers() {
try {
await memberStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
watch(
repoId,
() => {
loadRepo()
loadMembers()
},
{ immediate: true },
)
// 현재 사용자가 이 저장소의 관리자인지(초대·역할변경·제거 권한)
const isAdmin = computed(() =>
members.value.some((m) => m.isYou && m.roleType === 'admin'),
)
// 헤더 아바타용 멤버(전체 로스터)
const headerMembers = computed(() => members.value.map((m) => m.user))
@@ -35,10 +76,122 @@ const filteredMembers = computed(() =>
}),
)
// 초대 모달
// --- 역할 변경 드롭다운 ---
const openRoleFor = ref<string | null>(null)
const roleBusy = ref(false)
function toggleRoleMenu(userId: string) {
openRoleFor.value = openRoleFor.value === userId ? null : userId
}
async function changeRole(userId: string, roleType: MemberRoleType) {
openRoleFor.value = null
roleBusy.value = true
try {
await memberStore.update(repoId.value, userId, { roleType })
} catch {
// 인터셉터가 알림 처리
} finally {
roleBusy.value = false
}
}
// --- 멤버 제거 ---
async function removeMember(userId: string, name: string) {
if (!window.confirm(`${name} 님을 저장소에서 제거하시겠습니까?`)) return
try {
await memberStore.remove(repoId.value, userId)
} catch {
// 인터셉터가 알림 처리
}
}
// --- 초대 모달 ---
const showInvite = ref(false)
const inviteEmail = ref('')
const inviteRole = ref<MemberRoleType>('member')
const inviteBusy = ref(false)
// --- 사용자 자동완성(타입어헤드) ---
const userApi = useUser()
const userResults = ref<ApiMember[]>([])
const usersLoading = ref(false)
const showUserDropdown = ref(false)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
// 현재 저장소에 이미 속한 사용자 id (자동완성에서 제외)
const memberIds = computed(() => new Set(members.value.map((m) => m.user.id)))
const userOptions = computed(() =>
userResults.value.filter((u) => !memberIds.value.has(u.id)),
)
// 서버 검색 실행 — 입력값으로 이메일/이름 부분일치, 비어 있으면 전체
async function runUserSearch() {
usersLoading.value = true
try {
userResults.value = await userApi.search(inviteEmail.value)
} catch {
userResults.value = [] // 인터셉터가 알림 처리
} finally {
usersLoading.value = false
}
}
function scheduleUserSearch() {
if (userSearchTimer) clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(runUserSearch, 200)
}
function onEmailFocus() {
showUserDropdown.value = true
void runUserSearch() // 포커스 시 전체(또는 현재 입력 기준) 목록 표시
}
function onEmailInput() {
showUserDropdown.value = true
scheduleUserSearch()
}
function onEmailBlur() {
// 항목은 @mousedown.prevent 로 선택하지만, 안전하게 약간 지연 후 닫는다
setTimeout(() => (showUserDropdown.value = false), 120)
}
function selectUser(u: ApiMember) {
inviteEmail.value = u.email
showUserDropdown.value = false
}
function openInvite() {
inviteEmail.value = ''
inviteRole.value = 'member'
userResults.value = []
showUserDropdown.value = false
showInvite.value = true
}
function closeInvite() {
showInvite.value = false
showUserDropdown.value = false
}
const canSubmitInvite = computed(
() => !inviteBusy.value && inviteEmail.value.trim().length > 0,
)
async function submitInvite() {
if (!canSubmitInvite.value) return
inviteBusy.value = true
try {
await memberStore.invite(repoId.value, {
email: inviteEmail.value.trim(),
roleType: inviteRole.value,
})
closeInvite()
} catch {
// 인터셉터가 알림 처리(미가입/중복 등 BIZ_001 서버 문구 노출)
} finally {
inviteBusy.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') showInvite.value = false
if (e.key === 'Escape') {
showInvite.value = false
openRoleFor.value = null
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
@@ -70,14 +223,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<RepoHeader
:repo="repo"
meta-text="멤버 5명"
:meta-text="`멤버 ${counts.all}`"
:members="headerMembers"
:more-count="0"
/>
<RepoTabs
:repo-id="repo.id"
active="members"
:task-count="12"
:task-count="repo.totalCount"
:member-count="counts.all"
/>
@@ -141,9 +294,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</button>
</div>
<button
v-if="isAdmin"
class="btn primary invite"
type="button"
@click="showInvite = true"
@click="openInvite"
>
<svg
viewBox="0 0 24 24"
@@ -164,7 +318,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</div>
<!-- 멤버 목록 -->
<div class="list">
<div
v-if="loading"
class="state-msg"
>
멤버를 불러오는
</div>
<div
v-else-if="filteredMembers.length === 0"
class="state-msg"
>
표시할 멤버가 없습니다.
</div>
<div
v-else
class="list"
>
<div
v-for="m in filteredMembers"
:key="m.user.id"
@@ -182,7 +351,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
></span>
</div>
<div class="mem-sub">
{{ m.email }} <span class="dot">·</span> {{ m.subRole }}
{{ m.email }}
</div>
</div>
<div
@@ -191,6 +360,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
>
<b>{{ m.taskCount }}</b> 담당
</div>
<!-- 소유자: 변경 불가 고정 표시 -->
<div
v-if="m.owner"
class="role fixed admin"
@@ -211,28 +381,64 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</span>
</div>
<button
<!-- 관리자: 역할 변경 드롭다운 -->
<div
v-else-if="isAdmin"
class="role-wrap"
>
<button
class="role"
:class="{ admin: m.roleType === 'admin' }"
type="button"
:disabled="roleBusy"
@click.stop="toggleRoleMenu(m.user.id)"
>
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
<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>
</button>
<div
v-if="openRoleFor === m.user.id"
class="role-menu"
>
<button
type="button"
:class="{ on: m.roleType === 'member' }"
@click="changeRole(m.user.id, 'member')"
>
멤버
</button>
<button
type="button"
:class="{ on: m.roleType === 'admin' }"
@click="changeRole(m.user.id, 'admin')"
>
관리자
</button>
</div>
</div>
<!-- 일반 멤버 시점: 변경 불가 표시 -->
<div
v-else
class="role"
type="button"
class="role readonly"
:class="{ admin: m.roleType === 'admin' }"
>
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
<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>
</button>
</div>
<button
v-if="isAdmin && !m.owner"
class="mem-remove"
:class="{ disabled: m.owner }"
type="button"
:title="m.owner ? '소유자는 제거할 수 없습니다' : '멤버 제거'"
title="멤버 제거"
@click="removeMember(m.user.id, m.user.name)"
>
<svg
viewBox="0 0 24 24"
@@ -281,81 +487,99 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
><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 mini"></span>한지민 <span class="x">×</span></span>
<span class="ichip ext">
<form
class="modal-body"
@submit.prevent="submitInvite"
>
<div class="m-field uc-field">
<label class="m-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="m-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span
class="avatar user-av"
:class="avatarColor(u.id)"
>{{ initialOf(u.name) }}</span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
<div class="m-field">
<label class="m-label">역할</label>
<div class="select-wrap">
<select v-model="inviteRole">
<option value="member">
멤버
</option>
<option value="admin">
관리자
</option>
</select>
<span class="caret">
<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>
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
<input
class="icombo-input"
placeholder="이름 또는 이메일 입력…"
>
</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 class="muted">(선택)</span></label>
<input
class="m-input"
type="text"
placeholder="함께 캠페인 준비해요!"
>
</div>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</div>
</form>
<div class="modal-foot">
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="showInvite = false"
@click="closeInvite"
>
취소
</button>
<button
class="mbtn primary"
type="button"
@click="showInvite = false"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<svg
viewBox="0 0 24 24"
@@ -365,7 +589,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
초대 보내기 (2)
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
</div>
</div>
@@ -416,6 +640,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
.mem-row:first-child {
border-top: none;
border-radius: 0.625rem 0.625rem 0 0;
}
.mem-row:last-child {
border-radius: 0 0 0.625rem 0.625rem;
}
.mem-row:hover {
background: #fafbfc;
@@ -589,6 +817,71 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
opacity: 0 !important;
pointer-events: none;
}
/* 로딩/빈 상태 메시지 */
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
/* 역할 변경 드롭다운 */
/* 전역 .list 의 overflow:hidden 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
대신 컨테이너 라운드 코너 클리핑은 각 행의 첫/마지막 모서리에서 유지된다. */
.list {
overflow: visible;
}
.role-wrap {
position: relative;
}
.role:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.role.readonly {
cursor: default;
background: #f7f8fa;
}
.role-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 20;
min-width: 6.5rem;
background: #fff;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.16);
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.role-menu button {
height: 2rem;
padding: 0 0.625rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
text-align: left;
cursor: pointer;
}
.role-menu button:hover {
background: #f1f2f4;
color: var(--text);
}
.role-menu button.on {
color: var(--accent);
background: var(--accent-weak);
}
</style>
<!-- 초대 모달은 Teleport body 렌더되므로 -scoped 전역 스타일 -->
@@ -599,7 +892,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
overflow: hidden;
/* overflow:hidden 제거 — 자동완성 드롭다운이 모달 밖으로 넘쳐도 잘리지 않도록 */
}
.invite-modal .modal-head {
display: flex;
@@ -644,6 +937,78 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
.invite-modal .modal-body {
padding: 0.25rem 1.5rem 0.5rem;
/* 전역 .modal-body(text-align:center) 상속 차단 — 폼은 좌측 정렬 */
text-align: left;
}
/* 모달 입력 필드(이메일/역할) — 세로 스택, 전체 너비 */
.invite-modal .m-field {
margin-top: 1.125rem;
}
.invite-modal .m-field:first-child {
margin-top: 0;
}
/* 사용자 자동완성 드롭다운 */
.invite-modal .uc-field {
position: relative;
}
.invite-modal .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 16rem;
overflow-y: auto;
}
.invite-modal .user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.invite-modal .user-dd-item:hover {
background: #f5f6f8;
}
.invite-modal .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-modal .user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-modal .user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
}
.invite-modal .m-label {
font-size: 0.781rem;
@@ -656,76 +1021,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
color: var(--text-3);
font-weight: 500;
}
.invite-modal .icombo {
position: relative;
}
.invite-modal .icombo-field {
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
box-shadow: 0 0 0 3px var(--accent-weak);
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.invite-modal .ichip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.1875rem 0.5rem 0.1875rem 0.25rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .ichip .avatar.mini {
width: 1.125rem;
height: 1.125rem;
font-size: 0.5625rem;
}
.invite-modal .ichip.ext {
background: var(--accent-weak);
border-color: var(--accent-border);
color: var(--accent);
}
.invite-modal .ichip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
}
.invite-modal .ichip .x:hover {
color: var(--red);
}
.invite-modal .icombo-input {
flex: 1;
min-width: 7.5rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.invite-modal .icombo-input::placeholder {
color: var(--text-3);
}
.invite-modal .m-row {
display: flex;
gap: 0.75rem;
margin-top: 1.125rem;
}
.invite-modal .m-role {
flex: 0 0 9.375rem;
}
.invite-modal .m-grow {
flex: 1;
}
.invite-modal .select-wrap {
position: relative;
}
@@ -800,6 +1095,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
border-top: 1px solid var(--border);
background: #fafbfc;
margin-top: 1rem;
/* overflow:hidden 제거에 따른 하단 라운드 보정 */
border-radius: 0 0 0.875rem 0.875rem;
}
.invite-modal .modal-foot .fnote {
font-size: 0.75rem;
@@ -807,6 +1104,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
margin-right: auto;
}
.invite-modal .mbtn {
/* 전역 .mbtn(flex:1, 확인 다이얼로그용) 상속 차단 — 버튼은 내용 너비로 우측 정렬 */
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
border-radius: var(--radius);
@@ -834,6 +1133,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
.invite-modal .mbtn.primary:hover {
background: var(--accent-hover);
}
.invite-modal .mbtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.invite-modal .mbtn svg {
width: 0.9375rem;
height: 0.9375rem;
@@ -5,6 +5,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { type Repo, type Visibility } from '@/mock/relay.mock'
@@ -181,6 +182,11 @@ function renameRepo() {
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</span>
</div>
<GiteaRepoBar
:clone-url="repo.cloneUrl"
:html-url="repo.htmlUrl"
class="gitea-bar-spacing"
/>
</div>
<div class="fblock">
@@ -449,6 +455,10 @@ textarea.tarea {
height: auto;
}
.gitea-bar-spacing {
margin-top: 0.6875rem;
}
.readonly {
display: flex;
align-items: center;
+119 -48
View File
@@ -1,38 +1,46 @@
<script setup lang="ts">
// 업무 상세 — 담당자(작업자) 관점. state 쿼리로 3가지 상태를 분기한다.
// - request : 진행 중 → 지시자에게 승인 요청
// - changes : 수정 요청됨 → 다시 승인 요청
// - approved: 승인 완료
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 업무 상세 — 담당자(작업자) 관점. 실제 task.status 로 카드/액션을 분기한다.
// - todo : 업무 시작(todo→prog)
// - prog : 승인 요청(prog→review)
// - review: 승인 대기 중(액션 없음)
// - changes: 다시 승인 요청(changes→review)
// - done : 승인 완료
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { TASK_DETAIL } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { TaskDetail } from '@/mock/relay.mock'
// 카드 스타일 분기용 뷰 상태(승인 요청 / 수정 요청 / 승인 완료)
type ViewState = 'request' | 'changes' | 'approved'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 쿼리에서 상태 결정(미지정/오류 시 request)
const state = computed<ViewState>(() => {
const s = String(route.query.state ?? 'request')
return s === 'changes' || s === 'approved' ? s : 'request'
const taskStore = useTaskStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null
}
}
watch(taskId, loadTask, { immediate: true })
// 실제 상태 → 카드 뷰 상태 매핑 (todo/prog/review → request 카드)
const viewState = computed<ViewState>(() => {
const s = task.value?.status
if (s === 'done') return 'approved'
if (s === 'changes') return 'changes'
return 'request'
})
// 상태별 표시 구성
const CONFIG: Record<ViewState, { badge: string; badgeLabel: string; statusLabel: string }> = {
request: { badge: 'prog', badgeLabel: '진행 중', statusLabel: '진행 중' },
changes: { badge: 'changes', badgeLabel: '수정 요청', statusLabel: '수정 요청' },
approved: { badge: 'done', badgeLabel: '완료', statusLabel: '완료' },
}
const cfg = computed(() => CONFIG[state.value])
// 완료 상태면 체크리스트 전부 완료로 표시
const checklist = computed(() =>
state.value === 'approved' ? task.checklist.map((c) => ({ ...c, done: true })) : task.checklist,
)
const checklist = computed(() => task.value?.checklist ?? [])
const checklistDone = computed(() => checklist.value.filter((c) => c.done).length)
const checklistPct = computed(() =>
checklist.value.length ? Math.round((checklistDone.value / checklist.value.length) * 100) : 0,
@@ -44,9 +52,32 @@ const showDelete = ref(false)
const showRequest = ref(false)
const commentText = ref('')
function confirmDelete() {
// 상태 전이 — 담당자: 시작(todo→prog) / 승인요청(prog→review) / 재요청(changes→review)
async function changeTo(status: TaskDetail['status']) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
async function startTask() {
await changeTo('prog')
}
async function submitApprovalRequest() {
showRequest.value = false
await changeTo('review')
}
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
@@ -78,7 +109,10 @@ function renderMention(text: string): string {
<AppShell>
<!-- 본문 v-html 내부에서 생성한 신뢰 마크업(멘션 강조) 렌더한다 -->
<!-- eslint-disable vue/no-v-html -->
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -109,8 +143,8 @@ function renderMention(text: string): string {
<div class="ph-titlerow">
<span
class="st-badge"
:class="cfg.badge"
>{{ cfg.badgeLabel }}</span>
:class="task.status"
>{{ task.statusLabel }}</span>
<span class="ph-tid">#{{ task.id }}</span>
</div>
<h1 class="ph-title">
@@ -120,7 +154,7 @@ function renderMention(text: string): string {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -352,12 +386,12 @@ function renderMention(text: string): string {
<!-- 상태별 승인 카드 -->
<div
class="card ap-card"
:class="state"
:class="viewState"
>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
v-if="state === 'request'"
v-if="viewState === 'request'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -366,7 +400,7 @@ function renderMention(text: string): string {
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
<svg
v-else-if="state === 'changes'"
v-else-if="viewState === 'changes'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -384,12 +418,34 @@ function renderMention(text: string): string {
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">{{ state === 'request' ? '지시자에게 승인 요청' : state === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
<span class="ap-card-title">{{ viewState === 'request' ? '내 작업' : viewState === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
</div>
<template v-if="state === 'request'">
<!-- 승인 대기 (이미 요청 보냄) -->
<template v-if="task.status === 'review'">
<div class="ap-card-meta">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 업무 시작 -->
<template v-else-if="task.status === 'todo'">
<div class="ap-card-desc">
작업을 마쳤다 지시자 <b>정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
아직 시작하지 않은 업무입니다. 작업을 시작하 상태가 <b>진행 </b>으로 바뀝니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<div class="ap-card-desc">
작업을 마쳤다면 지시자에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
@@ -409,13 +465,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<template v-else-if="state === 'changes'">
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === 'changes'">
<div class="ap-card-meta">
<b>정태경</b>님이 수정을 요청했습니다 · 1시간
</div>
<div class="ap-card-note">
세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.
지시자가 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -435,10 +488,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-meta">
<b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 일이 모두 완료되었습니다.
지시자가 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
@@ -450,8 +503,8 @@ function renderMention(text: string): string {
</div>
<span
class="status-badge"
:class="state"
><span class="dot" />{{ cfg.statusLabel }}</span>
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -496,7 +549,10 @@ function renderMention(text: string): string {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -538,6 +594,16 @@ function renderMention(text: string): string {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- 모달 -->
<Teleport to="body">
<!-- 삭제 -->
@@ -566,7 +632,7 @@ function renderMention(text: string): string {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -622,7 +688,7 @@ function renderMention(text: string): string {
<button
class="mbtn request-send"
type="button"
@click="showRequest = false"
@click="submitApprovalRequest"
>
요청 보내기
</button>
@@ -637,6 +703,11 @@ function renderMention(text: string): string {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
+352 -149
View File
@@ -1,40 +1,147 @@
<script setup lang="ts">
// 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
import { computed, ref } from 'vue'
// 업무 작성/수정 — 제목/내용/체크리스트 + 사이드(담당자/마감/지시자) · 백엔드 연동
// taskId 파라미터가 있으면 '수정 모드'로 동작(기존 값 미리 채움 + PATCH 저장)
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useTask } from '@/composables/useTask'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
const isEdit = computed(() => taskId.value !== null)
// 제목
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
const repoStore = useRepoStore()
const memberStore = useMemberStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const taskApi = useTask()
// 담당자 (칩)
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): User {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// ISO → input[type=date] 용 'yyyy-MM-dd'
function toDateInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 저장소 헤더(빵부스러기) — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null
}
}
// 담당자 후보 = 저장소 멤버
async function loadMembers() {
try {
await memberStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
// 수정 모드: 기존 업무 값을 폼에 미리 채운다
async function loadTaskForEdit() {
if (taskId.value === null) return
try {
const t = await taskApi.get(repoId.value, taskId.value)
title.value = t.title
contentText.value = t.content.join('\n')
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
assignees.value = t.assignees.map(toUserView)
checklist.value = t.checklist.map((c) => ({ text: c.text, done: c.done }))
} catch {
// 404 등은 인터셉터가 알림 처리
}
}
watch(
[repoId, taskId],
() => {
loadRepo()
loadMembers()
loadTaskForEdit()
},
{ immediate: true },
)
// 제목 / 내용
const title = ref('')
const contentText = ref('')
// 지시자 = 현재 로그인 사용자
const issuer = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// --- 담당자 선택 (저장소 멤버 중) ---
const assignees = ref<User[]>([])
const assigneeQuery = ref('')
const showAssigneeDropdown = ref(false)
function removeAssignee(id: string) {
assignees.value = assignees.value.filter((a) => a.id !== id)
}
// 아직 선택되지 않은 멤버 후보(이름 부분일치)
const assigneeCandidates = computed(() => {
const selected = new Set(assignees.value.map((a) => a.id))
const kw = assigneeQuery.value.trim()
return memberStore.members
.filter((m) => !selected.has(m.user.id))
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
})
function addAssignee(userId: string) {
const m = memberStore.members.find((x) => x.user.id === userId)
if (m && !assignees.value.some((a) => a.id === userId)) {
assignees.value.push(m.user)
}
assigneeQuery.value = ''
showAssigneeDropdown.value = false
}
function onAssigneeBlur() {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showAssigneeDropdown.value = false), 120)
}
// 체크리스트
const checklist = ref<ChecklistItem[]>([
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
])
// --- 마감기한 ---
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
// 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성)
const checklist = ref<ChecklistItem[]>([])
const newItem = ref('')
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
const progressPct = computed(() =>
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
)
function toggleItem(item: ChecklistItem) {
item.done = !item.done
}
function removeItem(index: number) {
checklist.value.splice(index, 1)
}
@@ -45,25 +152,51 @@ function addItem() {
newItem.value = ''
}
// 첨부파일
const files = ref<Attachment[]>([
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
])
function removeFile(index: number) {
files.value.splice(index, 1)
// --- 제출 ---
const submitting = ref(false)
const canSubmit = computed(
() => !submitting.value && title.value.trim().length > 0 && assignees.value.length > 0,
)
async function submitTask() {
if (!canSubmit.value) return
submitting.value = true
try {
const content = contentText.value
.split('\n')
.map((p) => p.trim())
.filter((p) => p.length > 0)
const assigneeIds = assignees.value.map((a) => a.id)
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
if (isEdit.value && taskId.value !== null) {
// 수정 — 제목/내용/담당자/마감만 갱신(체크리스트 항목 편집은 5단계)
const updated = await taskStore.update(repoId.value, taskId.value, {
title: title.value.trim(),
content,
assigneeIds,
dueDate: dueIso,
})
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
} else {
const created = await taskStore.create(repoId.value, {
title: title.value.trim(),
content: content.length ? content : undefined,
assigneeIds,
dueDate: dueIso ?? undefined,
checklist: checklist.value.length
? checklist.value.map((c) => ({ text: c.text }))
: undefined,
})
router.push(`/repos/${repoId.value}/tasks/${created.id}`)
}
} catch {
// 인터셉터가 알림 처리(권한/검증 등)
} finally {
submitting.value = false
}
}
// 파일 종류 → 아이콘 클래스/라벨
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
pdf: { cls: 'fi-pdf', label: 'PDF' },
zip: { cls: 'fi-zip', label: 'ZIP' },
doc: { cls: 'fi-doc', label: 'DOC' },
img: { cls: 'fi-doc', label: 'IMG' },
}
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
function goBack() {
router.push(`/repos/${repoId.value}`)
}
@@ -87,12 +220,11 @@ function goBack() {
{{ repo?.name ?? '저장소' }}
</RouterLink>
<span class="sep">/</span>
업무
{{ isEdit ? '수정' : '새 업무' }}
</div>
<div class="pagehead">
<h1> 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<h1>{{ isEdit ? '업무 수정' : '새 업무 작성' }}</h1>
<div class="actions">
<button
class="btn ghost"
@@ -101,16 +233,11 @@ function goBack() {
>
취소
</button>
<button
class="btn"
type="button"
>
임시저장
</button>
<button
class="btn primary"
type="button"
@click="goBack"
:disabled="!canSubmit"
@click="submitTask"
>
<svg
width="15"
@@ -125,7 +252,7 @@ function goBack() {
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
지시 보내기
{{ submitting ? (isEdit ? '저장 중…' : '전송 중…') : (isEdit ? '저장' : '지시 보내기') }}
</button>
</div>
</div>
@@ -226,11 +353,11 @@ function goBack() {
><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>
<textarea
v-model="contentText"
class="editor-body"
placeholder="업무 배경과 요구사항을 작성하세요. (줄바꿈은 문단으로 저장됩니다)"
/>
</div>
</div>
@@ -260,7 +387,6 @@ function goBack() {
<span
class="cbox"
:class="{ done: item.done }"
@click="toggleItem(item)"
>
<svg
viewBox="0 0 24 24"
@@ -273,6 +399,7 @@ function goBack() {
</span>
<span class="txt">{{ item.text }}</span>
<span
v-if="!isEdit"
class="row-del"
@click="removeItem(index)"
>
@@ -287,7 +414,10 @@ function goBack() {
</span>
</div>
</div>
<div class="add-row">
<div
v-if="!isEdit"
class="add-row"
>
<span class="plus">+</span>
<input
v-model="newItem"
@@ -296,6 +426,12 @@ function goBack() {
@keydown.enter="addItem"
>
</div>
<p
v-else
class="checklist-note"
>
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
</p>
</div>
</section>
@@ -307,25 +443,15 @@ function goBack() {
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}</span>
</div>
<div class="combo">
<!-- 입력칸은 고정 선택된 담당자는 아래에 표기 -->
<div class="combo-field">
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
<input
v-model="assigneeQuery"
class="combo-input"
placeholder="이름 검색…"
placeholder="멤버 검색…"
autocomplete="off"
@focus="showAssigneeDropdown = true"
@blur="onAssigneeBlur"
>
<span class="combo-caret">
<svg
@@ -338,6 +464,53 @@ function goBack() {
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showAssigneeDropdown"
class="assignee-dd"
>
<button
v-for="m in assigneeCandidates"
:key="m.user.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addAssignee(m.user.id)"
>
<span
class="avatar"
:class="m.user.color"
>{{ m.user.initial }}</span>
<span class="ad-nm">{{ m.user.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="assigneeCandidates.length === 0"
class="assignee-dd-msg"
>
{{ memberStore.members.length === 0 ? '저장소 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
</div>
</div>
</div>
<!-- 선택된 담당자 (입력칸 아래에 표기) -->
<div
v-if="assignees.length"
class="assignee-chips"
>
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
</div>
</div>
</div>
@@ -345,65 +518,21 @@ function goBack() {
<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" /> 마감까지 15 남음
마감기한 <span class="muted">· 선택</span>
</div>
<input
v-model="dueDate"
class="input date-input"
type="date"
>
</div>
<div class="side-field">
<div class="side-label">
첨부파일 <span class="muted">· {{ files.length }}</span>
첨부파일 <span class="muted">· 5단계 예정</span>
</div>
<div class="files">
<div
v-for="(file, index) in files"
:key="file.name"
class="file"
>
<span
class="file-ico"
:class="FILE_META[file.kind].cls"
>{{ FILE_META[file.kind].label }}</span>
<span class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ file.size }}</span>
</span>
<span
class="x"
@click="removeFile(index)"
>
<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 class="dropzone is-disabled">
파일 첨부는 제공됩니다
</div>
</div>
</div>
@@ -414,14 +543,18 @@ function goBack() {
지시자
</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span
class="avatar"
:class="issuer.color"
>{{ issuer.initial }}</span>
<span>
<div class="nm">정태경</div>
<div class="rl">마케팅 그룹 리드</div>
<div class="nm">{{ issuer.name }}</div>
</span>
</div>
<div class="side-foot">
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 업무 추가됩니다.
{{ isEdit
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
: `지시를 보내면 담당자 ${assignees.length}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.` }}
</div>
</div>
</div>
@@ -452,17 +585,6 @@ function goBack() {
white-space: nowrap;
flex-shrink: 0;
}
.draft-tag {
font-size: 0.719rem;
font-weight: 600;
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
padding: 0.1875rem 0.5rem;
border-radius: 20px;
line-height: 1;
white-space: nowrap;
}
.pagehead .actions {
margin-left: auto;
display: flex;
@@ -573,17 +695,22 @@ function goBack() {
margin: 0 0.3125rem;
}
.editor-body {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-family: inherit;
color: var(--text);
min-height: 9.375rem;
line-height: 1.65;
border: none;
outline: none;
background: transparent;
resize: vertical;
}
.editor-body p {
margin-bottom: 0.6875rem;
}
.editor-body p:last-child {
margin-bottom: 0;
.editor-body::placeholder {
color: var(--text-3);
}
.mention {
color: var(--accent);
@@ -648,7 +775,6 @@ function goBack() {
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.cbox.done {
background: var(--green);
@@ -720,6 +846,11 @@ function goBack() {
.add-input::placeholder {
color: var(--text-3);
}
.checklist-note {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
}
/* 사이드바 */
.side {
@@ -762,6 +893,76 @@ function goBack() {
.combo {
position: relative;
}
/* 멤버 후보 드롭다운 */
.assignee-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.assignee-dd-item:hover {
background: #f5f6f8;
}
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
font-size: 0.625rem;
flex-shrink: 0;
}
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ad-em {
font-size: 0.719rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.date-input {
cursor: pointer;
}
.dropzone.is-disabled {
cursor: default;
opacity: 0.7;
}
.dropzone.is-disabled:hover {
border-color: var(--border-strong);
background: #fafbfc;
color: var(--text-3);
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
@@ -787,6 +988,13 @@ function goBack() {
width: 1rem;
height: 1rem;
}
/* 선택된 담당자 칩 — 입력칸 아래에 표기 */
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
@@ -973,11 +1181,6 @@ function goBack() {
font-size: 0.8125rem;
font-weight: 600;
}
.issuer .rl {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.side-foot {
font-size: 0.719rem;
color: var(--text-3);
+382 -31
View File
@@ -15,27 +15,45 @@ import {
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import {
TASK_DETAIL,
USERS,
type Attachment,
type Comment,
type Reply,
type TaskDetail,
type TaskStatus,
type User,
} from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { avatarColor, initialOf } from '@/shared/utils/format'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 상태 배지 클래스(시안의 prog/review/done/changes 와 동일)
const statusClass = computed<TaskStatus>(() => task.status)
const taskStore = useTaskStore()
const authStore = useAuthStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null // 404 등은 인터셉터가 알림 처리
}
}
watch(taskId, loadTask, { immediate: true })
// 상태 배지 클래스(prog/review/done/changes)
const statusClass = computed<TaskStatus>(() => task.value?.status ?? 'todo')
// 체크리스트 진행률
const checklistDone = computed(() => task.checklist.filter((c) => c.done).length)
const checklistPct = computed(() =>
task.checklist.length ? Math.round((checklistDone.value / task.checklist.length) * 100) : 0,
)
const checklistDone = computed(() => task.value?.checklist.filter((c) => c.done).length ?? 0)
const checklistPct = computed(() => {
const total = task.value?.checklist.length ?? 0
return total ? Math.round((checklistDone.value / total) * 100) : 0
})
// 본문 멘션(@…)을 강조 span 으로 변환
function renderMention(text: string): string {
@@ -85,10 +103,54 @@ function closeAllOverlays() {
moreOpen.value = false
}
// 모달에서 삭제 확정 저장소 상세로 이동(목업)
function confirmDelete() {
// 모달에서 삭제 확정 → DELETE 후 저장소 상세로 이동
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
async function approveTask() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
} catch {
// 인터셉터가 알림 처리
} finally {
showApprove.value = false
}
}
async function requestChanges() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
} catch {
// 인터셉터가 알림 처리
} finally {
showChanges.value = false
}
}
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
async function changeTo(status: TaskStatus) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
function startTask() {
void changeTo('prog')
}
function requestApproval() {
void changeTo('review')
}
/* ============================================================
@@ -96,13 +158,44 @@ function confirmDelete() {
* ============================================================ */
const activeTab = ref<'comments' | 'activity'>('comments')
// 댓글은 로컬 상태로 복제해 답글/등록을 반영
const comments = reactive<Comment[]>(task.comments.map((c) => ({ ...c, replies: [...c.replies] })))
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
const comments = reactive<Comment[]>([])
const replyOpen = ref<number | null>(null)
const replyText = ref('')
const commentText = ref('')
const me = USERS.jung // 현재 사용자(지시자)
// 현재 로그인 사용자 (댓글 작성자)
const me = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// 현재 사용자의 이 업무에 대한 역할(액션 분기용)
const isIssuer = computed(
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
)
const isAssignee = computed(
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
)
// 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent
const assigneeCardClass = computed(() => {
switch (task.value?.status) {
case 'changes':
return 'changes'
case 'review':
return 'review'
case 'done':
return 'done'
default:
return 'request'
}
})
function toggleReply(index: number) {
replyOpen.value = replyOpen.value === index ? null : index
@@ -112,7 +205,7 @@ function submitReply(index: number) {
const text = replyText.value.trim()
const target = comments[index]
if (!text || !target) return
const reply: Reply = { author: me, time: '방금', text }
const reply: Reply = { author: me.value, time: '방금', text }
target.replies.push(reply)
replyText.value = ''
replyOpen.value = null
@@ -120,7 +213,7 @@ function submitReply(index: number) {
function submitComment() {
const text = commentText.value.trim()
if (!text) return
comments.push({ author: me, roleBadge: '지시자', time: '방금', text, replies: [] })
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
commentText.value = ''
}
@@ -387,7 +480,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</button>
</template>
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -429,7 +525,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -791,9 +887,9 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<!-- 사이드바 -->
<aside class="side">
<!-- 승인 요청 카드 -->
<!-- 지시자: 승인 대기(review) 승인/수정요청 -->
<div
v-if="task.approval"
v-if="task.status === 'review' && isIssuer"
class="card ap-card review"
>
<div class="ap-card-head">
@@ -814,10 +910,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<span class="ap-card-title">승인 요청 도착</span>
</div>
<div class="ap-card-meta">
<b>{{ task.approval.requester }}</b>님이 작업 완료 승인을 요청했습니다 · {{ task.approval.requestedAgo }}
</div>
<div class="ap-card-note">
{{ task.approval.note }}
담당자가 작업 완료 승인을 요청했습니다. 검토 승인하거나 수정을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -853,12 +946,173 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 담당자: 시작 / 승인요청 / 재요청 / 대기중 / 완료 -->
<div
v-else-if="isAssignee"
class="card ap-card"
:class="assigneeCardClass"
>
<!-- 업무 시작 -->
<template v-if="task.status === 'todo'">
<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="M5 3 19 12 5 21V3z" /></svg>
</span>
<span class="ap-card-title">업무 시작</span>
</div>
<div class="ap-card-desc">
아직 시작하지 않은 업무입니다. 작업을 시작하면 상태가 <b>진행 </b>으로 바뀌고 지시자에게 표시됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 3 19 12 5 21V3z" /></svg>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<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>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<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>
</template>
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === '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>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<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>
</template>
<!-- 승인 대기 -->
<template v-else-if="task.status === 'review'">
<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="M12 8v4l3 2" /><circle
cx="12"
cy="12"
r="9"
/></svg>
</span>
<span class="ap-card-title">승인 대기 </span>
</div>
<div class="ap-card-desc">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.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>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">
상태
</div>
<span class="status-badge review"><span class="dot" />{{ task.statusLabel }}</span>
<span
class="status-badge"
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -903,7 +1157,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -945,6 +1202,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- ===== 모달 ===== -->
<Teleport to="body">
<!-- 삭제 -->
@@ -973,7 +1240,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -1021,7 +1288,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 승인할까요?
</h2>
<p class="modal-desc">
<b>{{ task.approval?.requester }}</b>님의 검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
</p>
</div>
<div class="modal-foot">
@@ -1035,7 +1302,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn approve"
type="button"
@click="showApprove = false"
@click="approveTask"
>
승인
</button>
@@ -1077,7 +1344,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn danger"
type="button"
@click="showChanges = false"
@click="requestChanges"
>
수정 요청 보내기
</button>
@@ -1419,6 +1686,11 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
@@ -2075,6 +2347,30 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.status-badge.review .dot {
background: var(--amber);
}
.status-badge.todo {
color: var(--text-2);
background: #f1f2f4;
border-color: var(--border-strong);
}
.status-badge.todo .dot {
background: var(--text-3);
}
.status-badge.changes {
color: var(--red);
background: var(--red-weak);
border-color: var(--red-border);
}
.status-badge.changes .dot {
background: var(--red);
}
.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;
@@ -2209,6 +2505,42 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
border-color: var(--amber-border);
background: var(--amber-weak);
}
/* 담당자 액션 카드(accent 테마) — 디자인 시안의 .ap-card.request */
.ap-card.request {
border-color: var(--accent-border);
background: var(--accent-weak);
}
.ap-card.request .ap-card-ico {
color: var(--accent);
border-color: var(--accent-border);
}
.ap-card.request .ap-card-title {
color: var(--accent-hover);
}
/* 수정 요청됨(빨강 테마) */
.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.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;
@@ -2245,6 +2577,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
color: var(--text);
font-weight: 600;
}
.ap-card-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.625rem;
line-height: 1.55;
}
.ap-card-desc b {
color: var(--text);
font-weight: 600;
}
.ap-card-note {
font-size: 0.781rem;
color: var(--text);
@@ -2295,6 +2637,15 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.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, 0.4);
}
.ap-btn.request-send:hover {
background: var(--accent-hover);
}
</style>
<!-- 모달/에이전트 패널은 Teleport body 렌더되므로 전역(scoped 미적용) 스타일 사용 -->
+7
View File
@@ -62,6 +62,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/TaskCreatePage.vue'),
meta: { requiresAuth: true },
},
{
// 업무 수정 — 생성 폼(TaskCreatePage)을 편집 모드로 재사용
path: '/repos/:repoId/tasks/:taskId/edit',
name: 'task-edit',
component: () => import('@/pages/relay/TaskCreatePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/tasks/:taskId',
name: 'task-detail',
+67
View File
@@ -1,5 +1,7 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
import type { TaskStatus } from '@/mock/relay.mock'
/**
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
*/
@@ -75,3 +77,68 @@ export function progressOf(done: number, total: number): ProgressInfo {
const label = pct === 0 ? '시작 전' : `${pct}% 완료`
return { pct, level, label }
}
/* ============================================================
* 업무(Task) 표시용 파생 값
* ============================================================ */
const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
/** 업무 상태 → 한국어 라벨 */
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '승인 대기',
done: '완료',
changes: '수정 요청',
}
export function taskStatusLabel(status: TaskStatus): string {
return TASK_STATUS_LABEL[status] ?? status
}
/** 'M월 D일' (예: '6월 8일') */
export function monthDayKo(input: Date | string): string {
const d = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}${d.getDate()}`
}
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
export interface TaskDueInfo {
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
dateLabel: string
/** D-day 라벨 (예: 'D-3', 'D-DAY', '2일 지남') — 마감 없으면 '' */
dday: string
/** 목록 행 라벨 (완료: '6월 9일 완료' / 진행: 'D-3' / 지남: '2일 지남') */
listLabel: string
/** 마감 지남 여부(완료 업무는 false) */
overdue: boolean
}
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
if (!dueDate) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
}
const due = new Date(dueDate)
if (Number.isNaN(due.getTime())) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
}
const dateLabel = `${due.getMonth() + 1}${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
// 자정 기준 일 수 차이
const startDue = new Date(due.getFullYear(), due.getMonth(), due.getDate())
const now = new Date()
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
if (isDone) {
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
}
if (days < 0) {
const passed = `${-days}일 지남`
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
}
const dday = days === 0 ? 'D-DAY' : `D-${days}`
return { dateLabel, dday, listLabel: dday, overdue: false }
}
+80
View File
@@ -0,0 +1,80 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useMember } from '@/composables/useMember'
import { useAuthStore } from '@/stores/auth.store'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type {
ApiRepoMember,
InviteMemberPayload,
UpdateMemberPayload,
} from '@/types/member'
import type { RepoMember as MemberView } from '@/mock/relay.mock'
// API 멤버 → 화면용 RepoMember(이니셜/색상 파생 + isYou 산출)
function toMemberView(api: ApiRepoMember, currentUserId: string | null): MemberView {
return {
user: {
id: api.user.id,
name: api.user.name,
role: api.user.role ?? '',
initial: initialOf(api.user.name),
color: avatarColor(api.user.id),
},
email: api.email,
taskCount: api.taskCount,
roleType: api.roleType,
isYou: api.user.id === currentUserId,
owner: api.owner,
}
}
// 저장소 멤버 스토어 — 현재 저장소의 멤버 목록 상태 + 초대/역할변경/제거 액션
export const useMemberStore = defineStore('member', () => {
const members = ref<MemberView[]>([])
const loading = ref(false)
const memberApi = useMember()
const authStore = useAuthStore()
// 현재 로그인 사용자 id (isYou 산출용)
function currentUserId(): string | null {
return authStore.user?.id ?? null
}
// 멤버 목록 조회
async function fetchList(repoId: string): Promise<void> {
loading.value = true
try {
const list = await memberApi.list(repoId)
members.value = list.map((m) => toMemberView(m, currentUserId()))
} finally {
loading.value = false
}
}
// 멤버 초대 — 성공 시 목록에 추가
async function invite(repoId: string, payload: InviteMemberPayload): Promise<void> {
const created = await memberApi.invite(repoId, payload)
members.value.push(toMemberView(created, currentUserId()))
}
// 멤버 역할/직무 변경 — 성공 시 해당 행 교체
async function update(
repoId: string,
userId: string,
payload: UpdateMemberPayload,
): Promise<void> {
const updated = await memberApi.update(repoId, userId, payload)
const view = toMemberView(updated, currentUserId())
const idx = members.value.findIndex((m) => m.user.id === userId)
if (idx >= 0) members.value[idx] = view
}
// 멤버 제거 — 성공 시 목록에서 제외
async function remove(repoId: string, userId: string): Promise<void> {
await memberApi.remove(repoId, userId)
members.value = members.value.filter((m) => m.user.id !== userId)
}
return { members, loading, fetchList, invite, update, remove }
})
+3
View File
@@ -39,6 +39,9 @@ function toRepoView(api: ApiRepo): RepoView {
progressPct: prog.pct,
progressLevel: prog.level,
progressLabel: prog.label,
cloneUrl: api.cloneUrl,
htmlUrl: api.htmlUrl,
giteaMissing: api.giteaMissing,
}
}
+163
View File
@@ -0,0 +1,163 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useTask } from '@/composables/useTask'
import {
avatarColor,
initialOf,
monthDayKo,
taskDueInfo,
taskStatusLabel,
} from '@/shared/utils/format'
import type {
ApiMember,
} from '@/types/repo'
import type {
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type {
RepoTask as RepoTaskView,
TaskDetail as TaskDetailView,
User as UserView,
} from '@/mock/relay.mock'
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): UserView {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// 지시자 없음(SET NULL) 대비 폴백 사용자
const UNKNOWN_USER: UserView = {
id: '',
name: '알 수 없음',
role: '',
initial: '?',
color: 'av-a',
}
// API 업무 목록 행 → 화면용 RepoTask
function toRepoTaskView(api: ApiRepoTask): RepoTaskView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const [, total] = api.checklist
return {
id: api.id,
title: api.title,
status: api.status,
dueLabel: due.listLabel || undefined,
overdue: due.overdue || undefined,
checklist: total > 0 ? api.checklist : undefined,
comments: api.commentCount > 0 ? api.commentCount : undefined,
assignees: api.assignees.map(toUserView),
}
}
// API 업무 상세 → 화면용 TaskDetail (comments/activities/files/approval 은 5단계)
function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
return {
id: api.id,
repoId: api.repoId,
repoName: api.repoName,
title: api.title,
status: api.status,
statusLabel: taskStatusLabel(api.status),
content: api.content,
checklist: api.checklist.map((c) => ({ text: c.text, done: c.done })),
comments: [],
activities: [],
assignees: api.assignees.map(toUserView),
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
dueLabel: due.dateLabel,
dday: due.dday,
files: [],
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
}
}
// 업무 스토어 — 저장소 업무 목록 + 현재 상세 + CRUD/상태 액션
export const useTaskStore = defineStore('task', () => {
const tasks = ref<RepoTaskView[]>([])
const current = ref<TaskDetailView | null>(null)
const loading = ref(false)
const taskApi = useTask()
// 목록 조회
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
loading.value = true
try {
const list = await taskApi.list(repoId, query)
tasks.value = list.map(toRepoTaskView)
} finally {
loading.value = false
}
}
// 단건 상세 조회
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
loading.value = true
try {
current.value = toTaskDetailView(await taskApi.get(repoId, taskId))
return current.value
} finally {
loading.value = false
}
}
// 생성 — 생성된 상세 반환
async function create(repoId: string, payload: CreateTaskPayload): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.create(repoId, payload))
current.value = view
return view
}
// 수정
async function update(
repoId: string,
taskId: number,
payload: UpdateTaskPayload,
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.update(repoId, taskId, payload))
current.value = view
return view
}
// 삭제
async function remove(repoId: string, taskId: number): Promise<void> {
await taskApi.remove(repoId, taskId)
tasks.value = tasks.value.filter((t) => t.id !== taskId)
if (current.value?.id === taskId) current.value = null
}
// 상태 변경
async function changeStatus(
repoId: string,
taskId: number,
status: TaskDetailView['status'],
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.changeStatus(repoId, taskId, status))
current.value = view
return view
}
return {
tasks,
current,
loading,
fetchList,
fetchOne,
create,
update,
remove,
changeStatus,
}
})
+26
View File
@@ -0,0 +1,26 @@
// 저장소 멤버 도메인 타입 — 백엔드 RepoMemberResponse / DTO 와 1:1
import type { ApiMember } from '@/types/repo'
// 권한 역할
export type MemberRoleType = 'admin' | 'member'
// 멤버(API 원시) — 백엔드 RepoMemberResponse 와 1:1 (isYou 는 프론트가 산출)
export interface ApiRepoMember {
user: ApiMember // { id, email, name, role }
email: string
taskCount: number
roleType: MemberRoleType
owner: boolean // 소유자(isOwner) — 역할 변경/제거 불가
}
// 멤버 초대 요청 — 이미 가입된 사용자를 이메일로 초대
export interface InviteMemberPayload {
email: string
roleType: MemberRoleType
}
// 멤버 역할 변경 요청
export interface UpdateMemberPayload {
roleType?: MemberRoleType
}
+3
View File
@@ -21,6 +21,9 @@ export interface ApiRepo {
memberCount: number
doneCount: number
totalCount: number
cloneUrl: string | null // Gitea HTTPS clone URL (미연동 시 null)
htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null)
giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신)
updatedAt: string
}
+63
View File
@@ -0,0 +1,63 @@
// 업무 도메인 타입 — 백엔드 TaskService 응답 / DTO 와 1:1
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
export interface ApiRepoTask {
id: number // 저장소 내 순번(seq)
title: string
status: TaskStatus
dueDate: string | null
checklist: [number, number] // [완료, 전체]
commentCount: number
assignees: ApiMember[]
}
// 체크리스트 항목(API 원시)
export interface ApiChecklistItem {
id: string
text: string
done: boolean
}
// 업무 상세(API 원시) — comments/activities/files 는 5단계에서 채움(현재 빈 배열)
export interface ApiTaskDetail {
id: number
repoId: string
repoName: string
title: string
status: TaskStatus
content: string[]
checklist: ApiChecklistItem[]
assignees: ApiMember[]
issuer: ApiMember | null
dueDate: string | null
createdAt: string
comments: unknown[]
activities: unknown[]
files: unknown[]
}
// 업무 목록 필터
export interface TaskListQuery {
status?: TaskStatus
assignee?: string
}
// 업무 생성 요청
export interface CreateTaskPayload {
title: string
content?: string[]
assigneeIds: string[]
dueDate?: string
checklist?: { text: string }[]
}
// 업무 수정 요청
export interface UpdateTaskPayload {
title?: string
content?: string[]
assigneeIds?: string[]
dueDate?: string | null
}