Compare commits

..

7 Commits

Author SHA1 Message Date
ttipo f968202dd0 Merge branch 'fix/todo-edit-focus' 2026-07-21 23:23:24 +09:00
ttipo 2d5ff51708 fix: 할 일 이름 수정 시 편집 상태에 갇히는 문제 수정
편집 입력이 v-for 안에 있어 문자열 ref 가 요소가 아닌 배열이 되었고,
focus() 가 조용히 무시돼 앞선 포커스 수정이 실제로는 적용되지 않았다.
포커스가 없으니 blur 도 발화하지 않아 다른 곳을 클릭해도 편집이 닫히지 않았다.

- 문자열 ref 를 함수 ref 로 교체해 입력 요소를 직접 받는다
- 바깥 클릭으로도 편집을 확정한다(blur 에만 의존하지 않는 안전장치)
  ESC 는 입력의 취소 처리와 겹치지 않도록 훅에서 제외
- nextTick 이후 편집 대상이 바뀌었으면 포커스를 주지 않는다

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:21:59 +09:00
ttipo e1577fadc4 Merge branch 'feat/task-to-todo' 2026-07-21 23:10:14 +09:00
ttipo 5303b2e92e feat: 할 일 완료 항목 정리 기능 추가
오래 쓴 라벨에 완료 항목이 계속 쌓여 카드가 길어지는 문제를 해소한다.

- 목록 상단에 '완료 항목 숨기기' 토글 — 화면에서만 걸러내고 데이터는 유지
- 라벨 메뉴에 '완료 항목 N개 삭제' — 확인 후 일괄 삭제(DELETE labels/:id/done-items)
- 완료 항목이 없으면 두 진입점 모두 노출하지 않는다
- 숨김 상태에서도 진행 집계는 전체 기준을 유지한다

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:08:50 +09:00
ttipo d110790385 style: 업무 작성 화면 할 일 추천 문구 정리
- 버튼의 반짝임 아이콘 제거, 문구를 'AI 추천' → '할 일 추천' 으로 변경
- 추천 모달 제목·aria-label 도 같은 문구로 통일
- 아이콘 제거로 쓰이지 않게 된 CSS 규칙 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:03:29 +09:00
ttipo 2ad0629975 fix: 할 일 화면 입력·삭제·동기화 문제 수정
할 일 기능 전반을 재검토해 사용자가 바로 겪는 문제를 고친다.

- 이름 수정 시 입력에 포커스를 주지 않아 타이핑도 blur 확정도 되지 않던 문제
- 한글 조합 중 Enter 가 미완성 문자열을 저장하던 문제(추가·수정 양쪽)
- 되돌릴 수 없는 할 일 삭제에 확인 다이얼로그 추가
- 추가 실패 시 입력이 사라지던 문제 — 성공했을 때만 비운다
- 수정 입력에 빠져 있던 maxlength 추가
- 체크 토글을 낙관적으로 반영하고 실패 시 되돌린다
- 같은 항목의 중복 요청을 막아 응답 역순 도착으로 인한 덮어쓰기 방지
- 편집 중인 행이 목록에서 사라지면 편집 상태를 정리
- 처리되지 않은 Promise 거부 제거
- 삭제 버튼이 키보드 포커스 시에도 보이도록 수정
- 업무에서 만든 라벨에 원본 업무 링크 표시(직접 만든 라벨은 표시하지 않음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:03:21 +09:00
ttipo 8d2436cd10 feat: 업무 상세에서 개인 할 일 만들기 추가
담당자가 업무를 자기 할 일 목록으로 옮길 수 있게 한다. 항목 출처는 두 갈래이며
확정 모달은 하나를 공유한다.

- 체크리스트가 있으면 그 항목을 카테고리 순으로 복사(완료 항목은 기본 해제)
- 없고 본문만 있으면 AI 가 본문을 분석해 담당자 관점의 실행 단계를 제안
- 둘 다 비어 있으면 버튼을 비활성화

- todo_labels.source_task_id 추가 — 같은 업무로 다시 만들면 새 라벨 대신 병합
- 병합 시 판정 기준은 '보완 내용을 뗀 원문', 반려된 항목은 완료를 되돌린다
- 수정 요청된 항목은 보완 내용을 제목에 함께 담는다
- 라벨 이름은 '프로젝트 - 업무 제목', 길면 프로젝트명부터 줄인다
- AI 프롬프트는 근거 없는 항목을 만들지 않도록 개수 하한 없이 제약

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:03:07 +09:00
21 changed files with 1534 additions and 56 deletions
@@ -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,
});
}
}
+4 -2
View File
@@ -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],
})
+129
View File
@@ -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;
}
+11
View File
@@ -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 -2
View File
@@ -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],
})
+185 -2
View File
@@ -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>
+142 -12
View File
@@ -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;
+14 -2
View File
@@ -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 }
}
+20
View File
@@ -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,
+5 -22
View File
@@ -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;
+106
View File
@@ -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 -8
View File
@@ -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;
}
+71 -6
View File
@@ -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,
+5
View File
@@ -6,3 +6,8 @@ export interface ChecklistSuggestionGroup {
export interface SuggestChecklistResult {
groups: ChecklistSuggestionGroup[]
}
// 개인 할 일 추천 — 업무 본문 분석 결과(수행 순서대로 나열된 실행 단계)
export interface SuggestTaskTodosResult {
items: string[]
}
+16
View File
@@ -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[]
}