feat: 업무 생성 화면 첨부파일 활성화 (제출 시 업로드)

업로드 API 가 기존 taskId 종속이라 막혀 있던 생성 화면 첨부를 2단계 제출로 해결.
선택한 파일을 메모리에 보유하다 업무 생성/수정 후 그 taskId 로 순차 업로드(베스트에포트,
실패 개수 안내). 수정 모드는 기존 첨부 표시·즉시 삭제 + 새 파일 업로드. 25MB 사전 필터.
백엔드 변경 없음(기존 업로드 엔드포인트 재사용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 17:18:35 +09:00
parent df97719e0f
commit 57a88ff741
+164 -5
View File
@@ -11,8 +11,10 @@ import { useTaskStore } from '@/stores/task.store'
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 { Repo, ChecklistItem, User } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { ApiAttachment } from '@/types/task'
import type { ChecklistSuggestionGroup } from '@/types/ai'
const route = useRoute()
@@ -77,6 +79,7 @@ async function loadTaskForEdit() {
assignees.value = t.assignees.map(toUserView)
// id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지)
checklist.value = t.checklist.map((c) => ({ id: c.id, text: c.text, done: c.done }))
existingFiles.value = t.files
} catch {
// 404 등은 인터셉터가 알림 처리
}
@@ -200,6 +203,58 @@ function dismissSuggestions() {
aiError.value = null
}
// --- 첨부파일 ---
// 생성 화면엔 업무가 아직 없어 바로 업로드 못 함 → 선택한 파일을 들고 있다가 제출 시 업로드.
// 수정 모드에선 기존 첨부(existingFiles)도 표시하고, 새 파일은 저장 시 업로드한다.
const MAX_FILE_SIZE = 25 * 1024 * 1024 // 25MB (백엔드 제한과 일치)
const existingFiles = ref<ApiAttachment[]>([])
const pendingFiles = ref<File[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const totalFileCount = computed(
() => existingFiles.value.length + pendingFiles.value.length,
)
function fileBadgeFor(name: string) {
return fileBadge(attachmentKindOf(name))
}
function triggerFilePicker() {
fileInput.value?.click()
}
function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
const picked = input.files ? Array.from(input.files) : []
const ok = picked.filter((f) => f.size <= MAX_FILE_SIZE)
pendingFiles.value.push(...ok)
if (ok.length < picked.length) {
window.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`)
}
input.value = '' // 같은 파일을 다시 선택할 수 있도록 초기화
}
function removePending(index: number) {
pendingFiles.value.splice(index, 1)
}
// 수정 모드: 기존 첨부 즉시 삭제(API)
async function removeExisting(fileId: string) {
if (taskId.value === null) return
try {
await taskApi.removeFile(repoId.value, taskId.value, fileId)
existingFiles.value = existingFiles.value.filter((f) => f.id !== fileId)
} catch {
// 인터셉터가 알림 처리
}
}
// 업무 생성/수정 후 대기 중인 파일들을 업로드(베스트에포트) → 실패 개수 반환
async function uploadPendingFiles(tid: number): Promise<number> {
let failed = 0
for (const file of pendingFiles.value) {
try {
await taskApi.uploadFile(repoId.value, tid, file)
} catch {
failed += 1
}
}
return failed
}
// --- 제출 ---
const submitting = ref(false)
const canSubmit = computed(
@@ -217,6 +272,7 @@ async function submitTask() {
const assigneeIds = assignees.value.map((a) => a.id)
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
let tid: number
if (isEdit.value && taskId.value !== null) {
// 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화
const updated = await taskStore.update(repoId.value, taskId.value, {
@@ -228,7 +284,7 @@ async function submitTask() {
c.id ? { id: c.id, text: c.text } : { text: c.text },
),
})
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
tid = updated.id
} else {
const created = await taskStore.create(repoId.value, {
title: title.value.trim(),
@@ -239,8 +295,17 @@ async function submitTask() {
? checklist.value.map((c) => ({ text: c.text }))
: undefined,
})
router.push(`/repos/${repoId.value}/tasks/${created.id}`)
tid = created.id
}
// 선택한 파일 업로드(업무가 생성/확정된 뒤에야 가능) — 베스트에포트
const failed = await uploadPendingFiles(tid)
if (failed > 0) {
window.alert(
`파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`,
)
}
router.push(`/repos/${repoId.value}/tasks/${tid}`)
} catch {
// 인터셉터가 알림 처리(권한/검증 등)
} finally {
@@ -669,10 +734,90 @@ function goBack() {
<div class="side-field">
<div class="side-label">
첨부파일 <span class="muted">· 5단계 예정</span>
첨부파일 <span
v-if="totalFileCount"
class="muted"
>· {{ totalFileCount }}</span>
</div>
<div class="dropzone is-disabled">
파일 첨부는 제공됩니다
<div
v-if="existingFiles.length || pendingFiles.length"
class="files"
>
<!-- 기존 첨부(수정 모드) -->
<div
v-for="f in existingFiles"
:key="f.id"
class="file"
>
<span
class="file-ico"
:class="fileBadgeFor(f.name).cls"
>{{ fileBadgeFor(f.name).label }}</span>
<span class="file-info">
<span class="file-name">{{ f.name }}</span>
<span class="file-size">{{ formatBytes(f.size) }}</span>
</span>
<span
class="x"
title="삭제"
@click="removeExisting(f.id)"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
<!-- 새로 선택(업로드 대기) -->
<div
v-for="(f, i) in pendingFiles"
:key="`p${i}`"
class="file"
>
<span
class="file-ico"
:class="fileBadgeFor(f.name).cls"
>{{ fileBadgeFor(f.name).label }}</span>
<span class="file-info">
<span class="file-name">{{ f.name }}</span>
<span class="file-size">{{ formatBytes(f.size) }} · 업로드 대기</span>
</span>
<span
class="x"
title="제거"
@click="removePending(i)"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
</div>
<button
class="dropzone"
type="button"
@click="triggerFilePicker"
>
<b>클릭하여 파일 선택</b>
</button>
<input
ref="fileInput"
type="file"
multiple
class="file-input-hidden"
@change="onPickFiles"
>
<div class="file-hint">
최대 25MB · 이미지·문서·압축 파일. 지시를 보내면 함께 업로드됩니다.
</div>
</div>
</div>
@@ -1352,11 +1497,13 @@ function goBack() {
/* 첨부파일 */
.dropzone {
width: 100%;
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius);
padding: 0.8125rem;
text-align: center;
color: var(--text-3);
font-family: inherit;
font-size: 0.781rem;
cursor: pointer;
background: #fafbfc;
@@ -1406,6 +1553,9 @@ function goBack() {
.fi-doc {
background: #3d8bf2;
}
.fi-img {
background: #2aa775;
}
.file-info {
min-width: 0;
flex: 1;
@@ -1438,6 +1588,15 @@ function goBack() {
background: #eceef1;
color: var(--red);
}
.file-input-hidden {
display: none;
}
.file-hint {
margin-top: 0.4375rem;
font-size: 0.719rem;
color: var(--text-3);
line-height: 1.45;
}
/* 지시자 */
.issuer {