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:
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { SuggestChecklistResult } from '@/types/ai'
|
||||
import type { SuggestChecklistResult, SuggestTaskTodosResult } from '@/types/ai'
|
||||
|
||||
// AI 요청 타임아웃(ms) — env VITE_AI_TIMEOUT_MS, 백엔드 AI_TIMEOUT_MS 와 동일 값 사용.
|
||||
// 미설정/비정상이면 기본 60초(AI 생성이 axios 기본 10초를 넘기므로).
|
||||
@@ -22,5 +22,17 @@ export function useAi() {
|
||||
})) as unknown as SuggestChecklistResult
|
||||
}
|
||||
|
||||
return { suggestChecklist }
|
||||
// 개인 할 일 추천(업무 본문 분석) — 체크리스트가 없는 업무에서만 호출한다.
|
||||
// 실패는 호출측에서 인라인 처리(silent)
|
||||
async function suggestTaskTodos(payload: {
|
||||
projectId: string
|
||||
taskSeq: number
|
||||
}): Promise<SuggestTaskTodosResult> {
|
||||
return (await api.post('/ai/suggest-task-todos', payload, {
|
||||
silent: true,
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
})) as unknown as SuggestTaskTodosResult
|
||||
}
|
||||
|
||||
return { suggestChecklist, suggestTaskTodos }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiTodoLabel,
|
||||
TodoFromTaskPayload,
|
||||
TodoItemPayload,
|
||||
TodoLabelPayload,
|
||||
} from '@/types/todo'
|
||||
@@ -27,6 +28,16 @@ export function useTodo() {
|
||||
await api.delete(`/todos/labels/${id}`)
|
||||
}
|
||||
|
||||
// 업무 → 할 일 생성. 같은 업무로 만든 라벨이 있으면 서버가 항목을 병합해 준다
|
||||
async function createFromTask(
|
||||
payload: TodoFromTaskPayload,
|
||||
): Promise<ApiTodoLabel> {
|
||||
return (await api.post(
|
||||
'/todos/labels/from-task',
|
||||
payload,
|
||||
)) as unknown as ApiTodoLabel
|
||||
}
|
||||
|
||||
// 항목
|
||||
async function createItem(
|
||||
labelId: string,
|
||||
@@ -51,6 +62,7 @@ export function useTodo() {
|
||||
createLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
createFromTask,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
|
||||
@@ -28,6 +28,8 @@ import type { ChecklistReviewItem } from '@/types/task'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { useTodo } from '@/composables/useTodo'
|
||||
import TaskTodoModal from '@/components/todo/TaskTodoModal.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import AvatarStack from '@/components/common/AvatarStack.vue'
|
||||
import StatusSelect from '@/components/StatusSelect.vue'
|
||||
@@ -45,6 +47,7 @@ const taskId = computed(() => Number(route.params.taskId))
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
const dialog = useDialog()
|
||||
const todo = useTodo()
|
||||
|
||||
// 업무 상세 — API 연동
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
@@ -57,6 +60,54 @@ async function loadTask() {
|
||||
}
|
||||
watch(taskId, loadTask, { immediate: true })
|
||||
|
||||
// --- 업무 → 개인 할 일 만들기 ---
|
||||
// 체크리스트가 있으면 그 항목을 카테고리 순서대로 복사하고,
|
||||
// 없으면 모달이 AI 로 본문을 분석해 실행 단계를 제안한다(양쪽 다 모달에서 확정).
|
||||
const todoModalOpen = ref(false)
|
||||
const todoSaving = ref(false)
|
||||
|
||||
// 모달에 넘길 씨앗 항목 — 체크리스트를 카테고리 정의 순서로 평탄화(할 일에는 카테고리가 없다).
|
||||
// 보완 내용(reviewNote)은 미리보기로 함께 보여주고, 실제 제목 합성은 서버가 한다.
|
||||
const todoSeedItems = computed(() =>
|
||||
CHECKLIST_CATEGORIES.flatMap((cat) =>
|
||||
(task.value?.checklist ?? [])
|
||||
.filter((c) => c.category === cat)
|
||||
.map((c) => ({
|
||||
text: c.text,
|
||||
done: c.done,
|
||||
note: c.reviewNote ?? null,
|
||||
})),
|
||||
),
|
||||
)
|
||||
// 체크리스트도 본문도 없으면 만들 근거가 없다.
|
||||
// 본문은 문단 배열이라 빈 문단만 있을 수 있으므로 공백을 걸러 판정한다.
|
||||
const canMakeTodo = computed(
|
||||
() =>
|
||||
todoSeedItems.value.length > 0 ||
|
||||
(task.value?.content ?? []).some((p) => p.trim()),
|
||||
)
|
||||
|
||||
async function confirmTodo(items: string[]): Promise<void> {
|
||||
if (todoSaving.value) return
|
||||
todoSaving.value = true
|
||||
try {
|
||||
const label = await todo.createFromTask({
|
||||
projectId: projectId.value,
|
||||
taskSeq: taskId.value,
|
||||
items,
|
||||
})
|
||||
todoModalOpen.value = false
|
||||
await dialog.alert(
|
||||
`'${label.name}' 라벨에 할 일 ${items.length}개를 추가했습니다.`,
|
||||
{ variant: 'success' },
|
||||
)
|
||||
} catch {
|
||||
// 오류 안내는 인터셉터가 처리 — 모달을 열어 둬 바로 다시 시도할 수 있게 한다
|
||||
} finally {
|
||||
todoSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 배지 클래스(prog/review/done/changes)
|
||||
const statusClass = computed<TaskStatus>(() => task.value?.status ?? 'todo')
|
||||
|
||||
@@ -1223,6 +1274,48 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 담당자 전용 — 업무를 내 할 일 목록으로 옮긴다(개인 전용, 업무에는 영향 없음) -->
|
||||
<div
|
||||
v-if="isAssignee"
|
||||
class="card ap-card"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="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>
|
||||
</span>
|
||||
<span class="ap-card-title">할 일로 만들기</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">
|
||||
이 업무를 <b>내 할 일</b> 목록에 추가합니다. 나만 보이며 업무 체크리스트에는 영향을 주지 않습니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
:disabled="!canMakeTodo"
|
||||
:title="canMakeTodo ? undefined : '체크리스트와 내용이 모두 비어 있어 만들 수 없습니다.'"
|
||||
@click="todoModalOpen = true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 5v14M5 12h14" /></svg>
|
||||
할 일로 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
@@ -1904,6 +1997,19 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 업무 → 개인 할 일 확정 모달(체크리스트 복사분·AI 추천분 공통) -->
|
||||
<TaskTodoModal
|
||||
v-if="todoModalOpen && task"
|
||||
:project-id="projectId"
|
||||
:task-seq="taskId"
|
||||
:task-title="task.title"
|
||||
:changes-review="isChangesReview"
|
||||
:seed-items="todoSeedItems"
|
||||
:saving="todoSaving"
|
||||
@confirm="confirmTodo"
|
||||
@close="todoModalOpen = false"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,3 +6,8 @@ export interface ChecklistSuggestionGroup {
|
||||
export interface SuggestChecklistResult {
|
||||
groups: ChecklistSuggestionGroup[]
|
||||
}
|
||||
|
||||
// 개인 할 일 추천 — 업무 본문 분석 결과(수행 순서대로 나열된 실행 단계)
|
||||
export interface SuggestTaskTodosResult {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface ApiTodoItem {
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다
|
||||
export interface ApiTodoSourceTask {
|
||||
projectId: string
|
||||
seq: number
|
||||
title: string
|
||||
}
|
||||
|
||||
// 라벨(할 일 묶음) — 항목과 진행 집계를 함께 내려받는다
|
||||
export interface ApiTodoLabel {
|
||||
id: string
|
||||
@@ -17,6 +24,8 @@ export interface ApiTodoLabel {
|
||||
items: ApiTodoItem[]
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
// 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null — 링크를 표시하지 않는다
|
||||
sourceTask: ApiTodoSourceTask | null
|
||||
}
|
||||
|
||||
// 생성/수정 payload
|
||||
@@ -29,3 +38,10 @@ export interface TodoItemPayload {
|
||||
title?: string
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
// 업무 → 할 일 생성 요청. 라벨 이름·색은 서버가 업무에서 파생하므로 보내지 않는다
|
||||
export interface TodoFromTaskPayload {
|
||||
projectId: string
|
||||
taskSeq: number
|
||||
items: string[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user