feat: 프로젝트 문서 첨부(생성/편집/상세)

프로젝트에 문서 파일을 첨부할 수 있도록 추가(업무 첨부 인프라·업로드 설정 재사용).

백엔드:
- ProjectAttachment 엔티티 + 마이그레이션(project_attachments)
- 업로드(관리자)/삭제(관리자)/다운로드(멤버) 엔드포인트, 멀터 디스크 저장
- 프로젝트 상세 응답에 attachments 포함

프론트:
- 생성 화면: 파일 staged 후 생성 직후 업로드
- 편집(설정) 화면: 기존 문서 목록 + 추가/삭제(즉시), 폼 미저장 보존
- 프로젝트 상세: 문서 목록(다운로드 전용)
- useProject.uploadFile/removeFile, ApiProjectAttachment 타입

권한: 업로드/삭제=관리자, 조회/다운로드=멤버. 형식/용량은 업무 첨부와 동일(25MB).
런타임 검증: 업로드·목록·다운로드·삭제 + 비관리자 403 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:32:35 +09:00
parent 916dd49887
commit 8fc9287747
12 changed files with 749 additions and 8 deletions
+138 -1
View File
@@ -4,17 +4,46 @@ import { ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectStore } from '@/stores/project.store'
import { useProject } from '@/composables/useProject'
import { useDialog } from '@/composables/useDialog'
import { formatBytes } from '@/shared/utils/format'
const router = useRouter()
const projectStore = useProjectStore()
const projectApi = useProject()
const dialog = useDialog()
const name = ref('')
const description = ref('')
const submitting = ref(false)
// 생성 — 성공 시 새 프로젝트 상세로 이동
// 첨부 문서 — 생성 전엔 메모리에 보관, 생성 직후 업로드
const pendingFiles = ref<File[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
function triggerFilePicker() {
fileInput.value?.click()
}
function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
pendingFiles.value.push(...(input.files ? Array.from(input.files) : []))
input.value = ''
}
function removePending(i: number) {
pendingFiles.value.splice(i, 1)
}
async function uploadPending(projectId: string): Promise<number> {
let failed = 0
for (const f of pendingFiles.value) {
try {
await projectApi.uploadFile(projectId, f)
} catch {
failed++
}
}
return failed
}
// 생성 — 성공 시 첨부 업로드 후 새 프로젝트 상세로 이동
async function createProject() {
if (submitting.value) return
if (!name.value.trim()) {
@@ -27,6 +56,13 @@ async function createProject() {
name: name.value.trim(),
description: description.value.trim() || undefined,
})
const failed = await uploadPending(project.id)
if (failed > 0) {
void dialog.alert(
`문서 ${failed}개 업로드에 실패했습니다. 프로젝트 설정에서 다시 첨부해 주세요.`,
{ variant: 'warning' },
)
}
await router.push(`/projects/${project.id}`)
} catch {
// 에러는 API 인터셉터에서 전역 처리
@@ -93,6 +129,48 @@ async function createProject() {
/>
</div>
<!-- 문서 첨부 -->
<div class="fblock">
<div class="flabel">
프로젝트 문서
</div>
<div class="fhint">
기획서·가이드 관련 문서를 첨부하세요. (1개당 최대 25MB)
</div>
<div class="files">
<div
v-for="(f, i) in pendingFiles"
:key="i"
class="file-row"
>
<span class="fr-name">{{ f.name }}</span>
<span class="fr-size">{{ formatBytes(f.size) }}</span>
<button
type="button"
class="fr-x"
title="제거"
@click="removePending(i)"
>
×
</button>
</div>
<button
type="button"
class="file-add"
@click="triggerFilePicker"
>
+ 파일 추가
</button>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="onPickFiles"
>
</div>
</div>
<!-- 푸터 -->
<div class="form-footer">
<RouterLink
@@ -199,6 +277,65 @@ textarea.tarea {
resize: vertical;
height: auto;
}
/* 첨부 파일 */
.files {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.file-row {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
.fr-name {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fr-size {
font-size: 0.75rem;
color: var(--text-3);
flex-shrink: 0;
}
.fr-x {
border: none;
background: none;
color: var(--text-3);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.fr-x:hover {
color: var(--red);
}
.file-add {
border: 1px dashed var(--border-strong);
background: #fff;
border-radius: var(--radius);
padding: 0.4375rem 0.875rem;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.file-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.form-footer {
display: flex;
align-items: center;
+92 -1
View File
@@ -15,7 +15,8 @@ import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
import { taskStatusLabel } from '@/shared/utils/format'
import { taskStatusLabel, formatBytes } from '@/shared/utils/format'
import { toAbsoluteApiUrl } from '@/composables/useApi'
import type { Project } from '@/mock/relay.mock'
const route = useRoute()
@@ -173,6 +174,36 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
:can-manage="repo.canManage"
/>
<!-- 프로젝트 문서(있을 때만, 다운로드 전용) -->
<div
v-if="repo.attachments && repo.attachments.length"
class="proj-docs"
>
<div class="pd-head">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
프로젝트 문서 <span class="pd-ct">{{ repo.attachments.length }}</span>
</div>
<div class="pd-list">
<a
v-for="a in repo.attachments"
:key="a.id"
class="pd-item"
:href="toAbsoluteApiUrl(a.url)"
download
>
<span class="pd-name">{{ a.name }}</span>
<span class="pd-size">{{ formatBytes(a.size) }}</span>
</a>
</div>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
@@ -456,6 +487,66 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
height: 0.875rem;
}
/* 프로젝트 문서 */
.proj-docs {
margin: 0.25rem 0 1rem;
padding: 0.875rem 1.125rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
}
.pd-head {
display: flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-2);
margin-bottom: 0.625rem;
}
.pd-head svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
.pd-head .pd-ct {
font-size: 0.719rem;
color: var(--text-3);
}
.pd-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pd-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: 100%;
padding: 0.375rem 0.6875rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
text-decoration: none;
color: inherit;
}
.pd-item:hover {
background: #fafbfc;
border-color: var(--border-strong);
}
.pd-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pd-size {
font-size: 0.719rem;
color: var(--text-3);
flex-shrink: 0;
}
.toolbar .search {
width: 15rem;
}
@@ -6,16 +6,25 @@ import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import { useProjectStore } from '@/stores/project.store'
import { useProject } from '@/composables/useProject'
import { useDialog } from '@/composables/useDialog'
import { formatBytes } from '@/shared/utils/format'
import { toAbsoluteApiUrl } from '@/composables/useApi'
import { type Project } from '@/mock/relay.mock'
import type { ApiProjectAttachment } from '@/types/project'
const route = useRoute()
const router = useRouter()
const projectId = computed(() => String(route.params.projectId))
const projectStore = useProjectStore()
const projectApi = useProject()
const dialog = useDialog()
const repo = ref<Project | null>(null)
// 첨부는 폼 미저장 입력을 보존하기 위해 별도 ref 로 관리(업/삭제 시 부분 갱신)
const attachments = ref<ApiProjectAttachment[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const fileBusy = ref(false)
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
const canManage = computed(() => repo.value?.canManage === true)
@@ -46,9 +55,53 @@ async function loadProject() {
}
displayTitle.value = repo.value.name
description.value = repo.value.desc
attachments.value = repo.value.attachments ?? []
}
watch(projectId, loadProject, { immediate: true })
// --- 문서 첨부 (관리자) ---
function triggerFilePicker() {
fileInput.value?.click()
}
async function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length || fileBusy.value) return
fileBusy.value = true
let failed = 0
for (const f of files) {
try {
attachments.value = [
await projectApi.uploadFile(projectId.value, f),
...attachments.value,
]
} catch {
failed++
}
}
fileBusy.value = false
if (failed > 0) {
void dialog.alert(`문서 ${failed}개 업로드에 실패했습니다.`, {
variant: 'warning',
})
}
}
async function removeAttachment(id: string) {
const ok = await dialog.confirm('이 문서를 삭제할까요?', {
title: '문서 삭제',
variant: 'danger',
confirmText: '삭제',
})
if (!ok) return
try {
await projectApi.removeFile(projectId.value, id)
attachments.value = attachments.value.filter((a) => a.id !== id)
} catch {
// 인터셉터 전역 처리
}
}
// 변경 저장 (admin 전용)
async function save() {
if (saving.value || !repo.value || !canManage.value) return
@@ -188,6 +241,60 @@ async function removeProject() {
</div>
</div>
<!-- 문서 -->
<h2 class="sec-title">
문서
</h2>
<div class="card">
<div class="fblock">
<div class="flabel">
프로젝트 문서
</div>
<div class="fhint">
기획서·가이드 관련 문서를 첨부하세요. (1개당 최대 25MB)
</div>
<div class="files">
<a
v-for="a in attachments"
:key="a.id"
class="file-row"
:href="toAbsoluteApiUrl(a.url)"
download
>
<span class="fr-name">{{ a.name }}</span>
<span class="fr-size">{{ formatBytes(a.size) }}</span>
<button
type="button"
class="fr-x"
title="삭제"
@click.prevent="removeAttachment(a.id)"
>×</button>
</a>
<div
v-if="attachments.length === 0"
class="files-empty"
>
첨부된 문서가 없습니다.
</div>
<button
type="button"
class="file-add"
:disabled="fileBusy"
@click="triggerFilePicker"
>
{{ fileBusy ? '업로드 중…' : '+ 파일 추가' }}
</button>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="onPickFiles"
>
</div>
</div>
</div>
<!-- 위험 구역 -->
<h2 class="sec-title danger">
위험 구역
@@ -405,6 +512,79 @@ textarea.tarea {
border-radius: 0 0 0.625rem 0.625rem;
}
/* 문서 첨부 */
.files {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.file-row {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
text-decoration: none;
color: inherit;
}
.file-row:hover {
background: #fafbfc;
}
.fr-name {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fr-size {
font-size: 0.75rem;
color: var(--text-3);
flex-shrink: 0;
}
.fr-x {
border: none;
background: none;
color: var(--text-3);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.fr-x:hover {
color: var(--red);
}
.files-empty {
font-size: 0.8125rem;
color: var(--text-3);
}
.file-add {
border: 1px dashed var(--border-strong);
background: #fff;
border-radius: var(--radius);
padding: 0.4375rem 0.875rem;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.file-add:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.file-add:disabled {
opacity: 0.6;
cursor: default;
}
/* 위험 구역 */
.danger-card {
background: #fff;