feat: 업무 상세에서 개인 할 일 만들기 추가

담당자가 업무를 자기 할 일 목록으로 옮길 수 있게 한다. 항목 출처는 두 갈래이며
확정 모달은 하나를 공유한다.

- 체크리스트가 있으면 그 항목을 카테고리 순으로 복사(완료 항목은 기본 해제)
- 없고 본문만 있으면 AI 가 본문을 분석해 담당자 관점의 실행 단계를 제안
- 둘 다 비어 있으면 버튼을 비활성화

- todo_labels.source_task_id 추가 — 같은 업무로 다시 만들면 새 라벨 대신 병합
- 병합 시 판정 기준은 '보완 내용을 뗀 원문', 반려된 항목은 완료를 되돌린다
- 수정 요청된 항목은 보완 내용을 제목에 함께 담는다
- 라벨 이름은 '프로젝트 - 업무 제목', 길면 프로젝트명부터 줄인다
- AI 프롬프트는 근거 없는 항목을 만들지 않도록 개수 하한 없이 제약

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:03:07 +09:00
parent 53f1eeddc7
commit 8d2436cd10
17 changed files with 1237 additions and 8 deletions
@@ -0,0 +1,625 @@
<script setup lang="ts">
// 업무 → 개인 할 일 확정 모달.
// 항목 출처는 두 가지지만 화면은 동일하다 —
// 1) 업무 체크리스트가 있으면 그 항목을 카테고리 순서대로 복사(완료 항목은 기본 해제)
// 2) 체크리스트가 없고 본문만 있으면 AI 가 본문을 분석해 실행 단계를 제안
// 시각 규격은 업무 작성 화면의 '할 일 추천' 모달과 동일하게 맞춘다
// (오버레이·헤더 배지·+/✓ 항목·개수 푸터). 두 화면이 같은 성격의 작업이라 규격을 통일한다.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAi } from '@/composables/useAi'
interface Props {
projectId: string
taskSeq: number
// 안내 문구에 노출할 원본 업무 제목
taskTitle: string
// 수정 요청 상태 — 안내 문구를 '승인/보완' 관점으로 바꾼다
changesReview?: boolean
// 업무 체크리스트에서 복사할 항목(카테고리 순 정렬 완료). 비어 있으면 AI 로 생성한다.
// note 는 지시자의 보완 내용(반려 사유)으로, 서버가 할 일 제목에 함께 담는다.
seedItems: { text: string; done: boolean; note?: string | null }[]
// 저장 진행 중 — 중복 제출 방지
saving?: boolean
}
const props = withDefaults(defineProps<Props>(), {
saving: false,
changesReview: false,
})
interface Emits {
(e: 'confirm', items: string[]): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
const ai = useAi()
// 화면 행 — 선택 여부는 사용자가 조정한다
interface Row {
text: string
selected: boolean
// 지시자의 보완 내용(있으면 항목 아래에 함께 보여준다)
note?: string | null
}
const rows = ref<Row[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// AI 로 만든 목록인지(안내 문구 분기용)
const fromAi = ref(false)
const selectedCount = computed(() => rows.value.filter((r) => r.selected).length)
const allSelected = computed(
() => rows.value.length > 0 && selectedCount.value === rows.value.length,
)
function toggle(row: Row): void {
row.selected = !row.selected
}
function toggleAll(): void {
const next = !allSelected.value
rows.value.forEach((r) => {
r.selected = next
})
}
// 서버가 내려준 구체적 사유를 우선 표시한다.
// (silent 요청이라 인터셉터 알림이 뜨지 않으므로 여기서 직접 꺼낸다)
// 인터셉터는 비즈니스 실패면 error 객체를, HTTP 실패면 axios 에러를 넘긴다.
function serverMessage(e: unknown): string | null {
const direct = (e as { message?: unknown })?.message
if (typeof direct === 'string' && direct.trim()) return direct
const nested = (
e as { response?: { data?: { error?: { message?: unknown } } } }
)?.response?.data?.error?.message
return typeof nested === 'string' && nested.trim() ? nested : null
}
// AI 추천 요청 — 체크리스트가 없는 업무에서만 호출된다
async function loadFromAi(): Promise<void> {
loading.value = true
error.value = null
try {
const res = await ai.suggestTaskTodos({
projectId: props.projectId,
taskSeq: props.taskSeq,
})
rows.value = res.items.map((text) => ({ text, selected: true }))
fromAi.value = true
if (rows.value.length === 0) {
// 근거 없는 항목을 지어내지 않도록 지시했으므로, 빈 결과는 정상 응답이다
error.value =
'업무 내용에서 만들 수 있는 할 일을 찾지 못했습니다. 내용을 보완한 뒤 다시 시도해 주세요.'
}
} catch (e) {
error.value =
serverMessage(e) ??
'할 일을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.'
} finally {
loading.value = false
}
}
// ESC 닫기 — 공용 BaseModal 과 동일하게 맞춘다(생성/저장 중에는 무시)
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
onMounted(() => {
if (props.seedItems.length > 0) {
// 완료된 체크리스트 항목은 기본 해제 — 이미 끝난 일을 다시 할 일로 만들지 않는다
rows.value = props.seedItems.map((i) => ({
text: i.text,
selected: !i.done,
note: i.note ?? null,
}))
return
}
void loadFromAi()
})
function close(): void {
if (loading.value || props.saving) return
emit('close')
}
function confirm(): void {
const items = rows.value.filter((r) => r.selected).map((r) => r.text)
if (items.length === 0) return
emit('confirm', items)
}
</script>
<template>
<Teleport to="body">
<div
class="tt-overlay"
@click.self="close"
>
<div
class="tt-modal"
role="dialog"
aria-modal="true"
aria-label=" 일로 만들기"
>
<!-- 헤더 -->
<div class="tt-head">
<div class="tt-head-top">
<div class="tt-badge">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /></svg>
</div>
<div class="tt-htext">
<div class="tt-title">
일로 만들기
<span
v-if="fromAi"
class="auto"
>자동 생성</span>
</div>
<div class="tt-sub">
<template v-if="loading">
업무 내용을 분석하고 있어요
</template>
<template v-else-if="error">
{{ error }}
</template>
<template v-else-if="fromAi">
업무 <b>{{ taskTitle }}</b> 내용을 분석해 <b>{{ rows.length }}</b> 항목을 제안했어요. 필요한 항목만 골라 추가하세요.
</template>
<template v-else-if="changesReview">
업무 <b>{{ taskTitle }}</b> 체크리스트에서 <b>{{ rows.length }}</b> 항목을 가져왔어요. 지시자가 승인한 항목은 빼고 <b>보완 요청된 항목</b> 골라 뒀습니다.
</template>
<template v-else>
업무 <b>{{ taskTitle }}</b> 체크리스트에서 <b>{{ rows.length }}</b> 항목을 가져왔어요. 이미 완료된 항목은 두었으니 필요한 항목만 골라 추가하세요.
</template>
</div>
</div>
</div>
<button
class="tt-x"
type="button"
aria-label="닫기"
:disabled="loading || saving"
@click="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>
<!-- 본문 -->
<div class="tt-body">
<div
v-if="loading"
class="tt-state"
>
잠시만 기다려 주세요 최대 1 정도 걸릴 있습니다.
</div>
<div
v-else-if="error"
class="tt-state err"
>
{{ error }}
</div>
<section
v-else
class="tt-sec"
>
<div class="tt-sec-head">
<span class="tt-sec-name"> 목록</span>
<span class="tt-sec-count">{{ rows.length }}</span>
<button
class="btn sm tt-sec-all"
type="button"
@click="toggleAll"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path :d="allSelected ? 'M5 12h14' : 'M5 12h14M12 5v14'" /></svg>
{{ allSelected ? '전체 해제' : '전체 추가' }}
</button>
</div>
<div class="tt-items">
<button
v-for="(row, i) in rows"
:key="i"
class="tt-item"
:class="{ added: row.selected }"
type="button"
@click="toggle(row)"
>
<span class="tt-item-check">
<svg
class="plus"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M5 12h14M12 5v14" /></svg>
<svg
class="tick"
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>
</span>
<span class="tt-item-body">
<span class="tt-item-text">{{ row.text }}</span>
<!-- 보완 내용 제목에 함께 담긴다 -->
<span
v-if="row.note"
class="tt-item-note"
>{{ row.note }}</span>
</span>
</button>
</div>
</section>
</div>
<!-- 푸터 -->
<div
v-if="!loading && !error"
class="tt-foot"
>
<div class="tt-foot-info">
<div class="tt-foot-count">
<span class="n">{{ selectedCount }}</span> 선택됨
</div>
<div class="tt-foot-hint">
나만 보이는 일이며 업무 체크리스트에는 영향을 주지 않습니다.
</div>
</div>
<button
class="btn ghost"
type="button"
:disabled="saving"
@click="close"
>
취소
</button>
<button
class="btn primary"
type="button"
:disabled="selectedCount === 0 || saving"
@click="confirm"
>
<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>
{{ saving ? '추가하는 중…' : selectedCount === 0 ? '할 일에 추가' : selectedCount + '개 할 일에 추가' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
/* 시각 규격은 업무 작성 화면의 '할 일 추천' 모달과 동일하게 맞춘다 */
.tt-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(22, 24, 29, 0.46);
display: flex;
align-items: center;
justify-content: center;
padding: 1.75rem;
}
.tt-modal {
width: 100%;
max-width: 37.5rem;
max-height: calc(100vh - 3.5rem);
background: #fff;
border-radius: 1rem;
box-shadow:
0 1.5rem 4.375rem rgba(20, 24, 33, 0.3),
0 0.25rem 0.875rem rgba(20, 24, 33, 0.14);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 헤더 */
.tt-head {
position: relative;
padding: 1.25rem 1.375rem 1.125rem;
background:
radial-gradient(120% 140% at 0% 0%, #f0eefe 0%, rgba(240, 238, 254, 0) 55%),
linear-gradient(180deg, #f6f5ff 0%, #ffffff 100%);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tt-head-top {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.tt-badge {
width: 2.375rem;
height: 2.375rem;
border-radius: 0.6875rem;
flex-shrink: 0;
background: linear-gradient(140deg, #6366f1, #4338ca);
display: grid;
place-items: center;
color: #fff;
box-shadow:
0 0.25rem 0.75rem rgba(79, 70, 229, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.tt-badge svg {
width: 1.3125rem;
height: 1.3125rem;
}
.tt-htext {
flex: 1;
min-width: 0;
padding-top: 0.0625rem;
}
.tt-title {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tt-title .auto {
font-size: 0.656rem;
font-weight: 700;
color: var(--accent);
background: #fff;
border: 1px solid var(--accent-border);
border-radius: 20px;
padding: 0.125rem 0.5rem;
white-space: nowrap;
}
.tt-sub {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.25rem;
line-height: 1.5;
}
.tt-sub b {
color: var(--text);
font-weight: 600;
}
.tt-x {
position: absolute;
top: 1rem;
right: 1rem;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: transparent;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
}
.tt-x:hover {
background: rgba(20, 24, 33, 0.06);
color: var(--text);
}
.tt-x:disabled {
opacity: 0.4;
cursor: default;
}
.tt-x svg {
width: 1.125rem;
height: 1.125rem;
}
/* 본문 */
.tt-body {
overflow-y: auto;
padding: 0.375rem 1.375rem 0.5rem;
flex: 1;
}
.tt-state {
padding: 2rem 0;
text-align: center;
font-size: 0.875rem;
color: var(--text-2);
}
.tt-state.err {
color: var(--red);
}
.tt-sec {
padding: 1rem 0 0.25rem;
}
.tt-sec-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tt-sec-name {
font-size: 0.813rem;
font-weight: 700;
color: var(--text);
}
.tt-sec-count {
font-size: 0.688rem;
font-weight: 700;
color: var(--text-3);
}
/* 전역 .btn.sm 규격을 그대로 쓰고, 배치만 보정한다 */
.tt-sec-all {
margin-left: auto;
}
/* 항목 목록 */
.tt-items {
border: 1px solid var(--border);
border-radius: 0.625rem;
overflow: hidden;
background: #fff;
}
.tt-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
text-align: left;
padding: 0.75rem 0.8125rem;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
border-top: 1px solid var(--border);
position: relative;
}
.tt-item:first-child {
border-top: none;
}
.tt-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.1875rem;
background: var(--accent);
opacity: 0;
transition: opacity 0.12s;
}
.tt-item:hover {
background: #fafaff;
}
.tt-item-check {
width: 1.375rem;
height: 1.375rem;
border-radius: 0.4375rem;
flex-shrink: 0;
border: 1.5px solid var(--border-strong);
background: #fff;
display: grid;
place-items: center;
color: #fff;
position: relative;
transition: all 0.13s;
}
.tt-item-check .plus {
width: 0.8125rem;
height: 0.8125rem;
color: var(--text-3);
transition: opacity 0.12s;
}
.tt-item-check .tick {
width: 0.8125rem;
height: 0.8125rem;
position: absolute;
opacity: 0;
transition: opacity 0.12s;
}
.tt-item:hover .tt-item-check {
border-color: var(--accent-border);
}
.tt-item:hover .tt-item-check .plus {
color: var(--accent);
}
.tt-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.tt-item-text {
font-size: 0.844rem;
color: var(--text);
line-height: 1.5;
letter-spacing: -0.00625rem;
}
/* 보완 내용 — 반려 사유라 붉은 계열로 구분한다 */
.tt-item-note {
font-size: 0.75rem;
line-height: 1.45;
color: var(--red);
}
.tt-item.added {
background: #f7f7ff;
}
.tt-item.added::before {
opacity: 1;
}
.tt-item.added .tt-item-check {
background: var(--accent);
border-color: var(--accent);
}
.tt-item.added .tt-item-check .plus {
opacity: 0;
}
.tt-item.added .tt-item-check .tick {
opacity: 1;
}
.tt-item.added .tt-item-text {
color: var(--text-2);
}
/* 푸터 */
.tt-foot {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.8125rem 1.375rem;
border-top: 1px solid var(--border);
background: #fbfbfd;
flex-shrink: 0;
}
.tt-foot-info {
margin-right: auto;
min-width: 0;
}
.tt-foot-count {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.tt-foot-count .n {
color: var(--accent);
}
.tt-foot-hint {
font-size: 0.719rem;
color: var(--text-3);
margin-top: 0.125rem;
}
/* 푸터 버튼은 전역 .btn / .btn.ghost / .btn.primary 를 그대로 쓴다.
전역 규격에 없는 비활성 표기만 여기서 보완한다. */
.tt-foot .btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
</style>