Compare commits

..

5 Commits

Author SHA1 Message Date
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
40 changed files with 1638 additions and 1442 deletions
+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"`);
}
}
-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;
}
}
+4 -13
View File
@@ -1,22 +1,13 @@
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 { 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 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입한다.
@Module({
imports: [
TypeOrmModule.forFeature([AiConversation, AiMessage]),
TaskModule,
ProjectModule,
],
controllers: [AiController, AiSuggestController],
imports: [ProjectModule],
controllers: [AiSuggestController],
providers: [AiService],
})
export class AiModule {}
+2 -277
View File
@@ -1,41 +1,13 @@
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';
// 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 +18,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 +37,7 @@ const SUGGEST_SYSTEM_PROMPT = [
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
].join('\n');
// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출.
// AI(Claude) 서비스 — 업무 생성 화면의 할 일(체크리스트) 추천을 담당한다.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
export class AiService {
@@ -106,12 +48,7 @@ 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>,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
@@ -121,7 +58,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 +66,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,
@@ -322,91 +196,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 +206,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;
}
@@ -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;
+13
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;
@@ -1252,6 +1256,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 +1308,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 +1556,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,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,51 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.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;
// 소속 할 일 — 라벨 삭제 시 함께 삭제(FK CASCADE)
// Relation<> 래퍼: 엔티티 순환참조 회피
@OneToMany(() => TodoItem, (item) => item.label)
items!: Relation<TodoItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+106
View File
@@ -0,0 +1,106 @@
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';
// 할 일(개인 체크리스트) 컨트롤러 — 라벨 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/: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);
}
}
+14
View File
@@ -0,0 +1,14 @@
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';
// 할 일(개인 체크리스트) 모듈
@Module({
imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem])],
controllers: [TodoController],
providers: [TodoService],
})
export class TodoModule {}
+205
View File
@@ -0,0 +1,205 @@
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 { CreateTodoLabelDto } from './dto/todo-label.dto';
import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto';
// 응답(원시) — 표시 음영색은 프론트가 color 로 파생
export interface TodoItemResponse {
id: string;
title: string;
done: boolean;
sortOrder: number;
}
export interface TodoLabelResponse {
id: string;
name: string;
color: string;
sortOrder: number;
items: TodoItemResponse[];
// 진행 표기(2/3)용 집계 — 프론트가 items 로 다시 세지 않도록 함께 내려준다
doneCount: number;
totalCount: number;
}
// 할 일(개인 체크리스트) 비즈니스 로직 — 라벨 1개에 항목 N개.
// 모든 라벨·항목은 개인 전용이라 소유자 본인만 접근할 수 있고,
// 남의 것은 존재 자체를 노출하지 않도록 404 로 처리한다.
@Injectable()
export class TodoService {
constructor(
@InjectRepository(TodoLabel)
private readonly labelRepo: Repository<TodoLabel>,
@InjectRepository(TodoItem)
private readonly itemRepo: Repository<TodoItem>,
) {}
// ----- 라벨 -----
// 본인 라벨 전체 + 소속 항목을 한 번에 반환(화면이 단일 요청으로 그린다)
async listLabels(ownerId: string): Promise<TodoLabelResponse[]> {
const rows = await this.labelRepo.find({
where: { owner: { id: ownerId } },
relations: ['items'],
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 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;
}
// 다음 정렬값 = 같은 묶음 안의 최대 + 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'],
});
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,
}));
return {
id: l.id,
name: l.name,
color: l.color,
sortOrder: l.sortOrder,
items,
doneCount: items.filter((i) => i.done).length,
totalCount: items.length,
};
}
}
+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,413 @@
<script setup lang="ts">
// 라벨 카드 — 헤더(색점 + 이름 + 진행 + 편집/삭제) + 할 일 행 목록 + 인라인 추가 입력.
// 전역 .list 카드 규격을 그대로 쓰고, 행은 카드 안에서 구분선으로 나눈다.
import { ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiTodoLabel, ApiTodoItem } from '@/types/todo'
const props = defineProps<{ label: ApiTodoLabel }>()
const emit = defineEmits<{
(e: 'add', title: string): void
(e: 'toggle', item: ApiTodoItem): void
(e: 'rename', item: ApiTodoItem, title: string): void
(e: 'remove', item: ApiTodoItem): void
(e: 'edit-label'): void
(e: 'delete-label'): void
}>()
// 라벨 메뉴(편집/삭제) 드롭다운
const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
useClickOutside(menuOpen, () => (menuOpen.value = false), [menuRef])
// 인라인 추가 입력
const draft = ref('')
function submitDraft(): void {
const title = draft.value.trim()
if (!title) return
emit('add', title)
draft.value = ''
}
// 항목 인라인 수정 — 한 번에 한 행만 편집 상태가 된다
const editingId = ref<string | null>(null)
const editDraft = ref('')
function startEdit(item: ApiTodoItem): void {
editingId.value = item.id
editDraft.value = item.title
}
function commitEdit(item: ApiTodoItem): void {
// 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>
<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
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"
v-model="editDraft"
class="tl-edit"
@keydown.enter="commitEdit(item)"
@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"
>
</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 {
display: grid;
}
.tl-del:hover {
background: #eceef1;
color: var(--text);
}
.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>
+3 -49
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 } 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,5 @@ export function useAi() {
})) as unknown as SuggestChecklistResult
}
return {
listConversations,
getConversation,
startConversation,
sendMessage,
deleteConversation,
suggestChecklist,
}
return { suggestChecklist }
}
+58
View File
@@ -0,0 +1,58 @@
import { useApi } from './useApi'
import type {
ApiTodoLabel,
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 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,
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;
+175
View File
@@ -0,0 +1,175 @@
<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 onAddItem(labelId: string, title: string): Promise<void> {
await store.addItem(labelId, title)
}
async function onToggleItem(item: ApiTodoItem): Promise<void> {
await store.toggleItem(item.id, !item.done)
}
async function onRenameItem(item: ApiTodoItem, title: string): Promise<void> {
await store.renameItem(item.id, title)
}
async function onRemoveItem(item: ApiTodoItem): Promise<void> {
await 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
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="onAddItem(label.id, $event)"
@toggle="onToggleItem"
@rename="onRenameItem"
@remove="onRemoveItem"
@edit-label="openEditLabel(label)"
@delete-label="onDeleteLabel(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;
}
/* 회의 로비와 동일하게 주요 버튼을 우측으로 밀어 배치 */
.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,
+98
View File
@@ -0,0 +1,98 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useTodo } from '@/composables/useTodo'
import type { 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 displayLabels = computed<ApiTodoLabel[]>(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return labels.value
const result: ApiTodoLabel[] = []
for (const l of labels.value) {
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 isFiltered = computed(() => keyword.value.trim() !== '')
// 전체 할 일 수(헤더 count-pill 표기용)
const totalCount = computed(() =>
labels.value.reduce((sum, l) => sum + l.totalCount, 0),
)
// 변경된 라벨 한 건을 목록에 반영
function replaceLabel(next: ApiTodoLabel): void {
labels.value = labels.value.map((l) => (l.id === next.id ? next : l))
}
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 addItem(labelId: string, title: string): Promise<void> {
replaceLabel(await api.createItem(labelId, title))
}
async function toggleItem(id: string, done: boolean): Promise<void> {
replaceLabel(await api.updateItem(id, { done }))
}
async function renameItem(id: string, title: string): Promise<void> {
replaceLabel(await api.updateItem(id, { title }))
}
async function removeItem(id: string): Promise<void> {
replaceLabel(await api.deleteItem(id))
}
return {
labels,
displayLabels,
keyword,
loading,
isFiltered,
totalCount,
load,
createLabel,
updateLabel,
deleteLabel,
addItem,
toggleItem,
renameItem,
removeItem,
}
})
-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
+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 // 첨부 파일 수
+31
View File
@@ -0,0 +1,31 @@
// 할 일(개인 체크리스트) 도메인 타입 — 백엔드 Todo 응답과 1:1
// 할 일 항목
export interface ApiTodoItem {
id: string
title: string
done: boolean
sortOrder: number
}
// 라벨(할 일 묶음) — 항목과 진행 집계를 함께 내려받는다
export interface ApiTodoLabel {
id: string
name: string
color: string
sortOrder: number
items: ApiTodoItem[]
doneCount: number
totalCount: number
}
// 생성/수정 payload
export interface TodoLabelPayload {
name: string
color: string
}
export interface TodoItemPayload {
title?: string
done?: boolean
}