diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 293416a..85381f2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/migrations/1783800000000-AddTodo.ts b/backend/src/migrations/1783800000000-AddTodo.ts new file mode 100644 index 0000000..b7918e0 --- /dev/null +++ b/backend/src/migrations/1783800000000-AddTodo.ts @@ -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 { + // 라벨(묶음) + 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 { + 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"`); + } +} diff --git a/backend/src/modules/todo/dto/todo-item.dto.ts b/backend/src/modules/todo/dto/todo-item.dto.ts new file mode 100644 index 0000000..67de2ed --- /dev/null +++ b/backend/src/modules/todo/dto/todo-item.dto.ts @@ -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; +} diff --git a/backend/src/modules/todo/dto/todo-label.dto.ts b/backend/src/modules/todo/dto/todo-label.dto.ts new file mode 100644 index 0000000..10e6a8d --- /dev/null +++ b/backend/src/modules/todo/dto/todo-label.dto.ts @@ -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) {} diff --git a/backend/src/modules/todo/entities/todo-item.entity.ts b/backend/src/modules/todo/entities/todo-item.entity.ts new file mode 100644 index 0000000..117e0ae --- /dev/null +++ b/backend/src/modules/todo/entities/todo-item.entity.ts @@ -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; + + @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; +} diff --git a/backend/src/modules/todo/entities/todo-label.entity.ts b/backend/src/modules/todo/entities/todo-label.entity.ts new file mode 100644 index 0000000..c51b9df --- /dev/null +++ b/backend/src/modules/todo/entities/todo-label.entity.ts @@ -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[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/todo/todo.controller.ts b/backend/src/modules/todo/todo.controller.ts new file mode 100644 index 0000000..df5cc1a --- /dev/null +++ b/backend/src/modules/todo/todo.controller.ts @@ -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 { + return this.todoService.listLabels(user.id); + } + + @Post('labels') + @ApiOperation({ summary: '할 일 라벨 생성' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + createLabel( + @CurrentUser() user: PublicUser, + @Body() dto: CreateTodoLabelDto, + ): Promise { + 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 { + 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 { + 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 { + 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 { + return this.todoService.deleteItem(id, user.id); + } +} diff --git a/backend/src/modules/todo/todo.module.ts b/backend/src/modules/todo/todo.module.ts new file mode 100644 index 0000000..9eb0fb4 --- /dev/null +++ b/backend/src/modules/todo/todo.module.ts @@ -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 {} diff --git a/backend/src/modules/todo/todo.service.ts b/backend/src/modules/todo/todo.service.ts new file mode 100644 index 0000000..0aa47b1 --- /dev/null +++ b/backend/src/modules/todo/todo.service.ts @@ -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, + @InjectRepository(TodoItem) + private readonly itemRepo: Repository, + ) {} + + // ----- 라벨 ----- + + // 본인 라벨 전체 + 소속 항목을 한 번에 반환(화면이 단일 요청으로 그린다) + async listLabels(ownerId: string): Promise { + 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 { + 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, + ownerId: string, + ): Promise { + 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 { + await this.assertLabelOwned(id, ownerId); + await this.labelRepo.delete({ id }); + } + + // ----- 항목 ----- + + async createItem( + labelId: string, + dto: CreateTodoItemDto, + ownerId: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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( + repo: Repository, + where: Record, + ): Promise { + 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 { + 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, + }; + } +} diff --git a/frontend/src/components/todo/TodoLabelCard.vue b/frontend/src/components/todo/TodoLabelCard.vue new file mode 100644 index 0000000..055445b --- /dev/null +++ b/frontend/src/components/todo/TodoLabelCard.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/frontend/src/components/todo/TodoLabelModal.vue b/frontend/src/components/todo/TodoLabelModal.vue new file mode 100644 index 0000000..235d533 --- /dev/null +++ b/frontend/src/components/todo/TodoLabelModal.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/frontend/src/composables/useTodo.ts b/frontend/src/composables/useTodo.ts new file mode 100644 index 0000000..858fdd5 --- /dev/null +++ b/frontend/src/composables/useTodo.ts @@ -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 { + return (await api.get('/todos/labels')) as unknown as ApiTodoLabel[] + } + async function createLabel(payload: TodoLabelPayload): Promise { + return (await api.post('/todos/labels', payload)) as unknown as ApiTodoLabel + } + async function updateLabel( + id: string, + payload: Partial, + ): Promise { + return (await api.patch(`/todos/labels/${id}`, payload)) as unknown as ApiTodoLabel + } + async function deleteLabel(id: string): Promise { + await api.delete(`/todos/labels/${id}`) + } + + // 항목 + async function createItem( + labelId: string, + title: string, + ): Promise { + return (await api.post(`/todos/labels/${labelId}/items`, { + title, + })) as unknown as ApiTodoLabel + } + async function updateItem( + id: string, + payload: TodoItemPayload, + ): Promise { + return (await api.patch(`/todos/items/${id}`, payload)) as unknown as ApiTodoLabel + } + async function deleteItem(id: string): Promise { + return (await api.delete(`/todos/items/${id}`)) as unknown as ApiTodoLabel + } + + return { + listLabels, + createLabel, + updateLabel, + deleteLabel, + createItem, + updateItem, + deleteItem, + } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 3e04204..15c70a3 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -31,9 +31,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 +207,26 @@ async function onLogout() { 내 업무 + + + + + + + 할 일 + +// 할 일 — 개인이 직접 적어 관리하는 체크리스트(라벨 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 { + 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 { + 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 { + await store.addItem(labelId, title) +} +async function onToggleItem(item: ApiTodoItem): Promise { + await store.toggleItem(item.id, !item.done) +} +async function onRenameItem(item: ApiTodoItem, title: string): Promise { + await store.renameItem(item.id, title) +} +async function onRemoveItem(item: ApiTodoItem): Promise { + await store.removeItem(item.id) +} + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3ea606f..a58f728 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/stores/todo.store.ts b/frontend/src/stores/todo.store.ts new file mode 100644 index 0000000..97b8769 --- /dev/null +++ b/frontend/src/stores/todo.store.ts @@ -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([]) + const keyword = ref('') + const loading = ref(false) + + // 검색 — 라벨 이름 또는 할 일 내용에 걸리면 남긴다. + // 라벨 이름이 걸린 경우는 항목을 그대로 두고, 항목만 걸린 경우는 걸린 항목만 보여준다. + const displayLabels = computed(() => { + 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 { + loading.value = true + try { + labels.value = await api.listLabels() + } finally { + loading.value = false + } + } + + // --- 라벨 --- + async function createLabel(payload: TodoLabelPayload): Promise { + const label = await api.createLabel(payload) + labels.value = [...labels.value, label] + } + async function updateLabel( + id: string, + payload: Partial, + ): Promise { + replaceLabel(await api.updateLabel(id, payload)) + } + async function deleteLabel(id: string): Promise { + await api.deleteLabel(id) + labels.value = labels.value.filter((l) => l.id !== id) + } + + // --- 항목 --- + async function addItem(labelId: string, title: string): Promise { + replaceLabel(await api.createItem(labelId, title)) + } + async function toggleItem(id: string, done: boolean): Promise { + replaceLabel(await api.updateItem(id, { done })) + } + async function renameItem(id: string, title: string): Promise { + replaceLabel(await api.updateItem(id, { title })) + } + async function removeItem(id: string): Promise { + replaceLabel(await api.deleteItem(id)) + } + + return { + labels, + displayLabels, + keyword, + loading, + isFiltered, + totalCount, + load, + createLabel, + updateLabel, + deleteLabel, + addItem, + toggleItem, + renameItem, + removeItem, + } +}) diff --git a/frontend/src/types/todo.ts b/frontend/src/types/todo.ts new file mode 100644 index 0000000..db6cbc0 --- /dev/null +++ b/frontend/src/types/todo.ts @@ -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 +}