feat: 업무 상세 부속·활동 피드·내 업무 집계 + 페이지네이션 (5~8단계)
- 5단계(업무 상세 부속): 체크리스트(상세 읽기전용/수정폼 추가·삭제)·댓글/답글·파일 첨부(서버 로컬 업로드 폴더)·승인 워크플로우(요청/승인/수정요청 노트) - 6단계(활동 피드): 저장소/멤버/업무 행위를 이벤트로 자동 적재, 저장소 활동 피드·업무 활동 탭 실연동(프론트가 type+payload로 문구 조립) - 7단계(내 업무): 담당/지시 저장소 횡단 집계, MyTasksPage 실연동, TaskAssigneePage(/view) 제거 - 8단계(진행): 목록 페이지네이션 통일 — 백엔드 전체(공통 유틸 + 5개 목록, 저장소 업무는 세그먼트/검색/카운트) + 프론트 더보기(저장소 목록·내 업무 완료, 멤버·저장소 업무 대기) - UI 보강: 업무 목록 댓글/첨부 분리표기, 첨부 배지 색상·라벨 통일, 완료 업무 D-Day 배지, 활동 이벤트 점검·보강(업무 수정/삭제·답글·첨부 삭제) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,9 @@ lerna-debug.log*
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# 업무 첨부 업로드 폴더(런타임 생성, 커밋 금지)
|
||||
/uploads
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
||||
@@ -25,6 +25,10 @@ RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 업무 첨부 업로드 폴더 — 비-root(node) 사용자가 쓸 수 있도록 미리 생성/소유권 부여
|
||||
# (운영에서 파일 영속이 필요하면 이 경로에 볼륨을 마운트한다: UPLOAD_DIR=/app/uploads)
|
||||
RUN mkdir -p /app/uploads && chown -R node:node /app/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 비-root 실행 (보안 규칙)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { UserModule } from './modules/user/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { RepoModule } from './modules/repo/repo.module';
|
||||
import { TaskModule } from './modules/task/task.module';
|
||||
import { ActivityModule } from './modules/activity/activity.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +66,7 @@ import { TaskModule } from './modules/task/task.module';
|
||||
AuthModule,
|
||||
RepoModule,
|
||||
TaskModule,
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 목록 페이지네이션 공통 — 오프셋 기반(?page&size), 표준 응답 { items, total, page, size }
|
||||
|
||||
// 페이지 응답 형태
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 정규화된 페이지 파라미터(+ DB skip/take)
|
||||
export interface PageParams {
|
||||
page: number;
|
||||
size: number;
|
||||
skip: number;
|
||||
take: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SIZE = 20;
|
||||
const MAX_SIZE = 100;
|
||||
|
||||
// page/size 정규화(범위 보정) + skip/take 계산
|
||||
export function resolvePage(page?: number, size?: number): PageParams {
|
||||
const p =
|
||||
Number.isFinite(page) && (page as number) > 0
|
||||
? Math.floor(page as number)
|
||||
: 1;
|
||||
const s =
|
||||
Number.isFinite(size) && (size as number) > 0
|
||||
? Math.min(Math.floor(size as number), MAX_SIZE)
|
||||
: DEFAULT_SIZE;
|
||||
return { page: p, size: s, skip: (p - 1) * s, take: s };
|
||||
}
|
||||
|
||||
// items + total → 표준 페이지 응답
|
||||
export function paginated<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
params: PageParams,
|
||||
): PaginatedResult<T> {
|
||||
return { items, total, page: params.page, size: params.size };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ActivityService, type ActivityResponse } from './activity.service';
|
||||
|
||||
// 활동 피드 컨트롤러 — 읽기 전용. 조직 모델상 인증 사용자면 열람 가능
|
||||
@ApiTags('Activity')
|
||||
@Controller('repos/:repoId/activity')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '저장소 활동 피드 (최신순)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
list(@Param('repoId') repoId: string): Promise<ActivityResponse[]> {
|
||||
return this.activityService.listForRepo(repoId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Activity } from './entities/activity.entity';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { ActivityService } from './activity.service';
|
||||
import { ActivityController } from './activity.controller';
|
||||
|
||||
// 활동 피드 모듈 — 다른 도메인 모듈(repo/task)이 import 해 ActivityService 로 이벤트를 적재한다.
|
||||
// (Activity 는 taskSeq 만 보관해 Task 엔티티를 참조하지 않으므로 순환 의존 없음)
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Activity, Repo])],
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
})
|
||||
export class ActivityModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { Repo } from '../repo/entities/repo.entity';
|
||||
import { Activity, type ActivityType } from './entities/activity.entity';
|
||||
|
||||
// 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
export interface ActivityResponse {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
actor: PublicUser | null;
|
||||
taskSeq: number | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 활동 적재 파라미터
|
||||
export interface RecordActivityParams {
|
||||
repoId: string; // Repo uuid
|
||||
actorId: string | null;
|
||||
type: ActivityType;
|
||||
payload?: Record<string, unknown>;
|
||||
taskSeq?: number | null;
|
||||
}
|
||||
|
||||
// 활동 피드 비즈니스 로직 — 다른 도메인 서비스가 record() 로 이벤트를 적재하고,
|
||||
// 저장소/업무 단위로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
private readonly logger = new Logger(ActivityService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Activity)
|
||||
private readonly activityRepo: Repository<Activity>,
|
||||
@InjectRepository(Repo)
|
||||
private readonly repoRepo: Repository<Repo>,
|
||||
) {}
|
||||
|
||||
// 활동 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
|
||||
async record(params: RecordActivityParams): Promise<void> {
|
||||
try {
|
||||
const activity = this.activityRepo.create({
|
||||
repo: { id: params.repoId } as Repo,
|
||||
actor: params.actorId ? { id: params.actorId } : null,
|
||||
type: params.type,
|
||||
payload: params.payload ?? {},
|
||||
taskSeq: params.taskSeq ?? null,
|
||||
});
|
||||
await this.activityRepo.save(activity);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`활동 적재 실패: ${params.type}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 활동 피드 — slugName 으로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트.
|
||||
async listForRepo(slugName: string): Promise<ActivityResponse[]> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repo.id } },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 업무 활동 — repoId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용.
|
||||
async listForTask(
|
||||
repoId: string,
|
||||
taskSeq: number,
|
||||
): Promise<ActivityResponse[]> {
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repoId }, taskSeq },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 저장소 단위(업무 무관) 활동만 — 필요 시 사용. taskSeq IS NULL
|
||||
async listRepoOnly(slugName: string): Promise<ActivityResponse[]> {
|
||||
const repo = await this.repoRepo.findOne({ where: { slugName } });
|
||||
if (!repo) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const rows = await this.activityRepo.find({
|
||||
where: { repo: { id: repo.id }, taskSeq: IsNull() },
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(a: Activity): ActivityResponse {
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
actor: a.actor ? UserService.toPublic(a.actor) : null,
|
||||
taskSeq: a.taskSeq,
|
||||
payload: a.payload ?? {},
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Repo } from '../../repo/entities/repo.entity';
|
||||
|
||||
// 활동 이벤트 타입 — 2~5단계 행위에서 자동 적재(쓰기 API 없음, 읽기 전용)
|
||||
export type ActivityType =
|
||||
// 저장소(설정)
|
||||
| 'repo.created'
|
||||
| 'repo.renamed'
|
||||
| 'repo.visibility_changed'
|
||||
// 멤버
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
// 업무
|
||||
| 'task.created'
|
||||
| 'task.status_changed'
|
||||
| 'task.comment_added'
|
||||
| 'task.file_attached'
|
||||
| 'task.assignees_changed'
|
||||
| 'task.due_changed'
|
||||
| 'task.edited'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed';
|
||||
|
||||
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
|
||||
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
|
||||
@Entity('activities')
|
||||
export class Activity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 저장소 (저장소 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피
|
||||
@Index()
|
||||
@ManyToOne(() => Repo, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'repo_id' })
|
||||
repo!: Relation<Repo>;
|
||||
|
||||
// 업무 활동이면 업무 순번(seq) — 업무 상세 활동 탭 필터용. 저장소 활동이면 null
|
||||
// (Task FK 대신 seq 를 저장해 업무 삭제와 무관하게 활동을 유지)
|
||||
@Index()
|
||||
@Column({ name: 'task_seq', type: 'int', nullable: true })
|
||||
taskSeq!: number | null;
|
||||
|
||||
// 행위자 (사용자 삭제 시에도 활동은 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'actor_id' })
|
||||
actor!: User | null;
|
||||
|
||||
// 이벤트 타입
|
||||
@Column({ type: 'varchar' })
|
||||
type!: ActivityType;
|
||||
|
||||
// 대상 정보(자유형) — 예: taskSeq/taskTitle, statusFrom/statusTo, targetName, fileName, visibility, newName, roleType
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -15,6 +18,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { MemberService, type RepoMemberResponse } from './member.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
import { UpdateMemberDto } from './dto/update-member.dto';
|
||||
|
||||
@@ -26,11 +30,15 @@ export class MemberController {
|
||||
constructor(private readonly memberService: MemberService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '저장소 멤버 목록 (인증 사용자 열람 가능)' })
|
||||
@ApiOperation({ summary: '저장소 멤버 목록 (페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
list(@Param('repoId') repoId: string): Promise<RepoMemberResponse[]> {
|
||||
return this.memberService.list(repoId);
|
||||
list(
|
||||
@Param('repoId') repoId: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<RepoMemberResponse>> {
|
||||
return this.memberService.list(repoId, page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Repository } from 'typeorm';
|
||||
import { MemberService } from './member.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
@@ -13,6 +14,7 @@ function makeService() {
|
||||
const memberRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findAndCount: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
@@ -22,13 +24,16 @@ function makeService() {
|
||||
const taskService = {
|
||||
assigneeCounts: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
// 활동 적재는 no-op 모킹
|
||||
const activity = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const svc = new MemberService(
|
||||
repoRepo as unknown as Repository<Repo>,
|
||||
memberRepo as unknown as Repository<RepoMember>,
|
||||
userService as unknown as UserService,
|
||||
taskService as unknown as TaskService,
|
||||
activity as unknown as ActivityService,
|
||||
);
|
||||
return { svc, repoRepo, memberRepo, userService, taskService };
|
||||
return { svc, repoRepo, memberRepo, userService, taskService, activity };
|
||||
}
|
||||
|
||||
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', visibility: 'private' };
|
||||
@@ -40,18 +45,22 @@ describe('MemberService', () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
// 목록에 현재 요청자가 없어도(비멤버) 거부하지 않는다
|
||||
memberRepo.find.mockResolvedValue([
|
||||
{
|
||||
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
|
||||
email: 'a@b.c',
|
||||
roleType: 'member',
|
||||
isOwner: false,
|
||||
},
|
||||
memberRepo.findAndCount.mockResolvedValue([
|
||||
[
|
||||
{
|
||||
user: { id: 'someone-else', email: 'a@b.c', name: 'A', role: null },
|
||||
email: 'a@b.c',
|
||||
roleType: 'member',
|
||||
isOwner: false,
|
||||
},
|
||||
],
|
||||
1,
|
||||
]);
|
||||
|
||||
const result = await svc.list('q3-launch');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].user.id).toBe('someone-else');
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].user.id).toBe('someone-else');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ import { Repository } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember, type MemberRoleType } from './entities/repo-member.entity';
|
||||
import { InviteMemberDto } from './dto/invite-member.dto';
|
||||
@@ -33,20 +39,32 @@ export class MemberService {
|
||||
private readonly memberRepo: Repository<RepoMember>,
|
||||
private readonly userService: UserService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly activity: ActivityService,
|
||||
) {}
|
||||
|
||||
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책).
|
||||
// 멤버 목록 — 조직 모델상 인증 사용자면 열람 가능(RepoService.findOne 과 동일 정책). 페이지네이션.
|
||||
// 쓰기(초대/역할변경/제거)는 admin 가드로 별도 제한한다.
|
||||
async list(slugName: string): Promise<RepoMemberResponse[]> {
|
||||
async list(
|
||||
slugName: string,
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<RepoMemberResponse>> {
|
||||
const repo = await this.getRepoOrThrow(slugName);
|
||||
const members = await this.memberRepo.find({
|
||||
const pg = resolvePage(page, size);
|
||||
const [members, total] = await this.memberRepo.findAndCount({
|
||||
where: { repo: { id: repo.id } },
|
||||
relations: ['user'],
|
||||
order: { createdAt: 'ASC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
// 사용자별 담당 업무 수 집계(taskCount)
|
||||
const counts = await this.taskService.assigneeCounts(repo.id);
|
||||
return members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0));
|
||||
return paginated(
|
||||
members.map((m) => this.toResponse(m, counts.get(m.user.id) ?? 0)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 멤버 초대 — admin 권한. 이미 가입된 사용자만 이메일로 초대
|
||||
@@ -86,6 +104,15 @@ export class MemberService {
|
||||
});
|
||||
const saved = await this.memberRepo.save(member);
|
||||
saved.user = user; // 관계 보장(응답 매핑용)
|
||||
|
||||
// 활동 적재 — 멤버 초대
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.invited',
|
||||
payload: { targetName: user.name, roleType: dto.roleType },
|
||||
});
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
@@ -110,6 +137,15 @@ export class MemberService {
|
||||
if (dto.roleType !== undefined) member.roleType = dto.roleType;
|
||||
|
||||
await this.memberRepo.save(member);
|
||||
|
||||
// 활동 적재 — 멤버 역할 변경
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.role_changed',
|
||||
payload: { targetName: member.user.name, roleType: member.roleType },
|
||||
});
|
||||
|
||||
return this.toResponse(member);
|
||||
}
|
||||
|
||||
@@ -129,7 +165,16 @@ export class MemberService {
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
const targetName = member.user.name; // 제거 전 이름 캡처
|
||||
await this.memberRepo.remove(member);
|
||||
|
||||
// 활동 적재 — 멤버 제거
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: actingUserId,
|
||||
type: 'member.removed',
|
||||
payload: { targetName },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 내부 헬퍼 ---
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -15,6 +18,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import { RepoService, type RepoResponse } from './repo.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
import { UpdateRepoDto } from './dto/update-repo.dto';
|
||||
|
||||
@@ -26,10 +30,13 @@ export class RepoController {
|
||||
constructor(private readonly repoService: RepoService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '조직 저장소 전체 목록' })
|
||||
@ApiOperation({ summary: '조직 저장소 전체 목록 (페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
findAll(): Promise<RepoResponse[]> {
|
||||
return this.repoService.findAll();
|
||||
findAll(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<RepoResponse>> {
|
||||
return this.repoService.findAll(page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { GiteaModule } from '../gitea/gitea.module';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { Repo } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { RepoService } from './repo.service';
|
||||
@@ -18,6 +19,7 @@ import { RepoSyncService } from './repo-sync.service';
|
||||
UserModule,
|
||||
GiteaModule,
|
||||
TaskModule,
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [RepoController, MemberController],
|
||||
providers: [RepoService, MemberService, RepoSyncService],
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
type GiteaRepoResult,
|
||||
} from '../gitea/gitea.service';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Repo, type RepoVisibility } from './entities/repo.entity';
|
||||
import { RepoMember } from './entities/repo-member.entity';
|
||||
import { CreateRepoDto } from './dto/create-repo.dto';
|
||||
@@ -58,6 +64,7 @@ export class RepoService {
|
||||
private readonly userService: UserService,
|
||||
private readonly gitea: GiteaService,
|
||||
private readonly taskService: TaskService,
|
||||
private readonly activity: ActivityService,
|
||||
) {}
|
||||
|
||||
// 저장소 생성 폼 메타데이터 — 소유자(조직) 고정 표시 + 템플릿 가용 정보.
|
||||
@@ -76,16 +83,26 @@ export class RepoService {
|
||||
};
|
||||
}
|
||||
|
||||
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분.
|
||||
// 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. 오프셋 페이지네이션.
|
||||
// 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델).
|
||||
async findAll(): Promise<RepoResponse[]> {
|
||||
const repos = await this.repoRepo.find({
|
||||
async findAll(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<RepoResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [repos, total] = await this.repoRepo.findAndCount({
|
||||
relations: ['members', 'members.user'],
|
||||
order: { updatedAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
// 저장소별 업무 완료/전체 수를 한 번에 집계해 진행률에 반영
|
||||
const counts = await this.taskService.countsByRepo(repos.map((r) => r.id));
|
||||
return repos.map((repo) => this.toResponse(repo, counts.get(repo.id)));
|
||||
return paginated(
|
||||
repos.map((repo) => this.toResponse(repo, counts.get(repo.id))),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드).
|
||||
@@ -157,6 +174,14 @@ export class RepoService {
|
||||
});
|
||||
await this.memberRepo.save(ownerMember);
|
||||
|
||||
// 활동 적재 — 저장소 생성
|
||||
await this.activity.record({
|
||||
repoId: saved.id,
|
||||
actorId: userId,
|
||||
type: 'repo.created',
|
||||
payload: { repoName: saved.name },
|
||||
});
|
||||
|
||||
// 갓 생성한 저장소는 업무가 없으므로 집계는 0/0
|
||||
return this.toResponse(await this.getEntityOrThrow(saved.slugName));
|
||||
} catch (err) {
|
||||
@@ -215,6 +240,24 @@ export class RepoService {
|
||||
});
|
||||
}
|
||||
|
||||
// 활동 적재 — 표시 제목 변경 / 공개범위 변경(설정 그룹)
|
||||
if (dto.name !== undefined) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
type: 'repo.renamed',
|
||||
payload: { newName: repo.name },
|
||||
});
|
||||
}
|
||||
if (visChanged) {
|
||||
await this.activity.record({
|
||||
repoId: repo.id,
|
||||
actorId: userId,
|
||||
type: 'repo.visibility_changed',
|
||||
payload: { visibility: repo.visibility },
|
||||
});
|
||||
}
|
||||
|
||||
return this.toResponse(
|
||||
await this.getEntityOrThrow(slugName),
|
||||
await this.taskCountsOf(repo.id),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 승인 요청(담당자 → 지시자) — 메시지 선택
|
||||
export class ApprovalRequestDto {
|
||||
@ApiProperty({
|
||||
description: '승인 요청 메시지(선택)',
|
||||
required: false,
|
||||
example: '가로형/세로형 1차 제작을 완료했습니다. 검토 부탁드립니다.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수정 요청(지시자 → 담당자) — 보완 내용 필수
|
||||
export class ApprovalChangesDto {
|
||||
@ApiProperty({
|
||||
description: '수정 요청 메시지(보완이 필요한 내용)',
|
||||
example: '세로형 카피 가독성이 낮으니 자간과 대비를 조정해 주세요.',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '수정 요청 내용을 입력해 주세요.' })
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 댓글 작성 요청 (fileId 로 기존 첨부를 함께 연결 가능)
|
||||
export class CreateCommentDto {
|
||||
@ApiProperty({
|
||||
description: '댓글 내용',
|
||||
example: '가로형 시안 검토 부탁드립니다.',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '댓글 내용을 입력해 주세요.' })
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({ description: '연결할 첨부파일 ID(선택)', required: false })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
// 답글 작성 요청
|
||||
export class CreateReplyDto {
|
||||
@ApiProperty({ description: '답글 내용', example: '네, 확인했습니다.' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '답글 내용을 입력해 주세요.' })
|
||||
@MaxLength(2000)
|
||||
text!: string;
|
||||
}
|
||||
@@ -7,10 +7,32 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 업무 수정 요청 — 제공된 필드만 갱신(체크리스트/댓글/파일은 5단계 별도 엔드포인트)
|
||||
// 수정 시 체크리스트 항목 — id 있으면 기존 항목 갱신(완료 상태 보존), 없으면 신규 추가
|
||||
export class UpdateChecklistItemInput {
|
||||
@ApiProperty({
|
||||
description: '기존 항목 ID(신규 추가 시 생략)',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
id?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '체크리스트 항목 내용',
|
||||
example: '세로형 배너 디자인',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' })
|
||||
@MaxLength(200)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
// 업무 수정 요청 — 제공된 필드만 갱신
|
||||
export class UpdateTaskDto {
|
||||
@ApiProperty({ description: '업무 제목', required: false })
|
||||
@IsOptional()
|
||||
@@ -48,4 +70,16 @@ export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsISO8601({}, { message: '올바른 날짜 형식을 입력해 주세요.' })
|
||||
dueDate?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'체크리스트 전체(추가/삭제/순서 동기화). id 있는 항목은 유지(완료 보존), 없으면 신규, 빠진 기존 항목은 삭제',
|
||||
type: [UpdateChecklistItemInput],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UpdateChecklistItemInput)
|
||||
checklist?: UpdateChecklistItemInput[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Task } from './task.entity';
|
||||
|
||||
// 첨부파일 아이콘 종류(프론트 표시용)
|
||||
export type AttachmentKind = 'pdf' | 'zip' | 'doc' | 'img';
|
||||
|
||||
// 업무 첨부파일 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관
|
||||
@Entity('attachments')
|
||||
export class Attachment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 업무 (업무 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피
|
||||
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task!: Relation<Task>;
|
||||
|
||||
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'uploader_id' })
|
||||
uploader!: User | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 디스크 저장명(업로드 폴더 내 고유 파일명)
|
||||
@Column({ name: 'stored_name' })
|
||||
storedName!: string;
|
||||
|
||||
// 파일 크기(바이트) — 표시용 변환은 프론트 formatBytes 가 담당
|
||||
@Column({ type: 'int' })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생해 저장)
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Task } from './task.entity';
|
||||
import { Attachment } from './attachment.entity';
|
||||
|
||||
// 업무 댓글 — 답글은 parent 를 가리키는 같은 엔티티로 표현(1단계 깊이)
|
||||
@Entity('comments')
|
||||
export class Comment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 업무 (업무 삭제 시 함께 삭제)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피
|
||||
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'task_id' })
|
||||
task!: Relation<Task>;
|
||||
|
||||
// 작성자 (사용자 삭제 시에도 댓글은 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'author_id' })
|
||||
author!: User | null;
|
||||
|
||||
// 부모 댓글 — 답글이면 설정, 최상위 댓글이면 null
|
||||
@ManyToOne(() => Comment, (c) => c.replies, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent!: Relation<Comment> | null;
|
||||
|
||||
// 이 댓글에 달린 답글들
|
||||
@OneToMany(() => Comment, (c) => c.parent)
|
||||
replies!: Relation<Comment>[];
|
||||
|
||||
// 첨부(선택) — 첨부 삭제 시 연결만 해제(SET NULL)
|
||||
@ManyToOne(() => Attachment, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'attachment_id' })
|
||||
attachment!: Relation<Attachment> | null;
|
||||
|
||||
// 댓글 본문
|
||||
@Column({ type: 'text' })
|
||||
text!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -73,6 +73,29 @@ export class Task {
|
||||
@OneToMany(() => ChecklistItem, (item) => item.task, { cascade: true })
|
||||
checklist!: Relation<ChecklistItem>[];
|
||||
|
||||
// --- 승인 워크플로우(5단계) ---
|
||||
|
||||
// 최근 승인 요청 메시지(담당자 → 지시자). 승인 대기(review) 상태에서 노출
|
||||
@Column({ name: 'approval_note', type: 'text', nullable: true })
|
||||
approvalNote!: string | null;
|
||||
|
||||
// 승인을 요청한 사용자 (사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'approval_requested_by' })
|
||||
approvalRequestedBy!: User | null;
|
||||
|
||||
// 승인 요청 시각
|
||||
@Column({
|
||||
name: 'approval_requested_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
approvalRequestedAt!: Date | null;
|
||||
|
||||
// 최근 수정 요청 메시지(지시자 → 담당자). 수정 요청(changes) 상태에서 노출
|
||||
@Column({ name: 'changes_note', type: 'text', nullable: true })
|
||||
changesNote!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
ParseIntPipe,
|
||||
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 MyTaskRowResponse } from './task.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
|
||||
// 내 업무(저장소 횡단) — 담당/지시 집계. 그룹핑·라벨은 프론트가 파생
|
||||
@ApiTags('MyTask')
|
||||
@Controller('tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MyTaskController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
@Get('assigned')
|
||||
@ApiOperation({ summary: '내가 담당인 업무 (저장소 횡단, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listAssigned(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<MyTaskRowResponse>> {
|
||||
return this.taskService.listAssigned(user.id, page, size);
|
||||
}
|
||||
|
||||
@Get('issued')
|
||||
@ApiOperation({ summary: '내가 지시한 업무 (저장소 횡단, 페이지네이션)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listIssued(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<MyTaskRowResponse>> {
|
||||
return this.taskService.listIssued(user.id, page, size);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,51 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
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 AttachmentResponse,
|
||||
type CommentReplyResponse,
|
||||
type CommentResponse,
|
||||
type RepoTaskListResult,
|
||||
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';
|
||||
import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto';
|
||||
import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
|
||||
import {
|
||||
MAX_FILE_SIZE,
|
||||
UPLOAD_DIR,
|
||||
type UploadedDiskFile,
|
||||
} from './upload.config';
|
||||
|
||||
// 업무 컨트롤러 — 모든 엔드포인트 인증 필요 (repoId = slugName, taskId = repo 내 순번)
|
||||
@ApiTags('Task')
|
||||
@@ -34,15 +55,20 @@ export class TaskController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '업무 목록 (상태/담당자 필터)' })
|
||||
@ApiOperation({
|
||||
summary: '업무 목록 (세그먼트/검색 + 페이지네이션 + 세그먼트 카운트)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' })
|
||||
list(
|
||||
@Param('repoId') repoId: string,
|
||||
@Query('status') status?: TaskStatus,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
@Query('segment') segment?: string,
|
||||
@Query('q') q?: string,
|
||||
@Query('assignee') assignee?: string,
|
||||
): Promise<RepoTaskResponse[]> {
|
||||
return this.taskService.list(repoId, { status, assignee });
|
||||
): Promise<RepoTaskListResult> {
|
||||
return this.taskService.list(repoId, { segment, q, assignee }, page, size);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -117,4 +143,150 @@ export class TaskController {
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.changeStatus(repoId, user.id, taskId, dto.status);
|
||||
}
|
||||
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
@Post(':taskId/comments')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '댓글 작성 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '작성 성공' })
|
||||
addComment(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: CreateCommentDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<CommentResponse> {
|
||||
return this.taskService.addComment(
|
||||
repoId,
|
||||
user.id,
|
||||
taskId,
|
||||
dto.text,
|
||||
dto.fileId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':taskId/comments/:commentId/replies')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '답글 작성 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '작성 성공' })
|
||||
addReply(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('commentId', ParseUUIDPipe) commentId: string,
|
||||
@Body() dto: CreateReplyDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<CommentReplyResponse> {
|
||||
return this.taskService.addReply(
|
||||
repoId,
|
||||
user.id,
|
||||
taskId,
|
||||
commentId,
|
||||
dto.text,
|
||||
);
|
||||
}
|
||||
|
||||
/* ===================== 파일 첨부 (5단계) ===================== */
|
||||
|
||||
@Post(':taskId/files')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: '파일 업로드 (멤버)' })
|
||||
@ApiResponse({ status: 201, description: '업로드 성공' })
|
||||
addFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@UploadedFile() file: UploadedDiskFile,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<AttachmentResponse> {
|
||||
return this.taskService.addFile(repoId, user.id, taskId, file);
|
||||
}
|
||||
|
||||
@Delete(':taskId/files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '파일 삭제 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async removeFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.taskService.removeFile(repoId, user.id, taskId, fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get(':taskId/files/:fileId/download')
|
||||
@ApiOperation({ summary: '파일 다운로드 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '파일 스트림' })
|
||||
async downloadFile(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { attachment, absolutePath } =
|
||||
await this.taskService.getFileForDownload(
|
||||
repoId,
|
||||
user.id,
|
||||
taskId,
|
||||
fileId,
|
||||
);
|
||||
// 표준 응답 래퍼를 거치지 않고 바이너리를 직접 스트리밍(@Res)
|
||||
res.setHeader('Content-Type', attachment.mime);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
|
||||
/* ===================== 승인 워크플로우 (5단계) ===================== */
|
||||
|
||||
@Post(':taskId/approval/request')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '승인 요청 (담당자 → 지시자)' })
|
||||
@ApiResponse({ status: 200, description: '요청 성공' })
|
||||
@ApiResponse({ status: 409, description: '허용되지 않는 전이 (BIZ_001)' })
|
||||
requestApproval(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: ApprovalRequestDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.requestApproval(repoId, user.id, taskId, dto.note);
|
||||
}
|
||||
|
||||
@Post(':taskId/approval/approve')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '승인 (지시자/admin)' })
|
||||
@ApiResponse({ status: 200, description: '승인 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
approve(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.approve(repoId, user.id, taskId);
|
||||
}
|
||||
|
||||
@Post(':taskId/approval/changes')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '수정 요청 (지시자/admin)' })
|
||||
@ApiResponse({ status: 200, description: '요청 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
requestChanges(
|
||||
@Param('repoId') repoId: string,
|
||||
@Param('taskId', ParseIntPipe) taskId: number,
|
||||
@Body() dto: ApprovalChangesDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<TaskDetailResponse> {
|
||||
return this.taskService.requestChanges(repoId, user.id, taskId, dto.note);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,27 @@ 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 { Comment } from './entities/comment.entity';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { TaskService } from './task.service';
|
||||
import { TaskController } from './task.controller';
|
||||
import { MyTaskController } from './my-task.controller';
|
||||
|
||||
// 업무 모듈 (업무 CRUD + 상태 전이)
|
||||
// 업무 모듈 (업무 CRUD + 상태 전이 + 체크리스트/댓글/첨부/승인)
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Task, ChecklistItem, Repo, RepoMember])],
|
||||
controllers: [TaskController],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Task,
|
||||
ChecklistItem,
|
||||
Comment,
|
||||
Attachment,
|
||||
Repo,
|
||||
RepoMember,
|
||||
]),
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [TaskController, MyTaskController],
|
||||
providers: [TaskService],
|
||||
exports: [TaskService],
|
||||
})
|
||||
|
||||
@@ -6,6 +6,9 @@ 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 { Comment } from './entities/comment.entity';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
|
||||
// 리포지토리 간이 모킹
|
||||
function makeService() {
|
||||
@@ -19,13 +22,33 @@ function makeService() {
|
||||
const checklistRepo = { create: jest.fn(), save: jest.fn() };
|
||||
const repoRepo = { findOne: jest.fn() };
|
||||
const memberRepo = { findOne: jest.fn(), find: jest.fn() };
|
||||
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
|
||||
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
// 활동 서비스 — 적재는 no-op, 업무 활동 조회는 빈 목록
|
||||
const activity = {
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
listForTask: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
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>,
|
||||
commentRepo as unknown as Repository<Comment>,
|
||||
attachmentRepo as unknown as Repository<Attachment>,
|
||||
activity as unknown as ActivityService,
|
||||
);
|
||||
return { svc, taskRepo, checklistRepo, repoRepo, memberRepo };
|
||||
return {
|
||||
svc,
|
||||
taskRepo,
|
||||
checklistRepo,
|
||||
repoRepo,
|
||||
memberRepo,
|
||||
commentRepo,
|
||||
attachmentRepo,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
const REPO = { id: 'repo-uuid', slugName: 'q3-launch', name: '캠페인' };
|
||||
@@ -109,6 +132,77 @@ describe('TaskService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('승인 워크플로우 (5단계)', () => {
|
||||
it('수정 요청(review→changes)은 지시자/admin 이 아니면 Forbidden', async () => {
|
||||
const { svc, taskRepo, repoRepo, memberRepo } = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'member',
|
||||
user: { id: 'assignee' },
|
||||
});
|
||||
taskRepo.findOne.mockResolvedValue({
|
||||
seq: 9,
|
||||
status: 'review',
|
||||
issuer: { id: 'someone-else' },
|
||||
assignees: [{ id: 'assignee' }],
|
||||
checklist: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.requestChanges('q3-launch', 'assignee', 9, '보완 바랍니다'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('승인 요청(prog→review)은 담당자가 가능', async () => {
|
||||
const {
|
||||
svc,
|
||||
taskRepo,
|
||||
repoRepo,
|
||||
memberRepo,
|
||||
commentRepo,
|
||||
attachmentRepo,
|
||||
} = makeService();
|
||||
repoRepo.findOne.mockResolvedValue(REPO);
|
||||
memberRepo.findOne.mockResolvedValue({
|
||||
roleType: 'member',
|
||||
user: { id: 'assignee', email: 'a@b.c', name: 'A', role: null },
|
||||
});
|
||||
const task = {
|
||||
seq: 9,
|
||||
status: 'prog',
|
||||
title: '배너',
|
||||
content: [],
|
||||
issuer: { id: 'boss' },
|
||||
assignees: [{ id: 'assignee' }],
|
||||
checklist: [],
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-06-08T00:00:00Z'),
|
||||
approvalRequestedBy: {
|
||||
id: 'assignee',
|
||||
email: 'a@b.c',
|
||||
name: 'A',
|
||||
role: null,
|
||||
},
|
||||
approvalRequestedAt: new Date('2026-06-10T00:00:00Z'),
|
||||
approvalNote: '검토 부탁드립니다',
|
||||
};
|
||||
taskRepo.findOne.mockResolvedValue(task);
|
||||
taskRepo.save.mockResolvedValue(task);
|
||||
commentRepo.find.mockResolvedValue([]);
|
||||
attachmentRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await svc.requestApproval(
|
||||
'q3-launch',
|
||||
'assignee',
|
||||
9,
|
||||
'검토 부탁드립니다',
|
||||
);
|
||||
|
||||
expect(task.status).toBe('review');
|
||||
expect(result.approval?.note).toBe('검토 부탁드립니다');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create — 담당자 멤버 검증', () => {
|
||||
it('담당자가 저장소 멤버가 아니면 BusinessException', async () => {
|
||||
const { svc, repoRepo, memberRepo } = makeService();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { AttachmentKind } from './entities/attachment.entity';
|
||||
|
||||
// 업무 첨부파일 업로드 폴더 — 서버 로컬 디스크에 보관(추후 S3/MinIO 로 교체 가능)
|
||||
// 도커 환경에서는 UPLOAD_DIR 로 볼륨 경로를 주입하고, 미설정 시 작업 디렉터리 하위 uploads 사용
|
||||
export const UPLOAD_DIR =
|
||||
process.env.UPLOAD_DIR || join(process.cwd(), 'uploads');
|
||||
|
||||
// 모듈 로드 시 업로드 폴더 존재 보장(multer dest 는 폴더를 자동 생성하지 않음)
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// 첨부 1건당 최대 크기(25MB)
|
||||
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
// multer(diskStorage) 가 채워주는 업로드 파일 메타 — @types/multer 의존 없이 필요한 필드만 정의
|
||||
export interface UploadedDiskFile {
|
||||
originalname: string; // 원본 파일명(비ASCII 는 latin1 인코딩으로 들어옴)
|
||||
filename: string; // 업로드 폴더 내 저장명
|
||||
size: number; // 바이트
|
||||
mimetype: string;
|
||||
path: string; // 절대 경로
|
||||
}
|
||||
|
||||
// MIME/확장자로 아이콘 종류 파생
|
||||
export function deriveKind(mime: string, name: string): AttachmentKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'img';
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
|
||||
if (
|
||||
mime.includes('zip') ||
|
||||
mime.includes('compressed') ||
|
||||
lower.endsWith('.zip')
|
||||
) {
|
||||
return 'zip';
|
||||
}
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
// multer 가 비ASCII 파일명을 latin1 로 디코딩하므로 UTF-8 로 복원
|
||||
export function decodeOriginalName(name: string): string {
|
||||
return Buffer.from(name, 'latin1').toString('utf8');
|
||||
}
|
||||
+33
-23
@@ -42,10 +42,14 @@
|
||||
status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프론트는 `BIZ_001` 코드일 때 서버 `message` 를 그대로 노출한다.
|
||||
예: 로그인 실패 → `BusinessException('BIZ_001', '이메일 또는 비밀번호가 올바르지 않습니다.', 401)`.
|
||||
|
||||
### 페이지네이션
|
||||
### 페이지네이션 — **8단계에서 통일(진행 중)**
|
||||
|
||||
목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`, 응답 `{ items: [...], total, page, size }`.
|
||||
(1단계에서는 단순 배열 허용, 7단계에서 페이지네이션으로 통일)
|
||||
목록 API 는 오프셋 기반: 쿼리 `?page=1&size=20`(기본 size 20, 최대 100), 응답 `{ items: [...], total, page, size }`.
|
||||
프론트는 **더보기 버튼**으로 점진 로드(시안에 페이지 UI 없음).
|
||||
|
||||
- **백엔드 적용 완료**: `GET /repos`, `/repos/:id/members`, `/tasks/assigned`, `/tasks/issued`, `/repos/:id/tasks`(세그먼트/검색 + `counts` 동봉). 공통 유틸 `common/pagination`.
|
||||
- **프론트 적용 완료**: 저장소 목록, 내 업무. **남음**: 멤버, 저장소 업무 목록(RepoDetailPage).
|
||||
- 응답이 단순 배열 → `{ items, total, page, size }` 로 바뀌었으므로 프론트 composable/store 가 함께 갱신돼야 한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -206,8 +210,8 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
- 생성 시 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[] }`
|
||||
`UpdateTaskDto`: `{ title?, content?: string[], assigneeIds?: uuid[], dueDate?: ISO|null, checklist?: {id?, text}[] }` — 제공된 필드만 갱신. `checklist` 제공 시 **전체 동기화**(id 있으면 기존 유지·완료 보존, 없으면 신규, 빠진 기존 항목은 삭제).
|
||||
`RepoTask`(목록 행): `{ id(seq), title, status, dueDate: ISO|null, checklist: [done,total], commentCount, fileCount, assignees: User[] }` — 목록 행은 댓글 수(말풍선)·첨부 수(클립)를 **분리 표기**(각 0이면 숨김).
|
||||
`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단계(현재 미포함).
|
||||
|
||||
@@ -219,14 +223,15 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
|
||||
---
|
||||
|
||||
## 5. 업무 상세 부속 — **5단계**
|
||||
## 5. 업무 상세 부속 — **5단계 ✅ 구현 완료**
|
||||
|
||||
### 체크리스트
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|---|---|---|---|
|
||||
| POST | `.../tasks/:taskId/checklist` | `{ text }` | `ChecklistItem` |
|
||||
| PATCH | `.../checklist/:itemId` | `{ text?, done? }` | `ChecklistItem` |
|
||||
| DELETE | `.../checklist/:itemId` | — | `null` |
|
||||
> 모든 엔드포인트 `JwtAuthGuard` + 저장소 멤버. `:taskId`=seq(정수), `:itemId`/`:fileId`/`:commentId`=uuid.
|
||||
> 파일 저장은 **서버 로컬 업로드 폴더**(`UPLOAD_DIR`, 기본 `cwd/uploads`) 채택. 승인 워크플로우는 4단계 상태머신(`POST .../status`)을 노트와 함께 확장한 형태.
|
||||
|
||||
### 체크리스트 — **상세 화면 읽기 전용 / 정의는 생성·수정 폼에서**
|
||||
- **완료 토글은 사용자가 하지 않는다** — 상세 화면에서는 표시만 하고, 완료는 승인 워크플로우에서 일괄 처리된다("승인 완료(→done) 시 체크리스트 항목 done 일괄 true", 4단계 부수효과).
|
||||
- **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화).
|
||||
- (5단계 초기 설계의 `POST/PATCH/DELETE .../checklist` 항목별 토글 API 는 제거됨 — 항목 편집은 업무 수정 PATCH 로 통합.)
|
||||
|
||||
### 댓글 / 답글
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
@@ -240,7 +245,7 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
| POST | `.../tasks/:taskId/files` | `multipart/form-data` | `Attachment` |
|
||||
| DELETE | `.../files/:fileId` | — | `null` |
|
||||
|
||||
> **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO). `Attachment`: `{ id, name, size, kind('pdf'|'zip'|'doc'|'img'), url }`.
|
||||
> **결정됨**: 파일 저장소 = **서버 로컬 업로드 폴더**(S3/MinIO 후순위). `Attachment`: `{ id, name, size(바이트), kind('pdf'|'zip'|'doc'|'img'), url(다운로드 경로), uploader: User|null, createdAt }`. 업로드 필드명 `file`. 다운로드는 `GET .../files/:fileId/download`(인증 쿠키로 접근, 인라인 스트리밍). 크기 표기는 프론트 `formatBytes`.
|
||||
|
||||
### 승인 워크플로우
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
@@ -251,29 +256,34 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
|
||||
|
||||
---
|
||||
|
||||
## 6. 활동 피드 — **6단계**
|
||||
## 6. 활동 피드 — **6단계 ✅ 구현 완료**
|
||||
|
||||
활동은 **2~5단계 행위에서 이벤트로 자동 적재**(쓰기 API 없음, 읽기만).
|
||||
|
||||
| 메서드 | 경로 | 응답 |
|
||||
|---|---|---|
|
||||
| GET | `/repos/:repoId/activity` | `RepoActivityDay[]`(날짜 그룹) |
|
||||
| (업무 활동) | `TaskDetail.activities` 에 포함 | `Activity[]` |
|
||||
| GET | `/repos/:repoId/activity` | `ApiActivity[]`(최신순, 최대 200) — 날짜 그룹핑은 프론트 |
|
||||
| (업무 활동) | `TaskDetail.activities` 에 포함 | `ApiActivity[]`(해당 업무 seq) |
|
||||
|
||||
`RepoActivityItem`: `{ tone, icon, actor: User, payload, createdAt }`
|
||||
> 서버는 이벤트 타입 + 행위자 + 대상만 저장하고, **표시 HTML 문구는 프론트에서 조립**(현재 mock 은 html 통문자열 — 연동 시 구조화 권장). 매핑: RepoActivityPage, TaskDetail 활동 탭.
|
||||
`ApiActivity`(원시): `{ id, type, actor: User|null, taskSeq: number|null, payload: object, createdAt }`
|
||||
- `type`: 저장소 `repo.created`/`repo.renamed`/`repo.visibility_changed`, 멤버 `member.invited`/`member.role_changed`/`member.removed`, 업무 `task.created`/`task.status_changed`/`task.comment_added`(답글 포함)/`task.file_attached`/`task.assignees_changed`/`task.due_changed`/`task.edited`(제목·내용·체크리스트)/`task.file_removed`/`task.removed`.
|
||||
- `payload`(타입별): `taskTitle`, `statusFrom`/`statusTo`, `targetName`, `roleType`, `newName`, `visibility`, `fileName`, `assigneeNames`, `dueDate` 등.
|
||||
> 업무 수정(`update`)은 **실제로 바뀐 측면만** 골라 적재한다(담당자 변경=`assignees_changed`, 마감 변경=`due_changed`, 제목·내용·체크리스트 변경=`edited`). 답글은 댓글과 동일 `task.comment_added`. 저장소 삭제는 활동도 함께 사라져 미적재, 저장소 설명/브랜치 변경·체크리스트 개별 완료는 미적재(승인 완료로 일괄 표현).
|
||||
> **서버는 이벤트 타입 + 행위자 + 대상(payload)만 저장**하고, 표시 문구/아이콘/톤·날짜 그룹은 **프론트가 type+payload 로 조립**(구조화 채택). 사용자 입력은 `escapeHtml` 후 신뢰 태그만 삽입(v-html XSS 방지). 적재는 베스트 에포트(실패해도 본 기능 안 막음), 6단계 도입 이후 발생분만 기록(소급 없음). 매핑: RepoActivityPage(저장소 활동), TaskDetail 활동 탭(업무 활동).
|
||||
|
||||
---
|
||||
|
||||
## 7. 내 업무 (`/api/tasks`) — **7단계**
|
||||
## 7. 내 업무 (`/api/tasks`) — **7단계 ✅ 구현 완료**
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/tasks/assigned` | — | `MyTaskGroup[]`(상태 그룹) — 내가 담당 |
|
||||
| GET | `/tasks/issued` | — | `MyTaskGroup[]` — 내가 지시 |
|
||||
| GET | `/tasks/assigned` | — | `MyTaskRow[]` — 내가 담당(저장소 횡단) |
|
||||
| GET | `/tasks/issued` | — | `MyTaskRow[]` — 내가 지시(저장소 횡단) |
|
||||
|
||||
저장소 횡단 집계. `MyTaskGroup`/`MyTaskRow` 는 mock 인터페이스와 동일하되 라벨류 제외, 원시값(`dueDate`, `status`) 기반.
|
||||
> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). `countActive` 는 프론트 유지.
|
||||
- 백엔드는 **플랫 행 배열**(`MyTaskRowResponse[]`)을 마감 임박순(마감 없음 뒤로)으로 반환하고, **상태 그룹핑·순서·라벨은 프론트가 파생**(활동 피드와 동일 패턴).
|
||||
- `MyTaskRowResponse`: `{ id(seq), repoId(slug), repoName, title, status, dueDate, checklist:[done,total], assignees: User[], issuer: User|null }`.
|
||||
- 프론트 파생: 담당 뷰=지시자 표시·그룹순서 `[changes,prog,todo,review,done]`, 지시 뷰=담당자 미니아바타·승인대기 강조(`내 승인 필요`)·그룹순서 `[review,prog,changes,todo,done]`. `dueLabel`/`dday`/`ddayLevel`/`overdue` 는 `taskDueInfo` 로 파생.
|
||||
> 매핑 페이지: MyTasksPage(담당/지시 탭, 상태 그룹). 행 클릭은 항상 `…/tasks/:taskId`(TaskDetailPage, 역할 기반 분기). **`TaskAssigneePage`(`/view`) 라우트·파일 제거 완료.**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록.
|
||||
> 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조.
|
||||
> 최종 갱신: 2026-06-17 · 현재 위치: **0~4단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강·멤버 프론트 연동·멤버 초대 자동완성·import 멤버 데드락 해소·cloneUrl/htmlUrl·giteaMissing 배지·업무 CRUD/상태머신/진행률 집계), 5단계(업무 상세 부속) 대기**
|
||||
> 남은 프론트 연동: 업무 상세의 댓글/체크리스트 토글/첨부/승인 노트(5단계). 그 외 6~8단계 미착수.
|
||||
> 최종 갱신: 2026-06-18 · 현재 위치: **0~7단계 완료(모든 화면 실데이터 연동). 8단계(마무리·선택) 진행 중 — 페이지네이션 통일 작업 중.**
|
||||
> 8단계 진행: **페이지네이션** 백엔드 전체 완료 + 프론트 2/4 도메인(저장소 목록·내 업무) 완료, 멤버·저장소 업무 도메인 대기. 그 외 8단계 항목(소셜 로그인·이메일 인증·알림·AI 패널·Redis 캐싱)은 미착수.
|
||||
|
||||
---
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
```
|
||||
0. 계약 정렬 ✅ → 1. 인증 ✅ → 2. 저장소 ✅ (+ Gitea 연동 ✅) → 3. 멤버 ✅(백엔드+프론트)
|
||||
→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 → 6. 활동 피드 → 7. 내 업무 → 8. 마무리
|
||||
→ 4. 업무 ✅(백엔드+프론트) → 5. 업무 상세 부속 ✅(백엔드+프론트) → 6. 활동 피드 ✅(백엔드+프론트) → 7. 내 업무 → 8. 마무리
|
||||
```
|
||||
|
||||
---
|
||||
@@ -130,7 +130,7 @@
|
||||
- `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단계** — 수정 폼에선 표시·읽기전용(제목/내용/담당자/마감만 저장).
|
||||
- **업무 수정 UI**: `TaskCreatePage` 를 생성/수정 겸용으로 확장. 라우트 `/repos/:repoId/tasks/:taskId/edit`(`task-edit`) 추가 → `taskId` 있으면 편집 모드(기존 값 미리 채움 + `useTask().get` 로 원시값 로드 + 제출 시 PATCH `taskStore.update`). 헤더/버튼 라벨 전환(업무 수정/저장). 상세·담당자 페이지의 ‘수정’ 버튼을 `…/edit` 로 연결(기존엔 빈 생성 폼으로 오연결). (※ 수정 폼의 체크리스트 항목 추가/삭제는 2026-06-18 에 지원 — `UpdateTaskDto.checklist` 동기화. 제목/내용/담당자/마감과 함께 저장.)
|
||||
- 댓글/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% 완료’) 제거(업무 수·바는 유지).
|
||||
@@ -151,34 +151,87 @@
|
||||
- `CreateRepoDto.useTemplate=true` 면 빈 저장소 대신 복제 생성. 프론트 생성폼 템플릿 드롭다운=실제 옵션(없음 / `TtiPo/project-base`).
|
||||
- 라이브 검증: 생성된 repo 의 파일 트리가 `project-base` 와 동일하게 복제됨 확인.
|
||||
|
||||
### 5단계 — 업무 상세 부속 ✅ (백엔드 + 프론트)
|
||||
|
||||
**백엔드** `backend/src/modules/task/`
|
||||
- 신규 엔티티: `Comment`(답글은 `parent` 자기참조로 1단계 깊이, `attachment` 선택 연결, author/parent `Relation<>`), `Attachment`(원본명·`storedName`·size·mime·kind, uploader SET NULL). `Task` 에 승인 워크플로우 필드 추가(`approvalNote`/`approvalRequestedBy`/`approvalRequestedAt`/`changesNote`).
|
||||
- **체크리스트**: **상세 화면 읽기 전용**(완료 토글은 사용자가 하지 않음 — 승인 완료 시 일괄 처리, 4단계 부수효과). **항목 추가/삭제(정의)는 업무 생성·수정 폼에서** 한다: 생성=`CreateTaskDto.checklist`, 수정=`UpdateTaskDto.checklist`(id 기준 전체 동기화 — 기존 유지·완료 보존, 신규 추가, 빠진 항목 삭제). — *2026-06-18: 5단계 초기의 항목별 토글 API 제거 → 수정 PATCH 로 통합*
|
||||
- **댓글/답글**: `POST .../comments`(`text`/`fileId?`), `POST .../comments/:commentId/replies` — 멤버. 답글은 최상위 댓글에만.
|
||||
- **파일 첨부**: `POST .../files`(multipart, `FileInterceptor` + `dest`), `DELETE .../files/:fileId`, `GET .../files/:fileId/download`(`@Res` 직접 스트리밍, `Content-Disposition` UTF-8) — 멤버. 비ASCII 파일명 latin1→UTF-8 복원, MIME/확장자로 `kind` 파생.
|
||||
- **승인 워크플로우**: `POST .../approval/request`(prog/changes→review, note 선택), `/approve`(review→done), `/changes`(review→changes, note 필수). 4단계 상태머신 전이 로직을 `transition()` 으로 추출해 `POST .../status` 와 공유(역할 검증 동일). 승인 정보는 review 상태일 때만 `approval{requester,requestedAt,note}` 로 노출, changes 상태는 `changesNote`.
|
||||
- `TaskDetailResponse` 에 `comments`/`files`/`approval`/`changesNote` 실데이터 채움. 목록 `commentCount` 실집계. `activities` 는 6단계라 빈 배열 유지.
|
||||
- **파일 저장 방식(결정됨)**: **서버 로컬 업로드 폴더**(`upload.config.ts` `UPLOAD_DIR`, 기본 `cwd/uploads`). `@types/multer` 의존 없이 `UploadedDiskFile` 인터페이스 자체 정의. 운영 Dockerfile 이 `/app/uploads` 를 `node` 소유로 생성(비-root 쓰기 가능). dev 는 `./backend:/app` 바인드라 `backend/uploads`(gitignore)로 영속, 운영 영속이 필요하면 해당 경로에 볼륨 마운트(추후). S3/MinIO 는 후순위.
|
||||
- 테스트 `task.service.spec.ts` 보강(승인 요청/수정요청 역할, 체크리스트 비멤버 차단). 백엔드 **21 tests pass**, build/lint 0 err.
|
||||
|
||||
**프론트** `frontend/src/`
|
||||
- `shared/utils/format.ts`: `formatBytes`(바이트→"18.4 MB"). `types/task.ts`: `ApiAttachment`/`ApiComment`/`ApiCommentReply`/`ApiApproval` + 부속 페이로드. `composables/useTask.ts`: 체크리스트/댓글/답글/파일(FormData)/승인 메서드. `stores/task.store.ts`: 댓글·첨부·승인 뷰 매핑(작성자 시간·역할배지·바이트 변환), 부속 변경 후 상세 재조회로 일관 갱신.
|
||||
- mock 뷰 타입(`ChecklistItem`/`Attachment`/`Comment`/`Reply`/`TaskDetail`)에 옵셔널 `id`/`url`/`changesNote` 추가(연동용, 기존 화면 호환).
|
||||
- **TaskDetailPage**: 체크리스트는 읽기 전용(표시만), 댓글/답글 실연동(로컬 데모 제거), 첨부 카드 항상 표시+업로드/다운로드 링크/삭제, 승인 요청 메모(prog/changes 카드 textarea)·수정 요청 메모(모달 textarea) 전송, 지시자 승인 카드에 요청 메모 노출·담당자 수정요청 카드에 보완 메모 노출. type-check/lint/build 0 err.
|
||||
|
||||
> **한계**: 댓글에 파일 첨부(`fileId`)는 백엔드만 지원(상세 화면 UI 미노출). 업무 삭제 시 디스크 파일은 베스트에포트 정리(DB 행은 FK CASCADE). AI 에이전트 패널은 여전히 로컬 데모(8단계).
|
||||
|
||||
### 6단계 — 활동 피드 ✅ (백엔드 + 프론트)
|
||||
|
||||
**백엔드** `backend/src/modules/activity/`
|
||||
- `Activity` 엔티티: `repo`(FK, CASCADE)·`taskSeq`(int nullable, 업무 활동 필터용 — Task FK 대신 seq 저장)·`actor`(User SET NULL)·`type`·`payload`(jsonb)·`createdAt`. 표시 문구/아이콘/톤은 저장하지 않고 **프론트가 type+payload 로 조립**(구조화 채택).
|
||||
- `ActivityService`: `record()`(베스트 에포트 적재 — 실패해도 본 흐름 안 막음), `listForRepo(slug)`(최신순 200건), `listForTask(repoId, seq)`. `ActivityController`: `GET /repos/:repoId/activity`(인증, 조직 모델상 누구나 열람).
|
||||
- **이벤트 자동 적재**(다른 서비스가 `ActivityService.record` 호출): RepoService(생성/이름변경/공개범위), MemberService(초대/역할변경/제거), TaskService(생성·상태전이[`transition` 단일 지점]·댓글/답글·파일 첨부/삭제·업무 삭제·**업무 수정[변경 측면별: 담당자/마감/일반]**). 상태전이는 `task.status_changed` 단일 타입 + `payload{statusFrom,statusTo}` 로 적재하고 프론트가 from→to 로 문구 파생(시작/승인요청/승인/수정요청). 업무 수정(`update`)은 이전 상태와 비교해 **실제 바뀐 측면만** 적재(`task.assignees_changed`/`task.due_changed`/`task.edited`). `ActivityModule` 을 RepoModule·TaskModule 이 import(Activity 는 taskSeq 만 보관해 Task 엔티티 미참조 → 순환 없음).
|
||||
- `TaskDetailResponse.activities` 를 `ActivityService.listForTask` 로 채움(기존 빈 배열 제거).
|
||||
|
||||
**프론트** `frontend/src/`
|
||||
- `format.ts`: `dayGroupKo`(오늘/어제/이번 주/날짜), `escapeHtml`(XSS 방지). `types/activity.ts`(`ApiActivity`), `composables/useActivity.ts`, `stores/activity.store.ts`(type+payload→톤/아이콘/문구 조립 + 날짜 그룹핑). **사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 후 신뢰 태그만 조립**해 v-html 렌더.
|
||||
- **RepoActivityPage**: mock(`REPO_ACTIVITY`/`findRepo`) 제거 → 실연동(저장소 헤더=repo.store, 활동=activity.store, tone 필터, 로딩/빈 상태).
|
||||
- **TaskDetailPage 활동 탭**: `task.store` 의 `toTaskActivityView` 로 API 활동을 시안 Activity 뷰(tone/icon/html/statusFrom·To/file)로 매핑 → 실데이터 표시.
|
||||
- 검증: 프론트 type-check/lint/build 0 err, 백엔드 build/lint 0 err·20 tests pass.
|
||||
|
||||
> **한계**: 활동은 **6단계 도입 이후 발생분만** 기록(소급 적재 없음). 활동 문구 내 업무 링크는 표시(`.tgt`)만 하고 클릭 라우팅은 생략. 체크리스트 개별 완료는 별도 이벤트 없이 승인 완료(`status_changed`→done)로 일괄 표현. 저장소 설명/브랜치 변경은 미적재.
|
||||
> *(2026-06-18 점검·보강: 업무 수정[담당자/마감/일반]·답글·업무 삭제·첨부 삭제 이벤트 추가.)*
|
||||
|
||||
---
|
||||
|
||||
### 7단계 — 내 업무 집계 ✅ (백엔드 + 프론트)
|
||||
- 백엔드 `MyTaskController` `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단, `TaskService.listAssigned/listIssued`, 마감 임박순). 플랫 행 배열 반환, 그룹핑·라벨은 프론트 파생.
|
||||
- 프론트 `format.ts` `taskDueInfo`에 `ddayLevel` 추가, `types/my-task.ts`/`composables/useMyTask.ts`/`stores/my-task.store.ts`(담당/지시 뷰별 그룹 순서·라벨·미니아바타·승인대기 강조). **MyTasksPage** mock 제거→실연동(로딩/빈상태).
|
||||
- **`TaskAssigneePage`(`/view`)·`task-assignee-view` 라우트·파일 제거 완료**(상세가 역할 기반 통합). 행 클릭은 항상 `…/tasks/:taskId`.
|
||||
- 라이브 검증: `/tasks/assigned`·`/tasks/issued` 200·정상.
|
||||
|
||||
---
|
||||
|
||||
## 3. 남은 단계
|
||||
|
||||
### 5단계 — 업무 상세 부속
|
||||
- 체크리스트 **개별 항목 토글/추가/삭제 영속**(현재 항목은 읽기 전용, 단 *승인 완료 시 일괄 완료*는 4단계에서 구현), 댓글/답글, **파일 첨부**, 승인 노트(승인 요청/수정 요청 시 메시지).
|
||||
- 승인 워크플로우 전용 엔드포인트(`approval/request|approve|changes`)는 상태 머신(4단계 `POST .../status`)에 노트·첨부를 더하는 형태로 확장.
|
||||
- **결정 필요**: 파일 저장소(로컬 볼륨 vs S3/MinIO).
|
||||
### 8단계 — 마무리(선택) · **진행 중**
|
||||
|
||||
### 6단계 — 활동 피드
|
||||
- 2~5단계 행위를 이벤트로 적재(쓰기 API 없음, 읽기만). `GET /repos/:repoId/activity`.
|
||||
- 프론트: **RepoActivityPage**, TaskDetail 활동 탭.
|
||||
- **결정 필요**: 활동 표현 — 현재 mock은 HTML 통문자열 → 구조화(타입+행위자+대상) 권장.
|
||||
#### 8-1. 페이지네이션 통일 — 백엔드 ✅ / 프론트 2/4 도메인
|
||||
|
||||
### 7단계 — 내 업무 집계
|
||||
- `GET /tasks/assigned`·`/tasks/issued`(저장소 횡단 집계). 프론트: **MyTasksPage**(담당/지시 탭).
|
||||
- **정리 대상**: 현재 mock `MyTasksPage` 가 유일하게 `…/tasks/:taskId/view`(`TaskAssigneePage`)로 링크 중. 4단계에서 상세(`TaskDetailPage`)가 역할 기반으로 통합됐으므로, 이 단계에서 MyTasks 를 실데이터로 연동하며 `/view` 링크를 `…/tasks/:taskId` 로 바꾸고 `TaskAssigneePage`/`task-assignee-view` 라우트를 제거한다.
|
||||
**결정(2026-06-18)**: 전체 목록 통일 + 프론트는 **더보기 버튼** 방식(시안에 페이지 UI 없음).
|
||||
|
||||
### 8단계 — 마무리(선택)
|
||||
- 소셜 로그인(Google/Kakao), 이메일 인증, 알림, AI 에이전트 패널, Redis 캐싱, 전체 페이지네이션 통일.
|
||||
**백엔드 ✅ (완료)**
|
||||
- 공통 유틸 `backend/src/common/pagination/pagination.ts`: `resolvePage(page,size)`(범위 보정 + skip/take), `paginated(items,total,pg)`, `PaginatedResult<T>{items,total,page,size}`. 기본 size 20, 최대 100.
|
||||
- 컨트롤러는 `@Query('page', DefaultValuePipe(1), ParseIntPipe)` / `size` 로 받아 서비스에 전달.
|
||||
- 적용: `GET /repos`(RepoService.findAll), `GET /repos/:id/members`(MemberService.list), `GET /tasks/assigned`·`/tasks/issued`(TaskService.listAssigned/listIssued). 응답이 배열→`{items,total,page,size}` 로 변경.
|
||||
- `GET /repos/:id/tasks`(TaskService.list)는 **세그먼트(all/prog/todo/done)·검색(q)·페이지네이션 + 세그먼트 카운트** 동봉(`RepoTaskListResult = PaginatedResult<RepoTaskResponse> & { counts }`). 세그먼트 prog=prog+review, changes는 전체에만 집계(기존 RepoDetailPage 동작 보존). 기존 `?status` 쿼리는 `?segment` 로 대체.
|
||||
- 검증: 백엔드 build/lint 0 err, 20 tests pass(member spec 의 list 응답 `{items,total}` 으로 갱신).
|
||||
|
||||
**프론트 — 2/4 도메인 ✅**
|
||||
- 공통 타입 `types/pagination.ts`(`Paginated<T>`, `DEFAULT_PAGE_SIZE=20`), 전역 `.load-more` 버튼 스타일(relay.css).
|
||||
- ✅ **저장소 목록**: `useRepo.list(page,size)`→`Paginated`, `repo.store`(total/page/hasMore/loadingMore + `loadMore` append), RepoListPage 하단 더보기 + total 카운트. 검색/공개여부 필터는 클라 유지(저장소 소규모).
|
||||
- ✅ **내 업무**: `useMyTask.assigned/issued(page,size)`, `my-task.store`(원시 행 누적 + 그룹핑은 computed 로 파생 → 더보기 시 같은 그룹에 합류, 담당/지시 각각 total/page/hasMore + `loadMoreAssigned`/`loadMoreIssued`), MyTasksPage 뷰별 더보기.
|
||||
- 검증: 프론트 type-check/lint/build 0 err.
|
||||
|
||||
**프론트 — 남은 2/4 도메인 ⏳**
|
||||
- ⏳ **멤버**(RepoMembersPage): 더보기 페이지네이션. 단 **TaskCreatePage 가 담당자 후보로 전체 멤버를 사용**하므로, 거기엔 전체 로드(모든 페이지 순회 `fetchAll`)를 별도 보장해야 함.
|
||||
- ⏳ **저장소 업무**(RepoDetailPage): 현재 세그먼트/검색이 **클라이언트 필터** → 백엔드(`segment`/`q`)로 이전 + 세그먼트 카운트(`counts`) 연동 + 더보기. `useTask.list`·`task.store.fetchList` 응답 형태 변경 반영. (가장 복잡)
|
||||
- ⚠️ **전환기 주의**: 백엔드 응답이 이미 `{items,...}` 로 바뀌어, 프론트 연동 전까지 **멤버·저장소 상세 화면은 일시적으로 동작이 어긋날 수 있음**(다음 작업에서 해소).
|
||||
|
||||
#### 8-2. 그 외(미착수)
|
||||
- 소셜 로그인(Google/Kakao OAuth), 이메일 인증, 알림, AI 에이전트 패널 실연동, Redis 캐싱.
|
||||
|
||||
---
|
||||
|
||||
## 4. 전환기 한계 (현재 시점)
|
||||
|
||||
- **RepoActivityPage** 는 아직 mock `findRepo` 사용(6단계에서 해소). (RepoMembersPage 는 3단계, RepoDetail 업무 목록은 4단계에서 프론트 연동 완료)
|
||||
- **업무 상세의 댓글·체크리스트 토글·첨부·승인 노트**는 5단계 영역 — 현재 댓글/에이전트는 로컬 데모, 첨부/승인노트 UI 는 자리만 표시. 진행률은 4단계 집계로 실데이터.
|
||||
- `src/mock/relay.mock.ts` 는 아직 타입 정의(`Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail` 등) + 미연동 화면(활동/내업무) 데이터 공급원으로 사용 중. 해당 도메인이 연동되면 데이터는 제거하고 타입만 `src/types/` 로 이전.
|
||||
- **모든 화면이 실데이터로 연동 완료.** 남은 미연동 요소는 **AI 에이전트 패널(로컬 데모, 8단계)** 뿐.
|
||||
- `src/mock/relay.mock.ts` 는 이제 **타입 정의 공급원으로만** 사용(여러 store/page 가 `Repo`/`User`/`TaskStatus`/`RepoTask`/`TaskDetail`/`Activity`/`MyTaskGroup` 등 뷰 타입을 import). 정적 데이터(`REPO_ACTIVITY`/`MY_TASKS_*`/`REPOS`/`USERS` 등)는 사용처가 없어졌다 — 추후 타입만 `src/types/` 로 이전하고 데이터는 제거 가능(현재는 유지).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -596,3 +596,28 @@ body {
|
||||
.modal-textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 더보기 버튼 (목록 페이지네이션 공통)
|
||||
* ============================================================ */
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 1.25rem auto 0;
|
||||
padding: 0.5rem 1.375rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.load-more:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
}
|
||||
.load-more:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
|
||||
// 활동 피드 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useActivity() {
|
||||
const api = useApi()
|
||||
|
||||
// 저장소 활동 피드(최신순)
|
||||
async function list(repoId: string): Promise<ApiActivity[]> {
|
||||
return (await api.get(`/repos/${repoId}/activity`)) as unknown as ApiActivity[]
|
||||
}
|
||||
|
||||
return { list }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiMyTaskRow } from '@/types/my-task'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 내 업무 API Composable — 저장소 횡단 집계(담당/지시), 오프셋 페이지네이션
|
||||
export function useMyTask() {
|
||||
const api = useApi()
|
||||
|
||||
// 내가 담당인 업무
|
||||
async function assigned(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
|
||||
return (await api.get('/tasks/assigned', {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiMyTaskRow>
|
||||
}
|
||||
|
||||
// 내가 지시한 업무
|
||||
async function issued(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
|
||||
return (await api.get('/tasks/issued', {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiMyTaskRow>
|
||||
}
|
||||
|
||||
return { assigned, issued }
|
||||
}
|
||||
@@ -5,13 +5,16 @@ import type {
|
||||
RepoCreateMeta,
|
||||
UpdateRepoPayload,
|
||||
} from '@/types/repo'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(): Promise<ApiRepo[]> {
|
||||
return (await api.get('/repos')) as unknown as ApiRepo[]
|
||||
async function list(page = 1, size = 20): Promise<Paginated<ApiRepo>> {
|
||||
return (await api.get('/repos', {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiRepo>
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiAttachment,
|
||||
ApiComment,
|
||||
ApiCommentReply,
|
||||
ApiRepoTask,
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
@@ -58,5 +61,98 @@ export function useTask() {
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove, changeStatus }
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
async function addComment(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
text: string,
|
||||
fileId?: string,
|
||||
): Promise<ApiComment> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/comments`, {
|
||||
text,
|
||||
...(fileId ? { fileId } : {}),
|
||||
})) as unknown as ApiComment
|
||||
}
|
||||
|
||||
async function addReply(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
commentId: string,
|
||||
text: string,
|
||||
): Promise<ApiCommentReply> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/tasks/${taskId}/comments/${commentId}/replies`,
|
||||
{ text },
|
||||
)) as unknown as ApiCommentReply
|
||||
}
|
||||
|
||||
/* ===================== 파일 첨부 (5단계) ===================== */
|
||||
|
||||
async function uploadFile(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
file: File,
|
||||
): Promise<ApiAttachment> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/files`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as unknown as ApiAttachment
|
||||
}
|
||||
|
||||
async function removeFile(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
await api.delete(`/repos/${repoId}/tasks/${taskId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
/* ===================== 승인 워크플로우 (5단계) ===================== */
|
||||
|
||||
async function requestApproval(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
note?: string,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/request`, {
|
||||
...(note ? { note } : {}),
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
async function approve(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/tasks/${taskId}/approval/approve`,
|
||||
)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
async function requestChanges(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
note: string,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/changes`, {
|
||||
note,
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
changeStatus,
|
||||
addComment,
|
||||
addReply,
|
||||
uploadFile,
|
||||
removeFile,
|
||||
requestApproval,
|
||||
approve,
|
||||
requestChanges,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,20 @@ export interface User {
|
||||
|
||||
/** 첨부파일 */
|
||||
export interface Attachment {
|
||||
/** 첨부 ID(API 연동 시) */
|
||||
id?: string
|
||||
name: string
|
||||
size: string
|
||||
/** 파일 아이콘 종류 */
|
||||
kind: 'pdf' | 'zip' | 'doc' | 'img'
|
||||
/** 다운로드 URL(API 연동 시) */
|
||||
url?: string
|
||||
}
|
||||
|
||||
/** 체크리스트 항목 */
|
||||
export interface ChecklistItem {
|
||||
/** 항목 ID(API 연동 시) */
|
||||
id?: string
|
||||
text: string
|
||||
done: boolean
|
||||
}
|
||||
@@ -76,11 +82,15 @@ export interface RepoTask {
|
||||
checklist?: [number, number]
|
||||
/** 코멘트 수 */
|
||||
comments?: number
|
||||
/** 첨부 파일 수 */
|
||||
files?: number
|
||||
assignees: User[]
|
||||
}
|
||||
|
||||
/** 댓글의 답글 */
|
||||
export interface Reply {
|
||||
/** 답글 ID(API 연동 시) */
|
||||
id?: string
|
||||
author: User
|
||||
time: string
|
||||
text: string
|
||||
@@ -88,6 +98,8 @@ export interface Reply {
|
||||
|
||||
/** 댓글 */
|
||||
export interface Comment {
|
||||
/** 댓글 ID(API 연동 시) */
|
||||
id?: string
|
||||
author: User
|
||||
/** 작성자 역할 배지(예: 지시자) */
|
||||
roleBadge?: string
|
||||
@@ -135,6 +147,8 @@ export interface TaskDetail {
|
||||
requestedAgo: string
|
||||
note: string
|
||||
}
|
||||
/** 수정 요청 메시지(수정 요청 상태일 때) */
|
||||
changesNote?: string
|
||||
issuedLabel: string
|
||||
}
|
||||
|
||||
@@ -463,6 +477,8 @@ export interface MyTaskRow {
|
||||
title: string
|
||||
status: TaskStatus
|
||||
repoName: string
|
||||
/** 라우팅용 저장소 식별자(slug) */
|
||||
repoId?: string
|
||||
/** 담당자 미니 아바타(지시 뷰) */
|
||||
assignees?: User[]
|
||||
/** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록
|
||||
import { computed, ref } from 'vue'
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록 (API 연동)
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { MY_TASKS_ASSIGNED, MY_TASKS_ISSUED, countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
import { useMyTaskStore } from '@/stores/my-task.store'
|
||||
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
|
||||
type View = 'assigned' | 'issued'
|
||||
const view = ref<View>('assigned')
|
||||
|
||||
const REPO = 'q3-launch-campaign'
|
||||
const myTaskStore = useMyTaskStore()
|
||||
onMounted(() => {
|
||||
void myTaskStore.fetchAll()
|
||||
})
|
||||
|
||||
// 행 클릭 시 이동 경로
|
||||
// - 담당 뷰: 담당자 관점 업무 상세(상태별 분기)
|
||||
// - 지시 뷰: 지시자 관점 업무 상세(TaskDetailPage)
|
||||
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
|
||||
function taskLink(row: MyTaskRow): string {
|
||||
if (view.value === 'issued') return `/repos/${REPO}/tasks/${row.id}`
|
||||
const state = row.status === 'changes' ? 'changes' : row.status === 'done' ? 'approved' : 'request'
|
||||
return `/repos/${REPO}/tasks/${row.id}/view?state=${state}`
|
||||
return `/repos/${row.repoId}/tasks/${row.id}`
|
||||
}
|
||||
|
||||
const groups = computed(() => (view.value === 'assigned' ? MY_TASKS_ASSIGNED : MY_TASKS_ISSUED))
|
||||
const groups = computed(() => (view.value === 'assigned' ? myTaskStore.assigned : myTaskStore.issued))
|
||||
|
||||
const assignedCount = countActive(MY_TASKS_ASSIGNED)
|
||||
const issuedCount = countActive(MY_TASKS_ISSUED)
|
||||
const assignedCount = computed(() => countActive(myTaskStore.assigned))
|
||||
const issuedCount = computed(() => countActive(myTaskStore.issued))
|
||||
// 지시 뷰의 승인 대기(내 승인 필요) 건수
|
||||
const issuedReviewCount = computed(
|
||||
() => MY_TASKS_ISSUED.find((g) => g.key === 'review')?.rows.length ?? 0,
|
||||
() => myTaskStore.issued.find((g) => g.key === 'review')?.rows.length ?? 0,
|
||||
)
|
||||
// 승인 대기 강조 배너는 지시 뷰에서 표시
|
||||
const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCount.value > 0)
|
||||
|
||||
const loading = computed(() => myTaskStore.loading)
|
||||
const isEmpty = computed(() => !loading.value && groups.value.length === 0)
|
||||
|
||||
// 더보기 — 현재 뷰(담당/지시) 기준
|
||||
const hasMore = computed(() =>
|
||||
view.value === 'assigned' ? myTaskStore.assignedHasMore : myTaskStore.issuedHasMore,
|
||||
)
|
||||
const loadingMore = computed(() => myTaskStore.loadingMore)
|
||||
function loadMore() {
|
||||
if (view.value === 'assigned') void myTaskStore.loadMoreAssigned()
|
||||
else void myTaskStore.loadMoreIssued()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,22 +50,6 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
<div class="pagehead">
|
||||
<h1>내 업무</h1>
|
||||
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
|
||||
<RouterLink
|
||||
class="new-btn"
|
||||
to="/repos/q3-launch-campaign/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>
|
||||
</div>
|
||||
|
||||
<!-- 담당 / 지시 토글 -->
|
||||
@@ -162,6 +159,20 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
<span><b>{{ issuedReviewCount }}건</b>의 업무가 내 승인을 기다리고 있어요. 검토 후 승인하거나 수정을 요청하세요.</span>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="state-msg"
|
||||
>
|
||||
내 업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="state-msg"
|
||||
>
|
||||
{{ view === 'assigned' ? '담당 중인 업무가 없습니다.' : '지시한 업무가 없습니다.' }}
|
||||
</div>
|
||||
|
||||
<!-- 상태 그룹 -->
|
||||
<div
|
||||
v-for="group in groups"
|
||||
@@ -332,6 +343,17 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -355,31 +377,6 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
padding-bottom: 0.125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn {
|
||||
margin-left: auto;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.9375rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.new-btn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 담당/지시 토글 */
|
||||
.viewtabs {
|
||||
@@ -396,6 +393,7 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
margin-right: 1.125rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--text-2);
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -405,6 +403,9 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.viewtab .tb-label {
|
||||
line-height: 1;
|
||||
}
|
||||
.viewtab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -489,6 +490,18 @@ const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCo
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.state-msg {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 상태 그룹 */
|
||||
.group {
|
||||
margin-bottom: 1.375rem;
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 활동 — 날짜 그룹 단위 활동 피드
|
||||
import { computed, defineComponent, h, ref } from 'vue'
|
||||
// 저장소 활동 — 날짜 그룹 단위 활동 피드 (API 연동)
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import { REPO_ACTIVITY, findRepo, findRepoMembers, type ActivityIcon } from '@/mock/relay.mock'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Repo, ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
const headerMembers = computed(() => findRepoMembers(repoId.value).map((m) => m.user))
|
||||
const repoStore = useRepoStore()
|
||||
const activityStore = useActivityStore()
|
||||
|
||||
// 저장소 헤더 + 활동 피드 로딩
|
||||
const repo = ref<Repo | null>(null)
|
||||
async function load() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
try {
|
||||
await activityStore.fetch(repoId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, load, { immediate: true })
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
// 필터 적용 후 빈 날짜 그룹은 제거
|
||||
const days = computed(() =>
|
||||
REPO_ACTIVITY.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
})).filter((d) => d.items.length > 0),
|
||||
activityStore.days
|
||||
.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
}))
|
||||
.filter((d) => d.items.length > 0),
|
||||
)
|
||||
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
const loading = computed(() => activityStore.loading)
|
||||
const isEmpty = computed(() => !loading.value && days.value.length === 0)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
@@ -77,15 +103,13 @@ const ActIcon = defineComponent({
|
||||
|
||||
<RepoHeader
|
||||
:repo="repo"
|
||||
meta-text="2시간 전 업데이트"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
:meta-text="repo.updatedAgo"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
active="activity"
|
||||
:task-count="12"
|
||||
:member-count="headerMembers.length"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
/>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -145,10 +169,27 @@ const ActIcon = defineComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="feed-state"
|
||||
>
|
||||
활동을 불러오는 중입니다…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="feed-state"
|
||||
>
|
||||
아직 기록된 활동이 없습니다.
|
||||
</div>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<!-- act-text 의 v-html 은 내부에서 생성한 신뢰 가능한 활동 마크업(목업)만 렌더한다 (XSS 위험 없음) -->
|
||||
<!-- act-text 의 v-html 은 store 에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="feed">
|
||||
<div
|
||||
v-else
|
||||
class="feed"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
@@ -351,4 +392,16 @@ const ActIcon = defineComponent({
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.feed-state {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -334,10 +334,26 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
{{ task.comments }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.files"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
{{ task.files }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status === 'done'"
|
||||
class="mi"
|
||||
|
||||
@@ -13,7 +13,7 @@ const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading, owner } = storeToRefs(repoStore)
|
||||
const { repos, loading, owner, total, hasMore, loadingMore } = storeToRefs(repoStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
@@ -45,7 +45,7 @@ const filteredRepos = computed(() =>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<span class="count-pill">{{ repos.length }}</span>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
<span
|
||||
v-if="giteaConnected"
|
||||
class="gitea-badge"
|
||||
@@ -316,6 +316,17 @@ const filteredRepos = computed(() =>
|
||||
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="repoStore.loadMore()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,8 @@ async function loadTaskForEdit() {
|
||||
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 }))
|
||||
// id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지)
|
||||
checklist.value = t.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done }))
|
||||
} catch {
|
||||
// 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -170,12 +171,15 @@ async function submitTask() {
|
||||
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,
|
||||
checklist: checklist.value.map((c) =>
|
||||
c.id ? { id: c.id, text: c.text } : { text: c.text },
|
||||
),
|
||||
})
|
||||
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
|
||||
} else {
|
||||
@@ -399,7 +403,6 @@ function goBack() {
|
||||
</span>
|
||||
<span class="txt">{{ item.text }}</span>
|
||||
<span
|
||||
v-if="!isEdit"
|
||||
class="row-del"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
@@ -414,10 +417,7 @@ function goBack() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isEdit"
|
||||
class="add-row"
|
||||
>
|
||||
<div class="add-row">
|
||||
<span class="plus">+</span>
|
||||
<input
|
||||
v-model="newItem"
|
||||
@@ -426,12 +426,6 @@ function goBack() {
|
||||
@keydown.enter="addItem"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="checklist-note"
|
||||
>
|
||||
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -846,11 +840,6 @@ function goBack() {
|
||||
.add-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.checklist-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.side {
|
||||
|
||||
@@ -17,14 +17,13 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import {
|
||||
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'
|
||||
import { attachmentKindOf, avatarColor, fileBadge, initialOf } from '@/shared/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -115,29 +114,34 @@ async function confirmDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
|
||||
// 승인(review→done) — 지시자 액션. 승인 워크플로우 엔드포인트 사용
|
||||
async function approveTask() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
|
||||
task.value = await taskStore.approve(repoId.value, task.value.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showApprove.value = false
|
||||
}
|
||||
}
|
||||
async function requestChanges() {
|
||||
// 수정 요청(review→changes) — 지시자 액션. 보완 메시지 필수
|
||||
const changesNote = ref('')
|
||||
async function submitChanges() {
|
||||
if (!task.value) return
|
||||
const note = changesNote.value.trim()
|
||||
if (!note) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
|
||||
task.value = await taskStore.requestChanges(repoId.value, task.value.id, note)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showChanges.value = false
|
||||
changesNote.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
|
||||
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog/changes→review)
|
||||
async function changeTo(status: TaskStatus) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
@@ -149,8 +153,21 @@ async function changeTo(status: TaskStatus) {
|
||||
function startTask() {
|
||||
void changeTo('prog')
|
||||
}
|
||||
function requestApproval() {
|
||||
void changeTo('review')
|
||||
// 승인 요청(메시지 선택) — 승인 워크플로우 엔드포인트로 노트와 함께 전송
|
||||
const approvalNote = ref('')
|
||||
async function sendApproval() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.requestApproval(
|
||||
repoId.value,
|
||||
task.value.id,
|
||||
approvalNote.value.trim() || undefined,
|
||||
)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
approvalNote.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -158,8 +175,8 @@ function requestApproval() {
|
||||
* ============================================================ */
|
||||
const activeTab = ref<'comments' | 'activity'>('comments')
|
||||
|
||||
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
|
||||
const comments = reactive<Comment[]>([])
|
||||
// 댓글 — 상세(API)에서 받아온 목록을 사용. 작성/답글은 store 를 통해 영속
|
||||
const comments = computed<Comment[]>(() => task.value?.comments ?? [])
|
||||
const replyOpen = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
const commentText = ref('')
|
||||
@@ -201,26 +218,68 @@ function toggleReply(index: number) {
|
||||
replyOpen.value = replyOpen.value === index ? null : index
|
||||
replyText.value = ''
|
||||
}
|
||||
function submitReply(index: number) {
|
||||
async function submitReply(index: number) {
|
||||
const text = replyText.value.trim()
|
||||
const target = comments[index]
|
||||
if (!text || !target) return
|
||||
const reply: Reply = { author: me.value, time: '방금', text }
|
||||
target.replies.push(reply)
|
||||
const target = comments.value[index]
|
||||
if (!text || !target?.id || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addReply(repoId.value, task.value.id, target.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
replyText.value = ''
|
||||
replyOpen.value = null
|
||||
}
|
||||
function submitComment() {
|
||||
async function submitComment() {
|
||||
const text = commentText.value.trim()
|
||||
if (!text) return
|
||||
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
|
||||
if (!text || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addComment(repoId.value, task.value.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
commentText.value = ''
|
||||
}
|
||||
|
||||
const commentTotal = computed(
|
||||
() => comments.length + comments.reduce((sum, c) => sum + c.replies.length, 0),
|
||||
() => comments.value.length + comments.value.reduce((sum, c) => sum + c.replies.length, 0),
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* 파일 첨부(5단계) — 업로드/삭제
|
||||
* ============================================================ */
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !task.value) {
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
task.value = await taskStore.uploadFile(repoId.value, task.value.id, file)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
async function removeFile(att: Attachment) {
|
||||
if (!task.value || !att.id) return
|
||||
try {
|
||||
task.value = await taskStore.removeFile(repoId.value, task.value.id, att.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 키보드 / 외부 클릭 처리
|
||||
* ============================================================ */
|
||||
@@ -435,12 +494,9 @@ function quickChip(cmd: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 아이콘 클래스/라벨 (유한 키 Record — 인덱스 접근이 undefined 로 넓어지지 않음)
|
||||
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
img: { cls: 'fi-img', label: 'IMG' },
|
||||
zip: { cls: 'fi-zip', label: 'ZIP' },
|
||||
pdf: { cls: 'fi-img', label: 'PDF' },
|
||||
doc: { cls: 'fi-img', label: 'DOC' },
|
||||
// 파일명(확장자) → 종류 배지(라벨/색상). 활동·댓글은 파일명만 있으므로 종류를 파생
|
||||
function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
return fileBadge(attachmentKindOf(name ?? ''))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -653,10 +709,14 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<span class="progress-txt">{{ checklistDone }} / {{ task.checklist.length }} 완료</span>
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
체크리스트는 읽기 전용 — 사용자가 개별 토글/추가/삭제하지 않는다.
|
||||
완료 처리는 승인 워크플로우(승인 완료 시 일괄 완료)로만 이루어진다.
|
||||
-->
|
||||
<div class="checklist">
|
||||
<div
|
||||
v-for="(item, i) in task.checklist"
|
||||
:key="i"
|
||||
:key="item.id ?? i"
|
||||
class="citem"
|
||||
:class="{ done: item.done }"
|
||||
>
|
||||
@@ -729,7 +789,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
v-if="c.file"
|
||||
class="c-file"
|
||||
>
|
||||
<span class="fi">IMG</span>{{ c.file }}
|
||||
<span
|
||||
class="fi"
|
||||
:class="badgeFor(c.file).cls"
|
||||
>{{ badgeFor(c.file).label }}</span>{{ c.file }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -780,8 +843,8 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
>
|
||||
<span
|
||||
class="c-av"
|
||||
style="background: #0ea5a3"
|
||||
>정</span>
|
||||
:class="me.color"
|
||||
>{{ me.initial }}</span>
|
||||
<div class="field">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
@@ -858,7 +921,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
v-if="a.file"
|
||||
class="act-file"
|
||||
>
|
||||
<span class="fi">IMG</span>{{ a.file }}
|
||||
<span
|
||||
class="fi"
|
||||
:class="badgeFor(a.file).cls"
|
||||
>{{ badgeFor(a.file).label }}</span>{{ a.file }}
|
||||
</div>
|
||||
<div
|
||||
v-if="a.statusFrom"
|
||||
@@ -910,7 +976,18 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<span class="ap-card-title">승인 요청 도착</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
담당자가 작업 완료 후 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
<template v-if="task.approval">
|
||||
<b>{{ task.approval.requester }}</b>님이 {{ task.approval.requestedAgo }} 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
</template>
|
||||
<template v-else>
|
||||
담당자가 작업 완료 후 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="task.approval && task.approval.note"
|
||||
class="ap-note-box"
|
||||
>
|
||||
{{ task.approval.note }}
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
@@ -1006,11 +1083,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<div class="ap-card-desc">
|
||||
작업을 마쳤다면 지시자 <b>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="approvalNote"
|
||||
class="ap-note"
|
||||
placeholder="승인 요청 메시지(선택) — 작업 내용이나 검토 포인트를 적어주세요."
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
@click="sendApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1042,11 +1124,22 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div
|
||||
v-if="task.changesNote"
|
||||
class="ap-note-box"
|
||||
>
|
||||
{{ task.changesNote }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="approvalNote"
|
||||
class="ap-note"
|
||||
placeholder="보완 내용 메시지(선택)"
|
||||
/>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
@click="sendApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -1151,33 +1244,88 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
||||
{{ task.dueLabel }}
|
||||
<span class="dday">{{ task.dday }}</span>
|
||||
{{ task.dueLabel || '마감 없음' }}
|
||||
<span
|
||||
v-if="task.dday"
|
||||
class="dday"
|
||||
>{{ task.dday }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="card side-card"
|
||||
>
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 · {{ task.files.length }}개
|
||||
<div class="side-label files-head">
|
||||
<span>첨부파일 · {{ task.files.length }}개</span>
|
||||
<button
|
||||
class="file-add"
|
||||
type="button"
|
||||
:disabled="uploading"
|
||||
@click="pickFile"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 5v14M5 12h14" /></svg>
|
||||
{{ uploading ? '업로드 중…' : '추가' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="files">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="file-hidden"
|
||||
type="file"
|
||||
@change="onFileChange"
|
||||
>
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="files"
|
||||
>
|
||||
<div
|
||||
v-for="f in task.files"
|
||||
:key="f.name"
|
||||
:key="f.id ?? f.name"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
<a
|
||||
class="file-ico"
|
||||
:class="FILE_META[f.kind].cls"
|
||||
>{{ FILE_META[f.kind].label }}</span>
|
||||
<span class="file-info"><span class="file-name">{{ f.name }}</span><span class="file-size">{{ f.size }}</span></span>
|
||||
:class="fileBadge(f.kind).cls"
|
||||
:href="f.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ fileBadge(f.kind).label }}</a>
|
||||
<a
|
||||
class="file-info"
|
||||
:href="f.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
><span class="file-name">{{ f.name }}</span><span class="file-size">{{ f.size }}</span></a>
|
||||
<button
|
||||
class="file-del"
|
||||
type="button"
|
||||
title="첨부 삭제"
|
||||
aria-label="첨부 삭제"
|
||||
@click="removeFile(f)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="files-empty"
|
||||
>
|
||||
아직 첨부된 파일이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1329,6 +1477,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
보완이 필요한 내용을 적어주세요. 담당자에게 전달되며 업무가 다시 <b>진행 중</b>으로 돌아갑니다.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="changesNote"
|
||||
class="modal-textarea"
|
||||
placeholder="예) 세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요."
|
||||
/>
|
||||
@@ -1344,7 +1493,8 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<button
|
||||
class="mbtn danger"
|
||||
type="button"
|
||||
@click="requestChanges"
|
||||
:disabled="!changesNote.trim()"
|
||||
@click="submitChanges"
|
||||
>
|
||||
수정 요청 보내기
|
||||
</button>
|
||||
@@ -2041,7 +2191,6 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.3125rem;
|
||||
background: #e25950;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -2254,7 +2403,6 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 4px;
|
||||
background: #e25950;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -2445,12 +2593,19 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
/* 파일 종류별 배지 색상 — 첨부 카드(.file-ico)·댓글(.c-file .fi)·활동(.act-file .fi) 공통 */
|
||||
.fi-img {
|
||||
background: #2aa775;
|
||||
}
|
||||
.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.file-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -2646,6 +2801,114 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
.ap-btn.request-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ===== 5단계: 첨부파일 ===== */
|
||||
.files-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.file-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-add:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.file-add:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.file-add svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.file-hidden {
|
||||
display: none;
|
||||
}
|
||||
a.file-ico,
|
||||
a.file-info {
|
||||
text-decoration: none;
|
||||
}
|
||||
/* 배지(.file-ico)는 흰 글자 유지, 파일명(.file-info)만 본문색 상속 */
|
||||
a.file-ico {
|
||||
color: #fff;
|
||||
}
|
||||
a.file-info {
|
||||
color: inherit;
|
||||
}
|
||||
.file-del {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.file:hover .file-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.file-del:hover {
|
||||
background: var(--red-weak);
|
||||
color: var(--red);
|
||||
}
|
||||
.file-del svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.files-empty {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* ===== 5단계: 승인/수정 요청 메모 ===== */
|
||||
.ap-note {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 3.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
.ap-note:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ap-note::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ap-note-box {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.55;
|
||||
background: rgba(20, 24, 33, 0.04);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5625rem 0.6875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 모달/에이전트 패널은 Teleport 로 body 에 렌더되므로 전역(scoped 미적용) 스타일 사용 -->
|
||||
|
||||
@@ -76,14 +76,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 담당자(작업자) 관점의 업무 상세 — ?state=request|changes|approved
|
||||
path: '/repos/:repoId/tasks/:taskId/view',
|
||||
name: 'task-assignee-view',
|
||||
component: () => import('@/pages/relay/TaskAssigneePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// '내 업무' — 다음 단계에서 구현 예정(현재는 임시 안내)
|
||||
// '내 업무' — 담당/지시 저장소 횡단 집계
|
||||
path: '/tasks',
|
||||
name: 'my-tasks',
|
||||
component: () => import('@/pages/relay/MyTasksPage.vue'),
|
||||
|
||||
@@ -78,6 +78,40 @@ export function progressOf(done: number, total: number): ProgressInfo {
|
||||
return { pct, level, label }
|
||||
}
|
||||
|
||||
/** 첨부 파일 종류 (배지 라벨/색상 키) */
|
||||
export type FileKind = 'pdf' | 'zip' | 'doc' | 'img'
|
||||
|
||||
/** 파일명(확장자)으로 종류 파생 — 활동/댓글처럼 종류 정보가 없을 때 사용 */
|
||||
export function attachmentKindOf(name: string): FileKind {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'img'
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) return 'zip'
|
||||
return 'doc'
|
||||
}
|
||||
|
||||
/** 파일 종류별 배지 라벨 + 색상 클래스(.fi-* — 첨부 카드/활동/댓글 공통) */
|
||||
export function fileBadge(kind: FileKind): { label: string; cls: string } {
|
||||
const map: Record<FileKind, { label: string; cls: string }> = {
|
||||
img: { label: 'IMG', cls: 'fi-img' },
|
||||
pdf: { label: 'PDF', cls: 'fi-pdf' },
|
||||
zip: { label: 'ZIP', cls: 'fi-zip' },
|
||||
doc: { label: 'DOC', cls: 'fi-doc' },
|
||||
}
|
||||
return map[kind]
|
||||
}
|
||||
|
||||
/** 바이트 수를 사람이 읽기 쉬운 단위로 (예: 19293798 → "18.4 MB") */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / Math.pow(1024, exp)
|
||||
// B 는 정수, 그 외는 소수 1자리
|
||||
const text = exp === 0 ? String(Math.round(value)) : value.toFixed(1)
|
||||
return `${text} ${units[exp]}`
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 업무(Task) 표시용 파생 값
|
||||
* ============================================================ */
|
||||
@@ -103,6 +137,32 @@ export function monthDayKo(input: Date | string): string {
|
||||
return `${d.getMonth() + 1}월 ${d.getDate()}일`
|
||||
}
|
||||
|
||||
/** 활동 피드 날짜 그룹 라벨 (오늘 / 어제 / 이번 주 / 'M월 D일') */
|
||||
export function dayGroupKo(input: Date | string): string {
|
||||
const d = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const startD = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const now = new Date()
|
||||
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = Math.round((startNow.getTime() - startD.getTime()) / 86_400_000)
|
||||
if (days <= 0) return '오늘'
|
||||
if (days === 1) return '어제'
|
||||
if (days < 7) return '이번 주'
|
||||
return monthDayKo(d)
|
||||
}
|
||||
|
||||
/** HTML 특수문자 이스케이프 — 활동 문구에 사용자 입력을 안전하게 삽입할 때 사용 */
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** D-day 강조 단계 (마감 없음/완료 시 '') */
|
||||
export type DdayLevel = '' | 'urgent' | 'soon' | 'calm'
|
||||
|
||||
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
|
||||
export interface TaskDueInfo {
|
||||
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
|
||||
@@ -113,16 +173,18 @@ export interface TaskDueInfo {
|
||||
listLabel: string
|
||||
/** 마감 지남 여부(완료 업무는 false) */
|
||||
overdue: boolean
|
||||
/** D-day 강조 단계 (urgent: 지남/임박, soon: 3일 내, calm: 그 외) */
|
||||
ddayLevel: DdayLevel
|
||||
}
|
||||
|
||||
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
|
||||
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
|
||||
if (!dueDate) {
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
|
||||
}
|
||||
const due = new Date(dueDate)
|
||||
if (Number.isNaN(due.getTime())) {
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
|
||||
}
|
||||
const dateLabel = `${due.getMonth() + 1}월 ${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
|
||||
|
||||
@@ -133,12 +195,20 @@ export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInf
|
||||
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
|
||||
|
||||
if (isDone) {
|
||||
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
|
||||
return {
|
||||
dateLabel,
|
||||
dday: '',
|
||||
listLabel: `${monthDayKo(due)} 완료`,
|
||||
overdue: false,
|
||||
ddayLevel: '',
|
||||
}
|
||||
}
|
||||
if (days < 0) {
|
||||
const passed = `${-days}일 지남`
|
||||
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
|
||||
return { dateLabel, dday: passed, listLabel: passed, overdue: true, ddayLevel: 'urgent' }
|
||||
}
|
||||
const dday = days === 0 ? 'D-DAY' : `D-${days}`
|
||||
return { dateLabel, dday, listLabel: dday, overdue: false }
|
||||
// 임박도: 1일 이내 urgent, 3일 이내 soon, 그 외 calm
|
||||
const ddayLevel: DdayLevel = days <= 1 ? 'urgent' : days <= 3 ? 'soon' : 'calm'
|
||||
return { dateLabel, dday, listLabel: dday, overdue: false, ddayLevel }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useActivity } from '@/composables/useActivity'
|
||||
import {
|
||||
avatarColor,
|
||||
dayGroupKo,
|
||||
escapeHtml,
|
||||
initialOf,
|
||||
monthDayKo,
|
||||
relativeTimeKo,
|
||||
taskStatusLabel,
|
||||
} from '@/shared/utils/format'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
import type {
|
||||
RepoActivityDay,
|
||||
RepoActivityItem,
|
||||
TaskStatus,
|
||||
} from '@/mock/relay.mock'
|
||||
|
||||
// payload(자유형)에서 문자열 값 안전 추출
|
||||
function pstr(payload: Record<string, unknown>, key: string): string {
|
||||
const v = payload[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
// 행위자/시간 등 공통 필드
|
||||
function baseOf(api: ApiActivity): Pick<RepoActivityItem, 'initial' | 'color' | 'time'> {
|
||||
return {
|
||||
initial: initialOf(api.actor?.name ?? '?'),
|
||||
color: avatarColor(api.actor?.id ?? ''),
|
||||
time: relativeTimeKo(api.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
// API 활동 → 화면용 RepoActivityItem (type+payload 로 톤/아이콘/문구 조립)
|
||||
// 사용자 입력(이름/제목/파일명/표시명)은 escapeHtml 로 감싸 XSS 를 방지한다.
|
||||
function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
|
||||
const base = baseOf(api)
|
||||
const actor = escapeHtml(api.actor?.name ?? '알 수 없음')
|
||||
const p = api.payload
|
||||
const title = escapeHtml(pstr(p, 'taskTitle'))
|
||||
const target = escapeHtml(pstr(p, 'targetName'))
|
||||
|
||||
switch (api.type) {
|
||||
case 'repo.created':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소를 생성했습니다` }
|
||||
case 'repo.renamed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 표시 제목을 ‘${escapeHtml(pstr(p, 'newName'))}’(으)로 변경했습니다`,
|
||||
}
|
||||
case 'repo.visibility_changed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'lock',
|
||||
html: `<b>${actor}</b>님이 저장소 공개 범위를 <b>${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}</b>(으)로 변경했습니다`,
|
||||
}
|
||||
case 'member.invited':
|
||||
return { ...base, tone: 'member', icon: 'member-add', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버로 초대했습니다` }
|
||||
case 'member.role_changed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'member',
|
||||
icon: 'member-check',
|
||||
html: `<b>${target}</b>님의 역할이 <b>${pstr(p, 'roleType') === 'admin' ? '관리자' : '멤버'}</b>(으)로 변경되었습니다`,
|
||||
}
|
||||
case 'member.removed':
|
||||
return { ...base, tone: 'member', icon: 'member-remove', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버에서 제거했습니다` }
|
||||
case 'task.created':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 지시했습니다` }
|
||||
case 'task.status_changed': {
|
||||
const to = pstr(p, 'statusTo')
|
||||
if (to === 'done') {
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st done">완료</span>했습니다` }
|
||||
}
|
||||
const label = taskStatusLabel(to as TaskStatus)
|
||||
const cls = to === 'prog' ? 'prog' : ''
|
||||
return {
|
||||
...base,
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 <span class="st ${cls}">${escapeHtml(label)}</span>(으)로 변경했습니다`,
|
||||
}
|
||||
}
|
||||
case 'task.comment_added':
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 댓글을 남겼습니다` }
|
||||
case 'task.file_attached':
|
||||
return {
|
||||
...base,
|
||||
tone: 'task',
|
||||
icon: 'check',
|
||||
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무에 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 첨부했습니다`,
|
||||
}
|
||||
case 'task.assignees_changed':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 담당자를 변경했습니다` }
|
||||
case 'task.due_changed': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const body = due
|
||||
? `마감기한을 <b>${escapeHtml(monthDayKo(due))}</b>(으)로 설정했습니다`
|
||||
: `마감기한을 해제했습니다`
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 ${body}` }
|
||||
}
|
||||
case 'task.edited':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 수정했습니다` }
|
||||
case 'task.file_removed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'task',
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 첨부 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 삭제했습니다`,
|
||||
}
|
||||
case 'task.removed':
|
||||
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 삭제했습니다` }
|
||||
default:
|
||||
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님의 활동` }
|
||||
}
|
||||
}
|
||||
|
||||
// 활동 스토어 — 저장소 활동 피드(날짜 그룹)
|
||||
export const useActivityStore = defineStore('activity', () => {
|
||||
const days = ref<RepoActivityDay[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const activityApi = useActivity()
|
||||
|
||||
// 저장소 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속)
|
||||
async function fetch(repoId: string): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await activityApi.list(repoId)
|
||||
const groups: RepoActivityDay[] = []
|
||||
let current: RepoActivityDay | null = null
|
||||
for (const a of list) {
|
||||
const day = dayGroupKo(a.createdAt)
|
||||
if (!current || current.day !== day) {
|
||||
current = { day, items: [] }
|
||||
groups.push(current)
|
||||
}
|
||||
current.items.push(toRepoActivityItem(a))
|
||||
}
|
||||
days.value = groups
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { days, loading, fetch }
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useMyTask } from '@/composables/useMyTask'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { avatarColor, initialOf, taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiMyTaskRow } from '@/types/my-task'
|
||||
import type {
|
||||
MyTaskGroup,
|
||||
MyTaskRow,
|
||||
TaskStatus,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 그룹 순서/라벨 — 담당 뷰와 지시 뷰가 다르다(시안 기준)
|
||||
const ASSIGNED_ORDER: TaskStatus[] = ['changes', 'prog', 'todo', 'review', 'done']
|
||||
const ISSUED_ORDER: TaskStatus[] = ['review', 'prog', 'changes', 'todo', 'done']
|
||||
const ASSIGNED_LABEL: Record<TaskStatus, string> = {
|
||||
changes: '수정 요청',
|
||||
prog: '진행 중',
|
||||
todo: '할 일',
|
||||
review: '승인 대기',
|
||||
done: '완료',
|
||||
}
|
||||
const ISSUED_LABEL: Record<TaskStatus, string> = {
|
||||
changes: '수정 요청함',
|
||||
prog: '진행 중',
|
||||
todo: '할 일',
|
||||
review: '승인 대기',
|
||||
done: '완료',
|
||||
}
|
||||
|
||||
// API 행 → 담당 뷰 행 (누가 지시했는지 표시)
|
||||
function toAssignedRow(api: ApiMyTaskRow): MyTaskRow {
|
||||
const due = taskDueInfo(api.dueDate, api.status === 'done')
|
||||
const [, total] = api.checklist
|
||||
const isDone = api.status === 'done'
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
repoName: api.repoName,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
metaText: `지시 · ${api.issuer?.name ?? '알 수 없음'}`,
|
||||
dueLabel: isDone ? due.listLabel : due.dateLabel || undefined,
|
||||
dday: due.dday || undefined,
|
||||
ddayLevel: due.ddayLevel || undefined,
|
||||
overdue: due.overdue || undefined,
|
||||
statusLabel: taskStatusLabel(api.status),
|
||||
done: isDone || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// API 행 → 지시 뷰 행 (담당자 미니 아바타 + 승인 대기 강조)
|
||||
function toIssuedRow(api: ApiMyTaskRow): MyTaskRow {
|
||||
const due = taskDueInfo(api.dueDate, api.status === 'done')
|
||||
const [, total] = api.checklist
|
||||
const isDone = api.status === 'done'
|
||||
const isReview = api.status === 'review'
|
||||
const names = api.assignees.map((a) => a.name).join(', ') || '미지정'
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
repoName: api.repoName,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
assignees: api.assignees.map(toUserView),
|
||||
metaText: api.status === 'changes' ? `${names}에게 수정 요청` : names,
|
||||
metaAttn: isReview || undefined,
|
||||
dueLabel: isDone ? due.listLabel : due.dateLabel || undefined,
|
||||
dday: due.dday || undefined,
|
||||
ddayLevel: due.ddayLevel || undefined,
|
||||
overdue: due.overdue || undefined,
|
||||
statusLabel: isReview ? undefined : taskStatusLabel(api.status),
|
||||
reviewBtn: isReview || undefined,
|
||||
done: isDone || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 행 배열 → 상태 그룹 배열(순서·라벨 적용, 빈 그룹 제외)
|
||||
function groupRows(
|
||||
rows: MyTaskRow[],
|
||||
order: TaskStatus[],
|
||||
labelMap: Record<TaskStatus, string>,
|
||||
issued: boolean,
|
||||
): MyTaskGroup[] {
|
||||
const groups: MyTaskGroup[] = []
|
||||
for (const key of order) {
|
||||
const groupRows = rows.filter((row) => row.status === key)
|
||||
if (groupRows.length === 0) continue
|
||||
const group: MyTaskGroup = { key, label: labelMap[key], rows: groupRows }
|
||||
// 지시 뷰의 승인 대기 그룹은 강조(내 승인 필요)
|
||||
if (issued && key === 'review') {
|
||||
group.attn = true
|
||||
group.action = '· 내 승인 필요'
|
||||
}
|
||||
groups.push(group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// 내 업무 스토어 — 담당/지시 두 뷰(상태 그룹) + 더보기 페이지네이션
|
||||
// 원시 행을 누적하고 그룹핑은 computed 로 파생(더보기 시 같은 그룹에 자연스럽게 합쳐짐)
|
||||
export const useMyTaskStore = defineStore('myTask', () => {
|
||||
const assignedRaw = ref<ApiMyTaskRow[]>([])
|
||||
const issuedRaw = ref<ApiMyTaskRow[]>([])
|
||||
const assignedTotal = ref(0)
|
||||
const issuedTotal = ref(0)
|
||||
const assignedPage = ref(0)
|
||||
const issuedPage = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const myTaskApi = useMyTask()
|
||||
|
||||
const assigned = computed(() =>
|
||||
groupRows(assignedRaw.value.map(toAssignedRow), ASSIGNED_ORDER, ASSIGNED_LABEL, false),
|
||||
)
|
||||
const issued = computed(() =>
|
||||
groupRows(issuedRaw.value.map(toIssuedRow), ISSUED_ORDER, ISSUED_LABEL, true),
|
||||
)
|
||||
const assignedHasMore = computed(() => assignedRaw.value.length < assignedTotal.value)
|
||||
const issuedHasMore = computed(() => issuedRaw.value.length < issuedTotal.value)
|
||||
|
||||
// 첫 페이지(리셋) — 담당/지시 동시 조회
|
||||
async function fetchAll(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const [a, i] = await Promise.all([
|
||||
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE),
|
||||
myTaskApi.issued(1, DEFAULT_PAGE_SIZE),
|
||||
])
|
||||
assignedRaw.value = a.items
|
||||
assignedTotal.value = a.total
|
||||
assignedPage.value = 1
|
||||
issuedRaw.value = i.items
|
||||
issuedTotal.value = i.total
|
||||
issuedPage.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 담당 뷰 다음 페이지(더보기)
|
||||
async function loadMoreAssigned(): Promise<void> {
|
||||
if (loadingMore.value || !assignedHasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE)
|
||||
assignedRaw.value.push(...a.items)
|
||||
assignedTotal.value = a.total
|
||||
assignedPage.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 지시 뷰 다음 페이지(더보기)
|
||||
async function loadMoreIssued(): Promise<void> {
|
||||
if (loadingMore.value || !issuedHasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE)
|
||||
issuedRaw.value.push(...i.items)
|
||||
issuedTotal.value = i.total
|
||||
issuedPage.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assigned,
|
||||
issued,
|
||||
loading,
|
||||
loadingMore,
|
||||
assignedHasMore,
|
||||
issuedHasMore,
|
||||
fetchAll,
|
||||
loadMoreAssigned,
|
||||
loadMoreIssued,
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { avatarColor, initialOf, progressOf, relativeTimeKo } from '@/shared/utils/format'
|
||||
import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock'
|
||||
@@ -50,6 +51,11 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
const repos = ref<RepoView[]>([])
|
||||
const current = ref<RepoView | null>(null)
|
||||
const loading = ref(false)
|
||||
// 페이지네이션(더보기) 상태
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = computed(() => repos.value.length < total.value)
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG)·템플릿 정보. 최초 1회 조회 후 캐시
|
||||
const owner = ref('')
|
||||
const templateAvailable = ref(false)
|
||||
@@ -66,17 +72,33 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
templateSlug.value = meta.template.slug
|
||||
}
|
||||
|
||||
// 목록 조회
|
||||
// 목록 첫 페이지 조회(리셋)
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await repoApi.list()
|
||||
repos.value = list.map(toRepoView)
|
||||
const res = await repoApi.list(1, DEFAULT_PAGE_SIZE)
|
||||
repos.value = res.items.map(toRepoView)
|
||||
total.value = res.total
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 페이지 이어붙이기(더보기)
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await repoApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
repos.value.push(...res.items.map(toRepoView))
|
||||
total.value = res.total
|
||||
page.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 조회
|
||||
async function fetchOne(id: string): Promise<RepoView | null> {
|
||||
loading.value = true
|
||||
@@ -104,6 +126,7 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
async function remove(id: string): Promise<void> {
|
||||
await repoApi.remove(id)
|
||||
repos.value = repos.value.filter((r) => r.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
@@ -111,11 +134,15 @@ export const useRepoStore = defineStore('repo', () => {
|
||||
repos,
|
||||
current,
|
||||
loading,
|
||||
total,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
owner,
|
||||
templateAvailable,
|
||||
templateSlug,
|
||||
fetchCreateMeta,
|
||||
fetchList,
|
||||
loadMore,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
|
||||
@@ -3,8 +3,11 @@ import { defineStore } from 'pinia'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
import {
|
||||
avatarColor,
|
||||
escapeHtml,
|
||||
formatBytes,
|
||||
initialOf,
|
||||
monthDayKo,
|
||||
relativeTimeKo,
|
||||
taskDueInfo,
|
||||
taskStatusLabel,
|
||||
} from '@/shared/utils/format'
|
||||
@@ -12,15 +15,22 @@ import type {
|
||||
ApiMember,
|
||||
} from '@/types/repo'
|
||||
import type {
|
||||
ApiAttachment,
|
||||
ApiComment,
|
||||
ApiRepoTask,
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
UpdateTaskPayload,
|
||||
} from '@/types/task'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
import type {
|
||||
Activity as ActivityView,
|
||||
Attachment as AttachmentView,
|
||||
Comment as CommentView,
|
||||
RepoTask as RepoTaskView,
|
||||
TaskDetail as TaskDetailView,
|
||||
TaskStatus,
|
||||
User as UserView,
|
||||
} from '@/mock/relay.mock'
|
||||
|
||||
@@ -56,13 +66,109 @@ function toRepoTaskView(api: ApiRepoTask): RepoTaskView {
|
||||
overdue: due.overdue || undefined,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
comments: api.commentCount > 0 ? api.commentCount : undefined,
|
||||
files: api.fileCount > 0 ? api.fileCount : undefined,
|
||||
assignees: api.assignees.map(toUserView),
|
||||
}
|
||||
}
|
||||
|
||||
// API 업무 상세 → 화면용 TaskDetail (comments/activities/files/approval 은 5단계)
|
||||
// API 첨부 → 화면용 Attachment(바이트 → 표시 크기)
|
||||
function toAttachmentView(a: ApiAttachment): AttachmentView {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
size: formatBytes(a.size),
|
||||
kind: a.kind,
|
||||
url: a.url,
|
||||
}
|
||||
}
|
||||
|
||||
// API 댓글 → 화면용 Comment(작성자/시간 파생, 지시자면 역할 배지)
|
||||
function toCommentView(c: ApiComment, issuerId: string | null): CommentView {
|
||||
return {
|
||||
id: c.id,
|
||||
author: c.author ? toUserView(c.author) : UNKNOWN_USER,
|
||||
roleBadge: c.author && issuerId && c.author.id === issuerId ? '지시자' : undefined,
|
||||
time: relativeTimeKo(c.createdAt),
|
||||
text: c.text,
|
||||
file: c.file?.name,
|
||||
replies: c.replies.map((r) => ({
|
||||
id: r.id,
|
||||
author: r.author ? toUserView(r.author) : UNKNOWN_USER,
|
||||
time: relativeTimeKo(r.createdAt),
|
||||
text: r.text,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// payload(자유형)에서 문자열 값 안전 추출
|
||||
function pstr(payload: Record<string, unknown>, key: string): string {
|
||||
const v = payload[key]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
// API 활동 → 업무 상세 활동 탭 뷰(Activity). 사용자 입력은 escapeHtml 로 안전 삽입
|
||||
function toTaskActivityView(api: ApiActivity): ActivityView {
|
||||
const actor = escapeHtml(api.actor?.name ?? '알 수 없음')
|
||||
const p = api.payload
|
||||
const time = relativeTimeKo(api.createdAt)
|
||||
switch (api.type) {
|
||||
case 'task.created':
|
||||
return { tone: 'accent', icon: 'flag', html: `<b>${actor}</b>님이 업무를 생성했습니다`, time }
|
||||
case 'task.status_changed': {
|
||||
const from = pstr(p, 'statusFrom')
|
||||
const to = pstr(p, 'statusTo')
|
||||
if (to === 'review') {
|
||||
return {
|
||||
tone: 'amber',
|
||||
icon: 'check-circle',
|
||||
html: `<b>${actor}</b>님이 승인을 요청했습니다`,
|
||||
time,
|
||||
statusFrom: taskStatusLabel(from as TaskStatus),
|
||||
statusTo: taskStatusLabel(to as TaskStatus),
|
||||
}
|
||||
}
|
||||
if (to === 'done') {
|
||||
return { tone: 'green', icon: 'check', html: `<b>${actor}</b>님이 업무를 완료(승인)했습니다`, time }
|
||||
}
|
||||
if (to === 'changes') {
|
||||
return { tone: 'amber', icon: 'check-circle', html: `<b>${actor}</b>님이 수정을 요청했습니다`, time }
|
||||
}
|
||||
if (to === 'prog') {
|
||||
return {
|
||||
tone: 'blue',
|
||||
icon: 'flag',
|
||||
html: `<b>${actor}</b>님이 업무를 ${from === 'changes' ? '다시 진행' : '시작'}했습니다`,
|
||||
time,
|
||||
}
|
||||
}
|
||||
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 상태를 변경했습니다`, time }
|
||||
}
|
||||
case 'task.comment_added':
|
||||
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 댓글을 남겼습니다`, time }
|
||||
case 'task.file_attached':
|
||||
return { tone: '', icon: 'paperclip', html: `<b>${actor}</b>님이 파일을 첨부했습니다`, time, file: pstr(p, 'fileName') }
|
||||
case 'task.assignees_changed':
|
||||
return { tone: 'blue', icon: 'users', html: `<b>${actor}</b>님이 담당자를 변경했습니다`, time }
|
||||
case 'task.due_changed': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const body = due
|
||||
? `마감기한을 <b>${escapeHtml(taskDueInfo(due, false).dateLabel)}</b>(으)로 설정했습니다`
|
||||
: '마감기한을 해제했습니다'
|
||||
return { tone: 'blue', icon: 'calendar', html: `<b>${actor}</b>님이 ${body}`, time }
|
||||
}
|
||||
case 'task.edited':
|
||||
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 업무를 수정했습니다`, time }
|
||||
case 'task.file_removed':
|
||||
return { tone: '', icon: 'paperclip', html: `<b>${actor}</b>님이 첨부 파일을 삭제했습니다`, time, file: pstr(p, 'fileName') }
|
||||
default:
|
||||
return { tone: '', icon: 'check', html: `<b>${actor}</b>님의 활동`, time }
|
||||
}
|
||||
}
|
||||
|
||||
// API 업무 상세 → 화면용 TaskDetail
|
||||
function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
const due = taskDueInfo(api.dueDate, api.status === 'done')
|
||||
const issuerId = api.issuer?.id ?? null
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
@@ -71,14 +177,24 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
status: api.status,
|
||||
statusLabel: taskStatusLabel(api.status),
|
||||
content: api.content,
|
||||
checklist: api.checklist.map((c) => ({ text: c.text, done: c.done })),
|
||||
comments: [],
|
||||
activities: [],
|
||||
checklist: api.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })),
|
||||
comments: api.comments.map((c) => toCommentView(c, issuerId)),
|
||||
activities: api.activities.map(toTaskActivityView),
|
||||
assignees: api.assignees.map(toUserView),
|
||||
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
|
||||
dueLabel: due.dateLabel,
|
||||
dday: due.dday,
|
||||
files: [],
|
||||
files: api.files.map(toAttachmentView),
|
||||
approval: api.approval
|
||||
? {
|
||||
requester: api.approval.requester?.name ?? '알 수 없음',
|
||||
requestedAgo: api.approval.requestedAt
|
||||
? relativeTimeKo(api.approval.requestedAt)
|
||||
: '',
|
||||
note: api.approval.note ?? '',
|
||||
}
|
||||
: undefined,
|
||||
changesNote: api.changesNote ?? undefined,
|
||||
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
|
||||
}
|
||||
}
|
||||
@@ -149,6 +265,82 @@ export const useTaskStore = defineStore('task', () => {
|
||||
return view
|
||||
}
|
||||
|
||||
// 부속 변경 후 상세를 다시 받아 일관된 뷰로 갱신하는 공통 헬퍼
|
||||
async function refresh(repoId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.get(repoId, taskId))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
async function addComment(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
text: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.addComment(repoId, taskId, text)
|
||||
return refresh(repoId, taskId)
|
||||
}
|
||||
|
||||
async function addReply(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
commentId: string,
|
||||
text: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.addReply(repoId, taskId, commentId, text)
|
||||
return refresh(repoId, taskId)
|
||||
}
|
||||
|
||||
/* ===================== 파일 첨부 (5단계) ===================== */
|
||||
|
||||
async function uploadFile(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
file: File,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.uploadFile(repoId, taskId, file)
|
||||
return refresh(repoId, taskId)
|
||||
}
|
||||
|
||||
async function removeFile(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
fileId: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.removeFile(repoId, taskId, fileId)
|
||||
return refresh(repoId, taskId)
|
||||
}
|
||||
|
||||
/* ===================== 승인 워크플로우 (5단계) ===================== */
|
||||
|
||||
async function requestApproval(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
note?: string,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.requestApproval(repoId, taskId, note))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
async function approve(repoId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.approve(repoId, taskId))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
async function requestChanges(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
note: string,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.requestChanges(repoId, taskId, note))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
current,
|
||||
@@ -159,5 +351,12 @@ export const useTaskStore = defineStore('task', () => {
|
||||
update,
|
||||
remove,
|
||||
changeStatus,
|
||||
addComment,
|
||||
addReply,
|
||||
uploadFile,
|
||||
removeFile,
|
||||
requestApproval,
|
||||
approve,
|
||||
requestChanges,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// 활동 피드 도메인 타입 — 백엔드 ActivityResponse 와 1:1
|
||||
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 활동 이벤트 타입(백엔드 ActivityType 과 동일 문자열)
|
||||
export type ApiActivityType =
|
||||
| 'repo.created'
|
||||
| 'repo.renamed'
|
||||
| 'repo.visibility_changed'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
| 'task.created'
|
||||
| 'task.status_changed'
|
||||
| 'task.comment_added'
|
||||
| 'task.file_attached'
|
||||
| 'task.assignees_changed'
|
||||
| 'task.due_changed'
|
||||
| 'task.edited'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed'
|
||||
|
||||
// 활동(API 원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립한다
|
||||
export interface ApiActivity {
|
||||
id: string
|
||||
type: ApiActivityType
|
||||
actor: ApiMember | null
|
||||
taskSeq: number | null
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 내 업무(저장소 횡단) 도메인 타입 — 백엔드 MyTaskRowResponse 와 1:1
|
||||
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생
|
||||
export interface ApiMyTaskRow {
|
||||
id: number // 저장소 내 순번(seq)
|
||||
repoId: string // slug (라우팅용)
|
||||
repoName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
dueDate: string | null
|
||||
checklist: [number, number] // [완료, 전체]
|
||||
assignees: ApiMember[]
|
||||
issuer: ApiMember | null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 목록 페이지네이션 공통 타입 — 백엔드 PaginatedResult<T> 와 1:1
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// 기본 페이지 크기
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiActivity } from '@/types/activity'
|
||||
|
||||
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
|
||||
export interface ApiRepoTask {
|
||||
@@ -11,6 +12,7 @@ export interface ApiRepoTask {
|
||||
dueDate: string | null
|
||||
checklist: [number, number] // [완료, 전체]
|
||||
commentCount: number
|
||||
fileCount: number // 첨부 파일 수
|
||||
assignees: ApiMember[]
|
||||
}
|
||||
|
||||
@@ -21,7 +23,43 @@ export interface ApiChecklistItem {
|
||||
done: boolean
|
||||
}
|
||||
|
||||
// 업무 상세(API 원시) — comments/activities/files 는 5단계에서 채움(현재 빈 배열)
|
||||
// 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1
|
||||
export interface ApiAttachment {
|
||||
id: string
|
||||
name: string
|
||||
size: number // 바이트
|
||||
kind: 'pdf' | 'zip' | 'doc' | 'img'
|
||||
url: string // 다운로드 경로
|
||||
uploader: ApiMember | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 답글(API 원시)
|
||||
export interface ApiCommentReply {
|
||||
id: string
|
||||
author: ApiMember | null
|
||||
text: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 댓글(API 원시, 답글 포함)
|
||||
export interface ApiComment {
|
||||
id: string
|
||||
author: ApiMember | null
|
||||
text: string
|
||||
file: ApiAttachment | null
|
||||
createdAt: string
|
||||
replies: ApiCommentReply[]
|
||||
}
|
||||
|
||||
// 승인 요청 정보(API 원시)
|
||||
export interface ApiApproval {
|
||||
requester: ApiMember | null
|
||||
requestedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
// 업무 상세(API 원시) — 백엔드 TaskDetailResponse 와 1:1 (activities 는 6단계)
|
||||
export interface ApiTaskDetail {
|
||||
id: number
|
||||
repoId: string
|
||||
@@ -34,9 +72,11 @@ export interface ApiTaskDetail {
|
||||
issuer: ApiMember | null
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
comments: unknown[]
|
||||
activities: unknown[]
|
||||
files: unknown[]
|
||||
comments: ApiComment[]
|
||||
activities: ApiActivity[]
|
||||
files: ApiAttachment[]
|
||||
approval: ApiApproval | null
|
||||
changesNote: string | null
|
||||
}
|
||||
|
||||
// 업무 목록 필터
|
||||
@@ -60,4 +100,23 @@ export interface UpdateTaskPayload {
|
||||
content?: string[]
|
||||
assigneeIds?: string[]
|
||||
dueDate?: string | null
|
||||
// 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제)
|
||||
checklist?: { id?: string; text: string }[]
|
||||
}
|
||||
|
||||
// 댓글/답글 작성 요청(5단계)
|
||||
export interface AddCommentPayload {
|
||||
text: string
|
||||
fileId?: string
|
||||
}
|
||||
export interface AddReplyPayload {
|
||||
text: string
|
||||
}
|
||||
|
||||
// 승인 워크플로우 요청(5단계)
|
||||
export interface ApprovalRequestPayload {
|
||||
note?: string
|
||||
}
|
||||
export interface ApprovalChangesPayload {
|
||||
note: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user