Compare commits

...

16 Commits

Author SHA1 Message Date
ttipo fdf6544bf1 Merge branch 'chore/docker-log-rotation' 2026-08-06 19:45:00 +09:00
ttipo 18ffef8ac8 chore: 컨테이너 로그 로테이션 설정 추가
json-file 드라이버 기본값(무제한)으로 로그가 호스트 디스크를 채워
PostgreSQL 이 could not write init file 로 실패하는 문제를 방지한다.
전 서비스에 서비스당 30MB(10MB x 3) 상한을 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:44:54 +09:00
ttipo 6db7b1d67e Merge branch 'fix/upload-volume' 2026-07-21 23:39:02 +09:00
ttipo da65799e2a fix: 업무 첨부 저장 경로에 볼륨 연결
backend 컨테이너에 볼륨이 없어 업무 첨부가 컨테이너 내부에만 쌓였고,
재배포로 컨테이너가 교체될 때 파일이 모두 사라졌다. DB 의 첨부 레코드는
남아 있어 목록에는 보이지만 다운로드는 실패한다.

- backend 에 UPLOAD_DIR=/app/uploads 주입 + ${UPLOAD_DATA_DIR} 볼륨 연결
- 루트 env 예시에 UPLOAD_DATA_DIR 추가(다른 *_DATA_DIR 과 동일한 규격)

이미 유실된 파일은 복구되지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 23:37:21 +09:00
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
ttipo 53f1eeddc7 Merge branch 'feat/todo-list' 2026-07-21 21:17:55 +09:00
ttipo 65d7abcd68 fix: 업무 완료일을 마감일이 아닌 실제 완료 시각으로 표시
목록의 "N월 N일 완료" 라벨이 완료 시각이 아니라 마감일(dueDate)을 쓰고 있어,
마감일을 지시 당일로 잡은 업무는 지시 날짜가 완료일처럼 보였다.
완료 시각을 저장하는 컬럼 자체가 없던 것이 원인이다.

- tasks.completed_at 추가 — done 진입 시 기록, 이탈 시 null(재승인 시 갱신)
- 목록/내 업무 응답에 completedAt 노출, taskDueInfo 가 이 값으로 라벨 생성
- 기존 완료 업무는 활동 로그(task.status_changed)의 시각으로 백필
- 백필 불가 건과 마감일 없는 완료 업무는 날짜 없이 "완료"만 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:11:11 +09:00
ttipo 38b3762b2c refactor: 헤더 AI 채팅 어시스턴트 제거
사용하지 않는 기능이라 헤더 버튼과 관련 로직을 모두 걷어낸다.
업무 생성 화면의 AI 할 일 추천(/ai/suggest-checklist)은 그대로 유지한다.

- 프론트: AgentChatPanel, ai.store, 헤더 버튼·패널 마킹, useAi 의 대화 함수와
  types/ai 의 대화 타입 제거. 채팅 전용이던 shared/utils/markdown.ts 도 함께 삭제
- 백엔드: AiController(대화 5개 엔드포인트), AiConversation·AiMessage 엔티티,
  SendMessageDto, AiService 의 채팅 로직 제거. AiModule 의 TaskModule 의존도 해제
- ai_conversations·ai_messages 테이블 삭제 마이그레이션 추가(down 은 초기 스키마 복원)
- 문서: api-contract 의 채팅 API 절 삭제, integration-progress 8-6 은 이력이라
  삭제 대신 제거됨 표기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:24:14 +09:00
ttipo f550c4d46e style: 콘텐츠 영역 최대 폭 제한 해제
.page 의 max-width(67.5rem)와 가운데 정렬을 제거해 콘텐츠가 화면
전체 폭을 사용하도록 한다. 좌우 여백(1.5rem)은 그대로 유지한다.

프로젝트 생성·설정처럼 좁은 단이 필요한 폼 화면은 각 페이지에서
자체 max-width 를 유지한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:23:46 +09:00
ttipo 29a8ef841f feat: 개인 할 일(체크리스트) 기능 추가
사이드바에 '할 일' 메뉴를 추가하고, 라벨 1개에 할 일 N개를 담는
개인 전용 체크리스트를 구현한다.

- todo_labels / todo_items 엔티티 + 마이그레이션 (소유자·라벨 CASCADE)
- 라벨·항목 CRUD API — 항목 변경 응답은 '변경된 라벨' 단위라 카드 하나만 갱신
- 모든 라벨·항목은 소유자 본인만 접근하며 타인 것은 존재를 숨김(404)
- 화면: 라벨 카드 세로 나열, 인라인 추가·수정, 완료 항목은 취소선 후 하단 정렬
- 기존 .list / .toolbar / BaseModal 등 공용 디자인 규격 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:23:32 +09:00
50 changed files with 3172 additions and 1463 deletions
+7
View File
@@ -38,6 +38,13 @@ REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# 업로드(업무 첨부)
# ==========================================
# UPLOAD_DATA_DIR: 업무 첨부 파일 보관 경로(호스트). 컨테이너의 /app/uploads 에 마운트된다.
# 볼륨이 없으면 재배포 시 첨부 파일이 유실된다.
UPLOAD_DATA_DIR=./data/uploads
# ==========================================
# API 경로
# ==========================================
+7
View File
@@ -38,6 +38,13 @@ REDIS_IMAGE=redis:7-alpine
REDIS_PASSWORD=change-me-redis-password
REDIS_DATA_DIR=./data/redis
# ==========================================
# 업로드(업무 첨부)
# ==========================================
# UPLOAD_DATA_DIR: 업무 첨부 파일 보관 경로(호스트). 컨테이너의 /app/uploads 에 마운트된다.
# 볼륨이 없으면 재배포 시 첨부 파일이 유실된다.
UPLOAD_DATA_DIR=./data/uploads
# ==========================================
# API 경로
# ==========================================
+2
View File
@@ -22,6 +22,7 @@ import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
import { MeetingModule } from './modules/meeting/meeting.module';
import { DriveModule } from './modules/drive/drive.module';
import { TodoModule } from './modules/todo/todo.module';
@Module({
imports: [
@@ -106,6 +107,7 @@ import { DriveModule } from './modules/drive/drive.module';
ScheduleModule,
MeetingModule,
DriveModule,
TodoModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 할 일(개인 체크리스트) — 라벨/항목 테이블 추가.
// 라벨은 소유자 전용이고, 사용자 삭제 시 라벨과 항목이 연쇄 삭제된다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTodo1783800000000 implements MigrationInterface {
name = 'AddTodo1783800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 라벨(묶음)
await queryRunner.query(
`CREATE TABLE "todo_labels" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid NOT NULL, CONSTRAINT "PK_todo_labels" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_todo_labels_owner" ON "todo_labels" ("owner_id")`,
);
// 항목(할 일)
await queryRunner.query(
`CREATE TABLE "todo_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "done" boolean NOT NULL DEFAULT false, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "label_id" uuid, CONSTRAINT "PK_todo_items" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_todo_items_label" ON "todo_items" ("label_id")`,
);
// FK
await queryRunner.query(
`ALTER TABLE "todo_labels" ADD CONSTRAINT "FK_todo_labels_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "todo_items" ADD CONSTRAINT "FK_todo_items_label" FOREIGN KEY ("label_id") REFERENCES "todo_labels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "todo_items" DROP CONSTRAINT "FK_todo_items_label"`,
);
await queryRunner.query(
`ALTER TABLE "todo_labels" DROP CONSTRAINT "FK_todo_labels_owner"`,
);
await queryRunner.query(`DROP TABLE "todo_items"`);
await queryRunner.query(`DROP TABLE "todo_labels"`);
}
}
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// AI 채팅(헤더 어시스턴트) 기능 제거 — 대화/메시지 테이블 삭제.
// 업무 생성 화면의 할 일 추천(/ai/suggest-checklist)은 DB 를 쓰지 않으므로 영향이 없다.
// down() 은 초기 스키마(CreateInitialSchema)의 정의를 그대로 되살린다(데이터는 복구되지 않음).
export class DropAiConversations1783900000000 implements MigrationInterface {
name = 'DropAiConversations1783900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 메시지 → 대화 순서로 FK 를 먼저 끊고 테이블을 지운다
await queryRunner.query(
`ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`,
);
await queryRunner.query(`DROP TABLE "ai_messages"`);
await queryRunner.query(`DROP TABLE "ai_conversations"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 업무 완료 시각(completed_at) 추가.
// 종전에는 목록의 '완료' 날짜를 마감일(due_date)로 표기해 실제 완료일과 달랐다.
// 기존 완료 업무는 활동 로그(task.status_changed, statusTo=done)의 시각으로 백필한다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCompletedAt1784000000000 implements MigrationInterface {
name = 'AddTaskCompletedAt1784000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" ADD "completed_at" TIMESTAMP WITH TIME ZONE`,
);
// 백필 — 업무별 '가장 최근' 완료 전이 시각을 사용한다.
// (수정 요청 후 재승인된 업무는 마지막 승인 시점이 잡힌다)
// activities 는 task FK 대신 (project_id, task_seq) 를 갖고, tasks 는 (project_id, seq) 가 유일하다.
// 활동 기록이 없는 과거 업무는 null 로 남고, 프론트는 날짜 없이 '완료'만 표시한다.
// activities.created_at 은 timestamp(무시간대, UTC 저장)이므로
// AT TIME ZONE 'UTC' 로 명시 변환해 세션 타임존과 무관하게 정확히 옮긴다.
await queryRunner.query(`
UPDATE "tasks" t SET "completed_at" = a."created_at" AT TIME ZONE 'UTC'
FROM (
SELECT DISTINCT ON ("project_id", "task_seq")
"project_id", "task_seq", "created_at"
FROM "activities"
WHERE "type" = 'task.status_changed'
AND "payload"->>'statusTo' = 'done'
ORDER BY "project_id", "task_seq", "created_at" DESC
) a
WHERE t."project_id" = a."project_id"
AND t."seq" = a."task_seq"
AND t."status" = 'done'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "completed_at"`);
}
}
@@ -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,
});
}
}
-97
View File
@@ -1,97 +0,0 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import {
AiService,
type ConversationDetail,
type ConversationSummary,
type SendResult,
} from './ai.service';
import { SendMessageDto } from './dto/send-message.dto';
// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프).
@ApiTags('AI')
@Controller('ai/conversations')
@UseGuards(JwtAuthGuard)
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get()
@ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
list(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<ConversationSummary>> {
return this.aiService.listConversations(user.id, page, size);
}
@Get(':id')
@ApiOperation({ summary: '대화 상세 (메시지 포함)' })
@ApiResponse({ status: 200, description: '조회 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
get(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<ConversationDetail> {
return this.aiService.getConversation(user.id, id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '새 대화 시작 (첫 메시지)' })
@ApiResponse({ status: 201, description: '생성 + AI 응답' })
start(
@CurrentUser() user: PublicUser,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.startConversation(user.id, dto.content);
}
@Post(':id/messages')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({ summary: '대화에 메시지 추가' })
@ApiResponse({ status: 200, description: 'AI 응답' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
send(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SendMessageDto,
): Promise<SendResult> {
return this.aiService.addMessage(user.id, id, dto.content);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '대화 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 404, description: '대화 없음 (RES_001)' })
async remove(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<null> {
await this.aiService.deleteConversation(user.id, id);
return null;
}
}
+6 -13
View File
@@ -1,22 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TaskModule } from '../task/task.module';
import { ProjectModule } from '../project/project.module';
import { AiController } from './ai.controller';
import { TaskModule } from '../task/task.module';
import { AiSuggestController } from './ai-suggest.controller';
import { AiService } from './ai.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속.
// Task/ProjectModule 을 가져와 사용자 업무·프로젝트 컨텍스트를 시스템 프롬프트에 주입한다.
// AI 모듈 — Claude(Anthropic) 기반 업무 할 일(체크리스트) 추천.
// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입하고,
// TaskModule 로 개인 할 일 추천의 원본 업무(제목·본문)를 읽는다.
@Module({
imports: [
TypeOrmModule.forFeature([AiConversation, AiMessage]),
TaskModule,
ProjectModule,
],
controllers: [AiController, AiSuggestController],
imports: [ProjectModule, TaskModule],
controllers: [AiSuggestController],
providers: [AiService],
})
export class AiModule {}
+131 -277
View File
@@ -1,41 +1,14 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { TaskService } from '../task/task.service';
import type { TaskStatus } from '../task/entities/task.entity';
import { ProjectService } from '../project/project.service';
import { AiConversation } from './entities/ai-conversation.entity';
import { AiMessage } from './entities/ai-message.entity';
import { TaskService } from '../task/task.service';
// Anthropic Messages API 응답(필요 필드만)
interface AnthropicResponse {
content?: { type: string; text?: string }[];
}
// 외부 노출 형태
export interface ConversationSummary {
id: string;
title: string;
updatedAt: Date;
}
export interface ConversationDetail {
id: string;
title: string;
messages: { role: 'user' | 'assistant'; content: string }[];
}
export interface SendResult {
conversationId: string;
title: string;
reply: string;
}
// 할 일 추천 — 카테고리별 항목
export interface ChecklistSuggestionGroup {
category: string;
@@ -46,36 +19,6 @@ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
// AI 요청 타임아웃 기본값(ms) — env AI_TIMEOUT_MS 로 재정의. 프론트와 동일 값을 사용한다.
const DEFAULT_AI_TIMEOUT_MS = 60_000;
const MAX_TOKENS = 1024;
const TITLE_MAX = 60;
// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약)
const CONTEXT_TASK_LIMIT = 30;
// 컨텍스트로 주입할 프로젝트 최대 개수
const CONTEXT_PROJECT_LIMIT = 30;
// 주제 한정(가드레일) + 데이터 근거 지시
const SYSTEM_PROMPT = [
'당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.',
'',
'[역할]',
"- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.",
'- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.',
'',
'[규칙]',
'- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.',
'- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.',
"- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.",
"- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.",
'- 프로젝트는 한글 표시 제목으로 설명하세요.',
].join('\n');
// 업무 상태 → 한국어 라벨(컨텍스트 표기용)
const STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
prog: '진행 중',
review: '검토 대기',
changes: '수정 요청',
done: '완료',
};
// 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제
const SUGGEST_SYSTEM_PROMPT = [
@@ -95,7 +38,37 @@ const SUGGEST_SYSTEM_PROMPT = [
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// 개인 할 일 추천용 시스템 프롬프트 — '산출물'이 아니라 담당자의 '실행 단계'를 뽑는다.
// (체크리스트 추천과 층위가 다르므로 프롬프트를 분리한다)
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()
export class AiService {
@@ -106,12 +79,8 @@ export class AiService {
constructor(
config: ConfigService,
private readonly taskService: TaskService,
private readonly projectService: ProjectService,
@InjectRepository(AiConversation)
private readonly convRepo: Repository<AiConversation>,
@InjectRepository(AiMessage)
private readonly msgRepo: Repository<AiMessage>,
private readonly taskService: TaskService,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
@@ -121,7 +90,7 @@ export class AiService {
const t = Number(config.get<string>('AI_TIMEOUT_MS'));
this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_AI_TIMEOUT_MS;
if (!this.apiKey) {
this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화');
this.logger.warn('CLAUDE_API_KEY 미설정 — AI 추천 비활성화');
}
}
@@ -129,69 +98,6 @@ export class AiService {
return this.apiKey.length > 0;
}
// 내 대화 목록(최근순, 페이지네이션)
async listConversations(
userId: string,
page?: number,
size?: number,
): Promise<PaginatedResult<ConversationSummary>> {
const params = resolvePage(page, size);
const [rows, total] = await this.convRepo.findAndCount({
where: { user: { id: userId } },
order: { updatedAt: 'DESC' },
skip: params.skip,
take: params.take,
});
return paginated(
rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })),
total,
params,
);
}
// 대화 상세(본인 소유만) — 메시지 시간순
async getConversation(
userId: string,
conversationId: string,
): Promise<ConversationDetail> {
const conv = await this.getOwnedConversation(userId, conversationId);
const messages = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
return {
id: conv.id,
title: conv.title,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
};
}
// 새 대화 시작 — 첫 메시지로 대화 생성 후 응답
async startConversation(
userId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.convRepo.save(
this.convRepo.create({
user: { id: userId },
title: this.makeTitle(content),
}),
);
return this.appendAndReply(conv, content, userId);
}
// 기존 대화에 메시지 추가 후 응답(본인 소유만)
async addMessage(
userId: string,
conversationId: string,
content: string,
): Promise<SendResult> {
this.ensureEnabled();
const conv = await this.getOwnedConversation(userId, conversationId);
return this.appendAndReply(conv, content, userId);
}
// 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안
async suggestChecklist(
userId: string,
@@ -227,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
@@ -322,91 +325,6 @@ export class AiService {
);
}
// 대화 삭제(본인 소유만) — 메시지 CASCADE
async deleteConversation(
userId: string,
conversationId: string,
): Promise<void> {
const conv = await this.getOwnedConversation(userId, conversationId);
await this.convRepo.remove(conv);
}
// 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신.
// Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지.
private async appendAndReply(
conv: AiConversation,
content: string,
userId: string,
): Promise<SendResult> {
const userMsg = await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'user',
content,
}),
);
const history = await this.msgRepo.find({
where: { conversation: { id: conv.id } },
order: { createdAt: 'ASC' },
});
let reply: string;
try {
const system = await this.buildSystemPrompt(userId);
reply = await this.callClaude(
history.map((m) => ({ role: m.role, content: m.content })),
system,
);
} catch (e) {
// 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제
await this.msgRepo.delete(userMsg.id);
const remaining = await this.msgRepo.count({
where: { conversation: { id: conv.id } },
});
if (remaining === 0) {
await this.convRepo.delete(conv.id);
}
throw e;
}
await this.msgRepo.save(
this.msgRepo.create({
conversation: { id: conv.id },
role: 'assistant',
content: reply,
}),
);
// 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn)
await this.convRepo.save(conv);
return { conversationId: conv.id, title: conv.title, reply };
}
// 소유권 확인 — 본인 대화가 아니면 404(존재 비노출)
private async getOwnedConversation(
userId: string,
conversationId: string,
): Promise<AiConversation> {
const conv = await this.convRepo.findOne({
where: { id: conversationId, user: { id: userId } },
});
if (!conv) {
throw new BusinessException(
'RES_001',
'대화를 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
return conv;
}
private makeTitle(content: string): string {
const firstLine = content.trim().split('\n')[0].trim();
if (!firstLine) return '새 대화';
return firstLine.length > TITLE_MAX
? `${firstLine.slice(0, TITLE_MAX)}`
: firstLine;
}
private ensureEnabled(): void {
if (!this.enabled) {
throw new BusinessException(
@@ -417,70 +335,6 @@ export class AiService {
}
}
// 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트
private async buildSystemPrompt(userId: string): Promise<string> {
const context = await this.buildUserContext(userId);
return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`;
}
// 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행
private async buildUserContext(userId: string): Promise<string> {
const today = new Date(Date.now() + 9 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10); // KST 기준 오늘
try {
const assigned = await this.taskService.listAssigned(
userId,
1,
CONTEXT_TASK_LIMIT,
);
const issued = await this.taskService.listIssued(userId, 1, 1);
const projects = await this.projectService.findAll(
1,
CONTEXT_PROJECT_LIMIT,
);
const lines = [`오늘 날짜: ${today} (KST)`, ''];
// 담당 업무
lines.push(`내가 담당한 업무 (${assigned.total}건):`);
if (assigned.items.length === 0) {
lines.push('- (없음)');
} else {
for (const t of assigned.items) {
const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음';
const [done, totalC] = t.checklist;
lines.push(
`- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`,
);
}
if (assigned.total > assigned.items.length) {
lines.push(`- … 외 ${assigned.total - assigned.items.length}`);
}
}
lines.push('', `내가 지시한 업무: ${issued.total}`);
// 프로젝트 목록(인증 사용자는 전체 열람 가능)
lines.push('', `프로젝트 목록 (${projects.total}개):`);
if (projects.items.length === 0) {
lines.push('- (없음)');
} else {
for (const p of projects.items) {
lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}`);
}
if (projects.total > projects.items.length) {
lines.push(`- … 외 ${projects.total - projects.items.length}`);
}
}
return lines.join('\n');
} catch (e) {
this.logger.warn(
`사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`,
);
return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`;
}
}
// Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구
private async callClaude(
messages: { role: 'user' | 'assistant'; content: string }[],
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다.
export class SendMessageDto {
@ApiProperty({ description: '사용자 메시지 내용' })
@IsString()
@IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' })
@MaxLength(8000, { message: '메시지가 너무 깁니다.' })
content!: string;
}
@@ -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;
}
@@ -1,33 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N).
@Entity('ai_conversations')
export class AiConversation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'user_id' })
user!: Relation<User>;
// 첫 사용자 메시지로 자동 생성되는 제목
@Column({ type: 'varchar', length: 120 })
title!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// 새 메시지 추가 시 갱신(최근 대화 정렬용)
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -1,30 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { AiConversation } from './ai-conversation.entity';
// AI 대화 메시지 1건 — 대화에 종속(CASCADE).
@Entity('ai_messages')
export class AiMessage {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'conversation_id' })
conversation!: Relation<AiConversation>;
@Column({ type: 'varchar' })
role!: 'user' | 'assistant';
@Column({ type: 'text' })
content!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -102,6 +102,11 @@ export class Task {
})
approvalRequestedAt!: Date | null;
// 완료(done) 전이 시각 — 목록의 '완료' 날짜 표기용.
// done 에서 벗어나면(수정 요청 등) null 로 초기화하고, 재승인 시 최신 시각으로 갱신한다.
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt!: Date | null;
// 최근 수정 요청 메시지(지시자 → 담당자). 수정 요청(changes) 상태에서 노출
@Column({ name: 'changes_note', type: 'text', nullable: true })
changesNote!: string | null;
+24
View File
@@ -59,6 +59,8 @@ export interface ProjectTaskResponse {
title: string;
status: TaskStatus;
dueDate: string | null;
// 완료 시각(done 이 아니거나 기록이 없으면 null)
completedAt: string | null;
checklist: [number, number]; // [완료, 전체]
commentCount: number;
fileCount: number; // 첨부 파일 수
@@ -146,6 +148,8 @@ export interface MyTaskRowResponse {
title: string;
status: TaskStatus;
dueDate: string | null;
// 완료 시각(done 이 아니거나 기록이 없으면 null)
completedAt: string | null;
checklist: [number, number]; // [완료, 전체]
assignees: PublicUser[];
issuer: PublicUser | null;
@@ -465,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,
@@ -1252,6 +1267,7 @@ export class TaskService {
title: task.title,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
completedAt: task.completedAt ? task.completedAt.toISOString() : null,
checklist: [done, checklist.length],
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
@@ -1303,6 +1319,13 @@ export class TaskService {
if (next === 'done' || next === 'changes') {
task.reviewedBy = membership.user;
}
// 완료 시각 기록 — 완료로 들어가면 지금 시각, 완료에서 벗어나면(수정 요청 등) 초기화.
// 마감일(dueDate)과 별개의 값이므로 목록의 '완료' 날짜는 이 값을 쓴다.
if (next === 'done') {
task.completedAt = new Date();
} else if (from === 'done') {
task.completedAt = null;
}
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
@@ -1544,6 +1567,7 @@ export class TaskService {
title: task.title,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
completedAt: task.completedAt ? task.completedAt.toISOString() : null,
checklist: [done, checklist.length],
commentCount,
fileCount,
@@ -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[];
}
@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
// 할 일 항목 생성 DTO
export class CreateTodoItemDto {
@ApiProperty({ description: '할 일 내용', example: '견적서 검토' })
@IsString()
@IsNotEmpty({ message: '할 일 내용을 입력해 주세요.' })
@MaxLength(200, { message: '할 일은 최대 200자입니다.' })
title!: string;
}
// 할 일 항목 수정 DTO — 내용 변경과 완료 토글에 함께 쓴다
export class UpdateTodoItemDto {
@ApiPropertyOptional({ description: '할 일 내용', example: '견적서 재검토' })
@IsOptional()
@IsString()
@IsNotEmpty({ message: '할 일 내용을 입력해 주세요.' })
@MaxLength(200, { message: '할 일은 최대 200자입니다.' })
title?: string;
@ApiPropertyOptional({ description: '완료 여부', example: true })
@IsOptional()
@IsBoolean()
done?: boolean;
}
@@ -0,0 +1,21 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
// 할 일 라벨 생성 DTO
export class CreateTodoLabelDto {
@ApiProperty({ description: '라벨 이름', example: '업무 준비' })
@IsString()
@IsNotEmpty({ message: '라벨 이름을 입력해 주세요.' })
@MaxLength(40, { message: '라벨 이름은 최대 40자입니다.' })
name!: string;
@ApiProperty({ description: '라벨 색(hex)', example: '#1d4ed8' })
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/, {
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
})
color!: string;
}
// 할 일 라벨 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateTodoLabelDto extends PartialType(CreateTodoLabelDto) {}
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { TodoLabel } from './todo-label.entity';
// 할 일 항목 — 제목과 완료 여부만 갖는 최소 구성.
// (마감일·담당자가 필요한 일은 '내 업무'가 담당하므로 여기서는 다루지 않는다)
@Entity('todo_items')
export class TodoItem {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 라벨 — 라벨 삭제 시 항목도 함께 삭제
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => TodoLabel, (label) => label.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'label_id' })
label!: Relation<TodoLabel>;
@Column({ type: 'varchar' })
title!: string;
@Column({ type: 'boolean', default: false })
done!: boolean;
// 라벨 안에서의 표시 순서 — 작을수록 위
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,61 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
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개).
// 개인 전용이므로 소유자만 조회·수정할 수 있고, 사용자 삭제 시 함께 정리된다.
@Entity('todo_labels')
export class TodoLabel {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소유자 — 항상 존재한다(공용 라벨 개념 없음)
@Index()
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'owner_id' })
owner!: User;
// 표시 이름(예: 업무 준비, 개인)
@Column({ type: 'varchar' })
name!: string;
// 라벨 색(hex, 예: #1d4ed8) — 카드 헤더의 점 색으로 쓴다
@Column({ type: 'varchar' })
color!: string;
// 표시 순서(카드 정렬) — 작을수록 위
@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)
items!: Relation<TodoItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+133
View File
@@ -0,0 +1,133 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { 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개.
// 모든 응답은 라벨 단위라 화면이 카드 하나만 갈아끼우면 된다.
@ApiTags('Todo')
@Controller('todos')
@UseGuards(JwtAuthGuard)
export class TodoController {
constructor(private readonly todoService: TodoService) {}
// ----- 라벨 -----
@Get('labels')
@ApiOperation({ summary: '내 할 일 라벨 목록(항목 포함)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listLabels(@CurrentUser() user: PublicUser): Promise<TodoLabelResponse[]> {
return this.todoService.listLabels(user.id);
}
@Post('labels')
@ApiOperation({ summary: '할 일 라벨 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createLabel(
@CurrentUser() user: PublicUser,
@Body() dto: CreateTodoLabelDto,
): Promise<TodoLabelResponse> {
return this.todoService.createLabel(dto, user.id);
}
@Patch('labels/:id')
@ApiOperation({ summary: '할 일 라벨 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateLabel(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTodoLabelDto,
): Promise<TodoLabelResponse> {
return this.todoService.updateLabel(id, dto, user.id);
}
@Delete('labels/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '할 일 라벨 삭제(소속 할 일도 함께 삭제)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteLabel(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.todoService.deleteLabel(id, user.id);
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')
@ApiOperation({ summary: '할 일 추가' })
@ApiResponse({ status: 201, description: '생성 성공' })
createItem(
@CurrentUser() user: PublicUser,
@Param('labelId', ParseUUIDPipe) labelId: string,
@Body() dto: CreateTodoItemDto,
): Promise<TodoLabelResponse> {
return this.todoService.createItem(labelId, dto, user.id);
}
@Patch('items/:id')
@ApiOperation({ summary: '할 일 수정(내용 변경·완료 토글)' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateItem(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTodoItemDto,
): Promise<TodoLabelResponse> {
return this.todoService.updateItem(id, dto, user.id);
}
@Delete('items/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '할 일 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
deleteItem(
@CurrentUser() user: PublicUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<TodoLabelResponse> {
return this.todoService.deleteItem(id, user.id);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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]), TaskModule],
controllers: [TodoController],
providers: [TodoService],
})
export class TodoModule {}
+388
View File
@@ -0,0 +1,388 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
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 {
id: string;
title: string;
done: boolean;
sortOrder: number;
}
// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다
export interface TodoSourceTaskResponse {
projectId: string;
seq: number;
title: string;
}
export interface TodoLabelResponse {
id: string;
name: string;
color: string;
sortOrder: number;
items: TodoItemResponse[];
// 진행 표기(2/3)용 집계 — 프론트가 items 로 다시 세지 않도록 함께 내려준다
doneCount: number;
totalCount: number;
// 원본 업무 — 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null
sourceTask: TodoSourceTaskResponse | null;
}
// 할 일(개인 체크리스트) 비즈니스 로직 — 라벨 1개에 항목 N개.
// 모든 라벨·항목은 개인 전용이라 소유자 본인만 접근할 수 있고,
// 남의 것은 존재 자체를 노출하지 않도록 404 로 처리한다.
@Injectable()
export class TodoService {
constructor(
@InjectRepository(TodoLabel)
private readonly labelRepo: Repository<TodoLabel>,
@InjectRepository(TodoItem)
private readonly itemRepo: Repository<TodoItem>,
private readonly taskService: TaskService,
) {}
// ----- 라벨 -----
// 본인 라벨 전체 + 소속 항목을 한 번에 반환(화면이 단일 요청으로 그린다)
async listLabels(ownerId: string): Promise<TodoLabelResponse[]> {
const rows = await this.labelRepo.find({
where: { owner: { id: ownerId } },
relations: ['items', 'sourceTask', 'sourceTask.project'],
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
return rows.map((l) => this.toLabelResponse(l));
}
async createLabel(
dto: CreateTodoLabelDto,
ownerId: string,
): Promise<TodoLabelResponse> {
const sortOrder = await this.nextSortOrder(this.labelRepo, {
owner: { id: ownerId },
});
const saved = await this.labelRepo.save(
this.labelRepo.create({
owner: { id: ownerId } as User,
name: dto.name.trim(),
color: dto.color,
sortOrder,
}),
);
// 방금 만든 라벨은 항목이 없다
return this.toLabelResponse({ ...saved, items: [] });
}
async updateLabel(
id: string,
dto: Partial<CreateTodoLabelDto>,
ownerId: string,
): Promise<TodoLabelResponse> {
const label = await this.assertLabelOwned(id, ownerId);
if (dto.name !== undefined) label.name = dto.name.trim();
if (dto.color !== undefined) label.color = dto.color;
await this.labelRepo.save(label);
return this.findLabelOrThrow(id, ownerId);
}
// 라벨 삭제 — 소속 항목도 FK CASCADE 로 함께 삭제된다.
async deleteLabel(id: string, ownerId: string): Promise<void> {
await this.assertLabelOwned(id, ownerId);
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(
labelId: string,
dto: CreateTodoItemDto,
ownerId: string,
): Promise<TodoLabelResponse> {
await this.assertLabelOwned(labelId, ownerId);
const sortOrder = await this.nextSortOrder(this.itemRepo, {
label: { id: labelId },
});
await this.itemRepo.save(
this.itemRepo.create({
label: { id: labelId } as TodoLabel,
title: dto.title.trim(),
sortOrder,
}),
);
// 화면이 카드 단위로 갱신할 수 있도록 라벨 전체를 돌려준다
return this.findLabelOrThrow(labelId, ownerId);
}
async updateItem(
id: string,
dto: UpdateTodoItemDto,
ownerId: string,
): Promise<TodoLabelResponse> {
const item = await this.assertItemOwned(id, ownerId);
if (dto.title !== undefined) item.title = dto.title.trim();
if (dto.done !== undefined) item.done = dto.done;
await this.itemRepo.save(item);
return this.findLabelOrThrow(item.label.id, ownerId);
}
async deleteItem(id: string, ownerId: string): Promise<TodoLabelResponse> {
const item = await this.assertItemOwned(id, ownerId);
const labelId = item.label.id;
await this.itemRepo.delete({ id });
return this.findLabelOrThrow(labelId, ownerId);
}
// ----- 내부 헬퍼 -----
// 소유 라벨인지 검증하고 반환(남의 라벨은 존재를 숨긴다)
private async assertLabelOwned(
id: string,
ownerId: string,
): Promise<TodoLabel> {
const label = await this.labelRepo.findOne({
where: { id, owner: { id: ownerId } },
});
if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.');
return label;
}
// 소유 항목인지 검증하고 반환(라벨 소유자로 판정)
private async assertItemOwned(
id: string,
ownerId: string,
): Promise<TodoItem> {
const item = await this.itemRepo.findOne({
where: { id, label: { owner: { id: ownerId } } },
relations: ['label'],
});
if (!item) throw new NotFoundException('할 일을 찾을 수 없습니다.');
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>,
where: Record<string, unknown>,
): Promise<number> {
const [last] = await repo.find({
where: where as never,
order: { sortOrder: 'DESC' } as never,
take: 1,
});
return (last?.sortOrder ?? -1) + 1;
}
private async findLabelOrThrow(
id: string,
ownerId: string,
): Promise<TodoLabelResponse> {
const label = await this.labelRepo.findOne({
where: { id, owner: { id: ownerId } },
relations: ['items', 'sourceTask', 'sourceTask.project'],
});
if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.');
return this.toLabelResponse(label);
}
private toLabelResponse(l: TodoLabel): TodoLabelResponse {
// 미완료 먼저, 그 안에서는 등록 순서대로
const items = [...(l.items ?? [])]
.sort((a, b) =>
a.done === b.done ? a.sortOrder - b.sortOrder : a.done ? 1 : -1,
)
.map((i) => ({
id: i.id,
title: i.title,
done: i.done,
sortOrder: i.sortOrder,
}));
// 원본 업무는 '업무에서 만든 라벨'에만 있다. 직접 만든 라벨이거나
// 원본 업무가 삭제된 경우(FK SET NULL) null 이며, 화면은 링크를 숨긴다.
const src = l.sourceTask;
return {
id: l.id,
name: l.name,
color: l.color,
sortOrder: l.sortOrder,
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,
};
}
}
+21
View File
@@ -1,3 +1,13 @@
# 컨테이너 로그 로테이션 공통 설정.
# 기본값(json-file, 무제한)으로 두면 로그가 호스트 디스크를 계속 먹다가
# 결국 100% 를 채워 PostgreSQL 이 "could not write init file" 로 죽는다.
# 서비스당 최대 30MB(10MB x 3) 로 상한을 건다.
x-logging: &default-logging
driver: json-file
options:
max-size: '10m'
max-file: '3'
services:
# ----------------------------------------
# 1. Frontend (Nginx 정적 호스팅)
@@ -19,6 +29,7 @@ services:
- "80"
environment:
- NODE_ENV=${APP_ENV}
logging: *default-logging
restart: unless-stopped
networks:
- app-network
@@ -77,6 +88,13 @@ services:
# 웹 푸시(VAPID) — 비밀 아닌 값만 루트에서 주입(PRIVATE_KEY 는 backend env_file)
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
# 업무 첨부 저장 경로(컨테이너 내부) — 아래 볼륨과 짝을 이룬다
- UPLOAD_DIR=/app/uploads
volumes:
# 업무 첨부는 로컬 디스크에 저장된다. 볼륨이 없으면 재배포로 컨테이너가
# 교체될 때 파일이 모두 사라지고, DB 의 첨부 레코드만 남아 다운로드가 실패한다.
- ${UPLOAD_DATA_DIR}:/app/uploads
logging: *default-logging
restart: unless-stopped
networks:
- app-network
@@ -114,6 +132,7 @@ services:
POSTGRES_DB: ${DB_NAME}
volumes:
- ${DB_DATA_DIR}:/var/lib/postgresql/data
logging: *default-logging
restart: always
networks:
- app-network
@@ -142,6 +161,7 @@ services:
- "6379"
volumes:
- ${REDIS_DATA_DIR}:/data
logging: *default-logging
restart: always
networks:
- app-network
@@ -185,6 +205,7 @@ services:
volumes:
# 클러스터링 시에는 redis 비밀번호가 담긴 사본을 마운트(DEPLOY.md 참고)
- ./livekit/livekit.prod.yaml:/etc/livekit/livekit.yaml:ro
logging: *default-logging
restart: unless-stopped
networks:
- app-network
+5 -12
View File
@@ -336,26 +336,19 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done
Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조.
### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)**
### AI 할 일 추천 (`/api/ai`) — **✅ 완료**
| 메서드 | 경로 | 요청 | 응답 | 비고 |
|---|---|---|---|---|
| GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) |
| GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) |
| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 |
| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 |
| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) |
| POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 |
- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기).
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지).
- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트).
- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크).
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출).
- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용(+첨부 문서 추출 텍스트)을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵).
- DB 를 쓰지 않는다(무상태). 헤더의 AI 채팅 어시스턴트는 제거되었으며 대화/메시지 테이블도 삭제됨(`DropAiConversations1783900000000`).
### 그 외(미착수)
메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.)
메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전은 완료.)
---
+6 -1
View File
@@ -309,7 +309,12 @@
- **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정.
검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음.
#### 8-6. AI 채팅 패널 (2026-06-19)
#### 8-6. AI 채팅 패널 — **🗑 제거됨 (2026-07-21)**
> **이 절은 과거 이력이다.** 헤더의 AI 채팅 어시스턴트는 "사용하지 않는다"는 판단으로 전부 제거되었다.
> 제거 범위: `AgentChatPanel.vue`·`stores/ai.store.ts`·`shared/utils/markdown.ts`·`AiController`·`AiConversation`/`AiMessage` 엔티티·`SendMessageDto`·`AiService` 의 채팅 로직,
> DB 테이블은 `DropAiConversations1783900000000` 마이그레이션으로 삭제.
> **업무 생성 화면의 AI 할 일 추천(`POST /ai/suggest-checklist`)은 그대로 유지된다** — 아래 설명 중 채팅 관련 내용만 폐기된 것이다.
**지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨.
+2 -2
View File
@@ -101,9 +101,9 @@ body {
/* ============================================================
* 3. 페이지 컨테이너 / 브레드크럼
* ============================================================ */
/* 폭 제한 없이 화면 전체를 채운다 — 좌우 여백(1.5rem)만 유지.
개별 폼 화면처럼 좁은 단이 필요한 곳은 각 페이지에서 max-width 를 따로 건다. */
.page {
max-width: 67.5rem; /* 1080px */
margin: 0 auto;
padding: 0 1.5rem 4.375rem;
}
-718
View File
@@ -1,718 +0,0 @@
<script setup lang="ts">
// AI 채팅 패널 — Claude API 와 대화. 대화/이력은 DB(ai.store)에 보관.
// 우측 슬라이드 패널. 채팅 뷰 ↔ 지난 대화(기록) 목록 뷰 전환.
import { nextTick, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAiStore } from '@/stores/ai.store'
import { useDialog } from '@/composables/useDialog'
import { relativeTimeKo } from '@/shared/utils/format'
import { renderMarkdown } from '@/shared/utils/markdown'
interface Props {
open: boolean
}
const props = defineProps<Props>()
interface Emits {
(e: 'close'): void
}
const emit = defineEmits<Emits>()
const aiStore = useAiStore()
const dialog = useDialog()
const { conversations, listLoading, messages, loading, error } =
storeToRefs(aiStore)
// 입력값과 뷰 전환만 컴포넌트 로컬
const input = ref('')
const showHistory = ref(false)
const bodyEl = ref<HTMLElement | null>(null)
function scrollToBottom() {
void nextTick(() => {
if (bodyEl.value && !showHistory.value) {
bodyEl.value.scrollTop = bodyEl.value.scrollHeight
}
})
}
watch(() => [messages.value.length, loading.value], scrollToBottom)
watch(
() => props.open,
(open) => {
if (open && !showHistory.value) scrollToBottom()
},
)
async function send() {
const text = input.value
if (!text.trim() || loading.value) return
input.value = ''
const ok = await aiStore.send(text)
if (!ok) input.value = text // 실패 시 입력 복원
}
function newChat() {
aiStore.newConversation()
showHistory.value = false
input.value = ''
}
async function toggleHistory() {
showHistory.value = !showHistory.value
if (showHistory.value) await aiStore.loadConversations()
}
async function openConv(id: string) {
await aiStore.openConversation(id)
showHistory.value = false
}
async function removeConv(id: string) {
const ok = await dialog.confirm('이 대화를 삭제할까요?', {
variant: 'danger',
confirmText: '삭제',
})
if (!ok) return
await aiStore.removeConversation(id)
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="ac-backdrop"
@click="emit('close')"
/>
<aside
class="ac-panel"
:class="{ open }"
aria-label="AI 채팅"
>
<!-- 헤더 -->
<header class="ac-head">
<div class="ac-title">
<span class="ac-logo">AI</span>
<div class="ac-name">
{{ showHistory ? '지난 대화' : 'Relay 어시스턴트' }}
</div>
</div>
<div class="ac-headbtns">
<button
class="ac-iconbtn"
type="button"
:title="showHistory ? '대화로 돌아가기' : '지난 대화'"
:aria-label="showHistory ? '대화로 돌아가기' : '지난 대화'"
:class="{ on: showHistory }"
@click="toggleHistory"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l3 2" />
</svg>
</button>
<button
class="ac-iconbtn"
type="button"
title="새 대화"
aria-label=" 대화"
:disabled="loading"
@click="newChat"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button
class="ac-iconbtn"
type="button"
title="닫기"
aria-label="닫기"
@click="emit('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>
</header>
<!-- 기록(지난 대화) -->
<div
v-if="showHistory"
class="ac-body ac-history"
>
<div
v-if="listLoading && conversations.length === 0"
class="ac-hist-empty"
>
불러오는
</div>
<div
v-else-if="conversations.length === 0"
class="ac-hist-empty"
>
지난 대화가 없습니다.
</div>
<template v-else>
<div
v-for="c in conversations"
:key="c.id"
class="ac-hist-item"
@click="openConv(c.id)"
>
<div class="ac-hist-main">
<div class="ac-hist-title">
{{ c.title }}
</div>
<div class="ac-hist-time">
{{ relativeTimeKo(c.updatedAt) }}
</div>
</div>
<button
class="ac-hist-del"
type="button"
title="삭제"
aria-label="대화 삭제"
@click.stop="removeConv(c.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V6" />
</svg>
</button>
</div>
</template>
</div>
<!-- 채팅 -->
<div
v-else
ref="bodyEl"
class="ac-body"
>
<div
v-if="messages.length === 0"
class="ac-welcome"
>
<div class="ac-welcome-logo">
AI
</div>
<div class="ac-welcome-title">
무엇을 도와드릴까요?
</div>
<div class="ac-welcome-sub">
업무나 일정에 대해 자유롭게 질문해 보세요.
</div>
</div>
<div
v-for="(m, i) in messages"
:key="i"
class="ac-msg"
:class="m.role"
>
<span
v-if="m.role === 'assistant'"
class="ac-ava"
>AI</span>
<!-- AI 응답은 마크다운 렌더(renderMarkdown escapeHtml 신뢰 태그만 조립 XSS 안전) -->
<!-- eslint-disable vue/no-v-html -->
<div
v-if="m.role === 'assistant'"
class="ac-bubble ac-md"
v-html="renderMarkdown(m.content)"
/>
<!-- eslint-enable vue/no-v-html -->
<div
v-else
class="ac-bubble"
>
{{ m.content }}
</div>
</div>
<div
v-if="loading"
class="ac-msg assistant"
>
<span class="ac-ava">AI</span>
<div class="ac-bubble ac-typing">
<span /><span /><span />
</div>
</div>
<div
v-if="error"
class="ac-error"
>
{{ error }}
</div>
</div>
<!-- 입력 (채팅 뷰에서만) -->
<footer
v-if="!showHistory"
class="ac-foot"
>
<div class="ac-inputrow">
<textarea
v-model="input"
class="ac-input"
rows="1"
placeholder="메시지를 입력하세요…"
:disabled="loading"
@keydown.enter.exact.prevent="send"
/>
<button
class="ac-send"
type="button"
title="전송"
aria-label="전송"
:disabled="loading || !input.trim()"
@click="send"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" />
</svg>
</button>
</div>
<div class="ac-hint">
Enter 전송 · Shift+Enter 줄바꿈
</div>
</footer>
</aside>
</Teleport>
</template>
<style scoped>
.ac-backdrop {
position: fixed;
inset: 0;
background: rgba(17, 18, 22, 0.32);
z-index: 60;
}
.ac-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 26rem;
max-width: 96vw;
background: #fff;
border-left: 1px solid var(--border);
box-shadow: -0.5rem 0 1.5rem rgba(17, 18, 22, 0.12);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.22s ease;
z-index: 61;
}
.ac-panel.open {
transform: translateX(0);
}
/* 헤더 */
.ac-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.ac-title {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.ac-logo {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: var(--accent);
color: #fff;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 800;
flex-shrink: 0;
}
.ac-name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text);
}
.ac-headbtns {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.ac-iconbtn {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
border-radius: 0.5rem;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
}
.ac-iconbtn:hover {
background: #f1f2f4;
color: var(--text);
}
.ac-iconbtn.on {
background: var(--accent-weak);
color: var(--accent);
}
.ac-iconbtn:disabled {
opacity: 0.5;
cursor: default;
}
.ac-iconbtn svg {
width: 1.0625rem;
height: 1.0625rem;
}
/* 대화 영역 */
.ac-body {
flex: 1;
overflow-y: auto;
padding: 1.125rem 1rem;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.ac-welcome {
margin: auto;
text-align: center;
padding: 1.5rem 0;
}
.ac-welcome-logo {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
font-weight: 800;
margin: 0 auto 0.875rem;
}
.ac-welcome-title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.ac-welcome-sub {
font-size: 0.8125rem;
color: var(--text-3);
margin-top: 0.375rem;
line-height: 1.5;
}
.ac-msg {
display: flex;
gap: 0.5rem;
max-width: 100%;
}
.ac-msg.user {
justify-content: flex-end;
}
.ac-ava {
width: 1.625rem;
height: 1.625rem;
border-radius: 0.5rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 800;
flex-shrink: 0;
margin-top: 0.125rem;
}
.ac-bubble {
font-size: 0.875rem;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
padding: 0.5625rem 0.75rem;
border-radius: 0.75rem;
max-width: 19rem;
}
.ac-msg.assistant .ac-bubble {
background: #f4f5f7;
border-top-left-radius: 0.25rem;
}
/* 마크다운 렌더 영역 — v-html 내부라 :deep() 로 타겟 */
.ac-bubble.ac-md {
white-space: normal;
}
.ac-md :deep(p) {
margin: 0 0 0.5em;
}
.ac-md :deep(p:last-child) {
margin-bottom: 0;
}
.ac-md :deep(ul),
.ac-md :deep(ol) {
margin: 0.25em 0 0.5em;
padding-left: 1.25em;
}
.ac-md :deep(li) {
margin: 0.15em 0;
}
.ac-md :deep(strong) {
font-weight: 700;
}
.ac-md :deep(code) {
background: rgba(0, 0, 0, 0.06);
padding: 0.05em 0.3em;
border-radius: 0.25rem;
font-size: 0.85em;
font-family: ui-monospace, 'SFMono-Regular', monospace;
}
.ac-md :deep(pre.md-pre) {
background: #1f2430;
color: #e6e8ec;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.4em 0;
}
.ac-md :deep(pre.md-pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.8125rem;
}
.ac-md :deep(.md-h) {
font-weight: 700;
margin: 0.4em 0 0.25em;
}
.ac-md :deep(.md-h1) {
font-size: 1.05em;
}
.ac-md :deep(hr) {
border: none;
border-top: 1px solid var(--border-strong);
margin: 0.6em 0;
}
.ac-md :deep(a) {
color: var(--accent);
text-decoration: underline;
}
.ac-msg.user .ac-bubble {
background: var(--accent);
color: #fff;
border-top-right-radius: 0.25rem;
}
/* 타이핑 인디케이터 */
.ac-typing {
display: inline-flex;
gap: 0.25rem;
align-items: center;
}
.ac-typing span {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--text-3);
animation: ac-blink 1.2s infinite ease-in-out both;
}
.ac-typing span:nth-child(2) {
animation-delay: 0.18s;
}
.ac-typing span:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes ac-blink {
0%,
80%,
100% {
opacity: 0.25;
}
40% {
opacity: 1;
}
}
.ac-error {
font-size: 0.8125rem;
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
/* 기록(지난 대화) 목록 */
.ac-history {
gap: 0;
padding: 0.5rem 0.5rem;
}
.ac-hist-empty {
margin: auto;
text-align: center;
font-size: 0.8125rem;
color: var(--text-3);
padding: 2rem 0;
}
.ac-hist-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.625rem;
border-radius: 0.5rem;
cursor: pointer;
}
.ac-hist-item:hover {
background: #f4f5f7;
}
.ac-hist-main {
flex: 1;
min-width: 0;
}
.ac-hist-title {
font-size: 0.875rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-hist-time {
font-size: 0.6875rem;
color: var(--text-3);
margin-top: 0.1875rem;
}
.ac-hist-del {
width: 1.75rem;
height: 1.75rem;
border: none;
background: transparent;
border-radius: 0.4375rem;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
opacity: 0;
}
.ac-hist-item:hover .ac-hist-del {
opacity: 1;
}
.ac-hist-del:hover {
background: var(--red-weak);
color: var(--red);
}
.ac-hist-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 입력 */
.ac-foot {
border-top: 1px solid var(--border);
padding: 0.75rem 1rem 0.875rem;
flex-shrink: 0;
}
.ac-inputrow {
display: flex;
align-items: flex-end;
gap: 0.5rem;
border: 1px solid var(--border-strong);
border-radius: 0.625rem;
padding: 0.375rem 0.375rem 0.375rem 0.75rem;
background: #fff;
}
.ac-inputrow:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.ac-input {
flex: 1;
border: none;
outline: none;
resize: none;
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text);
background: transparent;
max-height: 7rem;
padding: 0.25rem 0;
}
.ac-input::placeholder {
color: var(--text-3);
}
.ac-send {
width: 2rem;
height: 2rem;
border: none;
border-radius: 0.5rem;
background: var(--accent);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.ac-send:hover:not(:disabled) {
background: var(--accent-hover);
}
.ac-send:disabled {
opacity: 0.45;
cursor: default;
}
.ac-send svg {
width: 1rem;
height: 1rem;
}
.ac-hint {
font-size: 0.6875rem;
color: var(--text-3);
margin-top: 0.4375rem;
text-align: center;
}
</style>
@@ -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>
@@ -0,0 +1,543 @@
<script setup lang="ts">
// 라벨 카드 — 헤더(색점 + 이름 + 진행 + 편집/삭제) + 할 일 행 목록 + 인라인 추가 입력.
// 전역 .list 카드 규격을 그대로 쓰고, 행은 카드 안에서 구분선으로 나눈다.
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'
// 추가는 emit 이 아니라 함수 prop 으로 받는다 —
// 성공 여부를 알아야 실패 시 입력을 남겨 둘 수 있기 때문(emit 은 반환값이 없다).
const props = defineProps<{
label: ApiTodoLabel
addItem: (title: string) => Promise<void>
}>()
const emit = defineEmits<{
(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
}>()
// 라벨 메뉴(편집/삭제) 드롭다운
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('')
const adding = ref(false)
async function submitDraft(e: KeyboardEvent): Promise<void> {
if (isComposing(e)) return
const title = draft.value.trim()
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('')
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()
}
// 바깥 클릭으로도 편집을 확정한다.
// 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
const title = editDraft.value.trim()
editingId.value = null
if (!title || title === item.title) return
emit('rename', item, title)
}
function cancelEdit(): void {
editingId.value = null
}
</script>
<template>
<section class="list tl-card">
<!-- 헤더 -->
<header class="tl-head">
<span
class="tl-dot"
:style="{ background: props.label.color }"
/>
<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"
class="tl-menu"
>
<button
type="button"
class="tl-menu-btn"
:aria-label="`${props.label.name} 라벨 메뉴`"
@click="menuOpen = !menuOpen"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
><circle
cx="5"
cy="12"
r="1"
/><circle
cx="12"
cy="12"
r="1"
/><circle
cx="19"
cy="12"
r="1"
/></svg>
</button>
<div
v-if="menuOpen"
class="tl-menu-pop"
>
<button
type="button"
@click="menuOpen = false; emit('edit-label')"
>
라벨 편집
</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"
@click="menuOpen = false; emit('delete-label')"
>
라벨 삭제
</button>
</div>
</div>
</header>
<!-- -->
<div
v-for="item in props.label.items"
:key="item.id"
class="tl-row"
:class="{ done: item.done }"
>
<button
type="button"
class="tl-check"
role="checkbox"
:aria-checked="item.done"
:aria-label="item.title"
@click="emit('toggle', item)"
>
<svg
v-if="item.done"
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>
</button>
<input
v-if="editingId === item.id"
:ref="setEditInput"
v-model="editDraft"
class="tl-edit"
maxlength="200"
@keydown.enter="commitEdit(item, $event)"
@keydown.esc="cancelEdit"
@blur="commitEdit(item)"
>
<button
v-else
type="button"
class="tl-title"
title="클릭해서 수정"
@click="startEdit(item)"
>
{{ item.title }}
</button>
<button
type="button"
class="tl-del"
:aria-label="`${item.title} 삭제`"
@click="emit('remove', item)"
>
<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="tl-add">
<span class="tl-add-ico">
<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>
</span>
<input
v-model="draft"
class="tl-add-input"
placeholder="할 일을 입력하고 Enter"
maxlength="200"
@keydown.enter="submitDraft"
>
<span
v-if="adding"
class="tl-add-busy"
aria-live="polite"
>추가 </span>
</div>
</section>
</template>
<style scoped>
.tl-card {
margin-bottom: 0.875rem;
}
/* 헤더 */
.tl-head {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0.75rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.tl-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 0.1875rem;
flex-shrink: 0;
}
.tl-name {
flex: 1;
min-width: 0;
font-size: 0.938rem;
font-weight: 700;
letter-spacing: -0.0125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-progress {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-3);
white-space: nowrap;
}
/* 라벨 메뉴 */
.tl-menu {
position: relative;
}
.tl-menu-btn {
width: 1.75rem;
height: 1.75rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
}
.tl-menu-btn:hover {
background: #eceef1;
color: var(--text);
}
.tl-menu-btn svg {
width: 1rem;
height: 1rem;
}
.tl-menu-pop {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 20;
min-width: 8rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: var(--shadow-pop);
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.tl-menu-pop button {
border: none;
background: none;
font-family: inherit;
font-size: 0.813rem;
font-weight: 600;
color: var(--text-2);
text-align: left;
padding: 0.4375rem 0.5rem;
border-radius: 0.3125rem;
cursor: pointer;
white-space: nowrap;
}
.tl-menu-pop button:hover {
background: #f4f5f7;
color: var(--text);
}
.tl-menu-pop button.danger {
color: var(--red);
}
.tl-menu-pop button.danger:hover {
background: color-mix(in srgb, var(--red) 8%, #fff);
color: var(--red);
}
/* 할 일 행 */
.tl-row {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0 0.875rem;
height: 2.5rem;
border-bottom: 1px solid var(--border);
}
.tl-row:hover {
background: #fafbfc;
}
.tl-check {
width: 1.0625rem;
height: 1.0625rem;
flex-shrink: 0;
border: 1px solid var(--border-strong);
border-radius: 0.25rem;
background: #fff;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.tl-check:hover {
border-color: var(--accent);
}
.tl-check svg {
width: 0.75rem;
height: 0.75rem;
}
.tl-row.done .tl-check {
background: var(--accent);
border-color: var(--accent);
}
.tl-title {
flex: 1;
min-width: 0;
border: none;
background: none;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
text-align: left;
padding: 0;
cursor: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-row.done .tl-title {
color: var(--text-3);
text-decoration: line-through;
}
.tl-edit {
flex: 1;
min-width: 0;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: #fff;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
padding: 0.1875rem 0.375rem;
outline: none;
}
.tl-del {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: none;
place-items: center;
}
/* 키보드로 탭 이동했을 때도 보이게 한다 — 보이지 않는 포커스 정거장을 만들지 않는다 */
.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;
}
/* 인라인 추가 */
.tl-add {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0 0.875rem;
height: 2.5rem;
}
.tl-add-ico {
display: inline-flex;
color: var(--text-3);
}
.tl-add-ico svg {
width: 1rem;
height: 1rem;
}
.tl-add-input {
flex: 1;
min-width: 0;
border: none;
background: none;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
outline: none;
padding: 0;
}
.tl-add-input::placeholder {
color: var(--text-3);
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
// 할 일 라벨 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
// 색 파생 방식은 일정 카테고리 모달과 동일하게 맞춘다(채도/명도 고정, hue 만 선택)
import { computed, ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
const props = defineProps<{ init: ApiTodoLabel | null }>()
const emit = defineEmits<{
(e: 'save', payload: TodoLabelPayload): void
(e: 'close'): void
}>()
const LABEL_SAT = 62
const LABEL_LIG = 46
function hslToHex(h: number, s: number, l: number): string {
s /= 100
l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) =>
l - 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))}`
}
function hexToHue(hex: string): number {
const m = hex.replace('#', '')
const r = parseInt(m.slice(0, 2), 16) / 255
const g = parseInt(m.slice(2, 4), 16) / 255
const b = parseInt(m.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d) {
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return Math.round(h)
}
const isEdit = computed(() => !!props.init)
const name = ref(props.init?.name ?? '')
const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210)
const color = computed(() => hslToHex(hue.value, LABEL_SAT, LABEL_LIG))
const valid = computed(() => !!name.value.trim())
function submit(): void {
if (!valid.value) return
emit('save', { name: name.value.trim(), color: color.value })
}
</script>
<template>
<BaseModal
:title="isEdit ? '라벨 편집' : '새 라벨'"
size="sm"
@close="emit('close')"
>
<label class="form-field"><span class="form-label"><span class="req">*</span> 이름</span>
<input
v-model="name"
class="form-input"
placeholder="예: 업무 준비"
autofocus
@keydown.enter="submit"
>
</label>
<div class="form-field">
<span class="form-label">색상</span>
<div class="lb-color">
<span
class="lb-dot"
:style="{ background: color }"
/>
<input
v-model.number="hue"
type="range"
min="0"
max="359"
class="hue-slider"
:style="{ '--cur': color }"
>
</div>
</div>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lb-color {
display: flex;
align-items: center;
gap: 0.75rem;
}
.lb-dot {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius);
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 1rem;
margin: 0.375rem 0;
border-radius: 0.5rem;
cursor: pointer;
outline: none;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: var(--cur);
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18);
cursor: pointer;
}
.hue-slider::-moz-range-thumb {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background: var(--cur);
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18);
cursor: pointer;
}
</style>
+14 -48
View File
@@ -1,53 +1,14 @@
import { useApi } from './useApi'
import type {
ConversationDetail,
ConversationSummary,
SendResult,
SuggestChecklistResult,
} from '@/types/ai'
import type { Paginated } from '@/types/pagination'
import type { SuggestChecklistResult, SuggestTaskTodosResult } from '@/types/ai'
// AI 요청 타임아웃(ms) — env VITE_AI_TIMEOUT_MS, 백엔드 AI_TIMEOUT_MS 와 동일 값 사용.
// 미설정/비정상이면 기본 60초(AI 생성이 axios 기본 10초를 넘기므로).
const AI_TIMEOUT_MS = Number(import.meta.env.VITE_AI_TIMEOUT_MS) || 60000
// AI 도메인 API Composable — 대화/메시지는 서버(DB)에서 관리(본인 소유 스코프)
// AI 도메인 API Composable — 업무 생성 화면의 할 일 추천에서 사용
export function useAi() {
const api = useApi()
async function listConversations(
page = 1,
size = 30,
): Promise<Paginated<ConversationSummary>> {
return (await api.get('/ai/conversations', {
params: { page, size },
})) as unknown as Paginated<ConversationSummary>
}
async function getConversation(id: string): Promise<ConversationDetail> {
return (await api.get(
`/ai/conversations/${id}`,
)) as unknown as ConversationDetail
}
// 새 대화 시작(첫 메시지)
async function startConversation(content: string): Promise<SendResult> {
return (await api.post('/ai/conversations', {
content,
})) as unknown as SendResult
}
// 기존 대화에 메시지 추가
async function sendMessage(id: string, content: string): Promise<SendResult> {
return (await api.post(`/ai/conversations/${id}/messages`, {
content,
})) as unknown as SendResult
}
async function deleteConversation(id: string): Promise<void> {
await api.delete(`/ai/conversations/${id}`)
}
// 할 일(체크리스트) 추천 — 실패는 호출측에서 인라인 처리(silent)
// AI 생성은 axios 기본 10초를 넘기므로 백엔드와 동일한 AI_TIMEOUT_MS 적용
async function suggestChecklist(payload: {
@@ -61,12 +22,17 @@ export function useAi() {
})) as unknown as SuggestChecklistResult
}
return {
listConversations,
getConversation,
startConversation,
sendMessage,
deleteConversation,
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 }
}
+78
View File
@@ -0,0 +1,78 @@
import { useApi } from './useApi'
import type {
ApiTodoLabel,
TodoFromTaskPayload,
TodoItemPayload,
TodoLabelPayload,
} from '@/types/todo'
// 할 일 도메인 API Composable — 인터셉터가 success/data 를 언래핑.
// 항목 관련 응답은 모두 '변경된 라벨' 한 건이라 화면이 카드 하나만 교체하면 된다.
export function useTodo() {
const api = useApi()
// 라벨
async function listLabels(): Promise<ApiTodoLabel[]> {
return (await api.get('/todos/labels')) as unknown as ApiTodoLabel[]
}
async function createLabel(payload: TodoLabelPayload): Promise<ApiTodoLabel> {
return (await api.post('/todos/labels', payload)) as unknown as ApiTodoLabel
}
async function updateLabel(
id: string,
payload: Partial<TodoLabelPayload>,
): Promise<ApiTodoLabel> {
return (await api.patch(`/todos/labels/${id}`, payload)) as unknown as ApiTodoLabel
}
async function deleteLabel(id: string): Promise<void> {
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,
title: string,
): Promise<ApiTodoLabel> {
return (await api.post(`/todos/labels/${labelId}/items`, {
title,
})) as unknown as ApiTodoLabel
}
async function updateItem(
id: string,
payload: TodoItemPayload,
): Promise<ApiTodoLabel> {
return (await api.patch(`/todos/items/${id}`, payload)) as unknown as ApiTodoLabel
}
async function deleteItem(id: string): Promise<ApiTodoLabel> {
return (await api.delete(`/todos/items/${id}`)) as unknown as ApiTodoLabel
}
return {
listLabels,
createLabel,
updateLabel,
deleteLabel,
clearDoneItems,
createFromTask,
createItem,
updateItem,
deleteItem,
}
}
+24 -42
View File
@@ -10,16 +10,12 @@ import { usePush } from '@/composables/usePush'
import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import RelayMark from '@/components/RelayMark.vue'
import AgentChatPanel from '@/components/AgentChatPanel.vue'
import type { NotificationView } from '@/types/notification'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// AI 채팅 패널 — 모든 화면 툴바에서 열 수 있다
const agentOpen = ref(false)
// 사이드바 접기(데스크톱) — localStorage 에 상태 보존
const collapsed = ref(localStorage.getItem('relay.sidebar.collapsed') === '1')
function toggleCollapse() {
@@ -31,9 +27,12 @@ function toggleCollapse() {
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting' | 'drive'>(() => {
const activeNav = computed<
'tasks' | 'todos' | 'projects' | 'schedule' | 'meeting' | 'drive'
>(() => {
if (route.path.startsWith('/projects')) return 'projects'
if (route.path.startsWith('/schedule')) return 'schedule'
if (route.path.startsWith('/todos')) return 'todos'
if (route.path.startsWith('/meeting')) return 'meeting'
if (route.path.startsWith('/drive')) return 'drive'
return 'tasks'
@@ -204,6 +203,26 @@ async function onLogout() {
</svg>
<span> 업무</span>
</RouterLink>
<RouterLink
to="/todos"
class="nav-item"
title="할 일"
:class="{ active: activeNav === 'todos' }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m3 7 2 2 4-4" />
<path d="m3 17 2 2 4-4" />
<path d="M13 6h8M13 12h8M13 18h8" />
</svg>
<span> </span>
</RouterLink>
<RouterLink
to="/projects"
class="nav-item"
@@ -379,29 +398,6 @@ async function onLogout() {
<slot name="actions" />
<div class="topbar-right">
<!-- AI 어시스턴트 모든 화면에서 상시 노출 -->
<button
class="icon-btn agent"
type="button"
title="AI 어시스턴트"
aria-label="AI 어시스턴트"
@click="agentOpen = true"
>
<svg
width="17"
height="17"
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>
</button>
<div class="noti-wrap">
<button
class="icon-btn noti-btn"
@@ -531,12 +527,6 @@ async function onLogout() {
<slot />
</div>
<!-- AI 채팅 패널 어디서든 열림 -->
<AgentChatPanel
:open="agentOpen"
@close="agentOpen = false"
/>
</div>
</template>
@@ -807,14 +797,6 @@ async function onLogout() {
background: #f1f2f4;
color: var(--text);
}
/* AI 어시스턴트 버튼 강조 */
.icon-btn.agent {
color: var(--accent);
}
.icon-btn.agent:hover {
background: var(--accent-weak);
color: var(--accent-hover);
}
/* 알림 종 + 드롭다운 */
.noti-wrap {
position: relative;
+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>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
// 할 일 — 개인이 직접 적어 관리하는 체크리스트(라벨 1개에 할 일 N개).
// (프로젝트에서 배정·지시되는 '내 업무'와는 별개의 개인용 목록)
import { onMounted, ref } from 'vue'
import AppShell from '@/layouts/AppShell.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import TodoLabelCard from '@/components/todo/TodoLabelCard.vue'
import TodoLabelModal from '@/components/todo/TodoLabelModal.vue'
import { useTodoStore } from '@/stores/todo.store'
import { useDialogStore } from '@/stores/dialog.store'
import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
const store = useTodoStore()
const dialog = useDialogStore()
// 라벨 모달 — init 이 null 이면 생성, 있으면 편집
const labelModal = ref<{ init: ApiTodoLabel | null } | null>(null)
onMounted(() => {
void store.load().catch(() => {
// 인터셉터가 에러 토스트 처리
})
})
// ----- 라벨 -----
function openNewLabel(): void {
labelModal.value = { init: null }
}
function openEditLabel(label: ApiTodoLabel): void {
labelModal.value = { init: label }
}
async function onSaveLabel(payload: TodoLabelPayload): Promise<void> {
const init = labelModal.value?.init
if (init) await store.updateLabel(init.id, payload)
else await store.createLabel(payload)
labelModal.value = null
}
async function onDeleteLabel(label: ApiTodoLabel): Promise<void> {
const msg = label.totalCount
? `'${label.name}' 라벨과 여기에 등록된 할 일 ${label.totalCount}건이 함께 삭제됩니다. 계속할까요?`
: `'${label.name}' 라벨을 삭제할까요?`
const ok = await dialog.confirm(msg, {
title: '라벨 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
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))
}
// ----- 할 일 -----
// 오류 안내는 API 인터셉터가 토스트로 처리하므로 여기서는 거부만 삼킨다.
// (삼키지 않으면 템플릿 핸들러에서 처리되지 않은 Promise 거부가 남는다)
function swallow(p: Promise<unknown>): void {
void p.catch(() => undefined)
}
// 추가만 실패를 호출측(카드)에 알려야 한다 — 입력을 지울지 남길지 판단하기 위해
function onAddItem(labelId: string, title: string): Promise<void> {
return store.addItem(labelId, 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> {
// 되돌릴 수 없는 삭제라 라벨 삭제와 동일하게 확인을 받는다
const ok = await dialog.confirm(`'${item.title}' 할 일을 삭제할까요?`, {
title: '할 일 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
swallow(store.removeItem(item.id))
}
</script>
<template>
<AppShell>
<div class="page">
<nav class="crumb">
<b> </b>
</nav>
<div class="pagehead">
<h1> </h1>
<span class="count-pill">{{ store.totalCount }}</span>
</div>
<p class="lede">
스스로 챙겨야 일을 라벨로 묶어 두고 하나씩 체크하세요.
</p>
<!-- 툴바: 검색 + 라벨 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle
cx="11"
cy="11"
r="7"
/><path d="m20 20-3.5-3.5" /></svg>
<input
v-model="store.keyword"
type="search"
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"
@click="openNewLabel"
>
<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> 라벨
</button>
</div>
<!-- 로딩 / 상태 프로젝트·화상회의와 동일하게 상단 전체폭 카드 -->
<EmptyState v-if="store.loading && store.labels.length === 0">
불러오는
</EmptyState>
<EmptyState v-else-if="store.displayLabels.length === 0">
{{
store.isFiltered
? '검색 결과가 없습니다.'
: '아직 라벨이 없습니다. 라벨을 만들고 일을 등록해 보세요.'
}}
</EmptyState>
<!-- 라벨 카드 -->
<TodoLabelCard
v-for="label in store.displayLabels"
:key="label.id"
:label="label"
: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>
<TodoLabelModal
v-if="labelModal"
:init="labelModal.init"
@save="onSaveLabel"
@close="labelModal = null"
/>
</AppShell>
</template>
<style scoped>
/* 헤더 규격 — 프로젝트·화상회의 화면과 동일(.pagehead/.lede 는 전역 정의가 없어 페이지마다 선언) */
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
margin: 0.25rem 0 1.125rem;
}
/* 회의 로비와 동일하게 주요 버튼을 우측으로 밀어 배치 */
/* 완료 숨김 토글은 주요 버튼 왼쪽에 붙인다 */
.hide-done {
margin-left: auto;
}
.hide-done + .new-label {
margin-left: 0;
}
.new-label {
margin-left: auto;
}
</style>
+7
View File
@@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/MyTasksPage.vue'),
meta: { requiresAuth: true },
},
{
// 할 일 — 개인 체크리스트('내 업무'와 달리 스스로 등록해 관리)
path: '/todos',
name: 'todos',
component: () => import('@/pages/relay/TodoPage.vue'),
meta: { requiresAuth: true },
},
{
// 일정 — 전사 공유 주간 캘린더(타임라인)
path: '/schedule',
+26 -8
View File
@@ -183,15 +183,33 @@ export interface TaskDueInfo {
ddayLevel: DdayLevel
}
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
if (!dueDate) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
/**
* dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산
*
* 완료 업무의 목록 라벨은 마감일이 아니라 실제 완료 시각(completedAt)을 쓴다.
* 완료 기록이 없는 과거 업무(백필 불가 건)는 날짜 없이 '완료'만 표시한다.
*/
export function taskDueInfo(
dueDate: string | null,
isDone: boolean,
completedAt: string | null = null,
): TaskDueInfo {
// 완료 업무의 목록 라벨 — 마감일 유무와 무관하게 완료 시각으로 결정
const doneListLabel = (): string => {
const label = completedAt ? monthDayKo(completedAt) : ''
return label ? `${label} 완료` : '완료'
}
const empty: TaskDueInfo = {
dateLabel: '',
dday: '',
listLabel: isDone ? doneListLabel() : '',
overdue: false,
ddayLevel: '',
}
if (!dueDate) return empty
const due = new Date(dueDate)
if (Number.isNaN(due.getTime())) {
return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' }
}
if (Number.isNaN(due.getTime())) return empty
const dateLabel = `${due.getMonth() + 1}${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
// 자정 기준 일 수 차이
@@ -204,7 +222,7 @@ export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInf
return {
dateLabel,
dday: '',
listLabel: `${monthDayKo(due)} 완료`,
listLabel: doneListLabel(),
overdue: false,
ddayLevel: '',
}
Binary file not shown.
-119
View File
@@ -1,119 +0,0 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAi } from '@/composables/useAi'
import type { ChatMessage, ConversationSummary } from '@/types/ai'
// AI 채팅 상태 — 대화/메시지는 서버(DB)에 보관. 스토어는 현재 열린 대화 + 목록 캐시를 관리.
export const useAiStore = defineStore('ai', () => {
const api = useAi()
// 대화 목록(최근순)
const conversations = ref<ConversationSummary[]>([])
const listLoading = ref(false)
// 현재 열린 대화
const currentId = ref<string | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// 대화 목록 로드(패널/기록 뷰 진입 시)
async function loadConversations(): Promise<void> {
listLoading.value = true
try {
const { items } = await api.listConversations(1, 30)
conversations.value = items
} catch {
// 목록 로드 실패는 조용히(빈 목록 유지)
} finally {
listLoading.value = false
}
}
// 새 대화 — DB 호출 없이 화면만 비운다(첫 메시지 전송 시 생성)
function newConversation(): void {
currentId.value = null
messages.value = []
error.value = null
}
// 기존 대화 열기 — 메시지 로드
async function openConversation(id: string): Promise<void> {
if (loading.value) return
error.value = null
try {
const detail = await api.getConversation(id)
currentId.value = detail.id
messages.value = detail.messages
} catch {
error.value = '대화를 불러오지 못했습니다.'
}
}
// 메시지 전송 — 성공 여부 반환(실패 시 호출측이 입력 복원)
async function send(text: string): Promise<boolean> {
const content = text.trim()
if (!content || loading.value) return false
error.value = null
messages.value.push({ role: 'user', content })
loading.value = true
try {
if (currentId.value === null) {
const res = await api.startConversation(content)
currentId.value = res.conversationId
messages.value.push({ role: 'assistant', content: res.reply })
conversations.value.unshift({
id: res.conversationId,
title: res.title,
updatedAt: new Date().toISOString(),
})
} else {
const res = await api.sendMessage(currentId.value, content)
messages.value.push({ role: 'assistant', content: res.reply })
bumpConversation(currentId.value)
}
return true
} catch {
// 실패 — 낙관적으로 추가한 사용자 메시지 롤백(백엔드도 롤백) + 에러 표시
messages.value.pop()
error.value = '응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.'
return false
} finally {
loading.value = false
}
}
// 대화 삭제 — 목록에서 제거, 현재 대화였다면 새 대화로
async function removeConversation(id: string): Promise<void> {
try {
await api.deleteConversation(id)
conversations.value = conversations.value.filter((c) => c.id !== id)
if (currentId.value === id) newConversation()
} catch {
error.value = '대화를 삭제하지 못했습니다.'
}
}
// 대화를 목록 맨 위로 이동 + 시각 갱신
function bumpConversation(id: string): void {
const idx = conversations.value.findIndex((c) => c.id === id)
if (idx < 0) return
const [c] = conversations.value.splice(idx, 1)
if (!c) return
c.updatedAt = new Date().toISOString()
conversations.value.unshift(c)
}
return {
conversations,
listLoading,
currentId,
messages,
loading,
error,
loadConversations,
newConversation,
openConversation,
send,
removeConversation,
}
})
+2 -2
View File
@@ -42,7 +42,7 @@ const ISSUED_LABEL: Record<TaskStatus, string> = {
// API 행 → 담당 뷰 행 (누가 지시했는지 표시)
function toAssignedRow(api: ApiMyTaskRow): MyTaskRow {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt)
const [, total] = api.checklist
const isDone = api.status === 'done'
return {
@@ -64,7 +64,7 @@ function toAssignedRow(api: ApiMyTaskRow): MyTaskRow {
// API 행 → 지시 뷰 행 (담당자 미니 아바타 + 승인 대기 강조)
function toIssuedRow(api: ApiMyTaskRow): MyTaskRow {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt)
const [, total] = api.checklist
const isDone = api.status === 'done'
const isReview = api.status === 'review'
+1 -1
View File
@@ -56,7 +56,7 @@ const UNKNOWN_USER: UserView = {
// API 업무 목록 행 → 화면용 ProjectTask
function toProjectTaskView(api: ApiProjectTask): ProjectTaskView {
const due = taskDueInfo(api.dueDate, api.status === 'done')
const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt)
const [, total] = api.checklist
return {
id: api.id,
+163
View File
@@ -0,0 +1,163 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useTodo } from '@/composables/useTodo'
import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo'
// 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD.
// 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다.
export const useTodoStore = defineStore('todo', () => {
const api = useTodo()
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()
// 진행 집계(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 source) {
if (l.name.toLowerCase().includes(kw)) {
result.push(l)
continue
}
const hit = l.items.filter((i) => i.title.toLowerCase().includes(kw))
if (hit.length) result.push({ ...l, items: hit })
}
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 {
labels.value = await api.listLabels()
} finally {
loading.value = false
}
}
// --- 라벨 ---
async function createLabel(payload: TodoLabelPayload): Promise<void> {
const label = await api.createLabel(payload)
labels.value = [...labels.value, label]
}
async function updateLabel(
id: string,
payload: Partial<TodoLabelPayload>,
): Promise<void> {
replaceLabel(await api.updateLabel(id, payload))
}
async function deleteLabel(id: string): Promise<void> {
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> {
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> {
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> {
if (pendingItemIds.value.has(id)) return
markPending(id, true)
try {
replaceLabel(await api.deleteItem(id))
} finally {
markPending(id, false)
}
}
return {
labels,
displayLabels,
keyword,
loading,
hideDone,
isFiltered,
hasDoneItems,
totalCount,
load,
createLabel,
updateLabel,
deleteLabel,
clearDoneItems,
addItem,
toggleItem,
renameItem,
removeItem,
}
})
+5 -27
View File
@@ -1,30 +1,3 @@
// AI 채팅 메시지 — 백엔드 메시지와 1:1
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
// 대화 목록 항목
export interface ConversationSummary {
id: string
title: string
updatedAt: string
}
// 대화 상세(메시지 포함)
export interface ConversationDetail {
id: string
title: string
messages: ChatMessage[]
}
// 메시지 전송 결과(대화 생성/이어가기 공통)
export interface SendResult {
conversationId: string
title: string
reply: string
}
// 할 일(체크리스트) 추천 — 카테고리별 항목
export interface ChecklistSuggestionGroup {
category: string
@@ -33,3 +6,8 @@ export interface ChecklistSuggestionGroup {
export interface SuggestChecklistResult {
groups: ChecklistSuggestionGroup[]
}
// 개인 할 일 추천 — 업무 본문 분석 결과(수행 순서대로 나열된 실행 단계)
export interface SuggestTaskTodosResult {
items: string[]
}
+2
View File
@@ -21,6 +21,8 @@ export interface ApiMyTaskRow {
title: string
status: TaskStatus
dueDate: string | null
// 완료 시각(done 이 아니거나 기록이 없으면 null)
completedAt: string | null
checklist: [number, number] // [완료, 전체]
assignees: ApiMember[]
issuer: ApiMember | null
+2
View File
@@ -18,6 +18,8 @@ export interface ApiProjectTask {
title: string
status: TaskStatus
dueDate: string | null
// 완료 시각(done 이 아니거나 기록이 없으면 null)
completedAt: string | null
checklist: [number, number] // [완료, 전체]
commentCount: number
fileCount: number // 첨부 파일 수
+47
View File
@@ -0,0 +1,47 @@
// 할 일(개인 체크리스트) 도메인 타입 — 백엔드 Todo 응답과 1:1
// 할 일 항목
export interface ApiTodoItem {
id: string
title: string
done: boolean
sortOrder: number
}
// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다
export interface ApiTodoSourceTask {
projectId: string
seq: number
title: string
}
// 라벨(할 일 묶음) — 항목과 진행 집계를 함께 내려받는다
export interface ApiTodoLabel {
id: string
name: string
color: string
sortOrder: number
items: ApiTodoItem[]
doneCount: number
totalCount: number
// 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null — 링크를 표시하지 않는다
sourceTask: ApiTodoSourceTask | null
}
// 생성/수정 payload
export interface TodoLabelPayload {
name: string
color: string
}
export interface TodoItemPayload {
title?: string
done?: boolean
}
// 업무 → 할 일 생성 요청. 라벨 이름·색은 서버가 업무에서 파생하므로 보내지 않는다
export interface TodoFromTaskPayload {
projectId: string
taskSeq: number
items: string[]
}