diff --git a/backend/src/migrations/1784100000000-AddTodoLabelSourceTask.ts b/backend/src/migrations/1784100000000-AddTodoLabelSourceTask.ts new file mode 100644 index 0000000..e268d20 --- /dev/null +++ b/backend/src/migrations/1784100000000-AddTodoLabelSourceTask.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 할 일 라벨의 원본 업무 참조(source_task_id) 추가. +// 업무 상세의 '할 일로 만들기'로 생성된 라벨을 식별해, 같은 업무로 다시 만들 때 +// 새 라벨을 만들지 않고 기존 라벨에 항목을 병합하기 위한 연결이다. +// 업무가 삭제돼도 개인 할 일은 유지해야 하므로 SET NULL(연결만 끊는다). +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddTodoLabelSourceTask1784100000000 implements MigrationInterface { + name = 'AddTodoLabelSourceTask1784100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "todo_labels" ADD "source_task_id" uuid`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_todo_labels_source_task" ON "todo_labels" ("source_task_id")`, + ); + await queryRunner.query( + `ALTER TABLE "todo_labels" ADD CONSTRAINT "FK_todo_labels_source_task" FOREIGN KEY ("source_task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "todo_labels" DROP CONSTRAINT "FK_todo_labels_source_task"`, + ); + await queryRunner.query(`DROP INDEX "IDX_todo_labels_source_task"`); + await queryRunner.query( + `ALTER TABLE "todo_labels" DROP COLUMN "source_task_id"`, + ); + } +} diff --git a/backend/src/modules/ai/ai-suggest.controller.ts b/backend/src/modules/ai/ai-suggest.controller.ts index cb0846c..1fcc4dc 100644 --- a/backend/src/modules/ai/ai-suggest.controller.ts +++ b/backend/src/modules/ai/ai-suggest.controller.ts @@ -13,6 +13,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { PublicUser } from '../user/user.service'; import { AiService, type ChecklistSuggestionGroup } from './ai.service'; import { SuggestChecklistDto } from './dto/suggest-checklist.dto'; +import { SuggestTaskTodosDto } from './dto/suggest-task-todos.dto'; // AI 보조 — 업무 작성 시 할 일(체크리스트) 추천 @ApiTags('AI') @@ -37,4 +38,23 @@ export class AiSuggestController { content: dto.content, }); } + + @Post('suggest-task-todos') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 1000, ttl: 60_000 } }) + @ApiOperation({ + summary: '개인 할 일 추천 — 업무 본문 분석(체크리스트 없는 업무용)', + }) + @ApiResponse({ status: 200, description: '실행 단계 목록' }) + @ApiResponse({ status: 400, description: '업무 내용 없음 (BIZ_001)' }) + @ApiResponse({ status: 404, description: '프로젝트·업무 없음 (RES_001)' }) + suggestTaskTodos( + @CurrentUser() user: PublicUser, + @Body() dto: SuggestTaskTodosDto, + ): Promise<{ items: string[] }> { + return this.aiService.suggestTaskTodos(user.id, { + projectId: dto.projectId, + taskSeq: dto.taskSeq, + }); + } } diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 9cf3aca..9b4d9fc 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { ProjectModule } from '../project/project.module'; +import { TaskModule } from '../task/task.module'; import { AiSuggestController } from './ai-suggest.controller'; import { AiService } from './ai.service'; // AI 모듈 — Claude(Anthropic) 기반 업무 할 일(체크리스트) 추천. -// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입한다. +// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입하고, +// TaskModule 로 개인 할 일 추천의 원본 업무(제목·본문)를 읽는다. @Module({ - imports: [ProjectModule], + imports: [ProjectModule, TaskModule], controllers: [AiSuggestController], providers: [AiService], }) diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 23b018e..88dbcb2 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -2,6 +2,7 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { BusinessException } from '../../common/exceptions/business.exception'; import { ProjectService } from '../project/project.service'; +import { TaskService } from '../task/task.service'; // Anthropic Messages API 응답(필요 필드만) interface AnthropicResponse { @@ -37,6 +38,36 @@ const SUGGEST_SYSTEM_PROMPT = [ '{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}', ].join('\n'); +// 개인 할 일 추천용 시스템 프롬프트 — '산출물'이 아니라 담당자의 '실행 단계'를 뽑는다. +// (체크리스트 추천과 층위가 다르므로 프롬프트를 분리한다) +const TASK_TODO_SYSTEM_PROMPT = [ + '당신은 업무 실행 보조자입니다.', + "주어진 업무를 담당자가 실제로 수행할 '개인 할 일' 목록으로 분해합니다.", + '', + '[규칙]', + '- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.', + '- 출력은 완결된 단일 JSON 객체여야 합니다. 배열 []·문자열 "" 을 정확히 닫고 중간에 끊지 마세요.', + '- 각 항목은 한국어로 된 간결한 한 문장이며, 담당자가 바로 착수할 수 있는 행동이어야 합니다.', + '- 항목은 수행 순서대로 나열하고, 최대 12개까지만 제안하세요.', + '- 각 항목은 60자를 넘기지 마세요.', + "- 기능 명세나 산출물 목록이 아니라 '내가 다음에 할 일' 관점으로 작성하세요.", + '', + '[근거 규칙 — 가장 중요]', + '- 모든 항목은 업무 내용에 실제로 적힌 서술에서 직접 도출되어야 합니다.', + '- 업무 내용에 없는 작업·기한·담당자·도구·산출물·검수 절차를 추가하지 마세요.', + '- 일반적인 업무 절차라는 이유로 항목을 채워 넣지 마세요. 추측해서 보완하지 마세요.', + '- 개수를 맞추기 위해 항목을 만들어내지 마세요. 내용에서 도출되는 만큼만 제안하며, 1~2개여도 괜찮습니다.', + '- 도출할 항목이 전혀 없으면 빈 배열({"items":[]})로 응답하세요.', + '', + '[형식]', + '{"items":["...","..."]}', +].join('\n'); + +// 할 일 항목 최대 길이 — 백엔드 CreateTodoItemDto(200자)보다 짧게 잡아 화면 가독성을 지킨다 +const TODO_ITEM_MAX_LEN = 100; +// 개인 할 일 추천 최대 개수 +const TODO_ITEM_MAX_COUNT = 12; + // AI(Claude) 서비스 — 업무 생성 화면의 할 일(체크리스트) 추천을 담당한다. // 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. @Injectable() @@ -49,6 +80,7 @@ export class AiService { constructor( config: ConfigService, private readonly projectService: ProjectService, + private readonly taskService: TaskService, ) { this.apiKey = (config.get('CLAUDE_API_KEY') ?? '').trim(); this.model = ( @@ -101,6 +133,103 @@ export class AiService { return { groups: this.parseSuggestions(raw) }; } + // 개인 할 일 추천 — 업무 제목/본문을 담당자 관점의 실행 단계로 분해한다. + // 체크리스트가 있는 업무는 호출자(프론트)가 이 API 대신 체크리스트를 그대로 쓴다. + async suggestTaskTodos( + userId: string, + input: { projectId: string; taskSeq: number }, + ): Promise<{ items: string[] }> { + this.ensureEnabled(); + const project = await this.projectService.findOne(input.projectId, userId); + const { task } = await this.taskService.findEntity( + input.projectId, + input.taskSeq, + ); + const content = (task.content ?? []).join('\n').trim(); + if (!content) { + throw new BusinessException( + 'BIZ_001', + '업무 내용이 없어 할 일을 만들 수 없습니다.', + HttpStatus.BAD_REQUEST, + ); + } + + const userMsg = [ + `[프로젝트] ${project.name}`, + `설명: ${project.description ?? '(없음)'}`, + '', + `[업무] 제목: ${task.title}`, + '내용:', + content, + ].join('\n'); + + const raw = await this.callClaude( + [{ role: 'user', content: userMsg }], + TASK_TODO_SYSTEM_PROMPT, + 2000, + ); + return { items: this.parseTodoItems(raw) }; + } + + // 개인 할 일 추천 응답 파싱 — 표준 파싱 실패 시 문자열 배열만 정규식으로 복구한다. + // 빈 배열은 '내용에서 도출할 항목이 없음'이라는 정당한 응답이므로 오류로 보지 않고, + // 응답 자체를 해석하지 못한 경우에만 실패로 처리한다. + private parseTodoItems(raw: string): string[] { + const text = raw + .trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/, '') + .trim(); + + let items: unknown[] = []; + let parsed = false; + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + const obj = JSON.parse(text.slice(start, end + 1)) as { + items?: unknown; + }; + if (Array.isArray(obj.items)) { + items = obj.items; + parsed = true; + } + } catch { + // 표준 파싱 실패 → 아래 견고 파싱으로 폴백 + } + } + + // 폴백 — "items":[ ... ] 블록에서 문자열만 추출 + if (!parsed) { + const m = /"items"\s*:\s*\[([\s\S]*?)\]/.exec(text); + if (m) { + parsed = true; + items = (m[1].match(/"(?:[^"\\]|\\.)*"/g) ?? []) + .map((s) => { + try { + return JSON.parse(s) as unknown; + } catch { + return null; + } + }) + .filter((s) => s !== null); + } + } + + if (!parsed) { + this.logger.warn( + `할 일 추천 응답을 해석하지 못함 — 원본 ${raw.length}자: ${raw.slice(0, 300)}`, + ); + throw this.suggestFailed(); + } + + return items + .filter((x): x is string => typeof x === 'string') + .map((s) => s.trim().slice(0, TODO_ITEM_MAX_LEN)) + .filter((s) => s.length > 0) + .slice(0, TODO_ITEM_MAX_COUNT); + } + // Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어) private parseSuggestions(raw: string): ChecklistSuggestionGroup[] { const text = raw diff --git a/backend/src/modules/ai/dto/suggest-task-todos.dto.ts b/backend/src/modules/ai/dto/suggest-task-todos.dto.ts new file mode 100644 index 0000000..534329e --- /dev/null +++ b/backend/src/modules/ai/dto/suggest-task-todos.dto.ts @@ -0,0 +1,19 @@ +import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +// 개인 할 일 추천 요청 — 업무 본문을 분석해 담당자 관점의 실행 단계를 제안한다. +// 제목·본문은 서버가 업무에서 직접 읽으므로 식별자만 받는다. +// (체크리스트가 이미 있는 업무는 이 API 를 호출하지 않고 그 항목을 그대로 쓴다) +export class SuggestTaskTodosDto { + @ApiProperty({ description: '프로젝트 식별자' }) + @IsString() + @IsNotEmpty({ message: '프로젝트가 필요합니다.' }) + projectId!: string; + + @ApiProperty({ description: '업무 순번(seq)', example: 9 }) + @Type(() => Number) + @IsInt() + @Min(1) + taskSeq!: number; +} diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 0b7df97..5a6170b 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -469,6 +469,17 @@ export class TaskService { return this.buildDetail(task, project, viewerId); } + // 업무 엔티티(+체크리스트)와 소속 프로젝트 — 업무를 원본으로 삼는 다른 모듈(할 일 생성)용. + // 열람 권한은 상세 조회(findOne)와 동일하게 인증 사용자면 가능하다. + async findEntity( + projectId: string, + seq: number, + ): Promise<{ task: Task; project: Project }> { + const project = await this.getProjectOrThrow(projectId); + const task = await this.getTaskOrThrow(project.id, seq); + return { task, project }; + } + // 업무 생성 — admin(지시자) 권한. 담당자는 프로젝트 멤버만 지정 가능 async create( projectId: string, diff --git a/backend/src/modules/todo/dto/create-todo-from-task.dto.ts b/backend/src/modules/todo/dto/create-todo-from-task.dto.ts new file mode 100644 index 0000000..18b27ac --- /dev/null +++ b/backend/src/modules/todo/dto/create-todo-from-task.dto.ts @@ -0,0 +1,41 @@ +import { + ArrayMaxSize, + ArrayNotEmpty, + IsArray, + IsInt, + IsNotEmpty, + IsString, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +// 업무 → 개인 할 일 생성 요청. +// 항목은 화면 모달에서 사용자가 확정한 것만 담기며(체크리스트 복사분·AI 추천분 공통), +// 라벨 이름·색은 서버가 원본 업무에서 파생하므로 받지 않는다. +export class CreateTodoFromTaskDto { + @ApiProperty({ description: '프로젝트 식별자' }) + @IsString() + @IsNotEmpty({ message: '프로젝트가 필요합니다.' }) + projectId!: string; + + @ApiProperty({ description: '업무 순번(seq)', example: 9 }) + @Type(() => Number) + @IsInt() + @Min(1) + taskSeq!: number; + + @ApiProperty({ + description: '생성할 할 일 내용 목록', + type: [String], + example: ['견적서 검토', '담당자 회신'], + }) + @IsArray() + @ArrayNotEmpty({ message: '추가할 할 일을 하나 이상 선택해 주세요.' }) + @ArrayMaxSize(50, { message: '할 일은 한 번에 최대 50개까지 추가합니다.' }) + @IsString({ each: true }) + @IsNotEmpty({ each: true, message: '할 일 내용을 입력해 주세요.' }) + @MaxLength(200, { each: true, message: '할 일은 최대 200자입니다.' }) + items!: string[]; +} diff --git a/backend/src/modules/todo/entities/todo-label.entity.ts b/backend/src/modules/todo/entities/todo-label.entity.ts index c51b9df..ad79afd 100644 --- a/backend/src/modules/todo/entities/todo-label.entity.ts +++ b/backend/src/modules/todo/entities/todo-label.entity.ts @@ -11,6 +11,7 @@ import { type Relation, } from 'typeorm'; import { User } from '../../user/entities/user.entity'; +import { Task } from '../../task/entities/task.entity'; import { TodoItem } from './todo-item.entity'; // 할 일 라벨 — 개인 체크리스트의 묶음 단위(라벨 1개 : 할 일 N개). @@ -38,6 +39,15 @@ export class TodoLabel { @Column({ name: 'sort_order', type: 'int', default: 0 }) sortOrder!: number; + // 원본 업무 — '할 일로 만들기'로 생성된 라벨만 값을 갖는다(수동 생성은 null). + // 같은 업무로 다시 만들면 새 라벨 대신 이 라벨에 항목을 병합한다. + // 업무가 삭제돼도 개인 할 일은 남아야 하므로 SET NULL(연결만 끊는다). + // Relation<> 래퍼: 엔티티 순환참조 회피 + @Index() + @ManyToOne(() => Task, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'source_task_id' }) + sourceTask!: Relation | null; + // 소속 할 일 — 라벨 삭제 시 함께 삭제(FK CASCADE) // Relation<> 래퍼: 엔티티 순환참조 회피 @OneToMany(() => TodoItem, (item) => item.label) diff --git a/backend/src/modules/todo/todo.controller.ts b/backend/src/modules/todo/todo.controller.ts index df5cc1a..4754a52 100644 --- a/backend/src/modules/todo/todo.controller.ts +++ b/backend/src/modules/todo/todo.controller.ts @@ -18,6 +18,7 @@ import type { PublicUser } from '../user/user.service'; import { TodoService, type TodoLabelResponse } from './todo.service'; import { CreateTodoLabelDto, UpdateTodoLabelDto } from './dto/todo-label.dto'; import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto'; +import { CreateTodoFromTaskDto } from './dto/create-todo-from-task.dto'; // 할 일(개인 체크리스트) 컨트롤러 — 라벨 1개에 항목 N개. // 모든 응답은 라벨 단위라 화면이 카드 하나만 갈아끼우면 된다. @@ -69,6 +70,20 @@ export class TodoController { return { success: true }; } + @Post('labels/from-task') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '업무에서 할 일 생성 — 같은 업무의 라벨이 있으면 항목을 병합', + }) + @ApiResponse({ status: 200, description: '생성·병합 성공' }) + @ApiResponse({ status: 404, description: '프로젝트·업무 없음 (RES_001)' }) + createFromTask( + @CurrentUser() user: PublicUser, + @Body() dto: CreateTodoFromTaskDto, + ): Promise { + return this.todoService.createFromTask(dto, user.id); + } + // ----- 항목 ----- @Post('labels/:labelId/items') diff --git a/backend/src/modules/todo/todo.module.ts b/backend/src/modules/todo/todo.module.ts index 9eb0fb4..9cbe329 100644 --- a/backend/src/modules/todo/todo.module.ts +++ b/backend/src/modules/todo/todo.module.ts @@ -4,10 +4,12 @@ import { TodoLabel } from './entities/todo-label.entity'; import { TodoItem } from './entities/todo-item.entity'; import { TodoService } from './todo.service'; import { TodoController } from './todo.controller'; +import { TaskModule } from '../task/task.module'; -// 할 일(개인 체크리스트) 모듈 +// 할 일(개인 체크리스트) 모듈. +// TaskModule 은 '업무 → 할 일 만들기'에서 원본 업무를 읽기 위해 가져온다. @Module({ - imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem])], + imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem]), TaskModule], controllers: [TodoController], providers: [TodoService], }) diff --git a/backend/src/modules/todo/todo.service.ts b/backend/src/modules/todo/todo.service.ts index 0aa47b1..0bded63 100644 --- a/backend/src/modules/todo/todo.service.ts +++ b/backend/src/modules/todo/todo.service.ts @@ -4,8 +4,39 @@ import { Repository } from 'typeorm'; import { User } from '../user/entities/user.entity'; import { TodoLabel } from './entities/todo-label.entity'; import { TodoItem } from './entities/todo-item.entity'; +import { Task } from '../task/entities/task.entity'; +import { TaskService } from '../task/task.service'; import { CreateTodoLabelDto } from './dto/todo-label.dto'; import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto'; +import { CreateTodoFromTaskDto } from './dto/create-todo-from-task.dto'; + +// 업무에서 파생한 라벨의 이름 최대 길이 — CreateTodoLabelDto 의 제한과 동일 +const LABEL_NAME_MAX_LEN = 40; +// 라벨 이름이 길어질 때 프로젝트명에 허용하는 최대 길이(나머지는 업무 제목 몫) +const LABEL_PROJECT_MAX_LEN = 14; +// 할 일 제목 최대 길이 — CreateTodoItemDto 의 제한과 동일 +const ITEM_TITLE_MAX_LEN = 200; +// 할 일 제목에 보완 내용을 덧붙일 때 쓰는 구분자. +// 이 형식은 서버가 만들고 서버가 되읽으므로(재실행 시 원문 판정) 한곳에서만 정의한다. +const NOTE_SEPARATOR = ' — '; +// 라벨 색의 고정 채도/명도 — 프론트 TodoLabelModal 과 동일한 값 +const LABEL_SAT = 62; +const LABEL_LIG = 46; + +// HSL → hex(#RRGGBB). 라벨 색 규격이 hex 라서 변환해 저장한다. +function hslToHex(h: number, s: number, l: number): string { + const sn = s / 100; + const ln = l / 100; + const k = (n: number) => (n + h / 30) % 12; + const a = sn * Math.min(ln, 1 - ln); + const f = (n: number) => + ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + const to = (x: number) => + Math.round(255 * x) + .toString(16) + .padStart(2, '0'); + return `#${to(f(0))}${to(f(8))}${to(f(4))}`; +} // 응답(원시) — 표시 음영색은 프론트가 color 로 파생 export interface TodoItemResponse { @@ -15,6 +46,13 @@ export interface TodoItemResponse { sortOrder: number; } +// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다 +export interface TodoSourceTaskResponse { + projectId: string; + seq: number; + title: string; +} + export interface TodoLabelResponse { id: string; name: string; @@ -24,6 +62,8 @@ export interface TodoLabelResponse { // 진행 표기(2/3)용 집계 — 프론트가 items 로 다시 세지 않도록 함께 내려준다 doneCount: number; totalCount: number; + // 원본 업무 — 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null + sourceTask: TodoSourceTaskResponse | null; } // 할 일(개인 체크리스트) 비즈니스 로직 — 라벨 1개에 항목 N개. @@ -36,6 +76,7 @@ export class TodoService { private readonly labelRepo: Repository, @InjectRepository(TodoItem) private readonly itemRepo: Repository, + private readonly taskService: TaskService, ) {} // ----- 라벨 ----- @@ -44,7 +85,7 @@ export class TodoService { async listLabels(ownerId: string): Promise { const rows = await this.labelRepo.find({ where: { owner: { id: ownerId } }, - relations: ['items'], + relations: ['items', 'sourceTask', 'sourceTask.project'], order: { sortOrder: 'ASC', createdAt: 'ASC' }, }); return rows.map((l) => this.toLabelResponse(l)); @@ -87,6 +128,98 @@ export class TodoService { await this.labelRepo.delete({ id }); } + // 업무 → 개인 할 일 생성(병합). + // 같은 업무로 이미 만든 라벨이 있으면 새로 만들지 않고 그 라벨에 항목을 덧붙인다. + // 이미 같은 내용의 항목이 있으면 건너뛰어 중복 누적을 막는다. + async createFromTask( + dto: CreateTodoFromTaskDto, + ownerId: string, + ): Promise { + const { task, project } = await this.taskService.findEntity( + dto.projectId, + dto.taskSeq, + ); + + // 체크리스트 원문 → 검토 상태. 보완 내용을 할 일 제목에 덧붙이고, + // 반려된(미완료) 항목은 기존 할 일의 완료를 되돌리는 판단에 쓴다. + const review = new Map( + (task.checklist ?? []).map((c) => [ + c.text.trim(), + { done: c.done, note: c.reviewNote?.trim() || null }, + ]), + ); + + let label = await this.labelRepo.findOne({ + where: { owner: { id: ownerId }, sourceTask: { id: task.id } }, + relations: ['items', 'sourceTask', 'sourceTask.project'], + }); + + if (!label) { + label = await this.labelRepo.save( + this.labelRepo.create({ + owner: { id: ownerId } as User, + name: this.taskLabelName(project.name, task.title), + color: this.taskLabelColor(task.seq), + sortOrder: await this.nextSortOrder(this.labelRepo, { + owner: { id: ownerId }, + }), + sourceTask: { id: task.id } as Task, + }), + ); + label.items = []; + } + + let sortOrder = await this.nextSortOrder(this.itemRepo, { + label: { id: label.id }, + }); + const fresh: TodoItem[] = []; + const touched: TodoItem[] = []; + + for (const raw of dto.items) { + const base = raw.trim(); + if (!base) continue; + const reviewed = review.get(base); + // 보완 내용이 있으면 제목에 덧붙인다(왜 다시 해야 하는지 할 일에 남긴다) + const title = reviewed?.note + ? this.clip( + `${base}${NOTE_SEPARATOR}${reviewed.note}`, + ITEM_TITLE_MAX_LEN, + ) + : this.clip(base, ITEM_TITLE_MAX_LEN); + + // 같은 항목인지 판정은 '보완 내용을 뗀 원문' 기준 — 보완 내용이 바뀌어도 중복 생성하지 않는다 + const dup = (label.items ?? []).find( + (i) => this.itemBaseTitle(i.title) === base, + ); + + if (dup) { + // 지시자가 반려한 항목은 이미 완료 처리해 뒀더라도 다시 열어 준다 + if (reviewed && !reviewed.done) { + if (dup.done || dup.title !== title) { + dup.done = false; + dup.title = title; + touched.push(dup); + } + } + continue; + } + + const item = this.itemRepo.create({ + label: { id: label.id } as TodoLabel, + title, + sortOrder: sortOrder++, + }); + fresh.push(item); + // 같은 요청 안에서의 중복도 막는다 + label.items = [...(label.items ?? []), item]; + } + + if (fresh.length > 0) await this.itemRepo.save(fresh); + if (touched.length > 0) await this.itemRepo.save(touched); + + return this.findLabelOrThrow(label.id, ownerId); + } + // ----- 항목 ----- async createItem( @@ -155,6 +288,39 @@ export class TodoService { return item; } + // 긴 문자열을 최대 길이로 자르되, 잘렸음을 말줄임표로 표시 + private clip(text: string, max: number): string { + const t = text.trim(); + return t.length <= max ? t : `${t.slice(0, max - 1)}…`; + } + + // 업무 기반 라벨 이름 — '프로젝트 - 업무 제목'. + // 전체가 제한을 넘으면 식별력이 큰 업무 제목을 살리고 프로젝트명을 먼저 줄인다. + private taskLabelName(projectName: string, taskTitle: string): string { + const sep = ' - '; + const project = projectName.trim(); + const title = taskTitle.trim(); + if (project.length + sep.length + title.length <= LABEL_NAME_MAX_LEN) { + return `${project}${sep}${title}`; + } + const head = this.clip(project, LABEL_PROJECT_MAX_LEN); + const rest = LABEL_NAME_MAX_LEN - head.length - sep.length; + return `${head}${sep}${this.clip(title, rest)}`; + } + + // 할 일 제목에서 보완 내용을 뗀 원문 — 재실행 시 같은 항목인지 판정하는 기준 + private itemBaseTitle(title: string): string { + const at = title.indexOf(NOTE_SEPARATOR); + return (at >= 0 ? title.slice(0, at) : title).trim(); + } + + // 업무 기반 라벨의 색 — 업무 순번으로 hue 를 분산시켜 카드가 서로 구분되게 한다. + // 채도/명도는 라벨 모달(TodoLabelModal)과 동일한 값으로 고정한다. + private taskLabelColor(seq: number): string { + const hue = (seq * 47) % 360; + return hslToHex(hue, LABEL_SAT, LABEL_LIG); + } + // 다음 정렬값 = 같은 묶음 안의 최대 + 1 private async nextSortOrder( repo: Repository, @@ -174,7 +340,7 @@ export class TodoService { ): Promise { const label = await this.labelRepo.findOne({ where: { id, owner: { id: ownerId } }, - relations: ['items'], + relations: ['items', 'sourceTask', 'sourceTask.project'], }); if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.'); return this.toLabelResponse(label); @@ -192,6 +358,9 @@ export class TodoService { done: i.done, sortOrder: i.sortOrder, })); + // 원본 업무는 '업무에서 만든 라벨'에만 있다. 직접 만든 라벨이거나 + // 원본 업무가 삭제된 경우(FK SET NULL) null 이며, 화면은 링크를 숨긴다. + const src = l.sourceTask; return { id: l.id, name: l.name, @@ -200,6 +369,9 @@ export class TodoService { items, doneCount: items.filter((i) => i.done).length, totalCount: items.length, + sourceTask: src?.project + ? { projectId: src.project.id, seq: src.seq, title: src.title } + : null, }; } } diff --git a/frontend/src/components/todo/TaskTodoModal.vue b/frontend/src/components/todo/TaskTodoModal.vue new file mode 100644 index 0000000..911d720 --- /dev/null +++ b/frontend/src/components/todo/TaskTodoModal.vue @@ -0,0 +1,625 @@ + + + + + diff --git a/frontend/src/composables/useAi.ts b/frontend/src/composables/useAi.ts index ce5e4d2..6a5fbe6 100644 --- a/frontend/src/composables/useAi.ts +++ b/frontend/src/composables/useAi.ts @@ -1,5 +1,5 @@ import { useApi } from './useApi' -import type { SuggestChecklistResult } from '@/types/ai' +import type { SuggestChecklistResult, SuggestTaskTodosResult } from '@/types/ai' // AI 요청 타임아웃(ms) — env VITE_AI_TIMEOUT_MS, 백엔드 AI_TIMEOUT_MS 와 동일 값 사용. // 미설정/비정상이면 기본 60초(AI 생성이 axios 기본 10초를 넘기므로). @@ -22,5 +22,17 @@ export function useAi() { })) as unknown as SuggestChecklistResult } - return { suggestChecklist } + // 개인 할 일 추천(업무 본문 분석) — 체크리스트가 없는 업무에서만 호출한다. + // 실패는 호출측에서 인라인 처리(silent) + async function suggestTaskTodos(payload: { + projectId: string + taskSeq: number + }): Promise { + return (await api.post('/ai/suggest-task-todos', payload, { + silent: true, + timeout: AI_TIMEOUT_MS, + })) as unknown as SuggestTaskTodosResult + } + + return { suggestChecklist, suggestTaskTodos } } diff --git a/frontend/src/composables/useTodo.ts b/frontend/src/composables/useTodo.ts index 858fdd5..a8ef265 100644 --- a/frontend/src/composables/useTodo.ts +++ b/frontend/src/composables/useTodo.ts @@ -1,6 +1,7 @@ import { useApi } from './useApi' import type { ApiTodoLabel, + TodoFromTaskPayload, TodoItemPayload, TodoLabelPayload, } from '@/types/todo' @@ -27,6 +28,16 @@ export function useTodo() { await api.delete(`/todos/labels/${id}`) } + // 업무 → 할 일 생성. 같은 업무로 만든 라벨이 있으면 서버가 항목을 병합해 준다 + async function createFromTask( + payload: TodoFromTaskPayload, + ): Promise { + return (await api.post( + '/todos/labels/from-task', + payload, + )) as unknown as ApiTodoLabel + } + // 항목 async function createItem( labelId: string, @@ -51,6 +62,7 @@ export function useTodo() { createLabel, updateLabel, deleteLabel, + createFromTask, createItem, updateItem, deleteItem, diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index 67db345..71cdc08 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -28,6 +28,8 @@ import type { ChecklistReviewItem } from '@/types/task' import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' import { useDialog } from '@/composables/useDialog' +import { useTodo } from '@/composables/useTodo' +import TaskTodoModal from '@/components/todo/TaskTodoModal.vue' import UserAvatar from '@/components/UserAvatar.vue' import AvatarStack from '@/components/common/AvatarStack.vue' import StatusSelect from '@/components/StatusSelect.vue' @@ -45,6 +47,7 @@ const taskId = computed(() => Number(route.params.taskId)) const taskStore = useTaskStore() const authStore = useAuthStore() const dialog = useDialog() +const todo = useTodo() // 업무 상세 — API 연동 const task = ref(null) @@ -57,6 +60,54 @@ async function loadTask() { } watch(taskId, loadTask, { immediate: true }) +// --- 업무 → 개인 할 일 만들기 --- +// 체크리스트가 있으면 그 항목을 카테고리 순서대로 복사하고, +// 없으면 모달이 AI 로 본문을 분석해 실행 단계를 제안한다(양쪽 다 모달에서 확정). +const todoModalOpen = ref(false) +const todoSaving = ref(false) + +// 모달에 넘길 씨앗 항목 — 체크리스트를 카테고리 정의 순서로 평탄화(할 일에는 카테고리가 없다). +// 보완 내용(reviewNote)은 미리보기로 함께 보여주고, 실제 제목 합성은 서버가 한다. +const todoSeedItems = computed(() => + CHECKLIST_CATEGORIES.flatMap((cat) => + (task.value?.checklist ?? []) + .filter((c) => c.category === cat) + .map((c) => ({ + text: c.text, + done: c.done, + note: c.reviewNote ?? null, + })), + ), +) +// 체크리스트도 본문도 없으면 만들 근거가 없다. +// 본문은 문단 배열이라 빈 문단만 있을 수 있으므로 공백을 걸러 판정한다. +const canMakeTodo = computed( + () => + todoSeedItems.value.length > 0 || + (task.value?.content ?? []).some((p) => p.trim()), +) + +async function confirmTodo(items: string[]): Promise { + if (todoSaving.value) return + todoSaving.value = true + try { + const label = await todo.createFromTask({ + projectId: projectId.value, + taskSeq: taskId.value, + items, + }) + todoModalOpen.value = false + await dialog.alert( + `'${label.name}' 라벨에 할 일 ${items.length}개를 추가했습니다.`, + { variant: 'success' }, + ) + } catch { + // 오류 안내는 인터셉터가 처리 — 모달을 열어 둬 바로 다시 시도할 수 있게 한다 + } finally { + todoSaving.value = false + } +} + // 상태 배지 클래스(prog/review/done/changes) const statusClass = computed(() => task.value?.status ?? 'todo') @@ -1223,6 +1274,48 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { + +
+
+ + + + 할 일로 만들기 +
+
+ 이 업무를 내 할 일 목록에 추가합니다. 나만 보이며 업무 체크리스트에는 영향을 주지 않습니다. +
+
+ +
+
+
@@ -1904,6 +1997,19 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
+ + + diff --git a/frontend/src/types/ai.ts b/frontend/src/types/ai.ts index fabf4d8..05bb841 100644 --- a/frontend/src/types/ai.ts +++ b/frontend/src/types/ai.ts @@ -6,3 +6,8 @@ export interface ChecklistSuggestionGroup { export interface SuggestChecklistResult { groups: ChecklistSuggestionGroup[] } + +// 개인 할 일 추천 — 업무 본문 분석 결과(수행 순서대로 나열된 실행 단계) +export interface SuggestTaskTodosResult { + items: string[] +} diff --git a/frontend/src/types/todo.ts b/frontend/src/types/todo.ts index db6cbc0..47fab51 100644 --- a/frontend/src/types/todo.ts +++ b/frontend/src/types/todo.ts @@ -8,6 +8,13 @@ export interface ApiTodoItem { sortOrder: number } +// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다 +export interface ApiTodoSourceTask { + projectId: string + seq: number + title: string +} + // 라벨(할 일 묶음) — 항목과 진행 집계를 함께 내려받는다 export interface ApiTodoLabel { id: string @@ -17,6 +24,8 @@ export interface ApiTodoLabel { items: ApiTodoItem[] doneCount: number totalCount: number + // 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null — 링크를 표시하지 않는다 + sourceTask: ApiTodoSourceTask | null } // 생성/수정 payload @@ -29,3 +38,10 @@ export interface TodoItemPayload { title?: string done?: boolean } + +// 업무 → 할 일 생성 요청. 라벨 이름·색은 서버가 업무에서 파생하므로 보내지 않는다 +export interface TodoFromTaskPayload { + projectId: string + taskSeq: number + items: string[] +}