Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 875eddf659 | |||
| c01f8e535c | |||
| e2fe089321 | |||
| b626bbdf6c | |||
| ae385355b8 | |||
| 2b38e21188 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(엔티티만 참조해 순환 없음).
|
||||
|
||||
@@ -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>` 로 확인.
|
||||
|
||||
@@ -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)으로 변환하세요.
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
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 |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 미적용) 스타일 사용 -->
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||