feat: 프로젝트 드라이브 폴더 연동 + AI 검토 문서 전환

프로젝트 직접 업로드(project_attachments) 폐기, AI 검토 문서를 드라이브 폴더 연동으로 전환. 폴더 연동 CRUD·연동 폴더 문서에서 텍스트 지연 추출(NCP 다운로드, xlsx 포함)·AI 추천 주입. 폴더 선택 피커 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:21:23 +09:00
parent f023bd0ed7
commit cf1f4db6af
20 changed files with 1526 additions and 478 deletions
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 프로젝트 문서의 AI 추천 반영 상태 배지
// ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
import { computed } from 'vue'
interface Props {
status: 'ready' | 'failed' | 'unsupported'
status: 'ready' | 'pending' | 'failed' | 'unsupported'
}
const props = defineProps<Props>()
@@ -15,6 +15,11 @@ const META: Record<Props['status'], { label: string; title: string; cls: string
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
cls: 'ok',
},
pending: {
label: '추출 대기',
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
cls: 'muted',
},
failed: {
label: '추출 실패',
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
@@ -0,0 +1,208 @@
<script setup lang="ts">
// 드라이브 폴더 선택 모달 — 내 드라이브를 탐색해 프로젝트에 연동할 폴더를 고른다.
import { ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useDrive } from '@/composables/useDrive'
import type { DriveCrumb, DriveFolder } from '@/types/drive'
const emit = defineEmits<{
(e: 'select', folder: { id: string; name: string }): void
(e: 'close'): void
}>()
const drive = useDrive()
const loading = ref(false)
const breadcrumb = ref<DriveCrumb[]>([])
const folders = ref<DriveFolder[]>([])
async function load(id: string | null): Promise<void> {
loading.value = true
try {
const res = await drive.list(id ?? undefined)
breadcrumb.value = res.breadcrumb
folders.value = res.folders
} finally {
loading.value = false
}
}
function pick(f: DriveFolder): void {
emit('select', { id: f.id, name: f.name })
}
void load(null)
</script>
<template>
<BaseModal
title="연동할 폴더 선택"
@close="emit('close')"
>
<template #subtitle>
<b> 드라이브</b> 폴더(하위 포함) 프로젝트의 AI 검토 문서로 연동합니다.
</template>
<nav class="dfp-crumb">
<a
v-if="breadcrumb.length"
href="#"
@click.prevent="load(null)"
>드라이브</a>
<b v-else>드라이브</b>
<template
v-for="(c, i) in breadcrumb"
:key="c.id"
>
<span class="dfp-sep"></span>
<a
v-if="i < breadcrumb.length - 1"
href="#"
@click.prevent="load(c.id)"
>{{ c.name }}</a>
<b v-else>{{ c.name }}</b>
</template>
</nav>
<p
v-if="loading"
class="dfp-msg"
>
불러오는
</p>
<EmptyState v-else-if="folders.length === 0">
위치에 하위 폴더가 없습니다. 드라이브에서 폴더를 먼저 만들어 주세요.
</EmptyState>
<div
v-else
class="dfp-list"
>
<div
v-for="f in folders"
:key="f.id"
class="dfp-row"
>
<button
class="dfp-open"
type="button"
title="폴더 열기"
@click="load(f.id)"
>
<span class="dfp-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
</span>
<span class="dfp-name">{{ f.name }}</span>
</button>
<button
class="btn sm"
type="button"
@click="pick(f)"
>
연동
</button>
</div>
</div>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
닫기
</button>
</template>
</BaseModal>
</template>
<style scoped>
.dfp-crumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--text-2);
}
.dfp-crumb a {
color: var(--text-2);
text-decoration: none;
cursor: pointer;
}
.dfp-crumb a:hover {
color: var(--accent);
}
.dfp-sep {
color: var(--text-3);
opacity: 0.5;
}
.dfp-msg {
font-size: 0.844rem;
color: var(--text-3);
padding: 1rem 0;
text-align: center;
}
.dfp-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 0.625rem;
overflow: hidden;
max-height: 18rem;
overflow-y: auto;
}
.dfp-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
}
.dfp-row:first-child {
border-top: none;
}
.dfp-row:hover {
background: #fafbfc;
}
.dfp-open {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
padding: 0.125rem 0;
}
.dfp-ico {
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: grid;
place-items: center;
color: var(--accent);
background: var(--accent-weak);
}
.dfp-ico svg {
width: 1.125rem;
height: 1.125rem;
}
.dfp-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>