diff --git a/backend/src/modules/task/dto/create-task.dto.ts b/backend/src/modules/task/dto/create-task.dto.ts index d093ee0..1cde5f7 100644 --- a/backend/src/modules/task/dto/create-task.dto.ts +++ b/backend/src/modules/task/dto/create-task.dto.ts @@ -2,6 +2,7 @@ import { ArrayMaxSize, ArrayNotEmpty, IsArray, + IsIn, IsISO8601, IsNotEmpty, IsOptional, @@ -12,6 +13,10 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; +import { + CHECKLIST_CATEGORIES, + type ChecklistCategory, +} from '../entities/checklist-item.entity'; // 체크리스트 입력 항목 (생성 시 함께 등록) export class ChecklistInputDto { @@ -23,6 +28,15 @@ export class ChecklistInputDto { @IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' }) @MaxLength(200) text!: string; + + @ApiProperty({ + description: '카테고리(탭)', + enum: CHECKLIST_CATEGORIES, + required: false, + }) + @IsOptional() + @IsIn(CHECKLIST_CATEGORIES) + category?: ChecklistCategory; } // 업무 생성 요청 diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts index a6cfced..52b92bf 100644 --- a/backend/src/modules/task/dto/update-task.dto.ts +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -2,6 +2,7 @@ import { ArrayMaxSize, ArrayNotEmpty, IsArray, + IsIn, IsISO8601, IsNotEmpty, IsOptional, @@ -12,6 +13,10 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; +import { + CHECKLIST_CATEGORIES, + type ChecklistCategory, +} from '../entities/checklist-item.entity'; // 수정 시 체크리스트 항목 — id 있으면 기존 항목 갱신(완료 상태 보존), 없으면 신규 추가 export class UpdateChecklistItemInput { @@ -31,6 +36,15 @@ export class UpdateChecklistItemInput { @IsNotEmpty({ message: '체크리스트 항목 내용을 입력해 주세요.' }) @MaxLength(200) text!: string; + + @ApiProperty({ + description: '카테고리(탭)', + enum: CHECKLIST_CATEGORIES, + required: false, + }) + @IsOptional() + @IsIn(CHECKLIST_CATEGORIES) + category?: ChecklistCategory; } // 업무 수정 요청 — 제공된 필드만 갱신 diff --git a/backend/src/modules/task/entities/checklist-item.entity.ts b/backend/src/modules/task/entities/checklist-item.entity.ts index cdfc2c1..bd77787 100644 --- a/backend/src/modules/task/entities/checklist-item.entity.ts +++ b/backend/src/modules/task/entities/checklist-item.entity.ts @@ -8,6 +8,10 @@ import { } from 'typeorm'; import { Task } from './task.entity'; +// 체크리스트 카테고리(탭) — AI 추천 4개 영역과 동일 +export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const; +export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number]; + // 업무 체크리스트 항목 @Entity('checklist_items') export class ChecklistItem { @@ -23,6 +27,10 @@ export class ChecklistItem { @Column() text!: string; + // 카테고리(탭) — 필수/관리/보안/부가 + @Column({ type: 'varchar', default: '필수' }) + category!: ChecklistCategory; + // 완료 여부 @Column({ default: false }) done!: boolean; diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index b85cb92..294e34d 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -11,7 +11,10 @@ import { BusinessException } from '../../common/exceptions/business.exception'; import { Project } from '../project/entities/project.entity'; import { ProjectMember } from '../project/entities/project-member.entity'; import { Task, type TaskStatus } from './entities/task.entity'; -import { ChecklistItem } from './entities/checklist-item.entity'; +import { + ChecklistItem, + type ChecklistCategory, +} from './entities/checklist-item.entity'; import { Comment } from './entities/comment.entity'; import { Attachment, type AttachmentKind } from './entities/attachment.entity'; import { CreateTaskDto } from './dto/create-task.dto'; @@ -52,6 +55,7 @@ export interface ChecklistItemResponse { id: string; text: string; done: boolean; + category: ChecklistCategory; } // 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영 @@ -331,6 +335,7 @@ export class TaskService { checklist: (dto.checklist ?? []).map((c, idx) => this.checklistRepo.create({ text: c.text, + category: c.category ?? '필수', done: false, sortOrder: idx, }), @@ -521,7 +526,7 @@ export class TaskService { // 반환: 의미 있는 변경(추가/삭제/내용변경)이 있었는지 — 활동 적재 판단용(순서만 변경은 제외) private async syncChecklist( task: Task, - items: { id?: string; text: string }[], + items: { id?: string; text: string; category?: ChecklistCategory }[], ): Promise { const existing = task.checklist ?? []; const byId = new Map(existing.map((c) => [c.id, c])); @@ -530,11 +535,15 @@ export class TaskService { let changed = false; items.forEach((it, idx) => { + const category = it.category ?? '필수'; const found = it.id ? byId.get(it.id) : undefined; if (found) { - // 기존 항목 — 내용/순서만 갱신(완료 상태 done 은 보존) - if (found.text !== it.text) changed = true; // 내용 변경 감지(갱신 전 비교) + // 기존 항목 — 내용/카테고리/순서만 갱신(완료 상태 done 은 보존) + if (found.text !== it.text || found.category !== category) { + changed = true; // 변경 감지(갱신 전 비교) + } found.text = it.text; + found.category = category; found.sortOrder = idx; keepIds.add(found.id); toSave.push(found); @@ -544,6 +553,7 @@ export class TaskService { toSave.push( this.checklistRepo.create({ text: it.text, + category, done: false, sortOrder: idx, task, @@ -1232,7 +1242,12 @@ export class TaskService { const checklist = (task.checklist ?? []) .slice() .sort((a, b) => a.sortOrder - b.sortOrder) - .map((c) => ({ id: c.id, text: c.text, done: c.done })); + .map((c) => ({ + id: c.id, + text: c.text, + done: c.done, + category: c.category, + })); // 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출 const approval: ApprovalInfo | null = diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 3aad2c7..4ef0836 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -27,12 +27,22 @@ export interface Attachment { url?: string } +/** 체크리스트 카테고리(탭) — AI 추천 4개 영역과 동일 */ +export type ChecklistCategory = '필수' | '관리' | '보안' | '부가' +export const CHECKLIST_CATEGORIES: ChecklistCategory[] = [ + '필수', + '관리', + '보안', + '부가', +] + /** 체크리스트 항목 */ export interface ChecklistItem { /** 항목 ID(API 연동 시) */ id?: string text: string done: boolean + category: ChecklistCategory } /** 프로젝트 — 작업의 기본 단위(우선) */ diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index eb6cd5c..75864d1 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -12,7 +12,13 @@ import { useAuthStore } from '@/stores/auth.store' import { useTask } from '@/composables/useTask' import { useAi } from '@/composables/useAi' import { attachmentKindOf, fileBadge, formatBytes } from '@/shared/utils/format' -import type { Project, ChecklistItem, User } from '@/mock/relay.mock' +import { CHECKLIST_CATEGORIES } from '@/mock/relay.mock' +import type { + Project, + ChecklistItem, + ChecklistCategory, + User, +} from '@/mock/relay.mock' import type { ApiMember } from '@/types/repo' import type { ApiAttachment } from '@/types/task' import type { ChecklistSuggestionGroup } from '@/types/ai' @@ -78,7 +84,12 @@ async function loadTaskForEdit() { dueDate.value = t.dueDate ? toDateInput(t.dueDate) : '' assignees.value = t.assignees.map(toUserView) // id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지) - checklist.value = t.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })) + checklist.value = t.checklist.map((c) => ({ + id: c.id, + text: c.text, + done: c.done, + category: c.category, + })) existingFiles.value = t.files } catch { // 404 등은 인터셉터가 알림 처리 @@ -141,19 +152,29 @@ function onAssigneeBlur() { const dueDate = ref('') // yyyy-MM-dd (input[type=date]) // 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성) +// 항목은 카테고리(탭: 필수/관리/보안/부가)별로 추가한다. const checklist = ref([]) const newItem = ref('') +const activeCategory = ref('필수') const doneCount = computed(() => checklist.value.filter((c) => c.done).length) const progressPct = computed(() => checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0, ) -function removeItem(index: number) { - checklist.value.splice(index, 1) +// 활성 탭의 항목 / 카테고리별 개수 +const categoryItems = computed(() => + checklist.value.filter((c) => c.category === activeCategory.value), +) +function categoryCount(cat: ChecklistCategory): number { + return checklist.value.filter((c) => c.category === cat).length +} +// 인덱스가 아니라 항목 참조로 제거(탭 필터로 인덱스가 어긋나므로) +function removeItem(item: ChecklistItem) { + checklist.value = checklist.value.filter((c) => c !== item) } function addItem() { const text = newItem.value.trim() if (!text) return - checklist.value.push({ text, done: false }) + checklist.value.push({ text, done: false, category: activeCategory.value }) newItem.value = '' } @@ -228,25 +249,41 @@ const selectedCount = computed(() => selectedSuggestions.value.size) const suggestionTotal = computed( () => aiSuggestions.value?.reduce((s, g) => s + g.items.length, 0) ?? 0, ) -// 카테고리 → 섹션 스타일 클래스 +// 카테고리 → 섹션 스타일 클래스 (AI 전체명·체크리스트 단축명 모두 지원) function sectionClass(category: string): string { const map: Record = { '필수 기능': 'sec-1', + 필수: 'sec-1', '관리 기능': 'sec-2', + 관리: 'sec-2', '보안 기능': 'sec-3', + 보안: 'sec-3', '부가 기능': 'sec-4', + 부가: 'sec-4', } return map[category] ?? 'sec-1' } -// 선택 항목을 체크리스트에 일괄 추가 후 모달 닫기 +// AI 카테고리("필수 기능") → 체크리스트 카테고리("필수") +function aiCategoryToChecklist(aiCat: string): ChecklistCategory { + const short = aiCat.replace(/\s*기능$/, '').trim() + return (CHECKLIST_CATEGORIES as string[]).includes(short) + ? (short as ChecklistCategory) + : '필수' +} +// 선택 항목을 (AI 카테고리 → 체크리스트 카테고리) 매핑해 일괄 추가 후 모달 닫기 function commitSelected() { - if (selectedCount.value === 0) return - selectedSuggestions.value.forEach((t) => addSuggestion(t)) + if (selectedCount.value === 0 || !aiSuggestions.value) return + for (const group of aiSuggestions.value) { + const cat = aiCategoryToChecklist(group.category) + for (const item of group.items) { + if (isSelected(item)) addSuggestion(item, cat) + } + } dismissSuggestions() } -function addSuggestion(text: string) { +function addSuggestion(text: string, category: ChecklistCategory = '필수') { if (isSuggestionAdded(text)) return - checklist.value.push({ text, done: false }) + checklist.value.push({ text, done: false, category }) } function dismissSuggestions() { aiSuggestions.value = null @@ -377,7 +414,9 @@ async function submitTask() { assigneeIds, dueDate: dueIso, checklist: checklist.value.map((c) => - c.id ? { id: c.id, text: c.text } : { text: c.text }, + c.id + ? { id: c.id, text: c.text, category: c.category } + : { text: c.text, category: c.category }, ), }) tid = updated.id @@ -388,7 +427,7 @@ async function submitTask() { assigneeIds, dueDate: dueIso ?? undefined, checklist: checklist.value.length - ? checklist.value.map((c) => ({ text: c.text })) + ? checklist.value.map((c) => ({ text: c.text, category: c.category })) : undefined, }) tid = created.id @@ -627,9 +666,29 @@ function goBack() { {{ doneCount }} / {{ checklist.length }} 완료 + +
+ +
{{ item.text }}
+
+ '{{ activeCategory }}' 항목이 없습니다. 아래에서 추가하세요. +
+
@@ -1306,6 +1371,75 @@ function goBack() { color: var(--text-2); white-space: nowrap; } +/* 카테고리 탭 — 필수/관리/보안/부가 */ +.cat-tabs { + display: flex; + gap: 0.375rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} +.cat-tab { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.6875rem; + border: 1px solid var(--border); + border-radius: 999px; + background: #fff; + font-size: 0.781rem; + font-weight: 600; + color: var(--text-2); + cursor: pointer; + transition: + border-color 0.12s, + background 0.12s, + color 0.12s; +} +.cat-tab:hover { + background: #f7f8fa; +} +.cat-tab .cat-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + flex-shrink: 0; +} +.cat-tab.sec-1 .cat-dot { + background: var(--accent); +} +.cat-tab.sec-2 .cat-dot { + background: #1b7a47; +} +.cat-tab.sec-3 .cat-dot { + background: #c0394f; +} +.cat-tab.sec-4 .cat-dot { + background: #a96a13; +} +.cat-tab .cat-count { + font-size: 0.719rem; + font-weight: 700; + color: var(--text-3, var(--text-2)); + background: #eceef1; + border-radius: 999px; + padding: 0.0625rem 0.375rem; + min-width: 1.125rem; + text-align: center; +} +.cat-tab.active { + color: var(--text); + border-color: var(--text); + background: #fff; +} +.cat-tab.active .cat-count { + background: var(--text); + color: #fff; +} +.check-empty { + padding: 0.875rem 0; + font-size: 0.781rem; + color: var(--text-2); +} .checklist { display: flex; flex-direction: column; diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index ef921c0..3338e8d 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -13,7 +13,9 @@ import { import { useRoute, useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import { + CHECKLIST_CATEGORIES, type Attachment, + type ChecklistCategory, type Comment, type TaskDetail, type TaskStatus, @@ -53,6 +55,24 @@ const checklistPct = computed(() => { return total ? Math.round((checklistDone.value / total) * 100) : 0 }) +// 카테고리별 그룹(항목 있는 카테고리만, 필수/관리/보안/부가 고정 순서) +const checklistGroups = computed(() => + CHECKLIST_CATEGORIES.map((cat) => ({ + category: cat, + items: task.value?.checklist.filter((c) => c.category === cat) ?? [], + })).filter((g) => g.items.length > 0), +) +// 카테고리 → 색상 클래스(필수=sec-1 …) +function categoryClass(cat: ChecklistCategory): string { + const map: Record = { + 필수: 'sec-1', + 관리: 'sec-2', + 보안: 'sec-3', + 부가: 'sec-4', + } + return map[cat] +} + // 본문 멘션(@…)을 강조 span 으로 변환 function renderMention(text: string): string { const escaped = text.replace(/&/g, '&').replace(//g, '>') @@ -490,25 +510,45 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { -->
- + + {{ group.category }} + {{ group.items.length }} +
+
- - - {{ item.text }} + + + + {{ item.text }} +
+
+
+ 등록된 할 일이 없습니다.
@@ -1476,6 +1516,50 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { display: flex; flex-direction: column; } +/* 카테고리 그룹 — 필수/관리/보안/부가 */ +.cat-group + .cat-group { + margin-top: 0.625rem; +} +.cat-group-head { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0; +} +.cat-group-head .cat-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + flex-shrink: 0; +} +.cat-group-head.sec-1 .cat-dot { + background: var(--accent); +} +.cat-group-head.sec-2 .cat-dot { + background: #1b7a47; +} +.cat-group-head.sec-3 .cat-dot { + background: #c0394f; +} +.cat-group-head.sec-4 .cat-dot { + background: #a96a13; +} +.cat-group-head .cat-name { + font-size: 0.781rem; + font-weight: 700; + color: var(--text); + letter-spacing: -0.0125rem; +} +.cat-group-head .cat-count { + font-size: 0.719rem; + font-weight: 700; + color: var(--text-2); +} +.check-empty { + padding: 0.5rem 0; + font-size: 0.781rem; + color: var(--text-2); +} .citem { display: flex; align-items: center; diff --git a/frontend/src/stores/task.store.ts b/frontend/src/stores/task.store.ts index f167019..6e20db8 100644 --- a/frontend/src/stores/task.store.ts +++ b/frontend/src/stores/task.store.ts @@ -176,7 +176,12 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView { status: api.status, statusLabel: taskStatusLabel(api.status), content: api.content, - checklist: api.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done })), + checklist: api.checklist.map((c) => ({ + id: c.id, + text: c.text, + done: c.done, + category: c.category, + })), comments: api.comments.map((c) => toCommentView(c, issuerId)), activities: api.activities.map(toTaskActivityView), assignees: api.assignees.map(toUserView), diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 071fa09..599a855 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -1,6 +1,6 @@ // 업무 도메인 타입 — 백엔드 TaskService 응답 / DTO 와 1:1 -import type { TaskStatus } from '@/mock/relay.mock' +import type { TaskStatus, ChecklistCategory } from '@/mock/relay.mock' import type { ApiMember } from '@/types/repo' import type { ApiActivity } from '@/types/activity' import type { Paginated } from '@/types/pagination' @@ -22,6 +22,7 @@ export interface ApiChecklistItem { id: string text: string done: boolean + category: ChecklistCategory } // 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1 @@ -109,7 +110,7 @@ export interface CreateTaskPayload { content?: string[] assigneeIds: string[] dueDate?: string - checklist?: { text: string }[] + checklist?: { text: string; category?: ChecklistCategory }[] } // 업무 수정 요청 @@ -119,7 +120,7 @@ export interface UpdateTaskPayload { assigneeIds?: string[] dueDate?: string | null // 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제) - checklist?: { id?: string; text: string }[] + checklist?: { id?: string; text: string; category?: ChecklistCategory }[] } // 댓글/답글 작성 요청(5단계)