feat: 체크리스트를 필수·관리·보안·부가 카테고리 탭으로 구조화

업무 지시(생성/편집)·상세 화면의 '해야 할 일'을 AI 추천과 동일한
4개 영역(필수/관리/보안/부가)으로 분류한다.

- 백엔드: ChecklistItem 에 category 컬럼(기본 '필수') 추가, 생성/동기화
  DTO·서비스·응답에 category 반영
- 프론트: 생성/편집은 카테고리 탭 + 탭별 추가 입력, AI 추천 항목은
  소속 영역으로 매핑해 일괄 추가, 상세는 카테고리별 그룹 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:18:37 +09:00
parent 93c8ae2a22
commit c86c04f99e
9 changed files with 326 additions and 41 deletions
@@ -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;
}
// 업무 생성 요청
@@ -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;
}
// 업무 수정 요청 — 제공된 필드만 갱신
@@ -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;
+20 -5
View File
@@ -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<boolean> {
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 =
+10
View File
@@ -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
}
/** 프로젝트 — 작업의 기본 단위(우선) */
+150 -16
View File
@@ -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<ChecklistItem[]>([])
const newItem = ref('')
const activeCategory = ref<ChecklistCategory>('필수')
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<string, string> = {
'필수 기능': '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() {
<span class="progress-txt">{{ doneCount }} / {{ checklist.length }} 완료</span>
</div>
</div>
<!-- 카테고리 필수/관리/보안/부가 -->
<div
class="cat-tabs"
role="tablist"
>
<button
v-for="tab in CHECKLIST_CATEGORIES"
:key="tab"
type="button"
class="cat-tab"
:class="[sectionClass(tab), { active: activeCategory === tab }]"
role="tab"
:aria-selected="activeCategory === tab"
@click="activeCategory = tab"
>
<span class="cat-dot" />
{{ tab }}
<span class="cat-count">{{ categoryCount(tab) }}</span>
</button>
</div>
<div class="checklist">
<div
v-for="(item, index) in checklist"
v-for="(item, index) in categoryItems"
:key="index"
class="check-item"
:class="{ done: item.done }"
@@ -650,7 +709,7 @@ function goBack() {
<span class="txt">{{ item.text }}</span>
<span
class="row-del"
@click="removeItem(index)"
@click="removeItem(item)"
>
<svg
width="15"
@@ -662,13 +721,19 @@ function goBack() {
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
<div
v-if="categoryItems.length === 0"
class="check-empty"
>
'{{ activeCategory }}' 항목이 없습니다. 아래에서 추가하세요.
</div>
</div>
<div class="add-row">
<span class="plus">+</span>
<input
v-model="newItem"
class="add-input"
placeholder="항목 추가"
:placeholder="`'${activeCategory}' 항목 추가`"
@keydown.enter="addItem"
>
</div>
@@ -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;
+100 -16
View File
@@ -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<ChecklistCategory, string> = {
필수: 'sec-1',
관리: 'sec-2',
보안: 'sec-3',
부가: 'sec-4',
}
return map[cat]
}
// 본문 멘션(@…)을 강조 span 으로 변환
function renderMention(text: string): string {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -490,25 +510,45 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
-->
<div class="checklist">
<div
v-for="(item, i) in task.checklist"
:key="item.id ?? i"
class="citem"
:class="{ done: item.done }"
v-for="group in checklistGroups"
:key="group.category"
class="cat-group"
>
<span
class="cbox"
<div
class="cat-group-head"
:class="categoryClass(group.category)"
>
<span class="cat-dot" />
<span class="cat-name">{{ group.category }}</span>
<span class="cat-count">{{ group.items.length }}</span>
</div>
<div
v-for="(item, i) in group.items"
:key="item.id ?? i"
class="citem"
:class="{ done: item.done }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span class="ctext">{{ item.text }}</span>
<span
class="cbox"
:class="{ done: item.done }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span class="ctext">{{ item.text }}</span>
</div>
</div>
<div
v-if="checklistGroups.length === 0"
class="check-empty"
>
등록된 일이 없습니다.
</div>
</div>
</div>
@@ -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;
+6 -1
View File
@@ -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),
+4 -3
View File
@@ -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단계)