feat: 업무(Task) 모듈 4단계 — CRUD·상태머신·진행률 집계·역할 기반 상세 UI

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:12:11 +09:00
parent c01f8e535c
commit 875eddf659
27 changed files with 2466 additions and 342 deletions
+2
View File
@@ -12,6 +12,7 @@ import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
import { RepoModule } from './modules/repo/repo.module';
import { TaskModule } from './modules/task/task.module';
@Module({
imports: [
@@ -63,6 +64,7 @@ import { RepoModule } from './modules/repo/repo.module';
UserModule,
AuthModule,
RepoModule,
TaskModule,
],
controllers: [AppController],
providers: [
@@ -2,11 +2,12 @@ import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { MemberService } from './member.service';
import { UserService } from '../user/user.service';
import { TaskService } from '../task/task.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from './entities/repo.entity';
import { RepoMember } from './entities/repo-member.entity';
// 저장소/멤버 리포지토리 + UserService 간이 모킹
// 저장소/멤버 리포지토리 + UserService/TaskService 간이 모킹
function makeService() {
const repoRepo = { findOne: jest.fn() };
const memberRepo = {
@@ -17,12 +18,17 @@ function makeService() {
remove: jest.fn(),
};
const userService = { findByEmail: jest.fn() };
// 담당 업무 수 집계는 빈 Map(0건)으로 모킹
const taskService = {
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
};
const svc = new MemberService(
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
userService as unknown as UserService,
taskService as unknown as TaskService,
);
return { svc, repoRepo, memberRepo, userService };
return { svc, repoRepo, memberRepo, userService, taskService };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
@@ -30,17 +36,22 @@ const ADMIN = { roleType: 'admin' };
describe('MemberService', () => {
describe('list', () => {
it('멤버가 아니고 비공개면 접근 거부', async () => {
it('조직 모델: 멤버가 아니어도 인증 사용자면 목록 열람 가능', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 목록에 현재 사용자(other-user)가 없음
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
memberRepo.find.mockResolvedValue([
{ user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null } },
{
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
email: 'a@b.c',
roleType: 'member',
isOwner: false,
},
]);
await expect(svc.list('q3-launch', 'other-user')).rejects.toBeInstanceOf(
ForbiddenException,
);
const result = await svc.list('q3-launch');
expect(result).toHaveLength(1);
expect(result[0].user.id).toBe('someone-else');
});
});
+11 -14
View File
@@ -8,6 +8,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { TaskService } from '../task/task.service';
import { Repo } from './entities/repo.entity';
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
import { InviteMemberDto } from './dto/invite-member.dto';
@@ -17,7 +18,6 @@ import { UpdateMemberDto } from './dto/update-member.dto';
export interface RepoMemberResponse {
user: PublicUser;
email: string;
subRole: string | null;
taskCount: number; // TODO(4단계): 실제 담당 업무 수 집계
roleType: MemberRoleType;
owner: boolean; // 소유자 여부(isOwner)
@@ -32,21 +32,21 @@ export class MemberService {
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly taskService: TaskService,
) {}
// 멤버 목록 — 멤버이거나 공개 저장소만 조회 가능
async list(slugName: string, userId: string): Promise<RepoMemberResponse[]> {
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책).
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
async list(slugName: string): Promise<RepoMemberResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const members = await this.memberRepo.find({
where: { repo: { id: repo.id } },
relations: ['user'],
order: { createdAt: 'ASC' },
});
const isMember = members.some((m) => m.user.id === userId);
if (!isMember && repo.visibility !== 'public') {
throw new ForbiddenException();
}
return members.map((m) => this.toResponse(m));
// 사용자별 담당 업무 수 집계(taskCount)
const counts = await this.taskService.assigneeCounts(repo.id);
return members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0));
}
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
@@ -83,7 +83,6 @@ export class MemberService {
user,
roleType: dto.roleType,
isOwner: false,
subRole: dto.subRole?.trim() || null,
});
const saved = await this.memberRepo.save(member);
saved.user = user; // 관계 보장(응답 매핑용)
@@ -109,7 +108,6 @@ export class MemberService {
}
if (dto.roleType !== undefined) member.roleType = dto.roleType;
if (dto.subRole !== undefined) member.subRole = dto.subRole.trim() || null;
await this.memberRepo.save(member);
return this.toResponse(member);
@@ -170,13 +168,12 @@ export class MemberService {
}
}
// 엔티티 → 응답 매핑
private toResponse(member: RepoMember): RepoMemberResponse {
// 엔티티 → 응답 매핑 (taskCount 미제공 시 0)
private toResponse(member: RepoMember, taskCount = 0): RepoMemberResponse {
return {
user: UserService.toPublic(member.user),
email: member.user.email,
subRole: member.subRole,
taskCount: 0,
taskCount,
roleType: member.roleType,
owner: member.isOwner,
};
+2
View File
@@ -2,6 +2,7 @@ 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';
@@ -16,6 +17,7 @@ import { RepoSyncService } from './repo-sync.service';
TypeOrmModule.forFeature([Repo, RepoMember]),
UserModule,
GiteaModule,
TaskModule,
],
controllers: [RepoController, MemberController],
providers: [RepoService, MemberService, RepoSyncService],
+26 -9
View File
@@ -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';
@@ -56,6 +57,7 @@ export class RepoService {
private readonly memberRepo: Repository<RepoMember>,
private readonly userService: UserService,
private readonly gitea: GiteaService,
private readonly taskService: TaskService,
) {}
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
@@ -81,13 +83,15 @@ export class RepoService {
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 findOne(slugName: string): Promise<RepoResponse> {
const repo = await this.getEntityOrThrow(slugName);
return this.toResponse(repo);
return this.toResponse(repo, await this.taskCountsOf(repo.id));
}
// 저장소 생성 — Gitea 조직에 실제 repo 를 만들고, 생성자를 소유자(admin)로 등록
@@ -150,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 저장소를 정리(고아 방지, 베스트 에포트)
@@ -211,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),
);
}
// 저장소 삭제 — 소유자만 가능
@@ -317,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),
);
@@ -332,9 +349,9 @@ 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,
@@ -0,0 +1,22 @@
import { IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { TaskStatus } from '../entities/task.entity';
// 업무 상태 변경 요청 — 상태 머신 전이 규칙은 서비스에서 검증
const TASK_STATUSES: TaskStatus[] = [
'todo',
'prog',
'review',
'done',
'changes',
];
export class ChangeStatusDto {
@ApiProperty({
description: '변경할 상태',
enum: TASK_STATUSES,
example: 'prog',
})
@IsIn(TASK_STATUSES, { message: '올바른 업무 상태가 아닙니다.' })
status!: TaskStatus;
}
@@ -0,0 +1,78 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 체크리스트 입력 항목 (생성 시 함께 등록)
export class ChecklistInputDto {
@ApiProperty({
description: '체크리스트 항목 내용',
example: '가로형 배너 디자인',
})
@IsString()
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
@MaxLength(200)
text!: string;
}
// 업무 생성 요청
export class CreateTaskDto {
@ApiProperty({
description: '업무 제목',
example: '메인 배너 이미지 2종 디자인',
})
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title!: string;
@ApiProperty({
description: '본문 문단 배열(선택)',
type: [String],
required: false,
example: ['9월 신제품 캠페인 메인 배너를 제작합니다.'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
example: ['a1b2c3d4-...'],
})
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds!: string[];
@ApiProperty({
description: '마감 기한(ISO8601, 선택)',
required: false,
example: '2026-06-18T00:00:00Z',
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string;
@ApiProperty({
description: '체크리스트(선택)',
type: [ChecklistInputDto],
required: false,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
}
@@ -0,0 +1,51 @@
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트)
export class UpdateTaskDto {
@ApiProperty({ description: '업무 제목', required: false })
@IsOptional()
@IsString()
@IsNotEmpty({ message: '업무 제목을 입력해 주세요.' })
@MaxLength(200)
title?: string;
@ApiProperty({
description: '본문 문단 배열',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@IsString({ each: true })
content?: string[];
@ApiProperty({
description: '담당자 사용자 ID 배열(저장소 멤버만 가능)',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayNotEmpty({ message: '담당자를 한 명 이상 지정해 주세요.' })
@IsUUID('4', { each: true })
assigneeIds?: string[];
@ApiProperty({
description: '마감 기한(ISO8601). null 이면 마감 해제',
required: false,
nullable: true,
})
@IsOptional()
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
dueDate?: string | null;
}
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Task } from './task.entity';
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조 회피
@ManyToOne(() => Task, (task) => task.checklist, { onDelete: 'CASCADE' })
task!: Relation<Task>;
// 항목 내용
@Column()
text!: string;
// 완료 여부
@Column({ default: false })
done!: boolean;
// 표시 순서(생성 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Repo } from '../../repo/entities/repo.entity';
import { ChecklistItem } from './checklist-item.entity';
// 업무 상태 (상태 머신: todo → prog → review → done/changes, changes → review)
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes';
// 업무 엔티티 — 저장소에 종속, 저장소 내 순번(seq)으로 식별(#9)
@Entity('tasks')
@Unique(['repo', 'seq'])
export class Task {
// 내부 식별자 (UUID)
@PrimaryGeneratedColumn('uuid')
id!: string;
// 저장소 내 표시 순번(#9) — 라우팅/표시 식별자. 저장소별로 1부터 증가
@Index()
@Column({ type: 'int' })
seq!: number;
// 소속 저장소 (저장소 삭제 시 함께 삭제)
// Relation<> 래퍼: 엔티티 순환참조에서 메타데이터가 Repo 클래스를 즉시 참조하지 않도록 함
// FK 컬럼명을 snake_case 로 고정(집계 raw 쿼리의 task.repo_id 와 일치)
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'repo_id' })
repo!: Relation<Repo>;
// 업무 제목
@Column()
title!: string;
// 본문 문단 배열(멘션은 @로 시작) — jsonb 로 보관
@Column({ type: 'jsonb', default: () => "'[]'" })
content!: string[];
// 진행 상태
@Column({ type: 'varchar', default: 'todo' })
status!: TaskStatus;
// 마감 기한(선택)
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate!: Date | null;
// 지시자(업무 생성자) — 사용자 삭제 시에도 업무는 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 담당자 목록 (저장소 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
name: 'task_assignees',
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
assignees!: User[];
// 체크리스트 항목
@OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true })
checklist!: Relation<ChecklistItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+120
View File
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
TaskService,
type RepoTaskResponse,
type TaskDetailResponse,
} from './task.service';
import type { TaskStatus } from './entities/task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { ChangeStatusDto } from './dto/change-status.dto';
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
@ApiTags('Task')
@Controller('repos/:repoId/tasks')
@UseGuards(JwtAuthGuard)
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
@ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
list(
@Param('repoId') repoId: string,
@Query('status') status?: TaskStatus,
@Query('assignee') assignee?: string,
): Promise<RepoTaskResponse[]> {
return this.taskService.list(repoId, { status, assignee });
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '업무 생성 (admin)' })
@ApiResponse({ status: 201, description: '생성 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 400,
description: '담당자가 저장소 멤버가 아님 (BIZ_001)',
})
create(
@Param('repoId') repoId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.create(repoId, user.id, dto);
}
@Get(':taskId')
@ApiOperation({ summary: '업무 상세' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '업무를 찾을 수 없음 (RES_001)' })
findOne(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
): Promise<TaskDetailResponse> {
return this.taskService.findOne(repoId, taskId);
}
@Patch(':taskId')
@ApiOperation({ summary: '업무 수정 (admin)' })
@ApiResponse({ status: 200, description: '수정 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
update(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: UpdateTaskDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.update(repoId, user.id, taskId, dto);
}
@Delete(':taskId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 삭제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.taskService.remove(repoId, user.id, taskId);
return null;
}
@Post(':taskId/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업무 상태 변경 (상태 머신)' })
@ApiResponse({ status: 200, description: '변경 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({
status: 409,
description: '허용되지 않는 상태 전이 (BIZ_001)',
})
changeStatus(
@Param('repoId') repoId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: ChangeStatusDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { TaskService } from './task.service';
import { TaskController } from './task.controller';
// 업무 모듈 (업무 CRUD + 상태 전이)
@Module({
imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])],
controllers: [TaskController],
providers: [TaskService],
exports: [TaskService],
})
export class TaskModule {}
@@ -0,0 +1,132 @@
import { ForbiddenException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { TaskService } from './task.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
// 리포지토리 간이 모킹
function makeService() {
const taskRepo = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(),
};
const checklistRepo = { create: jest.fn(), save: jest.fn() };
const repoRepo = { findOne: jest.fn() };
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
const svc = new TaskService(
taskRepo as unknown as Repository<Task>,
checklistRepo as unknown as Repository<ChecklistItem>,
repoRepo as unknown as Repository<Repo>,
memberRepo as unknown as Repository<RepoMember>,
);
return { svc, taskRepo, checklistRepo, repoRepo, memberRepo };
}
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
describe('TaskService', () => {
describe('changeStatus — 상태 머신', () => {
it('허용되지 않는 전이(todo→done)는 BusinessException', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' });
taskRepo.findOne.mockResolvedValue({
seq: 1,
status: 'todo',
issuer: { id: 'admin' },
assignees: [],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'admin', 1, 'done'),
).rejects.toBeInstanceOf(BusinessException);
});
it('승인(review→done)은 지시자/admin 이 아니면 Forbidden', async () => {
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// 요청자는 일반 멤버(담당자)
memberRepo.findOne.mockResolvedValue({ roleType: 'member' });
taskRepo.findOne.mockResolvedValue({
seq: 9,
status: 'review',
issuer: { id: 'someone-else' },
assignees: [{ id: 'assignee' }],
checklist: [],
});
await expect(
svc.changeStatus('q3-launch', 'assignee', 9, 'done'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('비멤버는 상태 변경 불가(Forbidden)', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue(null); // 멤버십 없음
await expect(
svc.changeStatus('q3-launch', 'outsider', 1, 'prog'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('승인(review→done) 시 체크리스트를 모두 완료 처리한다', async () => {
const { svc, taskRepo, checklistRepo, repoRepo, memberRepo } =
makeService();
repoRepo.findOne.mockResolvedValue(REPO);
memberRepo.findOne.mockResolvedValue({ roleType: 'admin' }); // 지시자/admin
const task = {
seq: 9,
status: 'review',
title: '배너',
content: [],
issuer: { id: 'admin', email: 'a@b.c', name: 'A', role: null },
assignees: [],
checklist: [
{ id: 'c1', text: '가로형', done: true, sortOrder: 0 },
{ id: 'c2', text: '세로형', done: false, sortOrder: 1 },
{ id: 'c3', text: '모바일', done: false, sortOrder: 2 },
],
dueDate: null,
createdAt: new Date('2026-06-08T00:00:00Z'),
};
taskRepo.findOne.mockResolvedValue(task);
taskRepo.save.mockResolvedValue(task);
checklistRepo.save.mockResolvedValue(task.checklist);
const result = await svc.changeStatus('q3-launch', 'admin', 9, 'done');
expect(checklistRepo.save).toHaveBeenCalledTimes(1);
expect(task.checklist.every((c) => c.done)).toBe(true);
expect(result.checklist.every((c) => c.done)).toBe(true);
});
});
describe('create — 담당자 멤버 검증', () => {
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
const { svc, repoRepo, memberRepo } = makeService();
repoRepo.findOne.mockResolvedValue(REPO);
// assertAdmin 통과
memberRepo.findOne.mockResolvedValue({
roleType: 'admin',
user: { id: 'admin' },
});
// 요청한 담당자 2명 중 매칭 멤버십 1건만 → 비멤버 포함
memberRepo.find.mockResolvedValue([{ user: { id: 'u1' } }]);
await expect(
svc.create('q3-launch', 'admin', {
title: '배너 디자인',
assigneeIds: ['u1', 'u2'],
}),
).rejects.toBeInstanceOf(BusinessException);
});
});
});
+404
View File
@@ -0,0 +1,404 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Repo } from '../repo/entities/repo.entity';
import { RepoMember } from '../repo/entities/repo-member.entity';
import { Task, type TaskStatus } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
// 목록 행 응답 — 라벨류는 프론트가 파생(dueLabel/dday/statusLabel 등)
export interface RepoTaskResponse {
id: number; // 저장소 내 순번(seq)
title: string;
status: TaskStatus;
dueDate: string | null;
checklist: [number, number]; // [완료, 전체]
commentCount: number; // 5단계 전까지 0
assignees: PublicUser[];
}
// 체크리스트 항목 응답
export interface ChecklistItemResponse {
id: string;
text: string;
done: boolean;
}
// 업무 상세 응답 — comments/activities/files/approval 은 5단계에서 채운다(현재 빈 값)
export interface TaskDetailResponse {
id: number;
repoId: string; // slugName
repoName: string;
title: string;
status: TaskStatus;
content: string[];
checklist: ChecklistItemResponse[];
assignees: PublicUser[];
issuer: PublicUser | null;
dueDate: string | null;
createdAt: string;
comments: never[];
activities: never[];
files: never[];
}
// 상태 머신 전이 규칙(허용 목적지)
const TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
todo: ['prog'],
prog: ['review'],
review: ['done', 'changes'],
changes: ['review', 'prog'],
done: [],
};
// 상태 한국어 라벨(에러 문구용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '승인 대기',
done: '완료',
changes: '수정 요청',
};
// 업무 비즈니스 로직
@Injectable()
export class TaskService {
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(ChecklistItem)
private readonly checklistRepo: Repository<ChecklistItem>,
@InjectRepository(Repo)
private readonly repoRepo: Repository<Repo>,
@InjectRepository(RepoMember)
private readonly memberRepo: Repository<RepoMember>,
) {}
// 업무 목록 — 상태/담당자 필터(선택). seq 내림차순(최신 우선)
async list(
slugName: string,
filters: { status?: TaskStatus; assignee?: string },
): Promise<RepoTaskResponse[]> {
const repo = await this.getRepoOrThrow(slugName);
const qb = this.taskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.assignees', 'assignee')
.leftJoinAndSelect('task.checklist', 'checklist')
.where('task.repo = :repoId', { repoId: repo.id })
.orderBy('task.seq', 'DESC')
.addOrderBy('checklist.sort_order', 'ASC');
if (filters.status) {
qb.andWhere('task.status = :status', { status: filters.status });
}
// 담당자 필터: 해당 사용자가 담당인 업무만 (서브쿼리로 task id 추림)
if (filters.assignee) {
qb.andWhere(
'task.id IN ' +
qb
.subQuery()
.select('ta.task_id')
.from('task_assignees', 'ta')
.where('ta.user_id = :assignee')
.getQuery(),
{ assignee: filters.assignee },
);
}
const tasks = await qb.getMany();
return tasks.map((t) => this.toListResponse(t));
}
// 업무 단건 상세 — repo 내 순번(seq)으로 조회
async findOne(slugName: string, seq: number): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const task = await this.getTaskOrThrow(repo.id, seq);
return this.toDetailResponse(task, repo);
}
// 업무 생성 — admin(지시자) 권한. 담당자는 저장소 멤버만 지정 가능
async create(
slugName: string,
actingUserId: string,
dto: CreateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const issuer = await this.assertAdmin(repo.id, actingUserId);
const assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
// 저장소 내 다음 순번 채번
const last = await this.taskRepo.findOne({
where: { repo: { id: repo.id } },
order: { seq: 'DESC' },
});
const seq = (last?.seq ?? 0) + 1;
const task = this.taskRepo.create({
seq,
repo,
title: dto.title,
content: dto.content ?? [],
status: 'todo',
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
issuer: issuer.user,
assignees,
checklist: (dto.checklist ?? []).map((c, idx) =>
this.checklistRepo.create({
text: c.text,
done: false,
sortOrder: idx,
}),
),
});
const saved = await this.taskRepo.save(task);
return this.toDetailResponse(
await this.getTaskOrThrow(repo.id, saved.seq),
repo,
);
}
// 업무 수정 — admin 권한. 제공된 필드만 갱신
async update(
slugName: string,
actingUserId: string,
seq: number,
dto: UpdateTaskDto,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
if (dto.title !== undefined) task.title = dto.title;
if (dto.content !== undefined) task.content = dto.content;
if (dto.dueDate !== undefined) {
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
}
if (dto.assigneeIds !== undefined) {
task.assignees = await this.resolveMemberAssignees(
repo.id,
dto.assigneeIds,
);
}
await this.taskRepo.save(task);
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// 업무 삭제 — admin 권한
async remove(
slugName: string,
actingUserId: string,
seq: number,
): Promise<void> {
const repo = await this.getRepoOrThrow(slugName);
await this.assertAdmin(repo.id, actingUserId);
const task = await this.getTaskOrThrow(repo.id, seq);
await this.taskRepo.remove(task);
}
// 상태 변경 — 상태 머신 전이 규칙 + 역할 검증
async changeStatus(
slugName: string,
actingUserId: string,
seq: number,
next: TaskStatus,
): Promise<TaskDetailResponse> {
const repo = await this.getRepoOrThrow(slugName);
const membership = await this.memberRepo.findOne({
where: { repo: { id: repo.id }, user: { id: actingUserId } },
});
if (!membership) {
throw new ForbiddenException();
}
const task = await this.getTaskOrThrow(repo.id, seq);
const from = task.status;
if (from === next) {
return this.toDetailResponse(task, repo);
}
const allowed = TRANSITIONS[from] ?? [];
if (!allowed.includes(next)) {
throw new BusinessException(
'BIZ_001',
`'${STATUS_LABEL[from]}' 상태에서 '${STATUS_LABEL[next]}'(으)로 변경할 수 없습니다.`,
HttpStatus.CONFLICT,
);
}
const isAdmin = membership.roleType === 'admin';
const isIssuer = task.issuer?.id === actingUserId;
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
const permitted =
from === 'review'
? isAdmin || isIssuer
: isAdmin || isIssuer || isAssignee;
if (!permitted) {
throw new ForbiddenException();
}
task.status = next;
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리(부수효과로 영속)
if (next === 'done' && task.checklist?.length) {
for (const item of task.checklist) item.done = true;
await this.checklistRepo.save(task.checklist);
}
return this.toDetailResponse(await this.getTaskOrThrow(repo.id, seq), repo);
}
// --- 집계(다른 모듈에서 사용) ---
// 저장소별 업무 완료/전체 수 — RepoService 진행률 집계용
async countsByRepo(
repoIds: string[],
): Promise<Map<string, { done: number; total: number }>> {
const result = new Map<string, { done: number; total: number }>();
if (repoIds.length === 0) return result;
const rows = await this.taskRepo
.createQueryBuilder('task')
.select('task.repo_id', 'repoId')
.addSelect('COUNT(*)', 'total')
.addSelect("COUNT(*) FILTER (WHERE task.status = 'done')", 'done')
.where('task.repo_id IN (:...repoIds)', { repoIds })
.groupBy('task.repo_id')
.getRawMany<{ repoId: string; total: string; done: string }>();
for (const r of rows) {
result.set(r.repoId, {
done: Number(r.done),
total: Number(r.total),
});
}
return result;
}
// 저장소 내 사용자별 담당 업무 수 — MemberService taskCount 집계용
async assigneeCounts(repoId: string): Promise<Map<string, number>> {
const rows = await this.taskRepo
.createQueryBuilder('task')
.innerJoin('task_assignees', 'ta', 'ta.task_id = task.id')
.select('ta.user_id', 'userId')
.addSelect('COUNT(*)', 'count')
.where('task.repo_id = :repoId', { repoId })
.groupBy('ta.user_id')
.getRawMany<{ userId: string; count: string }>();
const map = new Map<string, number>();
for (const r of rows) map.set(r.userId, Number(r.count));
return map;
}
// --- 내부 헬퍼 ---
// slugName 으로 저장소 로딩, 없으면 404
private async getRepoOrThrow(slugName: string): Promise<Repo> {
const repo = await this.repoRepo.findOne({ where: { slugName } });
if (!repo) {
throw new NotFoundException();
}
return repo;
}
// repo 내 순번(seq)으로 업무(+관계) 로딩, 없으면 404
private async getTaskOrThrow(repoId: string, seq: number): Promise<Task> {
const task = await this.taskRepo.findOne({
where: { repo: { id: repoId }, seq },
relations: ['assignees', 'issuer', 'checklist'],
});
if (!task) {
throw new NotFoundException();
}
// 체크리스트 표시 순서 보정
task.checklist?.sort((a, b) => a.sortOrder - b.sortOrder);
return task;
}
// admin 권한 확인 — 멤버십(+user) 반환(지시자 지정용). 아니면 403
private async assertAdmin(
repoId: string,
userId: string,
): Promise<RepoMember> {
const membership = await this.memberRepo.findOne({
where: { repo: { id: repoId }, user: { id: userId } },
relations: ['user'],
});
if (!membership || membership.roleType !== 'admin') {
throw new ForbiddenException();
}
return membership;
}
// 담당자 ID 목록 → User[] 로 변환하며 "저장소 멤버" 인지 검증
private async resolveMemberAssignees(
repoId: string,
assigneeIds: string[],
): Promise<RepoMember['user'][]> {
const uniqueIds = [...new Set(assigneeIds)];
const memberships = await this.memberRepo.find({
where: { repo: { id: repoId }, user: { id: In(uniqueIds) } },
relations: ['user'],
});
if (memberships.length !== uniqueIds.length) {
throw new BusinessException(
'BIZ_001',
'담당자는 저장소 멤버만 지정할 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
return memberships.map((m) => m.user);
}
// 엔티티 → 목록 행 응답
private toListResponse(task: Task): RepoTaskResponse {
const checklist = task.checklist ?? [];
const done = checklist.filter((c) => c.done).length;
return {
id: task.seq,
title: task.title,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
checklist: [done, checklist.length],
commentCount: 0,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
};
}
// 엔티티 → 상세 응답
private toDetailResponse(task: Task, repo: Repo): TaskDetailResponse {
const checklist = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ id: c.id, text: c.text, done: c.done }));
return {
id: task.seq,
repoId: repo.slugName,
repoName: repo.name,
title: task.title,
status: task.status,
content: task.content ?? [],
checklist,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
createdAt: task.createdAt.toISOString(),
comments: [],
activities: [],
files: [],
};
}
}
+56 -26
View File
@@ -149,44 +149,73 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프
| 메서드 | 경로 | 요청 | 응답 | 권한 |
|---|---|---|---|---|
| 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단계 실연동).
---
@@ -258,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(엔티티만 참조해 순환 없음).
+59 -23
View File
@@ -2,8 +2,8 @@
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기**
> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시·giteaMissing 배지(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,7 +76,7 @@
- **삭제**: 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` 추가.
### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드)
@@ -87,22 +87,54 @@
- **추가형**: 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 유지). 멤버십은 역할/권한 전용.
> **TODO(프론트)**: RepoListPage 에 `giteaMissing` '원격 없음' 배지 표시. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.
> **프론트**: RepoListPage 에 `giteaMissing` '원격 없음' 배지(저장소 행 제목 옆) 표시. 헤더 'Gitea 연동됨' 배지는 실제 연동 여부(`htmlUrl` 보유)에 따라 노출되도록 보정. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상.
### 3단계 — 멤버 관리 ✅ (백엔드)
### 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단계 데이터라 지시자 이름만 넣어 근사.
### 저장소 생성 폼 보강 ✅ (소유자/설명/템플릿 — 백엔드+프론트)
@@ -123,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단계 — 활동 피드
@@ -139,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 캐싱, 전체 페이지네이션 통일.
@@ -147,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`** 로 대체(미연동 시 상수 폴백). 조직 도입 시 교체.
@@ -163,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` 를 명시적으로 리셋**해야 한다. (초대 모달 본문 좌측정렬·푸터 버튼 폭 깨짐의 원인이었음)
---
@@ -178,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 연동 설정/검증
@@ -189,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>` 로 확인.
+6 -20
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { Repo, User } from '@/mock/relay.mock'
interface Props {
@@ -122,25 +121,12 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
<!-- 진행률 스트립 추가 영역 -->
<slot name="extra" />
</div>
<div class="rh-actions">
<slot name="actions">
<RouterLink
class="btn primary"
:to="`/repos/${repo.id}/tasks/new`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5v14" />
</svg>
업무
</RouterLink>
</slot>
<!-- 액션 영역 페이지가 #actions 슬롯을 제공할 때만 렌더(기본 '새 업무' 버튼 제거) -->
<div
v-if="$slots.actions"
class="rh-actions"
>
<slot name="actions" />
</div>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
import { useApi } from './useApi'
import type {
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type { TaskStatus } from '@/mock/relay.mock'
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
export function useTask() {
const api = useApi()
// 업무 목록 (상태/담당자 필터 선택)
async function list(repoId: string, query: TaskListQuery = {}): Promise<ApiRepoTask[]> {
return (await api.get(`/repos/${repoId}/tasks`, {
params: query,
})) as unknown as ApiRepoTask[]
}
// 업무 단건 상세 (taskId = 저장소 내 순번)
async function get(repoId: string, taskId: number): Promise<ApiTaskDetail> {
return (await api.get(`/repos/${repoId}/tasks/${taskId}`)) as unknown as ApiTaskDetail
}
// 업무 생성 (admin)
async function create(repoId: string, payload: CreateTaskPayload): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks`, payload)) as unknown as ApiTaskDetail
}
// 업무 수정 (admin)
async function update(
repoId: string,
taskId: number,
payload: UpdateTaskPayload,
): Promise<ApiTaskDetail> {
return (await api.patch(
`/repos/${repoId}/tasks/${taskId}`,
payload,
)) as unknown as ApiTaskDetail
}
// 업무 삭제 (admin)
async function remove(repoId: string, taskId: number): Promise<void> {
await api.delete(`/repos/${repoId}/tasks/${taskId}`)
}
// 상태 변경 (상태 머신)
async function changeStatus(
repoId: string,
taskId: number,
status: TaskStatus,
): Promise<ApiTaskDetail> {
return (await api.post(`/repos/${repoId}/tasks/${taskId}/status`, {
status,
})) as unknown as ApiTaskDetail
}
return { list, get, create, update, remove, changeStatus }
}
+83 -5
View File
@@ -5,14 +5,17 @@ import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { Repo, TaskStatus } from '@/mock/relay.mock'
type Segment = 'all' | 'prog' | 'todo' | 'done'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const taskStore = useTaskStore()
// 저장소 헤더/진행률 — API 연동
const repo = ref<Repo | null>(null)
@@ -23,10 +26,25 @@ async function loadRepo() {
repo.value = null // 404/403 등은 인터셉터가 알림 처리
}
}
watch(repoId, loadRepo, { immediate: true })
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
const tasks = computed(() => findRepoTasks(repoId.value))
// 업무 목록 — API 연동
async function loadTasks() {
try {
await taskStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
watch(
repoId,
() => {
loadRepo()
loadTasks()
},
{ immediate: true },
)
const tasks = computed(() => taskStore.tasks)
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
)
@@ -121,6 +139,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</template>
</RepoHeader>
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
<GiteaRepoBar
:clone-url="repo.cloneUrl"
:html-url="repo.htmlUrl"
class="gitea-bar-spacing"
/>
<!-- 서브탭(공통) -->
<RepoTabs
:repo-id="repo.id"
@@ -189,10 +214,41 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</svg>
마감 임박순
</div>
<RouterLink
class="btn primary new-task"
:to="`/repos/${repo.id}/tasks/new`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
업무
</RouterLink>
</div>
<!-- 업무 목록 -->
<div class="list">
<div
v-if="taskStore.loading && tasks.length === 0"
class="state-msg"
>
업무를 불러오는
</div>
<div
v-else-if="filteredTasks.length === 0"
class="state-msg"
>
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
</div>
<div
v-else
class="list"
>
<RouterLink
v-for="task in filteredTasks"
:key="task.id"
@@ -334,6 +390,9 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.crumb {
padding: 1.125rem 0 0.75rem;
}
.gitea-bar-spacing {
margin-top: 0.75rem;
}
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
.prog-strip {
@@ -376,6 +435,16 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
.toolbar .search {
width: 15rem;
}
.new-task {
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
border: none;
text-decoration: none;
}
.new-task svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 업무 행 */
.task-row {
@@ -472,4 +541,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
text-align: center;
color: var(--text-2);
}
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
</style>
+46 -9
View File
@@ -20,6 +20,9 @@ onMounted(() => {
void repoStore.fetchCreateMeta()
})
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
// 검색어 + 공개여부 필터 적용
const filteredRepos = computed(() =>
repos.value.filter((repo) => {
@@ -43,7 +46,10 @@ const filteredRepos = computed(() =>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">{{ repos.length }}</span>
<span class="gitea-badge">
<span
v-if="giteaConnected"
class="gitea-badge"
>
<svg
viewBox="0 0 24 24"
fill="none"
@@ -201,6 +207,25 @@ const filteredRepos = computed(() =>
</svg>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
<span
v-if="repo.giteaMissing"
class="missing-badge"
title="Gitea 조직에서 원격 저장소를 찾을 수 없습니다."
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
<path d="m2 2 20 20" />
</svg>
원격 없음
</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
@@ -261,9 +286,6 @@ const filteredRepos = computed(() =>
:style="{ width: repo.progressPct + '%' }"
/>
</div>
<div class="stat-pct">
{{ repo.progressLabel }}
</div>
</div>
<span class="chev">
@@ -447,11 +469,6 @@ const filteredRepos = computed(() =>
.repo-stat .pbar {
width: 9.75rem;
}
.stat-pct {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.chev {
color: var(--text-3);
flex-shrink: 0;
@@ -468,4 +485,24 @@ const filteredRepos = computed(() =>
color: var(--text-3);
font-size: 0.844rem;
}
/* 원격(Gitea) 없음 배지 */
.missing-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
padding: 0.125rem 0.4375rem;
border-radius: 20px;
line-height: 1.4;
white-space: nowrap;
}
.missing-badge svg {
width: 0.75rem;
height: 0.75rem;
}
</style>
+119 -48
View File
@@ -1,38 +1,46 @@
<script setup lang="ts">
// 업무 상세 — 담당자(작업자) 관점. state 쿼리로 3가지 상태를 분기한다.
// - request : 진행 중 → 지시자에게 승인 요청
// - changes : 수정 요청됨 → 다시 승인 요청
// - approved: 승인 완료
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 업무 상세 — 담당자(작업자) 관점. 실제 task.status 로 카드/액션을 분기한다.
// - todo : 업무 시작(todo→prog)
// - prog : 승인 요청(prog→review)
// - review: 승인 대기 중(액션 없음)
// - changes: 다시 승인 요청(changes→review)
// - done : 승인 완료
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { TASK_DETAIL } from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import type { TaskDetail } from '@/mock/relay.mock'
// 카드 스타일 분기용 뷰 상태(승인 요청 / 수정 요청 / 승인 완료)
type ViewState = 'request' | 'changes' | 'approved'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 쿼리에서 상태 결정(미지정/오류 시 request)
const state = computed<ViewState>(() => {
const s = String(route.query.state ?? 'request')
return s === 'changes' || s === 'approved' ? s : 'request'
const taskStore = useTaskStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null
}
}
watch(taskId, loadTask, { immediate: true })
// 실제 상태 → 카드 뷰 상태 매핑 (todo/prog/review → request 카드)
const viewState = computed<ViewState>(() => {
const s = task.value?.status
if (s === 'done') return 'approved'
if (s === 'changes') return 'changes'
return 'request'
})
// 상태별 표시 구성
const CONFIG: Record<ViewState, { badge: string; badgeLabel: string; statusLabel: string }> = {
request: { badge: 'prog', badgeLabel: '진행 중', statusLabel: '진행 중' },
changes: { badge: 'changes', badgeLabel: '수정 요청', statusLabel: '수정 요청' },
approved: { badge: 'done', badgeLabel: '완료', statusLabel: '완료' },
}
const cfg = computed(() => CONFIG[state.value])
// 완료 상태면 체크리스트 전부 완료로 표시
const checklist = computed(() =>
state.value === 'approved' ? task.checklist.map((c) => ({ ...c, done: true })) : task.checklist,
)
const checklist = computed(() => task.value?.checklist ?? [])
const checklistDone = computed(() => checklist.value.filter((c) => c.done).length)
const checklistPct = computed(() =>
checklist.value.length ? Math.round((checklistDone.value / checklist.value.length) * 100) : 0,
@@ -44,9 +52,32 @@ const showDelete = ref(false)
const showRequest = ref(false)
const commentText = ref('')
function confirmDelete() {
// 상태 전이 — 담당자: 시작(todo→prog) / 승인요청(prog→review) / 재요청(changes→review)
async function changeTo(status: TaskDetail['status']) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
async function startTask() {
await changeTo('prog')
}
async function submitApprovalRequest() {
showRequest.value = false
await changeTo('review')
}
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
@@ -78,7 +109,10 @@ function renderMention(text: string): string {
<AppShell>
<!-- 본문 v-html 내부에서 생성한 신뢰 마크업(멘션 강조) 렌더한다 -->
<!-- eslint-disable vue/no-v-html -->
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -109,8 +143,8 @@ function renderMention(text: string): string {
<div class="ph-titlerow">
<span
class="st-badge"
:class="cfg.badge"
>{{ cfg.badgeLabel }}</span>
:class="task.status"
>{{ task.statusLabel }}</span>
<span class="ph-tid">#{{ task.id }}</span>
</div>
<h1 class="ph-title">
@@ -120,7 +154,7 @@ function renderMention(text: string): string {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -352,12 +386,12 @@ function renderMention(text: string): string {
<!-- 상태별 승인 카드 -->
<div
class="card ap-card"
:class="state"
:class="viewState"
>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
v-if="state === 'request'"
v-if="viewState === 'request'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -366,7 +400,7 @@ function renderMention(text: string): string {
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
<svg
v-else-if="state === 'changes'"
v-else-if="viewState === 'changes'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -384,12 +418,34 @@ function renderMention(text: string): string {
stroke-linejoin="round"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
</span>
<span class="ap-card-title">{{ state === 'request' ? '지시자에게 승인 요청' : state === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
<span class="ap-card-title">{{ viewState === 'request' ? '내 작업' : viewState === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
</div>
<template v-if="state === 'request'">
<!-- 승인 대기 (이미 요청 보냄) -->
<template v-if="task.status === 'review'">
<div class="ap-card-meta">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 업무 시작 -->
<template v-else-if="task.status === 'todo'">
<div class="ap-card-desc">
작업을 마쳤다 지시자 <b>정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
아직 시작하지 않은 업무입니다. 작업을 시작하 상태가 <b>진행 </b>으로 바뀝니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<div class="ap-card-desc">
작업을 마쳤다면 지시자에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
@@ -409,13 +465,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<template v-else-if="state === 'changes'">
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === 'changes'">
<div class="ap-card-meta">
<b>정태경</b>님이 수정을 요청했습니다 · 1시간
</div>
<div class="ap-card-note">
세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.
지시자가 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -435,10 +488,10 @@ function renderMention(text: string): string {
</button>
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-meta">
<b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 일이 모두 완료되었습니다.
지시자가 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
@@ -450,8 +503,8 @@ function renderMention(text: string): string {
</div>
<span
class="status-badge"
:class="state"
><span class="dot" />{{ cfg.statusLabel }}</span>
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -496,7 +549,10 @@ function renderMention(text: string): string {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -538,6 +594,16 @@ function renderMention(text: string): string {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- 모달 -->
<Teleport to="body">
<!-- 삭제 -->
@@ -566,7 +632,7 @@ function renderMention(text: string): string {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -622,7 +688,7 @@ function renderMention(text: string): string {
<button
class="mbtn request-send"
type="button"
@click="showRequest = false"
@click="submitApprovalRequest"
>
요청 보내기
</button>
@@ -637,6 +703,11 @@ function renderMention(text: string): string {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
+352 -149
View File
@@ -1,40 +1,147 @@
<script setup lang="ts">
// 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
import { computed, ref } from 'vue'
// 업무 작성/수정 — 제목/내용/체크리스트 + 사이드(담당자/마감/지시자) · 백엔드 연동
// taskId 파라미터가 있으면 '수정 모드'로 동작(기존 값 미리 채움 + PATCH 저장)
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { useTask } from '@/composables/useTask'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
const isEdit = computed(() => taskId.value !== null)
// 제목
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
const repoStore = useRepoStore()
const memberStore = useMemberStore()
const taskStore = useTaskStore()
const authStore = useAuthStore()
const taskApi = useTask()
// 담당자 (칩)
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): User {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// ISO → input[type=date] 용 'yyyy-MM-dd'
function toDateInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// 저장소 헤더(빵부스러기) — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null
}
}
// 담당자 후보 = 저장소 멤버
async function loadMembers() {
try {
await memberStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
// 수정 모드: 기존 업무 값을 폼에 미리 채운다
async function loadTaskForEdit() {
if (taskId.value === null) return
try {
const t = await taskApi.get(repoId.value, taskId.value)
title.value = t.title
contentText.value = t.content.join('\n')
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
assignees.value = t.assignees.map(toUserView)
checklist.value = t.checklist.map((c) => ({ text: c.text, done: c.done }))
} catch {
// 404 등은 인터셉터가 알림 처리
}
}
watch(
[repoId, taskId],
() => {
loadRepo()
loadMembers()
loadTaskForEdit()
},
{ immediate: true },
)
// 제목 / 내용
const title = ref('')
const contentText = ref('')
// 지시자 = 현재 로그인 사용자
const issuer = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// --- 담당자 선택 (저장소 멤버 중) ---
const assignees = ref<User[]>([])
const assigneeQuery = ref('')
const showAssigneeDropdown = ref(false)
function removeAssignee(id: string) {
assignees.value = assignees.value.filter((a) => a.id !== id)
}
// 아직 선택되지 않은 멤버 후보(이름 부분일치)
const assigneeCandidates = computed(() => {
const selected = new Set(assignees.value.map((a) => a.id))
const kw = assigneeQuery.value.trim()
return memberStore.members
.filter((m) => !selected.has(m.user.id))
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
})
function addAssignee(userId: string) {
const m = memberStore.members.find((x) => x.user.id === userId)
if (m && !assignees.value.some((a) => a.id === userId)) {
assignees.value.push(m.user)
}
assigneeQuery.value = ''
showAssigneeDropdown.value = false
}
function onAssigneeBlur() {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showAssigneeDropdown.value = false), 120)
}
// 체크리스트
const checklist = ref<ChecklistItem[]>([
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
])
// --- 마감기한 ---
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
// 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성)
const checklist = ref<ChecklistItem[]>([])
const newItem = ref('')
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
const progressPct = computed(() =>
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
)
function toggleItem(item: ChecklistItem) {
item.done = !item.done
}
function removeItem(index: number) {
checklist.value.splice(index, 1)
}
@@ -45,25 +152,51 @@ function addItem() {
newItem.value = ''
}
// 첨부파일
const files = ref<Attachment[]>([
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
])
function removeFile(index: number) {
files.value.splice(index, 1)
// --- 제출 ---
const submitting = ref(false)
const canSubmit = computed(
() => !submitting.value && title.value.trim().length > 0 && assignees.value.length > 0,
)
async function submitTask() {
if (!canSubmit.value) return
submitting.value = true
try {
const content = contentText.value
.split('\n')
.map((p) => p.trim())
.filter((p) => p.length > 0)
const assigneeIds = assignees.value.map((a) => a.id)
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
if (isEdit.value && taskId.value !== null) {
// 수정 — 제목/내용/담당자/마감만 갱신(체크리스트 항목 편집은 5단계)
const updated = await taskStore.update(repoId.value, taskId.value, {
title: title.value.trim(),
content,
assigneeIds,
dueDate: dueIso,
})
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
} else {
const created = await taskStore.create(repoId.value, {
title: title.value.trim(),
content: content.length ? content : undefined,
assigneeIds,
dueDate: dueIso ?? undefined,
checklist: checklist.value.length
? checklist.value.map((c) => ({ text: c.text }))
: undefined,
})
router.push(`/repos/${repoId.value}/tasks/${created.id}`)
}
} catch {
// 인터셉터가 알림 처리(권한/검증 등)
} finally {
submitting.value = false
}
}
// 파일 종류 → 아이콘 클래스/라벨
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
pdf: { cls: 'fi-pdf', label: 'PDF' },
zip: { cls: 'fi-zip', label: 'ZIP' },
doc: { cls: 'fi-doc', label: 'DOC' },
img: { cls: 'fi-doc', label: 'IMG' },
}
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
function goBack() {
router.push(`/repos/${repoId.value}`)
}
@@ -87,12 +220,11 @@ function goBack() {
{{ repo?.name ?? '저장소' }}
</RouterLink>
<span class="sep">/</span>
업무
{{ isEdit ? '수정' : '새 업무' }}
</div>
<div class="pagehead">
<h1> 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<h1>{{ isEdit ? '업무 수정' : '새 업무 작성' }}</h1>
<div class="actions">
<button
class="btn ghost"
@@ -101,16 +233,11 @@ function goBack() {
>
취소
</button>
<button
class="btn"
type="button"
>
임시저장
</button>
<button
class="btn primary"
type="button"
@click="goBack"
:disabled="!canSubmit"
@click="submitTask"
>
<svg
width="15"
@@ -125,7 +252,7 @@ function goBack() {
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
지시 보내기
{{ submitting ? (isEdit ? '저장 중…' : '전송 중…') : (isEdit ? '저장' : '지시 보내기') }}
</button>
</div>
</div>
@@ -226,11 +353,11 @@ function goBack() {
><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
</button>
</div>
<div class="editor-body">
<p>9 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1 시안 확정 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
<p> 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
</div>
<textarea
v-model="contentText"
class="editor-body"
placeholder="업무 배경과 요구사항을 작성하세요. (줄바꿈은 문단으로 저장됩니다)"
/>
</div>
</div>
@@ -260,7 +387,6 @@ function goBack() {
<span
class="cbox"
:class="{ done: item.done }"
@click="toggleItem(item)"
>
<svg
viewBox="0 0 24 24"
@@ -273,6 +399,7 @@ function goBack() {
</span>
<span class="txt">{{ item.text }}</span>
<span
v-if="!isEdit"
class="row-del"
@click="removeItem(index)"
>
@@ -287,7 +414,10 @@ function goBack() {
</span>
</div>
</div>
<div class="add-row">
<div
v-if="!isEdit"
class="add-row"
>
<span class="plus">+</span>
<input
v-model="newItem"
@@ -296,6 +426,12 @@ function goBack() {
@keydown.enter="addItem"
>
</div>
<p
v-else
class="checklist-note"
>
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
</p>
</div>
</section>
@@ -307,25 +443,15 @@ function goBack() {
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}</span>
</div>
<div class="combo">
<!-- 입력칸은 고정 선택된 담당자는 아래에 표기 -->
<div class="combo-field">
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
<input
v-model="assigneeQuery"
class="combo-input"
placeholder="이름 검색…"
placeholder="멤버 검색…"
autocomplete="off"
@focus="showAssigneeDropdown = true"
@blur="onAssigneeBlur"
>
<span class="combo-caret">
<svg
@@ -338,6 +464,53 @@ function goBack() {
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showAssigneeDropdown"
class="assignee-dd"
>
<button
v-for="m in assigneeCandidates"
:key="m.user.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addAssignee(m.user.id)"
>
<span
class="avatar"
:class="m.user.color"
>{{ m.user.initial }}</span>
<span class="ad-nm">{{ m.user.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="assigneeCandidates.length === 0"
class="assignee-dd-msg"
>
{{ memberStore.members.length === 0 ? '저장소 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
</div>
</div>
</div>
<!-- 선택된 담당자 (입력칸 아래에 표기) -->
<div
v-if="assignees.length"
class="assignee-chips"
>
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
</div>
</div>
</div>
@@ -345,65 +518,21 @@ function goBack() {
<div class="card side-card">
<div class="side-field">
<div class="side-label">
<span class="req">*</span> 마감기한
</div>
<div class="input">
<span class="ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
</span>
2026 6 30 () 18:00
</div>
<div class="date-meta">
<span class="dot" /> 마감까지 15 남음
마감기한 <span class="muted">· 선택</span>
</div>
<input
v-model="dueDate"
class="input date-input"
type="date"
>
</div>
<div class="side-field">
<div class="side-label">
첨부파일 <span class="muted">· {{ files.length }}</span>
첨부파일 <span class="muted">· 5단계 예정</span>
</div>
<div class="files">
<div
v-for="(file, index) in files"
:key="file.name"
class="file"
>
<span
class="file-ico"
:class="FILE_META[file.kind].cls"
>{{ FILE_META[file.kind].label }}</span>
<span class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ file.size }}</span>
</span>
<span
class="x"
@click="removeFile(index)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
</div>
<div class="dropzone">
파일을 끌어다 놓거나 <b>찾아보기</b>
<div class="dropzone is-disabled">
파일 첨부는 제공됩니다
</div>
</div>
</div>
@@ -414,14 +543,18 @@ function goBack() {
지시자
</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span
class="avatar"
:class="issuer.color"
>{{ issuer.initial }}</span>
<span>
<div class="nm">정태경</div>
<div class="rl">마케팅 그룹 리드</div>
<div class="nm">{{ issuer.name }}</div>
</span>
</div>
<div class="side-foot">
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 업무 추가됩니다.
{{ isEdit
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
: `지시를 보내면 담당자 ${assignees.length}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.` }}
</div>
</div>
</div>
@@ -452,17 +585,6 @@ function goBack() {
white-space: nowrap;
flex-shrink: 0;
}
.draft-tag {
font-size: 0.719rem;
font-weight: 600;
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
padding: 0.1875rem 0.5rem;
border-radius: 20px;
line-height: 1;
white-space: nowrap;
}
.pagehead .actions {
margin-left: auto;
display: flex;
@@ -573,17 +695,22 @@ function goBack() {
margin: 0 0.3125rem;
}
.editor-body {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.875rem 1rem;
font-size: 0.875rem;
font-family: inherit;
color: var(--text);
min-height: 9.375rem;
line-height: 1.65;
border: none;
outline: none;
background: transparent;
resize: vertical;
}
.editor-body p {
margin-bottom: 0.6875rem;
}
.editor-body p:last-child {
margin-bottom: 0;
.editor-body::placeholder {
color: var(--text-3);
}
.mention {
color: var(--accent);
@@ -648,7 +775,6 @@ function goBack() {
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.cbox.done {
background: var(--green);
@@ -720,6 +846,11 @@ function goBack() {
.add-input::placeholder {
color: var(--text-3);
}
.checklist-note {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
}
/* 사이드바 */
.side {
@@ -762,6 +893,76 @@ function goBack() {
.combo {
position: relative;
}
/* 멤버 후보 드롭다운 */
.assignee-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.assignee-dd-item:hover {
background: #f5f6f8;
}
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
font-size: 0.625rem;
flex-shrink: 0;
}
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ad-em {
font-size: 0.719rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.date-input {
cursor: pointer;
}
.dropzone.is-disabled {
cursor: default;
opacity: 0.7;
}
.dropzone.is-disabled:hover {
border-color: var(--border-strong);
background: #fafbfc;
color: var(--text-3);
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
@@ -787,6 +988,13 @@ function goBack() {
width: 1rem;
height: 1rem;
}
/* 선택된 담당자 칩 — 입력칸 아래에 표기 */
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
@@ -973,11 +1181,6 @@ function goBack() {
font-size: 0.8125rem;
font-weight: 600;
}
.issuer .rl {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.side-foot {
font-size: 0.719rem;
color: var(--text-3);
+382 -31
View File
@@ -15,27 +15,45 @@ import {
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import {
TASK_DETAIL,
USERS,
type Attachment,
type Comment,
type Reply,
type TaskDetail,
type TaskStatus,
type User,
} from '@/mock/relay.mock'
import { useTaskStore } from '@/stores/task.store'
import { useAuthStore } from '@/stores/auth.store'
import { avatarColor, initialOf } from '@/shared/utils/format'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const task = TASK_DETAIL
const taskId = computed(() => Number(route.params.taskId))
// 상태 배지 클래스(시안의 prog/review/done/changes 와 동일)
const statusClass = computed<TaskStatus>(() => task.status)
const taskStore = useTaskStore()
const authStore = useAuthStore()
// 업무 상세 — API 연동
const task = ref<TaskDetail | null>(null)
async function loadTask() {
try {
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
} catch {
task.value = null // 404 등은 인터셉터가 알림 처리
}
}
watch(taskId, loadTask, { immediate: true })
// 상태 배지 클래스(prog/review/done/changes)
const statusClass = computed<TaskStatus>(() => task.value?.status ?? 'todo')
// 체크리스트 진행률
const checklistDone = computed(() => task.checklist.filter((c) => c.done).length)
const checklistPct = computed(() =>
task.checklist.length ? Math.round((checklistDone.value / task.checklist.length) * 100) : 0,
)
const checklistDone = computed(() => task.value?.checklist.filter((c) => c.done).length ?? 0)
const checklistPct = computed(() => {
const total = task.value?.checklist.length ?? 0
return total ? Math.round((checklistDone.value / total) * 100) : 0
})
// 본문 멘션(@…)을 강조 span 으로 변환
function renderMention(text: string): string {
@@ -85,10 +103,54 @@ function closeAllOverlays() {
moreOpen.value = false
}
// 모달에서 삭제 확정 저장소 상세로 이동(목업)
function confirmDelete() {
// 모달에서 삭제 확정 → DELETE 후 저장소 상세로 이동
async function confirmDelete() {
if (!task.value) return
showDelete.value = false
router.push(`/repos/${repoId.value}`)
try {
await taskStore.remove(repoId.value, task.value.id)
router.push(`/repos/${repoId.value}`)
} catch {
// 인터셉터가 알림 처리
}
}
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
async function approveTask() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
} catch {
// 인터셉터가 알림 처리
} finally {
showApprove.value = false
}
}
async function requestChanges() {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
} catch {
// 인터셉터가 알림 처리
} finally {
showChanges.value = false
}
}
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
async function changeTo(status: TaskStatus) {
if (!task.value) return
try {
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
} catch {
// 인터셉터가 알림 처리
}
}
function startTask() {
void changeTo('prog')
}
function requestApproval() {
void changeTo('review')
}
/* ============================================================
@@ -96,13 +158,44 @@ function confirmDelete() {
* ============================================================ */
const activeTab = ref<'comments' | 'activity'>('comments')
// 댓글은 로컬 상태로 복제해 답글/등록을 반영
const comments = reactive<Comment[]>(task.comments.map((c) => ({ ...c, replies: [...c.replies] })))
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
const comments = reactive<Comment[]>([])
const replyOpen = ref<number | null>(null)
const replyText = ref('')
const commentText = ref('')
const me = USERS.jung // 현재 사용자(지시자)
// 현재 로그인 사용자 (댓글 작성자)
const me = computed<User>(() => {
const u = authStore.user
return {
id: u?.id ?? '',
name: u?.name ?? '나',
role: u?.role ?? '',
initial: initialOf(u?.name ?? '나'),
color: avatarColor(u?.id ?? ''),
}
})
// 현재 사용자의 이 업무에 대한 역할(액션 분기용)
const isIssuer = computed(
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
)
const isAssignee = computed(
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
)
// 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent
const assigneeCardClass = computed(() => {
switch (task.value?.status) {
case 'changes':
return 'changes'
case 'review':
return 'review'
case 'done':
return 'done'
default:
return 'request'
}
})
function toggleReply(index: number) {
replyOpen.value = replyOpen.value === index ? null : index
@@ -112,7 +205,7 @@ function submitReply(index: number) {
const text = replyText.value.trim()
const target = comments[index]
if (!text || !target) return
const reply: Reply = { author: me, time: '방금', text }
const reply: Reply = { author: me.value, time: '방금', text }
target.replies.push(reply)
replyText.value = ''
replyOpen.value = null
@@ -120,7 +213,7 @@ function submitReply(index: number) {
function submitComment() {
const text = commentText.value.trim()
if (!text) return
comments.push({ author: me, roleBadge: '지시자', time: '방금', text, replies: [] })
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
commentText.value = ''
}
@@ -387,7 +480,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</button>
</template>
<div class="page">
<div
v-if="task"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
@@ -429,7 +525,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<div class="actions">
<RouterLink
class="btn"
:to="`/repos/${repoId}/tasks/new`"
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
>
<svg
viewBox="0 0 24 24"
@@ -791,9 +887,9 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<!-- 사이드바 -->
<aside class="side">
<!-- 승인 요청 카드 -->
<!-- 지시자: 승인 대기(review) 승인/수정요청 -->
<div
v-if="task.approval"
v-if="task.status === 'review' && isIssuer"
class="card ap-card review"
>
<div class="ap-card-head">
@@ -814,10 +910,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<span class="ap-card-title">승인 요청 도착</span>
</div>
<div class="ap-card-meta">
<b>{{ task.approval.requester }}</b>님이 작업 완료 승인을 요청했습니다 · {{ task.approval.requestedAgo }}
</div>
<div class="ap-card-note">
{{ task.approval.note }}
담당자가 작업 완료 승인을 요청했습니다. 검토 승인하거나 수정을 요청하세요.
</div>
<div class="ap-card-actions">
<button
@@ -853,12 +946,173 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 담당자: 시작 / 승인요청 / 재요청 / 대기중 / 완료 -->
<div
v-else-if="isAssignee"
class="card ap-card"
:class="assigneeCardClass"
>
<!-- 업무 시작 -->
<template v-if="task.status === 'todo'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 3 19 12 5 21V3z" /></svg>
</span>
<span class="ap-card-title">업무 시작</span>
</div>
<div class="ap-card-desc">
아직 시작하지 않은 업무입니다. 작업을 시작하면 상태가 <b>진행 </b>으로 바뀌고 지시자에게 표시됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="startTask"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 3 19 12 5 21V3z" /></svg>
업무 시작
</button>
</div>
</template>
<!-- 진행 승인 요청 -->
<template v-else-if="task.status === 'prog'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
</span>
<span class="ap-card-title">지시자에게 승인 요청</span>
</div>
<div class="ap-card-desc">
작업을 마쳤다면 지시자 <b>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
승인 요청 보내기
</button>
</div>
</template>
<!-- 수정 요청됨 다시 승인 요청 -->
<template v-else-if="task.status === 'changes'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" /></svg>
</span>
<span class="ap-card-title">수정 요청됨</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div class="ap-card-actions">
<button
class="ap-btn request-send"
type="button"
@click="requestApproval"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
다시 승인 요청
</button>
</div>
</template>
<!-- 승인 대기 -->
<template v-else-if="task.status === 'review'">
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 8v4l3 2" /><circle
cx="12"
cy="12"
r="9"
/></svg>
</span>
<span class="ap-card-title">승인 대기 </span>
</div>
<div class="ap-card-desc">
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
</div>
</template>
<!-- 완료 -->
<template v-else>
<div class="ap-card-head">
<span class="ap-card-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
</span>
<span class="ap-card-title">승인 완료</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">
상태
</div>
<span class="status-badge review"><span class="dot" />{{ task.statusLabel }}</span>
<span
class="status-badge"
:class="task.status"
><span class="dot" />{{ task.statusLabel }}</span>
</div>
<div class="side-field">
<div class="side-label">
@@ -903,7 +1157,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<div class="card side-card">
<div
v-if="task.files.length"
class="card side-card"
>
<div class="side-field">
<div class="side-label">
첨부파일 · {{ task.files.length }}
@@ -945,6 +1202,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
</div>
</div>
<!-- 로딩/없음 상태 -->
<div
v-else
class="page"
>
<p class="task-empty">
업무를 불러오는 중이거나 찾을 없습니다.
</p>
</div>
<!-- ===== 모달 ===== -->
<Teleport to="body">
<!-- 삭제 -->
@@ -973,7 +1240,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 삭제할까요?
</h2>
<p class="modal-desc">
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 작업은 되돌릴 없습니다.
</p>
</div>
<div class="modal-foot">
@@ -1021,7 +1288,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
업무를 승인할까요?
</h2>
<p class="modal-desc">
<b>{{ task.approval?.requester }}</b>님의 검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
검토 요청을 승인합니다. 업무 상태가 <b>완료</b> 변경되고 담당자에게 알림이 전송됩니다.
</p>
</div>
<div class="modal-foot">
@@ -1035,7 +1302,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn approve"
type="button"
@click="showApprove = false"
@click="approveTask"
>
승인
</button>
@@ -1077,7 +1344,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
<button
class="mbtn danger"
type="button"
@click="showChanges = false"
@click="requestChanges"
>
수정 요청 보내기
</button>
@@ -1419,6 +1686,11 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.crumb {
padding: 1rem 0 0;
}
.task-empty {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
/* 페이지 헤더 */
.pagehead {
@@ -2075,6 +2347,30 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.status-badge.review .dot {
background: var(--amber);
}
.status-badge.todo {
color: var(--text-2);
background: #f1f2f4;
border-color: var(--border-strong);
}
.status-badge.todo .dot {
background: var(--text-3);
}
.status-badge.changes {
color: var(--red);
background: var(--red-weak);
border-color: var(--red-border);
}
.status-badge.changes .dot {
background: var(--red);
}
.status-badge.done {
color: var(--green);
background: var(--green-weak);
border-color: #cde8d8;
}
.status-badge.done .dot {
background: var(--green);
}
.assignees {
display: flex;
flex-direction: column;
@@ -2209,6 +2505,42 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
border-color: var(--amber-border);
background: var(--amber-weak);
}
/* 담당자 액션 카드(accent 테마) — 디자인 시안의 .ap-card.request */
.ap-card.request {
border-color: var(--accent-border);
background: var(--accent-weak);
}
.ap-card.request .ap-card-ico {
color: var(--accent);
border-color: var(--accent-border);
}
.ap-card.request .ap-card-title {
color: var(--accent-hover);
}
/* 수정 요청됨(빨강 테마) */
.ap-card.changes {
border-color: var(--red-border);
background: var(--red-weak);
}
.ap-card.changes .ap-card-ico {
color: var(--red);
border-color: var(--red-border);
}
.ap-card.changes .ap-card-title {
color: var(--red);
}
/* 승인 완료(초록 테마) */
.ap-card.done {
border-color: #cde8d8;
background: var(--green-weak);
}
.ap-card.done .ap-card-ico {
color: var(--green);
border-color: #cde8d8;
}
.ap-card.done .ap-card-title {
color: #167a44;
}
.ap-card-head {
display: flex;
align-items: center;
@@ -2245,6 +2577,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
color: var(--text);
font-weight: 600;
}
.ap-card-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.625rem;
line-height: 1.55;
}
.ap-card-desc b {
color: var(--text);
font-weight: 600;
}
.ap-card-note {
font-size: 0.781rem;
color: var(--text);
@@ -2295,6 +2637,15 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
.ap-btn.approve:hover {
background: #188a4b;
}
.ap-btn.request-send {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.ap-btn.request-send:hover {
background: var(--accent-hover);
}
</style>
<!-- 모달/에이전트 패널은 Teleport body 렌더되므로 전역(scoped 미적용) 스타일 사용 -->
+7
View File
@@ -62,6 +62,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/TaskCreatePage.vue'),
meta: { requiresAuth: true },
},
{
// 업무 수정 — 생성 폼(TaskCreatePage)을 편집 모드로 재사용
path: '/repos/:repoId/tasks/:taskId/edit',
name: 'task-edit',
component: () => import('@/pages/relay/TaskCreatePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/tasks/:taskId',
name: 'task-detail',
+67
View File
@@ -1,5 +1,7 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
import type { TaskStatus } from '@/mock/relay.mock'
/**
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
*/
@@ -75,3 +77,68 @@ export function progressOf(done: number, total: number): ProgressInfo {
const label = pct === 0 ? '시작 전' : `${pct}% 완료`
return { pct, level, label }
}
/* ============================================================
* 업무(Task) 표시용 파생 값
* ============================================================ */
const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
/** 업무 상태 → 한국어 라벨 */
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '승인 대기',
done: '완료',
changes: '수정 요청',
}
export function taskStatusLabel(status: TaskStatus): string {
return TASK_STATUS_LABEL[status] ?? status
}
/** 'M월 D일' (예: '6월 8일') */
export function monthDayKo(input: Date | string): string {
const d = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(d.getTime())) return ''
return `${d.getMonth() + 1}${d.getDate()}`
}
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
export interface TaskDueInfo {
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
dateLabel: string
/** D-day 라벨 (예: 'D-3', 'D-DAY', '2일 지남') — 마감 없으면 '' */
dday: string
/** 목록 행 라벨 (완료: '6월 9일 완료' / 진행: 'D-3' / 지남: '2일 지남') */
listLabel: string
/** 마감 지남 여부(완료 업무는 false) */
overdue: boolean
}
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
if (!dueDate) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
}
const due = new Date(dueDate)
if (Number.isNaN(due.getTime())) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
}
const dateLabel = `${due.getMonth() + 1}${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
// 자정 기준 일 수 차이
const startDue = new Date(due.getFullYear(), due.getMonth(), due.getDate())
const now = new Date()
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
if (isDone) {
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
}
if (days < 0) {
const passed = `${-days}일 지남`
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
}
const dday = days === 0 ? 'D-DAY' : `D-${days}`
return { dateLabel, dday, listLabel: dday, overdue: false }
}
+163
View File
@@ -0,0 +1,163 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useTask } from '@/composables/useTask'
import {
avatarColor,
initialOf,
monthDayKo,
taskDueInfo,
taskStatusLabel,
} from '@/shared/utils/format'
import type {
ApiMember,
} from '@/types/repo'
import type {
ApiRepoTask,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type {
RepoTask as RepoTaskView,
TaskDetail as TaskDetailView,
User as UserView,
} from '@/mock/relay.mock'
// API 멤버 → 화면용 User(이니셜/색상 파생)
function toUserView(m: ApiMember): UserView {
return {
id: m.id,
name: m.name,
role: m.role ?? '',
initial: initialOf(m.name),
color: avatarColor(m.id),
}
}
// 지시자 없음(SET NULL) 대비 폴백 사용자
const UNKNOWN_USER: UserView = {
id: '',
name: '알 수 없음',
role: '',
initial: '?',
color: 'av-a',
}
// API 업무 목록 행 → 화면용 RepoTask
function toRepoTaskView(api: ApiRepoTask): RepoTaskView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const [, total] = api.checklist
return {
id: api.id,
title: api.title,
status: api.status,
dueLabel: due.listLabel || undefined,
overdue: due.overdue || undefined,
checklist: total > 0 ? api.checklist : undefined,
comments: api.commentCount > 0 ? api.commentCount : undefined,
assignees: api.assignees.map(toUserView),
}
}
// API 업무 상세 → 화면용 TaskDetail (comments/activities/files/approval 은 5단계)
function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
return {
id: api.id,
repoId: api.repoId,
repoName: api.repoName,
title: api.title,
status: api.status,
statusLabel: taskStatusLabel(api.status),
content: api.content,
checklist: api.checklist.map((c) => ({ text: c.text, done: c.done })),
comments: [],
activities: [],
assignees: api.assignees.map(toUserView),
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
dueLabel: due.dateLabel,
dday: due.dday,
files: [],
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
}
}
// 업무 스토어 — 저장소 업무 목록 + 현재 상세 + CRUD/상태 액션
export const useTaskStore = defineStore('task', () => {
const tasks = ref<RepoTaskView[]>([])
const current = ref<TaskDetailView | null>(null)
const loading = ref(false)
const taskApi = useTask()
// 목록 조회
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
loading.value = true
try {
const list = await taskApi.list(repoId, query)
tasks.value = list.map(toRepoTaskView)
} finally {
loading.value = false
}
}
// 단건 상세 조회
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
loading.value = true
try {
current.value = toTaskDetailView(await taskApi.get(repoId, taskId))
return current.value
} finally {
loading.value = false
}
}
// 생성 — 생성된 상세 반환
async function create(repoId: string, payload: CreateTaskPayload): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.create(repoId, payload))
current.value = view
return view
}
// 수정
async function update(
repoId: string,
taskId: number,
payload: UpdateTaskPayload,
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.update(repoId, taskId, payload))
current.value = view
return view
}
// 삭제
async function remove(repoId: string, taskId: number): Promise<void> {
await taskApi.remove(repoId, taskId)
tasks.value = tasks.value.filter((t) => t.id !== taskId)
if (current.value?.id === taskId) current.value = null
}
// 상태 변경
async function changeStatus(
repoId: string,
taskId: number,
status: TaskDetailView['status'],
): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.changeStatus(repoId, taskId, status))
current.value = view
return view
}
return {
tasks,
current,
loading,
fetchList,
fetchOne,
create,
update,
remove,
changeStatus,
}
})
+63
View File
@@ -0,0 +1,63 @@
// 업무 도메인 타입 — 백엔드 TaskService 응답 / DTO 와 1:1
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
export interface ApiRepoTask {
id: number // 저장소 내 순번(seq)
title: string
status: TaskStatus
dueDate: string | null
checklist: [number, number] // [완료, 전체]
commentCount: number
assignees: ApiMember[]
}
// 체크리스트 항목(API 원시)
export interface ApiChecklistItem {
id: string
text: string
done: boolean
}
// 업무 상세(API 원시) — comments/activities/files 는 5단계에서 채움(현재 빈 배열)
export interface ApiTaskDetail {
id: number
repoId: string
repoName: string
title: string
status: TaskStatus
content: string[]
checklist: ApiChecklistItem[]
assignees: ApiMember[]
issuer: ApiMember | null
dueDate: string | null
createdAt: string
comments: unknown[]
activities: unknown[]
files: unknown[]
}
// 업무 목록 필터
export interface TaskListQuery {
status?: TaskStatus
assignee?: string
}
// 업무 생성 요청
export interface CreateTaskPayload {
title: string
content?: string[]
assigneeIds: string[]
dueDate?: string
checklist?: { text: string }[]
}
// 업무 수정 요청
export interface UpdateTaskPayload {
title?: string
content?: string[]
assigneeIds?: string[]
dueDate?: string | null
}