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:
@@ -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;
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user