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:
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
|
||||
// (업무 첨부 attachments 와 동일 구조, project_id FK)
|
||||
export class AddProjectAttachments1782500000000 implements MigrationInterface {
|
||||
name = 'AddProjectAttachments1782500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "project_attachments"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from './project.entity';
|
||||
import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관.
|
||||
// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용)
|
||||
@Entity('project_attachments')
|
||||
export class ProjectAttachment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'uploader_id' })
|
||||
uploader!: User | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 디스크 저장명(업로드 폴더 내 고유 파일명)
|
||||
@Column({ name: 'stored_name' })
|
||||
storedName!: string;
|
||||
|
||||
// 파일 크기(바이트)
|
||||
@Column({ type: 'int' })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생)
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -12,14 +12,35 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { ProjectService, type ProjectResponse } from './project.service';
|
||||
import {
|
||||
ProjectService,
|
||||
type ProjectResponse,
|
||||
type ProjectAttachmentResponse,
|
||||
} from './project.service';
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
MAX_FILE_SIZE,
|
||||
fileMimeFilter,
|
||||
type UploadedDiskFile,
|
||||
} from '../task/upload.config';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
@@ -91,4 +112,66 @@ export class ProjectController {
|
||||
await this.projectService.remove(projectId, user.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 문서 첨부 ===================== */
|
||||
|
||||
@Post(':projectId/files')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: fileMimeFilter,
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' })
|
||||
@ApiResponse({ status: 201, description: '업로드 성공' })
|
||||
addFile(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@UploadedFile() file: UploadedDiskFile,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectAttachmentResponse> {
|
||||
if (!file) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return this.projectService.addFile(projectId, user.id, file);
|
||||
}
|
||||
|
||||
@Delete(':projectId/files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async removeFile(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.projectService.removeFile(projectId, user.id, fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get(':projectId/files/:fileId/download')
|
||||
@ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '파일 스트림' })
|
||||
async downloadFile(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { attachment, absolutePath } =
|
||||
await this.projectService.getFileForDownload(projectId, user.id, fileId);
|
||||
res.setHeader('Content-Type', attachment.mime);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { ProjectAttachment } from './entities/project-attachment.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
@@ -15,7 +16,7 @@ import { ProjectMemberController } from './project-member.controller';
|
||||
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
paginated,
|
||||
@@ -13,10 +15,29 @@ import {
|
||||
} from '../../common/pagination/pagination';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { ProjectAttachment } from './entities/project-attachment.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
deriveKind,
|
||||
decodeOriginalName,
|
||||
type UploadedDiskFile,
|
||||
} from '../task/upload.config';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
// 프로젝트 첨부 응답
|
||||
export interface ProjectAttachmentResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
kind: AttachmentKind;
|
||||
url: string;
|
||||
uploader: PublicUser | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
|
||||
export interface ProjectResponse {
|
||||
id: string;
|
||||
@@ -27,9 +48,10 @@ export interface ProjectResponse {
|
||||
doneCount: number; // 완료 업무 수
|
||||
totalCount: number; // 전체 업무 수
|
||||
updatedAt: string;
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부
|
||||
canManage?: boolean; // admin
|
||||
isOwner?: boolean; // 소유자
|
||||
attachments?: ProjectAttachmentResponse[];
|
||||
}
|
||||
|
||||
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
|
||||
@@ -42,6 +64,8 @@ export class ProjectService {
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@InjectRepository(ProjectAttachment)
|
||||
private readonly attachmentRepo: Repository<ProjectAttachment>,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@@ -94,6 +118,7 @@ export class ProjectService {
|
||||
...base,
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
attachments: await this.listFiles(project.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +206,108 @@ export class ProjectService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 문서 첨부 ---
|
||||
|
||||
// 첨부 목록(최신순) — 프로젝트 상세에 포함
|
||||
async listFiles(projectId: string): Promise<ProjectAttachmentResponse[]> {
|
||||
const rows = await this.attachmentRepo.find({
|
||||
where: { project: { id: projectId } },
|
||||
relations: ['uploader'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((a) => this.toAttachmentResponse(a, projectId));
|
||||
}
|
||||
|
||||
// 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속
|
||||
async addFile(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
file: UploadedDiskFile,
|
||||
): Promise<ProjectAttachmentResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const uploader = await this.userService.findById(userId);
|
||||
|
||||
const name = decodeOriginalName(file.originalname);
|
||||
const saved = await this.attachmentRepo.save(
|
||||
this.attachmentRepo.create({
|
||||
project: { id: project.id } as Project,
|
||||
uploader: uploader ?? null,
|
||||
name,
|
||||
storedName: file.filename,
|
||||
size: file.size,
|
||||
mime: file.mimetype,
|
||||
kind: deriveKind(file.mimetype, name),
|
||||
}),
|
||||
);
|
||||
return this.toAttachmentResponse(
|
||||
{ ...saved, uploader: uploader ?? null },
|
||||
project.id,
|
||||
);
|
||||
}
|
||||
|
||||
// 첨부 삭제 — 관리자만. 디스크 파일도 정리
|
||||
async removeFile(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { id: fileId, project: { id: project.id } },
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await this.attachmentRepo.remove(attachment);
|
||||
await this.deleteFileFromDisk(attachment.storedName);
|
||||
}
|
||||
|
||||
// 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍
|
||||
async getFileForDownload(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ attachment: ProjectAttachment; absolutePath: string }> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectMember(project.id, userId);
|
||||
const attachment = await this.attachmentRepo.findOne({
|
||||
where: { id: fileId, project: { id: project.id } },
|
||||
});
|
||||
if (!attachment) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return {
|
||||
attachment,
|
||||
absolutePath: join(UPLOAD_DIR, attachment.storedName),
|
||||
};
|
||||
}
|
||||
|
||||
// 디스크 파일 삭제(베스트 에포트 — 없어도 무시)
|
||||
private async deleteFileFromDisk(storedName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(UPLOAD_DIR, storedName));
|
||||
} catch {
|
||||
// 이미 없거나 권한 문제 — 본 흐름을 막지 않는다
|
||||
}
|
||||
}
|
||||
|
||||
private toAttachmentResponse(
|
||||
att: ProjectAttachment,
|
||||
projectId: string,
|
||||
): ProjectAttachmentResponse {
|
||||
return {
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
kind: att.kind,
|
||||
url: `/api/projects/${projectId}/files/${att.id}/download`,
|
||||
uploader: att.uploader ? UserService.toPublic(att.uploader) : null,
|
||||
createdAt: att.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
|
||||
private async taskCountsByProject(
|
||||
ids: string[],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiProjectAttachment,
|
||||
CreateProjectPayload,
|
||||
ProjectListQuery,
|
||||
UpdateProjectPayload,
|
||||
@@ -41,5 +42,22 @@ export function useProject() {
|
||||
await api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
// 문서 첨부 업로드(관리자) — multipart/form-data
|
||||
async function uploadFile(
|
||||
projectId: string,
|
||||
file: File,
|
||||
): Promise<ApiProjectAttachment> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return (await api.post(`/projects/${projectId}/files`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as unknown as ApiProjectAttachment
|
||||
}
|
||||
|
||||
// 문서 첨부 삭제(관리자)
|
||||
async function removeFile(projectId: string, fileId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove, uploadFile, removeFile }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용)
|
||||
*/
|
||||
|
||||
import type { ApiProjectAttachment } from '@/types/project'
|
||||
|
||||
/** 업무 상태 */
|
||||
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
|
||||
|
||||
@@ -95,6 +97,8 @@ export interface Project {
|
||||
canManage?: boolean
|
||||
/** 소유자 여부 — 프로젝트 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
/** 문서 첨부 — 단건 조회에서만 채워짐 */
|
||||
attachments?: ApiProjectAttachment[]
|
||||
}
|
||||
|
||||
/** 프로젝트의 업무 목록 행 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,7 @@ function toProjectView(api: ApiProject): ProjectView {
|
||||
progressLabel: prog.label,
|
||||
canManage: api.canManage,
|
||||
isOwner: api.isOwner,
|
||||
attachments: api.attachments,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 프로젝트 문서 첨부(API 원시) — 백엔드 ProjectAttachmentResponse 와 1:1
|
||||
export interface ApiProjectAttachment {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
kind: 'pdf' | 'zip' | 'doc' | 'img'
|
||||
url: string // 다운로드 경로(/api/...)
|
||||
uploader: { id: string; name: string; avatarUrl: string | null } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1
|
||||
export interface ApiProject {
|
||||
id: string // UUID (라우팅 식별자)
|
||||
@@ -12,9 +23,10 @@ export interface ApiProject {
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
// 단건 조회에서만 — 현재 사용자 권한
|
||||
// 단건 조회에서만 — 현재 사용자 권한 + 문서 첨부
|
||||
canManage?: boolean // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean // 소유자 (프로젝트 삭제)
|
||||
attachments?: ApiProjectAttachment[]
|
||||
}
|
||||
|
||||
// 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)
|
||||
|
||||
Reference in New Issue
Block a user