feat: 업무 상세에서 개인 할 일 만들기 추가
담당자가 업무를 자기 할 일 목록으로 옮길 수 있게 한다. 항목 출처는 두 갈래이며 확정 모달은 하나를 공유한다. - 체크리스트가 있으면 그 항목을 카테고리 순으로 복사(완료 항목은 기본 해제) - 없고 본문만 있으면 AI 가 본문을 분석해 담당자 관점의 실행 단계를 제안 - 둘 다 비어 있으면 버튼을 비활성화 - todo_labels.source_task_id 추가 — 같은 업무로 다시 만들면 새 라벨 대신 병합 - 병합 시 판정 기준은 '보완 내용을 뗀 원문', 반려된 항목은 완료를 되돌린다 - 수정 요청된 항목은 보완 내용을 제목에 함께 담는다 - 라벨 이름은 '프로젝트 - 업무 제목', 길면 프로젝트명부터 줄인다 - AI 프롬프트는 근거 없는 항목을 만들지 않도록 개수 하한 없이 제약 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<string>('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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<Task> | null;
|
||||
|
||||
// 소속 할 일 — 라벨 삭제 시 함께 삭제(FK CASCADE)
|
||||
// Relation<> 래퍼: 엔티티 순환참조 회피
|
||||
@OneToMany(() => TodoItem, (item) => item.label)
|
||||
|
||||
@@ -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<TodoLabelResponse> {
|
||||
return this.todoService.createFromTask(dto, user.id);
|
||||
}
|
||||
|
||||
// ----- 항목 -----
|
||||
|
||||
@Post('labels/:labelId/items')
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<TodoLabel>,
|
||||
@InjectRepository(TodoItem)
|
||||
private readonly itemRepo: Repository<TodoItem>,
|
||||
private readonly taskService: TaskService,
|
||||
) {}
|
||||
|
||||
// ----- 라벨 -----
|
||||
@@ -44,7 +85,7 @@ export class TodoService {
|
||||
async listLabels(ownerId: string): Promise<TodoLabelResponse[]> {
|
||||
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<TodoLabelResponse> {
|
||||
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<T extends { sortOrder: number }>(
|
||||
repo: Repository<T>,
|
||||
@@ -174,7 +340,7 @@ export class TodoService {
|
||||
): Promise<TodoLabelResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user