Compare commits
7 Commits
53f1eeddc7
...
f968202dd0
| Author | SHA1 | Date | |
|---|---|---|---|
| f968202dd0 | |||
| 2d5ff51708 | |||
| e1577fadc4 | |||
| 5303b2e92e | |||
| d110790385 | |||
| 2ad0629975 | |||
| 8d2436cd10 |
@@ -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,32 @@ 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);
|
||||
}
|
||||
|
||||
@Delete('labels/:id/done-items')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '라벨의 완료된 할 일 일괄 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 404, description: '라벨 없음 (RES_001)' })
|
||||
clearDoneItems(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<TodoLabelResponse> {
|
||||
return this.todoService.clearDoneItems(id, 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,109 @@ 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 clearDoneItems(
|
||||
labelId: string,
|
||||
ownerId: string,
|
||||
): Promise<TodoLabelResponse> {
|
||||
await this.assertLabelOwned(labelId, ownerId);
|
||||
await this.itemRepo.delete({ label: { id: labelId }, done: true });
|
||||
return this.findLabelOrThrow(labelId, ownerId);
|
||||
}
|
||||
|
||||
// ----- 항목 -----
|
||||
|
||||
async createItem(
|
||||
@@ -155,6 +299,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 +351,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 +369,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 +380,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
<script setup lang="ts">
|
||||
// 업무 → 개인 할 일 확정 모달.
|
||||
// 항목 출처는 두 가지지만 화면은 동일하다 —
|
||||
// 1) 업무 체크리스트가 있으면 그 항목을 카테고리 순서대로 복사(완료 항목은 기본 해제)
|
||||
// 2) 체크리스트가 없고 본문만 있으면 AI 가 본문을 분석해 실행 단계를 제안
|
||||
// 시각 규격은 업무 작성 화면의 '할 일 추천' 모달과 동일하게 맞춘다
|
||||
// (오버레이·헤더 배지·+/✓ 항목·개수 푸터). 두 화면이 같은 성격의 작업이라 규격을 통일한다.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useAi } from '@/composables/useAi'
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
taskSeq: number
|
||||
// 안내 문구에 노출할 원본 업무 제목
|
||||
taskTitle: string
|
||||
// 수정 요청 상태 — 안내 문구를 '승인/보완' 관점으로 바꾼다
|
||||
changesReview?: boolean
|
||||
// 업무 체크리스트에서 복사할 항목(카테고리 순 정렬 완료). 비어 있으면 AI 로 생성한다.
|
||||
// note 는 지시자의 보완 내용(반려 사유)으로, 서버가 할 일 제목에 함께 담는다.
|
||||
seedItems: { text: string; done: boolean; note?: string | null }[]
|
||||
// 저장 진행 중 — 중복 제출 방지
|
||||
saving?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
saving: false,
|
||||
changesReview: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'confirm', items: string[]): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const ai = useAi()
|
||||
|
||||
// 화면 행 — 선택 여부는 사용자가 조정한다
|
||||
interface Row {
|
||||
text: string
|
||||
selected: boolean
|
||||
// 지시자의 보완 내용(있으면 항목 아래에 함께 보여준다)
|
||||
note?: string | null
|
||||
}
|
||||
const rows = ref<Row[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// AI 로 만든 목록인지(안내 문구 분기용)
|
||||
const fromAi = ref(false)
|
||||
|
||||
const selectedCount = computed(() => rows.value.filter((r) => r.selected).length)
|
||||
const allSelected = computed(
|
||||
() => rows.value.length > 0 && selectedCount.value === rows.value.length,
|
||||
)
|
||||
|
||||
function toggle(row: Row): void {
|
||||
row.selected = !row.selected
|
||||
}
|
||||
function toggleAll(): void {
|
||||
const next = !allSelected.value
|
||||
rows.value.forEach((r) => {
|
||||
r.selected = next
|
||||
})
|
||||
}
|
||||
|
||||
// 서버가 내려준 구체적 사유를 우선 표시한다.
|
||||
// (silent 요청이라 인터셉터 알림이 뜨지 않으므로 여기서 직접 꺼낸다)
|
||||
// 인터셉터는 비즈니스 실패면 error 객체를, HTTP 실패면 axios 에러를 넘긴다.
|
||||
function serverMessage(e: unknown): string | null {
|
||||
const direct = (e as { message?: unknown })?.message
|
||||
if (typeof direct === 'string' && direct.trim()) return direct
|
||||
const nested = (
|
||||
e as { response?: { data?: { error?: { message?: unknown } } } }
|
||||
)?.response?.data?.error?.message
|
||||
return typeof nested === 'string' && nested.trim() ? nested : null
|
||||
}
|
||||
|
||||
// AI 추천 요청 — 체크리스트가 없는 업무에서만 호출된다
|
||||
async function loadFromAi(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await ai.suggestTaskTodos({
|
||||
projectId: props.projectId,
|
||||
taskSeq: props.taskSeq,
|
||||
})
|
||||
rows.value = res.items.map((text) => ({ text, selected: true }))
|
||||
fromAi.value = true
|
||||
if (rows.value.length === 0) {
|
||||
// 근거 없는 항목을 지어내지 않도록 지시했으므로, 빈 결과는 정상 응답이다
|
||||
error.value =
|
||||
'업무 내용에서 만들 수 있는 할 일을 찾지 못했습니다. 내용을 보완한 뒤 다시 시도해 주세요.'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value =
|
||||
serverMessage(e) ??
|
||||
'할 일을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ESC 닫기 — 공용 BaseModal 과 동일하게 맞춘다(생성/저장 중에는 무시)
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||
|
||||
onMounted(() => {
|
||||
if (props.seedItems.length > 0) {
|
||||
// 완료된 체크리스트 항목은 기본 해제 — 이미 끝난 일을 다시 할 일로 만들지 않는다
|
||||
rows.value = props.seedItems.map((i) => ({
|
||||
text: i.text,
|
||||
selected: !i.done,
|
||||
note: i.note ?? null,
|
||||
}))
|
||||
return
|
||||
}
|
||||
void loadFromAi()
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
if (loading.value || props.saving) return
|
||||
emit('close')
|
||||
}
|
||||
function confirm(): void {
|
||||
const items = rows.value.filter((r) => r.selected).map((r) => r.text)
|
||||
if (items.length === 0) return
|
||||
emit('confirm', items)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="tt-overlay"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="tt-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="할 일로 만들기"
|
||||
>
|
||||
<!-- 헤더 -->
|
||||
<div class="tt-head">
|
||||
<div class="tt-head-top">
|
||||
<div class="tt-badge">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><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" /></svg>
|
||||
</div>
|
||||
<div class="tt-htext">
|
||||
<div class="tt-title">
|
||||
할 일로 만들기
|
||||
<span
|
||||
v-if="fromAi"
|
||||
class="auto"
|
||||
>자동 생성</span>
|
||||
</div>
|
||||
<div class="tt-sub">
|
||||
<template v-if="loading">
|
||||
업무 내용을 분석하고 있어요…
|
||||
</template>
|
||||
<template v-else-if="error">
|
||||
{{ error }}
|
||||
</template>
|
||||
<template v-else-if="fromAi">
|
||||
업무 <b>{{ taskTitle }}</b> 내용을 분석해 <b>{{ rows.length }}개</b> 항목을 제안했어요. 필요한 항목만 골라 추가하세요.
|
||||
</template>
|
||||
<template v-else-if="changesReview">
|
||||
업무 <b>{{ taskTitle }}</b> 체크리스트에서 <b>{{ rows.length }}개</b> 항목을 가져왔어요. 지시자가 승인한 항목은 빼고 <b>보완 요청된 항목</b>만 골라 뒀습니다.
|
||||
</template>
|
||||
<template v-else>
|
||||
업무 <b>{{ taskTitle }}</b> 체크리스트에서 <b>{{ rows.length }}개</b> 항목을 가져왔어요. 이미 완료된 항목은 빼 두었으니 필요한 항목만 골라 추가하세요.
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="tt-x"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
:disabled="loading || saving"
|
||||
@click="close"
|
||||
>
|
||||
<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 class="tt-body">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="tt-state"
|
||||
>
|
||||
잠시만 기다려 주세요… 최대 1분 정도 걸릴 수 있습니다.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="tt-state err"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<section
|
||||
v-else
|
||||
class="tt-sec"
|
||||
>
|
||||
<div class="tt-sec-head">
|
||||
<span class="tt-sec-name">할 일 목록</span>
|
||||
<span class="tt-sec-count">{{ rows.length }}</span>
|
||||
<button
|
||||
class="btn sm tt-sec-all"
|
||||
type="button"
|
||||
@click="toggleAll"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path :d="allSelected ? 'M5 12h14' : 'M5 12h14M12 5v14'" /></svg>
|
||||
{{ allSelected ? '전체 해제' : '전체 추가' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tt-items">
|
||||
<button
|
||||
v-for="(row, i) in rows"
|
||||
:key="i"
|
||||
class="tt-item"
|
||||
:class="{ added: row.selected }"
|
||||
type="button"
|
||||
@click="toggle(row)"
|
||||
>
|
||||
<span class="tt-item-check">
|
||||
<svg
|
||||
class="plus"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 12h14M12 5v14" /></svg>
|
||||
<svg
|
||||
class="tick"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="tt-item-body">
|
||||
<span class="tt-item-text">{{ row.text }}</span>
|
||||
<!-- 보완 내용 — 할 일 제목에 함께 담긴다 -->
|
||||
<span
|
||||
v-if="row.note"
|
||||
class="tt-item-note"
|
||||
>{{ row.note }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div
|
||||
v-if="!loading && !error"
|
||||
class="tt-foot"
|
||||
>
|
||||
<div class="tt-foot-info">
|
||||
<div class="tt-foot-count">
|
||||
<span class="n">{{ selectedCount }}</span>개 선택됨
|
||||
</div>
|
||||
<div class="tt-foot-hint">
|
||||
나만 보이는 할 일이며 업무 체크리스트에는 영향을 주지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn ghost"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
:disabled="selectedCount === 0 || saving"
|
||||
@click="confirm"
|
||||
>
|
||||
<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>
|
||||
{{ saving ? '추가하는 중…' : selectedCount === 0 ? '할 일에 추가' : selectedCount + '개 할 일에 추가' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 시각 규격은 업무 작성 화면의 '할 일 추천' 모달과 동일하게 맞춘다 */
|
||||
.tt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(22, 24, 29, 0.46);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
.tt-modal {
|
||||
width: 100%;
|
||||
max-width: 37.5rem;
|
||||
max-height: calc(100vh - 3.5rem);
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 1.5rem 4.375rem rgba(20, 24, 33, 0.3),
|
||||
0 0.25rem 0.875rem rgba(20, 24, 33, 0.14);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.tt-head {
|
||||
position: relative;
|
||||
padding: 1.25rem 1.375rem 1.125rem;
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, #f0eefe 0%, rgba(240, 238, 254, 0) 55%),
|
||||
linear-gradient(180deg, #f6f5ff 0%, #ffffff 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tt-head-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tt-badge {
|
||||
width: 2.375rem;
|
||||
height: 2.375rem;
|
||||
border-radius: 0.6875rem;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(140deg, #6366f1, #4338ca);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 0.25rem 0.75rem rgba(79, 70, 229, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.tt-badge svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.tt-htext {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 0.0625rem;
|
||||
}
|
||||
.tt-title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tt-title .auto {
|
||||
font-size: 0.656rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: #fff;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 20px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tt-sub {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tt-sub b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tt-x {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tt-x:hover {
|
||||
background: rgba(20, 24, 33, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
.tt-x:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.tt-x svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
/* 본문 */
|
||||
.tt-body {
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem 1.375rem 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
.tt-state {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.tt-state.err {
|
||||
color: var(--red);
|
||||
}
|
||||
.tt-sec {
|
||||
padding: 1rem 0 0.25rem;
|
||||
}
|
||||
.tt-sec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tt-sec-name {
|
||||
font-size: 0.813rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.tt-sec-count {
|
||||
font-size: 0.688rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* 전역 .btn.sm 규격을 그대로 쓰고, 배치만 보정한다 */
|
||||
.tt-sec-all {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 항목 목록 */
|
||||
.tt-items {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.tt-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.8125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-top: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
.tt-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.tt-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 0.1875rem;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tt-item:hover {
|
||||
background: #fafaff;
|
||||
}
|
||||
.tt-item-check {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.4375rem;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
transition: all 0.13s;
|
||||
}
|
||||
.tt-item-check .plus {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tt-item-check .tick {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.tt-item:hover .tt-item-check {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.tt-item:hover .tt-item-check .plus {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tt-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.tt-item-text {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.00625rem;
|
||||
}
|
||||
/* 보완 내용 — 반려 사유라 붉은 계열로 구분한다 */
|
||||
.tt-item-note {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--red);
|
||||
}
|
||||
.tt-item.added {
|
||||
background: #f7f7ff;
|
||||
}
|
||||
.tt-item.added::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.tt-item.added .tt-item-check {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tt-item.added .tt-item-check .plus {
|
||||
opacity: 0;
|
||||
}
|
||||
.tt-item.added .tt-item-check .tick {
|
||||
opacity: 1;
|
||||
}
|
||||
.tt-item.added .tt-item-text {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.tt-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8125rem 1.375rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fbfbfd;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tt-foot-info {
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt-foot-count {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tt-foot-count .n {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tt-foot-hint {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
/* 푸터 버튼은 전역 .btn / .btn.ghost / .btn.primary 를 그대로 쓴다.
|
||||
전역 규격에 없는 비활성 표기만 여기서 보완한다. */
|
||||
.tt-foot .btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// 라벨 카드 — 헤더(색점 + 이름 + 진행 + 편집/삭제) + 할 일 행 목록 + 인라인 추가 입력.
|
||||
// 전역 .list 카드 규격을 그대로 쓰고, 행은 카드 안에서 구분선으로 나눈다.
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
ref,
|
||||
watch,
|
||||
type ComponentPublicInstance,
|
||||
} from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import type { ApiTodoLabel, ApiTodoItem } from '@/types/todo'
|
||||
|
||||
const props = defineProps<{ label: ApiTodoLabel }>()
|
||||
// 추가는 emit 이 아니라 함수 prop 으로 받는다 —
|
||||
// 성공 여부를 알아야 실패 시 입력을 남겨 둘 수 있기 때문(emit 은 반환값이 없다).
|
||||
const props = defineProps<{
|
||||
label: ApiTodoLabel
|
||||
addItem: (title: string) => Promise<void>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'add', title: string): void
|
||||
(e: 'toggle', item: ApiTodoItem): void
|
||||
(e: 'rename', item: ApiTodoItem, title: string): void
|
||||
(e: 'remove', item: ApiTodoItem): void
|
||||
(e: 'edit-label'): void
|
||||
(e: 'delete-label'): void
|
||||
(e: 'clear-done'): void
|
||||
}>()
|
||||
|
||||
// 라벨 메뉴(편집/삭제) 드롭다운
|
||||
@@ -20,23 +32,76 @@ const menuOpen = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(menuOpen, () => (menuOpen.value = false), [menuRef])
|
||||
|
||||
// 인라인 추가 입력
|
||||
// 한글 등 IME 조합 중의 Enter 는 조합 확정용이라 제출로 보면 안 된다
|
||||
// (조합 중 Enter 를 제출로 처리하면 미완성 문자열이 저장된다)
|
||||
function isComposing(e: KeyboardEvent): boolean {
|
||||
return e.isComposing || e.keyCode === 229
|
||||
}
|
||||
|
||||
// 인라인 추가 입력 — 성공했을 때만 입력을 비운다(실패 시 다시 타이핑하지 않도록)
|
||||
const draft = ref('')
|
||||
function submitDraft(): void {
|
||||
const adding = ref(false)
|
||||
async function submitDraft(e: KeyboardEvent): Promise<void> {
|
||||
if (isComposing(e)) return
|
||||
const title = draft.value.trim()
|
||||
if (!title) return
|
||||
emit('add', title)
|
||||
draft.value = ''
|
||||
if (!title || adding.value) return
|
||||
adding.value = true
|
||||
try {
|
||||
await props.addItem(title)
|
||||
draft.value = ''
|
||||
} catch {
|
||||
// 오류 안내는 인터셉터가 처리 — 입력한 내용은 그대로 남긴다
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 인라인 수정 — 한 번에 한 행만 편집 상태가 된다
|
||||
const editingId = ref<string | null>(null)
|
||||
const editDraft = ref('')
|
||||
function startEdit(item: ApiTodoItem): void {
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
// v-for 안에서는 문자열 ref 가 '요소 배열'이 되어 focus() 를 호출할 수 없다.
|
||||
// 편집 입력은 한 번에 하나만 존재하므로 함수 ref 로 요소를 직접 받는다.
|
||||
function setEditInput(el: Element | ComponentPublicInstance | null): void {
|
||||
editInput.value = (el as HTMLInputElement | null) ?? null
|
||||
}
|
||||
async function startEdit(item: ApiTodoItem): Promise<void> {
|
||||
editingId.value = item.id
|
||||
editDraft.value = item.title
|
||||
// 입력이 그려진 뒤 포커스를 줘야 한다. 포커스가 없으면 타이핑도 blur 확정도 되지 않는다.
|
||||
await nextTick()
|
||||
if (editingId.value !== item.id) return
|
||||
editInput.value?.focus()
|
||||
editInput.value?.select()
|
||||
}
|
||||
function commitEdit(item: ApiTodoItem): void {
|
||||
|
||||
// 바깥 클릭으로도 편집을 확정한다.
|
||||
// blur 만 믿으면 포커스가 걸리지 않은 상황에서 편집 상태에 갇힌다(라벨 메뉴와 동일한 훅 사용).
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
useClickOutside(
|
||||
isEditing,
|
||||
() => {
|
||||
const item = props.label.items.find((i) => i.id === editingId.value)
|
||||
if (item) commitEdit(item)
|
||||
else cancelEdit()
|
||||
},
|
||||
[editInput],
|
||||
// ESC 는 입력의 @keydown.esc 가 '취소'로 처리한다.
|
||||
// 여기서도 잡으면 취소해야 할 상황에 저장돼 버린다.
|
||||
{ esc: false },
|
||||
)
|
||||
|
||||
// 편집 중인 행이 목록에서 사라지면(삭제·검색 필터) 편집 상태를 정리한다
|
||||
watch(
|
||||
() => props.label.items,
|
||||
(items) => {
|
||||
if (editingId.value && !items.some((i) => i.id === editingId.value)) {
|
||||
cancelEdit()
|
||||
}
|
||||
},
|
||||
)
|
||||
function commitEdit(item: ApiTodoItem, e?: KeyboardEvent): void {
|
||||
if (e && isComposing(e)) return
|
||||
// Enter 로 확정하면 입력이 사라지며 blur 가 뒤따라 한 번 더 호출된다.
|
||||
// 편집 중인 행이 아니면 무시해 같은 요청이 두 번 나가지 않게 한다.
|
||||
if (editingId.value !== item.id) return
|
||||
@@ -61,6 +126,23 @@ function cancelEdit(): void {
|
||||
<h2 class="tl-name">
|
||||
{{ props.label.name }}
|
||||
</h2>
|
||||
<!-- 원본 업무 링크 — 업무에서 만든 라벨에만 있다(직접 만든 라벨은 없음) -->
|
||||
<RouterLink
|
||||
v-if="props.label.sourceTask"
|
||||
class="tl-src"
|
||||
:to="`/projects/${props.label.sourceTask.projectId}/tasks/${props.label.sourceTask.seq}`"
|
||||
:title="`원본 업무: ${props.label.sourceTask.title}`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.5 1.5" /><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7L12 19" /></svg>
|
||||
업무 #{{ props.label.sourceTask.seq }}
|
||||
</RouterLink>
|
||||
<span class="tl-progress">{{ props.label.doneCount }}/{{ props.label.totalCount }}</span>
|
||||
<div
|
||||
ref="menuRef"
|
||||
@@ -102,6 +184,14 @@ function cancelEdit(): void {
|
||||
>
|
||||
라벨 편집
|
||||
</button>
|
||||
<!-- 완료 항목이 있을 때만 노출 — 쌓인 완료 항목을 정리하는 용도 -->
|
||||
<button
|
||||
v-if="props.label.doneCount > 0"
|
||||
type="button"
|
||||
@click="menuOpen = false; emit('clear-done')"
|
||||
>
|
||||
완료 항목 {{ props.label.doneCount }}개 삭제
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@@ -140,9 +230,11 @@ function cancelEdit(): void {
|
||||
</button>
|
||||
<input
|
||||
v-if="editingId === item.id"
|
||||
:ref="setEditInput"
|
||||
v-model="editDraft"
|
||||
class="tl-edit"
|
||||
@keydown.enter="commitEdit(item)"
|
||||
maxlength="200"
|
||||
@keydown.enter="commitEdit(item, $event)"
|
||||
@keydown.esc="cancelEdit"
|
||||
@blur="commitEdit(item)"
|
||||
>
|
||||
@@ -191,6 +283,11 @@ function cancelEdit(): void {
|
||||
maxlength="200"
|
||||
@keydown.enter="submitDraft"
|
||||
>
|
||||
<span
|
||||
v-if="adding"
|
||||
class="tl-add-busy"
|
||||
aria-live="polite"
|
||||
>추가 중…</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -369,13 +466,46 @@ function cancelEdit(): void {
|
||||
display: none;
|
||||
place-items: center;
|
||||
}
|
||||
.tl-row:hover .tl-del {
|
||||
/* 키보드로 탭 이동했을 때도 보이게 한다 — 보이지 않는 포커스 정거장을 만들지 않는다 */
|
||||
.tl-row:hover .tl-del,
|
||||
.tl-del:focus-visible {
|
||||
display: grid;
|
||||
}
|
||||
.tl-del:hover {
|
||||
background: #eceef1;
|
||||
color: var(--text);
|
||||
}
|
||||
/* 원본 업무 링크 — 라벨 이름 옆의 보조 정보라 작고 낮은 대비로 둔다 */
|
||||
.tl-src {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.4375rem;
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-src:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.tl-src svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.tl-add-busy {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tl-del svg {
|
||||
width: 0.813rem;
|
||||
height: 0.813rem;
|
||||
|
||||
@@ -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<SuggestTaskTodosResult> {
|
||||
return (await api.post('/ai/suggest-task-todos', payload, {
|
||||
silent: true,
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
})) as unknown as SuggestTaskTodosResult
|
||||
}
|
||||
|
||||
return { suggestChecklist, suggestTaskTodos }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiTodoLabel,
|
||||
TodoFromTaskPayload,
|
||||
TodoItemPayload,
|
||||
TodoLabelPayload,
|
||||
} from '@/types/todo'
|
||||
@@ -27,6 +28,23 @@ export function useTodo() {
|
||||
await api.delete(`/todos/labels/${id}`)
|
||||
}
|
||||
|
||||
// 라벨의 완료 항목 일괄 삭제 — 변경된 라벨을 돌려받아 카드만 교체한다
|
||||
async function clearDoneItems(labelId: string): Promise<ApiTodoLabel> {
|
||||
return (await api.delete(
|
||||
`/todos/labels/${labelId}/done-items`,
|
||||
)) as unknown as ApiTodoLabel
|
||||
}
|
||||
|
||||
// 업무 → 할 일 생성. 같은 업무로 만든 라벨이 있으면 서버가 항목을 병합해 준다
|
||||
async function createFromTask(
|
||||
payload: TodoFromTaskPayload,
|
||||
): Promise<ApiTodoLabel> {
|
||||
return (await api.post(
|
||||
'/todos/labels/from-task',
|
||||
payload,
|
||||
)) as unknown as ApiTodoLabel
|
||||
}
|
||||
|
||||
// 항목
|
||||
async function createItem(
|
||||
labelId: string,
|
||||
@@ -51,6 +69,8 @@ export function useTodo() {
|
||||
createLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
clearDoneItems,
|
||||
createFromTask,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
|
||||
@@ -570,20 +570,7 @@ function goBack() {
|
||||
title="프로젝트·업무 내용을 분석해 할 일을 추천합니다"
|
||||
@click="requestAiSuggestions"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3 13.4 8.6 19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4Z" />
|
||||
<path d="M18 16l.7 2.3L21 19l-2.3.7L18 22l-.7-2.3L15 19l2.3-.7Z" />
|
||||
</svg>
|
||||
{{ aiSuggesting ? '분석 중…' : 'AI 추천' }}
|
||||
{{ aiSuggesting ? '분석 중…' : '할 일 추천' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
@@ -817,7 +804,7 @@ function goBack() {
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- AI 추천 할 일 — 모달 -->
|
||||
<!-- 할 일 추천 — 모달 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="aiSuggesting || aiSuggestions || aiError"
|
||||
@@ -828,7 +815,7 @@ function goBack() {
|
||||
class="ai-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI 추천 할 일"
|
||||
aria-label="할 일 추천"
|
||||
>
|
||||
<!-- 헤더 -->
|
||||
<div class="ai-head">
|
||||
@@ -844,7 +831,7 @@ function goBack() {
|
||||
</div>
|
||||
<div class="ai-htext">
|
||||
<div class="ai-title">
|
||||
AI 추천 할 일 <span class="auto">자동 생성</span>
|
||||
할 일 추천 <span class="auto">자동 생성</span>
|
||||
</div>
|
||||
<div class="ai-sub">
|
||||
<template v-if="aiSuggesting">
|
||||
@@ -1400,10 +1387,6 @@ function goBack() {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.btn.ai svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 레이아웃 */
|
||||
.layout {
|
||||
@@ -1742,7 +1725,7 @@ function goBack() {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* AI 추천 할 일 */
|
||||
/* 할 일 추천 */
|
||||
.ai-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -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<TaskDetail | null>(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<void> {
|
||||
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<TaskStatus>(() => task.value?.status ?? 'todo')
|
||||
|
||||
@@ -1223,6 +1274,48 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 담당자 전용 — 업무를 내 할 일 목록으로 옮긴다(개인 전용, 업무에는 영향 없음) -->
|
||||
<div
|
||||
v-if="isAssignee"
|
||||
class="card ap-card"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="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" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">할 일로 만들기</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">
|
||||
이 업무를 <b>내 할 일</b> 목록에 추가합니다. 나만 보이며 업무 체크리스트에는 영향을 주지 않습니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
:disabled="!canMakeTodo"
|
||||
:title="canMakeTodo ? undefined : '체크리스트와 내용이 모두 비어 있어 만들 수 없습니다.'"
|
||||
@click="todoModalOpen = true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 5v14M5 12h14" /></svg>
|
||||
할 일로 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
@@ -1904,6 +1997,19 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 업무 → 개인 할 일 확정 모달(체크리스트 복사분·AI 추천분 공통) -->
|
||||
<TaskTodoModal
|
||||
v-if="todoModalOpen && task"
|
||||
:project-id="projectId"
|
||||
:task-seq="taskId"
|
||||
:task-title="task.title"
|
||||
:changes-review="isChangesReview"
|
||||
:seed-items="todoSeedItems"
|
||||
:saving="todoSaving"
|
||||
@confirm="confirmTodo"
|
||||
@close="todoModalOpen = false"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -48,18 +48,41 @@ async function onDeleteLabel(label: ApiTodoLabel): Promise<void> {
|
||||
await store.deleteLabel(label.id)
|
||||
}
|
||||
|
||||
async function onClearDone(label: ApiTodoLabel): Promise<void> {
|
||||
const ok = await dialog.confirm(
|
||||
`'${label.name}' 라벨의 완료된 할 일 ${label.doneCount}건을 삭제할까요?`,
|
||||
{ title: '완료 항목 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
swallow(store.clearDoneItems(label.id))
|
||||
}
|
||||
|
||||
// ----- 할 일 -----
|
||||
async function onAddItem(labelId: string, title: string): Promise<void> {
|
||||
await store.addItem(labelId, title)
|
||||
// 오류 안내는 API 인터셉터가 토스트로 처리하므로 여기서는 거부만 삼킨다.
|
||||
// (삼키지 않으면 템플릿 핸들러에서 처리되지 않은 Promise 거부가 남는다)
|
||||
function swallow(p: Promise<unknown>): void {
|
||||
void p.catch(() => undefined)
|
||||
}
|
||||
async function onToggleItem(item: ApiTodoItem): Promise<void> {
|
||||
await store.toggleItem(item.id, !item.done)
|
||||
|
||||
// 추가만 실패를 호출측(카드)에 알려야 한다 — 입력을 지울지 남길지 판단하기 위해
|
||||
function onAddItem(labelId: string, title: string): Promise<void> {
|
||||
return store.addItem(labelId, title)
|
||||
}
|
||||
async function onRenameItem(item: ApiTodoItem, title: string): Promise<void> {
|
||||
await store.renameItem(item.id, title)
|
||||
function onToggleItem(item: ApiTodoItem): void {
|
||||
swallow(store.toggleItem(item.id, !item.done))
|
||||
}
|
||||
function onRenameItem(item: ApiTodoItem, title: string): void {
|
||||
swallow(store.renameItem(item.id, title))
|
||||
}
|
||||
async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
await store.removeItem(item.id)
|
||||
// 되돌릴 수 없는 삭제라 라벨 삭제와 동일하게 확인을 받는다
|
||||
const ok = await dialog.confirm(`'${item.title}' 할 일을 삭제할까요?`, {
|
||||
title: '할 일 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
swallow(store.removeItem(item.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -98,6 +121,15 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
placeholder="라벨·할 일 검색…"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="store.hasDoneItems"
|
||||
type="button"
|
||||
class="btn hide-done"
|
||||
:aria-pressed="store.hideDone"
|
||||
@click="store.hideDone = !store.hideDone"
|
||||
>
|
||||
{{ store.hideDone ? '완료 항목 보기' : '완료 항목 숨기기' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary new-label"
|
||||
@@ -131,12 +163,13 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
v-for="label in store.displayLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
@add="onAddItem(label.id, $event)"
|
||||
:add-item="(title: string) => onAddItem(label.id, title)"
|
||||
@toggle="onToggleItem"
|
||||
@rename="onRenameItem"
|
||||
@remove="onRemoveItem"
|
||||
@edit-label="openEditLabel(label)"
|
||||
@delete-label="onDeleteLabel(label)"
|
||||
@clear-done="onClearDone(label)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +202,13 @@ async function onRemoveItem(item: ApiTodoItem): Promise<void> {
|
||||
margin: 0.25rem 0 1.125rem;
|
||||
}
|
||||
/* 회의 로비와 동일하게 주요 버튼을 우측으로 밀어 배치 */
|
||||
/* 완료 숨김 토글은 주요 버튼 왼쪽에 붙인다 */
|
||||
.hide-done {
|
||||
margin-left: auto;
|
||||
}
|
||||
.hide-done + .new-label {
|
||||
margin-left: 0;
|
||||
}
|
||||
.new-label {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useTodo } from '@/composables/useTodo'
|
||||
import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
|
||||
import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
|
||||
|
||||
// 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD.
|
||||
// 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다.
|
||||
@@ -11,14 +11,24 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
const labels = ref<ApiTodoLabel[]>([])
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
// 완료 항목 숨기기 — 화면에서만 걸러내며 데이터는 그대로 둔다
|
||||
const hideDone = ref(false)
|
||||
|
||||
// 검색 — 라벨 이름 또는 할 일 내용에 걸리면 남긴다.
|
||||
// 라벨 이름이 걸린 경우는 항목을 그대로 두고, 항목만 걸린 경우는 걸린 항목만 보여준다.
|
||||
const displayLabels = computed<ApiTodoLabel[]>(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return labels.value
|
||||
// 진행 집계(doneCount/totalCount)는 걸러내도 원본 수치를 유지한다 —
|
||||
// '2/5 중 완료 숨김' 상태에서도 전체 진행률을 알 수 있어야 한다
|
||||
const source = hideDone.value
|
||||
? labels.value.map((l) => ({
|
||||
...l,
|
||||
items: l.items.filter((i) => !i.done),
|
||||
}))
|
||||
: labels.value
|
||||
if (!kw) return source
|
||||
const result: ApiTodoLabel[] = []
|
||||
for (const l of labels.value) {
|
||||
for (const l of source) {
|
||||
if (l.name.toLowerCase().includes(kw)) {
|
||||
result.push(l)
|
||||
continue
|
||||
@@ -29,17 +39,42 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
return result
|
||||
})
|
||||
|
||||
// 완료 항목이 하나라도 있는지 — '완료 항목 정리' 노출 판단에 쓴다
|
||||
const hasDoneItems = computed(() =>
|
||||
labels.value.some((l) => l.doneCount > 0),
|
||||
)
|
||||
|
||||
const isFiltered = computed(() => keyword.value.trim() !== '')
|
||||
// 전체 할 일 수(헤더 count-pill 표기용)
|
||||
const totalCount = computed(() =>
|
||||
labels.value.reduce((sum, l) => sum + l.totalCount, 0),
|
||||
)
|
||||
|
||||
// 진행 중인 항목 변경 — 같은 항목에 중복 요청이 나가지 않도록 막는다.
|
||||
// (응답이 올 때까지 화면 상태가 그대로라, 막지 않으면 같은 값을 두 번 보내고
|
||||
// 두 응답이 역순으로 도착하면 나중 응답이 이겨 버린다)
|
||||
const pendingItemIds = ref<Set<string>>(new Set())
|
||||
function markPending(id: string, on: boolean): void {
|
||||
const next = new Set(pendingItemIds.value)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
pendingItemIds.value = next
|
||||
}
|
||||
|
||||
// 변경된 라벨 한 건을 목록에 반영
|
||||
function replaceLabel(next: ApiTodoLabel): void {
|
||||
labels.value = labels.value.map((l) => (l.id === next.id ? next : l))
|
||||
}
|
||||
|
||||
// 항목 한 건을 화면에서 먼저 바꾼다(낙관적 갱신). 진행 집계도 함께 맞춘다.
|
||||
function patchItemLocally(id: string, patch: Partial<ApiTodoItem>): void {
|
||||
labels.value = labels.value.map((l) => {
|
||||
if (!l.items.some((i) => i.id === id)) return l
|
||||
const items = l.items.map((i) => (i.id === id ? { ...i, ...patch } : i))
|
||||
return { ...l, items, doneCount: items.filter((i) => i.done).length }
|
||||
})
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -64,19 +99,46 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
await api.deleteLabel(id)
|
||||
labels.value = labels.value.filter((l) => l.id !== id)
|
||||
}
|
||||
async function clearDoneItems(id: string): Promise<void> {
|
||||
replaceLabel(await api.clearDoneItems(id))
|
||||
}
|
||||
|
||||
// --- 항목 ---
|
||||
async function addItem(labelId: string, title: string): Promise<void> {
|
||||
replaceLabel(await api.createItem(labelId, title))
|
||||
}
|
||||
// 체크 토글 — 응답을 기다리지 않고 먼저 반영하고, 실패하면 되돌린다
|
||||
async function toggleItem(id: string, done: boolean): Promise<void> {
|
||||
replaceLabel(await api.updateItem(id, { done }))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
const snapshot = labels.value
|
||||
markPending(id, true)
|
||||
patchItemLocally(id, { done })
|
||||
try {
|
||||
replaceLabel(await api.updateItem(id, { done }))
|
||||
} catch (e) {
|
||||
labels.value = snapshot
|
||||
throw e
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
async function renameItem(id: string, title: string): Promise<void> {
|
||||
replaceLabel(await api.updateItem(id, { title }))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
markPending(id, true)
|
||||
try {
|
||||
replaceLabel(await api.updateItem(id, { title }))
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
async function removeItem(id: string): Promise<void> {
|
||||
replaceLabel(await api.deleteItem(id))
|
||||
if (pendingItemIds.value.has(id)) return
|
||||
markPending(id, true)
|
||||
try {
|
||||
replaceLabel(await api.deleteItem(id))
|
||||
} finally {
|
||||
markPending(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -84,12 +146,15 @@ export const useTodoStore = defineStore('todo', () => {
|
||||
displayLabels,
|
||||
keyword,
|
||||
loading,
|
||||
hideDone,
|
||||
isFiltered,
|
||||
hasDoneItems,
|
||||
totalCount,
|
||||
load,
|
||||
createLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
clearDoneItems,
|
||||
addItem,
|
||||
toggleItem,
|
||||
renameItem,
|
||||
|
||||
@@ -6,3 +6,8 @@ export interface ChecklistSuggestionGroup {
|
||||
export interface SuggestChecklistResult {
|
||||
groups: ChecklistSuggestionGroup[]
|
||||
}
|
||||
|
||||
// 개인 할 일 추천 — 업무 본문 분석 결과(수행 순서대로 나열된 실행 단계)
|
||||
export interface SuggestTaskTodosResult {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user