diff --git a/docs/project-refactor-plan.md b/docs/project-refactor-plan.md index 675d5c9..e87d2f1 100644 --- a/docs/project-refactor-plan.md +++ b/docs/project-refactor-plan.md @@ -65,4 +65,6 @@ Project (이름/설명/공개범위) ← 작업의 기본 단위( - Phase 1-a: Project/ProjectMember 모듈 기반(엔티티·서비스·컨트롤러·"미분류" 부팅) ✅ (커밋 2f66cdd) - Phase 1-b: 백엔드 이전 ✅ — Activity(project FK·listForProject)·Repo(project FK, RepoMember 폐지)·Task(project 종속, seq 프로젝트 내 순번, 권한=ProjectMember) 전환. RepoService 프로젝트 종속(`/projects/:projectId/repos`, 멤버·업무카운트·캐시 제거). Task/Activity 컨트롤러 `/projects/...`. RepoSync→"미분류". AiService 프로젝트 컨텍스트. member.service/controller·repo-member·repo-cache 삭제. 검증 build/lint 0·14 tests. - **Phase 1 잔여(소소, 후속)**: ProjectResponse 카운트(repoCount/doneCount/totalCount 현재 0 stub) 집계 배선, ProjectMemberService 활동·알림 배선(TODO), TaskService.assigneeCounts→ProjectMemberService taskCount 연결. -- 다음 = Phase 2 프론트. +- Phase 2-a: 프론트 프로젝트 중심 재구성 ✅ — `types/project`·`useProject`·`project.store` 신설. `useTask`/`useMember`/`useActivity` 를 `/projects/:projectId/...` 로, task/my-task/activity 타입·스토어를 projectId/projectName 으로 전환. 알림 store 라우팅/문구 프로젝트화. 라우팅 `/projects/...` 전면 교체. 페이지 rename(Repo*→Project*): List/Create(간단 폼)/Detail/Settings/Members/Activity. `RepoHeader`→`ProjectHeader`, `RepoTabs`→`ProjectTabs`. AppShell 네비 "프로젝트". 죽은 파일(repo.store/useRepo/GiteaRepoBar) 삭제. 검증 type-check/lint/build 0. +- **Phase 2 잔여(후속)**: **저장소 연동 UI(Phase 2b)** — 프로젝트 내 "연동 저장소" 섹션 + Gitea 저장소 링크/생성 폼(백엔드 `/projects/:id/repos` 준비됨). 일부 화면 잔여 "저장소" 문구 정리. +- 다음 = Phase 1 카운트 배선 + Phase 2b 저장소 연동 UI. diff --git a/frontend/src/components/GiteaRepoBar.vue b/frontend/src/components/GiteaRepoBar.vue deleted file mode 100644 index cfddf8d..0000000 --- a/frontend/src/components/GiteaRepoBar.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - - - - - - - {{ cloneUrl }} - 클론 주소 없음 - - - - - - - - - {{ copied ? '복사됨' : '복사' }} - - - - - - - - Gitea에서 열기 - - - - - diff --git a/frontend/src/components/RepoHeader.vue b/frontend/src/components/ProjectHeader.vue similarity index 81% rename from frontend/src/components/RepoHeader.vue rename to frontend/src/components/ProjectHeader.vue index 1578df5..2132736 100644 --- a/frontend/src/components/RepoHeader.vue +++ b/frontend/src/components/ProjectHeader.vue @@ -1,16 +1,16 @@ + + + + + + + 프로젝트 + + / + 새 프로젝트 + + + 새 프로젝트 만들기 + + + 프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후 + 연동할 수 있습니다. + + + + + + + * 프로젝트 이름 + + + 목록과 화면에 표시되는 이름입니다. + + + + + + + + * 공개 범위 + + + + + 비공개 + 초대된 멤버만 접근하고 업무를 확인할 수 있습니다. + + + + + + 공개 + 조직 내 모든 구성원이 프로젝트를 보고 업무 현황을 열람할 수 있습니다. + + + + + + + + + + 프로젝트 설명 + + + 프로젝트의 목적과 범위를 간단히 적어주세요. + + + + + + + + + + + + diff --git a/frontend/src/pages/relay/RepoDetailPage.vue b/frontend/src/pages/relay/ProjectDetailPage.vue similarity index 93% rename from frontend/src/pages/relay/RepoDetailPage.vue rename to frontend/src/pages/relay/ProjectDetailPage.vue index 6a6b8ef..2458a2e 100644 --- a/frontend/src/pages/relay/RepoDetailPage.vue +++ b/frontend/src/pages/relay/ProjectDetailPage.vue @@ -5,26 +5,25 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' import { storeToRefs } from 'pinia' import { useRoute, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' -import RepoHeader from '@/components/RepoHeader.vue' -import RepoTabs from '@/components/RepoTabs.vue' -import GiteaRepoBar from '@/components/GiteaRepoBar.vue' +import ProjectHeader from '@/components/ProjectHeader.vue' +import ProjectTabs from '@/components/ProjectTabs.vue' import UserAvatar from '@/components/UserAvatar.vue' -import { useRepoStore } from '@/stores/repo.store' +import { useProjectStore } from '@/stores/project.store' import { useTaskStore } from '@/stores/task.store' import type { TaskListQuery, TaskSegment } from '@/types/task' -import type { Repo, TaskStatus } from '@/mock/relay.mock' +import type { Project, TaskStatus } from '@/mock/relay.mock' const route = useRoute() -const repoId = computed(() => String(route.params.repoId)) -const repoStore = useRepoStore() +const projectId = computed(() => String(route.params.projectId)) +const projectStore = useProjectStore() const taskStore = useTaskStore() const { tasks, counts, hasMore, loadingMore, loading } = storeToRefs(taskStore) // 저장소 헤더/진행률 — API 연동 -const repo = ref(null) +const repo = ref(null) async function loadRepo() { try { - repo.value = await repoStore.fetchOne(repoId.value) + repo.value = await projectStore.fetchOne(projectId.value) } catch { repo.value = null // 404/403 등은 인터셉터가 알림 처리 } @@ -42,7 +41,7 @@ function currentQuery(): TaskListQuery { // 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드 async function loadTasks() { try { - await taskStore.fetchList(repoId.value, currentQuery()) + await taskStore.fetchList(projectId.value, currentQuery()) } catch { // 인터셉터가 알림 처리 } @@ -51,7 +50,7 @@ async function loadTasks() { // 더보기 — 같은 조건으로 다음 페이지 이어붙이기 async function loadMoreTasks() { try { - await taskStore.loadMore(repoId.value, currentQuery()) + await taskStore.loadMore(projectId.value, currentQuery()) } catch { // 인터셉터가 알림 처리 } @@ -59,7 +58,7 @@ async function loadMoreTasks() { // 저장소 전환 시 헤더 + 목록 리셋 로드 watch( - repoId, + projectId, () => { loadRepo() loadTasks() @@ -108,7 +107,7 @@ const STATUS_LABEL: Record = { > 저장소 @@ -118,7 +117,7 @@ const STATUS_LABEL: Record = { - @@ -150,18 +149,11 @@ const STATUS_LABEL: Record = { - - - - + - = { = { :key="task.id" class="task-row" :class="{ 'is-done': task.status === 'done' }" - :to="`/repos/${repo.id}/tasks/${task.id}`" + :to="`/projects/${repo.id}/tasks/${task.id}`" > = { margin-top: 0.75rem; } -/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */ +/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */ .prog-strip { display: flex; align-items: center; diff --git a/frontend/src/pages/relay/RepoListPage.vue b/frontend/src/pages/relay/ProjectListPage.vue similarity index 77% rename from frontend/src/pages/relay/RepoListPage.vue rename to frontend/src/pages/relay/ProjectListPage.vue index af0e0b8..5b76324 100644 --- a/frontend/src/pages/relay/RepoListPage.vue +++ b/frontend/src/pages/relay/ProjectListPage.vue @@ -1,11 +1,11 @@ - - - - - - - 저장소 - - / - 새 저장소 - - - 새 저장소 만들기 - - - - - Gitea 연동됨 - - - - Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고 - 담당자에게 전달할 수 있습니다. - - - - - - - * 소유자 / 저장소 이름 소유자 고정 - - - 소유자는 현재 조직으로 고정되어 변경할 수 없습니다. - - - - {{ owner || '조직 불러오는 중…' }} - - - - - - - - / - - - - - - - - - - - {{ availMessage }} - - - 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다. - - - - - - - * 표시 제목 - - - Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다. - - - - - - - - * 공개 범위 - - - 저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다. - - - - - - - - - - 비공개 - 저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다. - - - - - - - - - - - 공개 - 조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다. - - - - - - - - - - * 프로젝트 설명 - - - 저장소 목록과 상단에 표시됩니다. 프로젝트의 목적과 범위를 간단히 적어주세요. - - - - - - - - 템플릿 - - - 템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 새 저장소에 그대로 복제됩니다. - (기본 브랜치는 템플릿을 따릅니다) - - - - - 없음 (빈 저장소) - - - {{ templateSlug }} 템플릿 - - - - - - - - - - - - - - * 메인 브랜치 - - - 저장소의 기본 브랜치 이름입니다. - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index 06f579d..019a1f2 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -5,26 +5,26 @@ import { computed, ref, watch } from 'vue' import { useRoute, useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import UserAvatar from '@/components/UserAvatar.vue' -import { useRepoStore } from '@/stores/repo.store' +import { useProjectStore } from '@/stores/project.store' import { useMemberStore } from '@/stores/member.store' 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 { Project, 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() const router = useRouter() -const repoId = computed(() => String(route.params.repoId)) +const projectId = computed(() => String(route.params.projectId)) // 수정 모드 여부 — taskId 파라미터 존재 시 편집 const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null)) const isEdit = computed(() => taskId.value !== null) -const repoStore = useRepoStore() +const projectStore = useProjectStore() const memberStore = useMemberStore() const taskStore = useTaskStore() const authStore = useAuthStore() @@ -52,10 +52,10 @@ function toDateInput(iso: string): string { } // 저장소 헤더(빵부스러기) — API 연동 -const repo = ref(null) +const repo = ref(null) async function loadRepo() { try { - repo.value = await repoStore.fetchOne(repoId.value) + repo.value = await projectStore.fetchOne(projectId.value) } catch { repo.value = null } @@ -63,7 +63,7 @@ async function loadRepo() { // 담당자 후보 = 저장소 멤버 async function loadMembers() { try { - await memberStore.fetchList(repoId.value) + await memberStore.fetchList(projectId.value) } catch { // 인터셉터가 알림 처리 } @@ -72,7 +72,7 @@ async function loadMembers() { async function loadTaskForEdit() { if (taskId.value === null) return try { - const t = await taskApi.get(repoId.value, taskId.value) + const t = await taskApi.get(projectId.value, taskId.value) title.value = t.title contentText.value = t.content.join('\n') dueDate.value = t.dueDate ? toDateInput(t.dueDate) : '' @@ -85,7 +85,7 @@ async function loadTaskForEdit() { } } watch( - [repoId, taskId], + [projectId, taskId], () => { loadRepo() loadMembers() @@ -175,7 +175,7 @@ async function requestAiSuggestions() { .map((p) => p.trim()) .filter((p) => p.length > 0) const { groups } = await ai.suggestChecklist({ - repoId: repoId.value, + projectId: projectId.value, title: title.value.trim(), content: content.length ? content : undefined, }) @@ -236,7 +236,7 @@ function removePending(index: number) { async function removeExisting(fileId: string) { if (taskId.value === null) return try { - await taskApi.removeFile(repoId.value, taskId.value, fileId) + await taskApi.removeFile(projectId.value, taskId.value, fileId) existingFiles.value = existingFiles.value.filter((f) => f.id !== fileId) } catch { // 인터셉터가 알림 처리 @@ -247,7 +247,7 @@ async function uploadPendingFiles(tid: number): Promise { let failed = 0 for (const file of pendingFiles.value) { try { - await taskApi.uploadFile(repoId.value, tid, file) + await taskApi.uploadFile(projectId.value, tid, file) } catch { failed += 1 } @@ -275,7 +275,7 @@ async function submitTask() { let tid: number if (isEdit.value && taskId.value !== null) { // 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화 - const updated = await taskStore.update(repoId.value, taskId.value, { + const updated = await taskStore.update(projectId.value, taskId.value, { title: title.value.trim(), content, assigneeIds, @@ -286,7 +286,7 @@ async function submitTask() { }) tid = updated.id } else { - const created = await taskStore.create(repoId.value, { + const created = await taskStore.create(projectId.value, { title: title.value.trim(), content: content.length ? content : undefined, assigneeIds, @@ -305,7 +305,7 @@ async function submitTask() { `파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`, ) } - router.push(`/repos/${repoId.value}/tasks/${tid}`) + router.push(`/projects/${projectId.value}/tasks/${tid}`) } catch { // 인터셉터가 알림 처리(권한/검증 등) } finally { @@ -314,7 +314,7 @@ async function submitTask() { } function goBack() { - router.push(`/repos/${repoId.value}`) + router.push(`/projects/${projectId.value}`) } @@ -323,14 +323,14 @@ function goBack() { 저장소 / {{ repo?.name ?? '저장소' }} diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index c36ad6c..58ce461 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -26,7 +26,7 @@ import { attachmentKindOf, fileBadge } from '@/shared/utils/format' const route = useRoute() const router = useRouter() -const repoId = computed(() => String(route.params.repoId)) +const projectId = computed(() => String(route.params.projectId)) const taskId = computed(() => Number(route.params.taskId)) const taskStore = useTaskStore() @@ -36,7 +36,7 @@ const authStore = useAuthStore() const task = ref(null) async function loadTask() { try { - task.value = await taskStore.fetchOne(repoId.value, taskId.value) + task.value = await taskStore.fetchOne(projectId.value, taskId.value) } catch { task.value = null // 404 등은 인터셉터가 알림 처리 } @@ -106,8 +106,8 @@ async function confirmDelete() { if (!task.value) return showDelete.value = false try { - await taskStore.remove(repoId.value, task.value.id) - router.push(`/repos/${repoId.value}`) + await taskStore.remove(projectId.value, task.value.id) + router.push(`/projects/${projectId.value}`) } catch { // 인터셉터가 알림 처리 } @@ -117,7 +117,7 @@ async function confirmDelete() { async function approveTask() { if (!task.value) return try { - task.value = await taskStore.approve(repoId.value, task.value.id) + task.value = await taskStore.approve(projectId.value, task.value.id) } catch { // 인터셉터가 알림 처리 } finally { @@ -131,7 +131,7 @@ async function submitChanges() { const note = changesNote.value.trim() if (!note) return try { - task.value = await taskStore.requestChanges(repoId.value, task.value.id, note) + task.value = await taskStore.requestChanges(projectId.value, task.value.id, note) } catch { // 인터셉터가 알림 처리 } finally { @@ -144,7 +144,7 @@ async function submitChanges() { async function changeTo(status: TaskStatus) { if (!task.value) return try { - task.value = await taskStore.changeStatus(repoId.value, task.value.id, status) + task.value = await taskStore.changeStatus(projectId.value, task.value.id, status) } catch { // 인터셉터가 알림 처리 } @@ -158,7 +158,7 @@ async function sendApproval() { if (!task.value) return try { task.value = await taskStore.requestApproval( - repoId.value, + projectId.value, task.value.id, approvalNote.value.trim() || undefined, ) @@ -221,7 +221,7 @@ async function submitReply(index: number) { const target = comments.value[index] if (!text || !target?.id || !task.value) return try { - task.value = await taskStore.addReply(repoId.value, task.value.id, target.id, text) + task.value = await taskStore.addReply(projectId.value, task.value.id, target.id, text) } catch { // 인터셉터가 알림 처리 } @@ -232,7 +232,7 @@ async function submitComment() { const text = commentText.value.trim() if (!text || !task.value) return try { - task.value = await taskStore.addComment(repoId.value, task.value.id, text) + task.value = await taskStore.addComment(projectId.value, task.value.id, text) } catch { // 인터셉터가 알림 처리 } @@ -261,7 +261,7 @@ async function onFileChange(e: Event) { } uploading.value = true try { - task.value = await taskStore.uploadFile(repoId.value, task.value.id, file) + task.value = await taskStore.uploadFile(projectId.value, task.value.id, file) } catch { // 인터셉터가 알림 처리 } finally { @@ -272,7 +272,7 @@ async function onFileChange(e: Event) { async function removeFile(att: Attachment) { if (!task.value || !att.id) return try { - task.value = await taskStore.removeFile(repoId.value, task.value.id, att.id) + task.value = await taskStore.removeFile(projectId.value, task.value.id, att.id) } catch { // 인터셉터가 알림 처리 } @@ -317,21 +317,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { > 저장소 / - {{ task.repoName }} + {{ task.projectName }} / 업무 @@ -356,7 +356,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { import('@/pages/relay/RepoListPage.vue'), + path: '/projects', + name: 'project-list', + component: () => import('@/pages/relay/ProjectListPage.vue'), meta: { requiresAuth: true }, }, { - // 정적 경로 '/repos/new' 는 동적 '/repos/:repoId' 보다 먼저 선언해 우선 매칭되게 한다 - path: '/repos/new', - name: 'repo-create', - component: () => import('@/pages/relay/RepoCreatePage.vue'), + // 정적 '/projects/new' 는 동적 '/projects/:projectId' 보다 먼저 선언 + path: '/projects/new', + name: 'project-create', + component: () => import('@/pages/relay/ProjectCreatePage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId', - name: 'repo-detail', - component: () => import('@/pages/relay/RepoDetailPage.vue'), + path: '/projects/:projectId', + name: 'project-detail', + component: () => import('@/pages/relay/ProjectDetailPage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId/members', - name: 'repo-members', - component: () => import('@/pages/relay/RepoMembersPage.vue'), + path: '/projects/:projectId/members', + name: 'project-members', + component: () => import('@/pages/relay/ProjectMembersPage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId/activity', - name: 'repo-activity', - component: () => import('@/pages/relay/RepoActivityPage.vue'), + path: '/projects/:projectId/activity', + name: 'project-activity', + component: () => import('@/pages/relay/ProjectActivityPage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId/settings', - name: 'repo-settings', - component: () => import('@/pages/relay/RepoSettingsPage.vue'), + path: '/projects/:projectId/settings', + name: 'project-settings', + component: () => import('@/pages/relay/ProjectSettingsPage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId/tasks/new', + path: '/projects/:projectId/tasks/new', name: 'task-create', component: () => import('@/pages/relay/TaskCreatePage.vue'), meta: { requiresAuth: true }, }, { // 업무 수정 — 생성 폼(TaskCreatePage)을 편집 모드로 재사용 - path: '/repos/:repoId/tasks/:taskId/edit', + path: '/projects/:projectId/tasks/:taskId/edit', name: 'task-edit', component: () => import('@/pages/relay/TaskCreatePage.vue'), meta: { requiresAuth: true }, }, { - path: '/repos/:repoId/tasks/:taskId', + path: '/projects/:projectId/tasks/:taskId', name: 'task-detail', component: () => import('@/pages/relay/TaskDetailPage.vue'), meta: { requiresAuth: true }, }, { - // '내 업무' — 담당/지시 저장소 횡단 집계 + // '내 업무' — 담당/지시 프로젝트 횡단 집계 path: '/tasks', name: 'my-tasks', component: () => import('@/pages/relay/MyTasksPage.vue'), @@ -103,14 +103,18 @@ router.beforeEach(async (to) => { await authStore.bootstrap() } - // 보호 라우트인데 미로그인 → 로그인 페이지로 (로그인 후에는 항상 저장소 목록으로 진입) + // 보호 라우트인데 미로그인 → 로그인 페이지로 (로그인 후에는 항상 프로젝트 목록으로 진입) if (to.meta.requiresAuth && !authStore.isLoggedIn) { return { name: 'login' } } - // 이미 로그인 상태에서 로그인/회원가입 진입 → 저장소 목록으로 - if (!to.meta.requiresAuth && authStore.isLoggedIn && (to.name === 'login' || to.name === 'signup')) { - return { path: '/repos' } + // 이미 로그인 상태에서 로그인/회원가입 진입 → 프로젝트 목록으로 + if ( + !to.meta.requiresAuth && + authStore.isLoggedIn && + (to.name === 'login' || to.name === 'signup') + ) { + return { path: '/projects' } } return true diff --git a/frontend/src/stores/activity.store.ts b/frontend/src/stores/activity.store.ts index 7fbc6a5..b7628ab 100644 --- a/frontend/src/stores/activity.store.ts +++ b/frontend/src/stores/activity.store.ts @@ -36,22 +36,26 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem { const target = escapeHtml(pstr(p, 'targetName')) switch (api.type) { - case 'repo.created': - return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 저장소를 생성했습니다` } - case 'repo.renamed': + case 'project.created': + return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 프로젝트를 생성했습니다` } + case 'project.renamed': return { ...base, tone: 'setting', icon: 'pencil', html: `${actor}님이 표시 제목을 ‘${escapeHtml(pstr(p, 'newName'))}’(으)로 변경했습니다`, } - case 'repo.visibility_changed': + case 'project.visibility_changed': return { ...base, tone: 'setting', icon: 'lock', - html: `${actor}님이 저장소 공개 범위를 ${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}(으)로 변경했습니다`, + html: `${actor}님이 프로젝트 공개 범위를 ${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}(으)로 변경했습니다`, } + case 'repo.linked': + return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 저장소 ${escapeHtml(pstr(p, 'repoName'))}을(를) 연동했습니다` } + case 'repo.unlinked': + return { ...base, tone: 'setting', icon: 'repo', html: `${actor}님이 저장소 ${escapeHtml(pstr(p, 'repoName'))} 연동을 해제했습니다` } case 'member.invited': return { ...base, tone: 'member', icon: 'member-add', html: `${actor}님이 ${target}님을 멤버로 초대했습니다` } case 'member.role_changed': @@ -127,10 +131,10 @@ export const useActivityStore = defineStore('activity', () => { const activityApi = useActivity() // 저장소 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속) - async function fetch(repoId: string): Promise { + async function fetch(projectId: string): Promise { loading.value = true try { - const list = await activityApi.list(repoId) + const list = await activityApi.list(projectId) const groups: RepoActivityDay[] = [] let current: RepoActivityDay | null = null for (const a of list) { diff --git a/frontend/src/stores/member.store.ts b/frontend/src/stores/member.store.ts index e1b049d..39d41f8 100644 --- a/frontend/src/stores/member.store.ts +++ b/frontend/src/stores/member.store.ts @@ -43,13 +43,13 @@ export const useMemberStore = defineStore('member', () => { // 멤버 목록 조회 — 백엔드는 페이지네이션이지만, 역할/검색 카운트와 담당자 후보 풀 // (TaskCreatePage)이 전수 기준이어야 하므로 모든 페이지를 순회해 전체를 로드한다. // (멤버는 팀 규모로 한정적이라 전체 로드가 안전하다) - async function fetchList(repoId: string): Promise { + async function fetchList(projectId: string): Promise { loading.value = true try { const acc: ApiRepoMember[] = [] let page = 1 for (;;) { - const { items, total } = await memberApi.list(repoId, page, DEFAULT_PAGE_SIZE) + const { items, total } = await memberApi.list(projectId, page, DEFAULT_PAGE_SIZE) acc.push(...items) if (items.length === 0 || acc.length >= total) break page += 1 @@ -61,26 +61,26 @@ export const useMemberStore = defineStore('member', () => { } // 멤버 초대 — 성공 시 목록에 추가 - async function invite(repoId: string, payload: InviteMemberPayload): Promise { - const created = await memberApi.invite(repoId, payload) + async function invite(projectId: string, payload: InviteMemberPayload): Promise { + const created = await memberApi.invite(projectId, payload) members.value.push(toMemberView(created, currentUserId())) } // 멤버 역할/직무 변경 — 성공 시 해당 행 교체 async function update( - repoId: string, + projectId: string, userId: string, payload: UpdateMemberPayload, ): Promise { - const updated = await memberApi.update(repoId, userId, payload) + const updated = await memberApi.update(projectId, userId, payload) const view = toMemberView(updated, currentUserId()) const idx = members.value.findIndex((m) => m.user.id === userId) if (idx >= 0) members.value[idx] = view } // 멤버 제거 — 성공 시 목록에서 제외 - async function remove(repoId: string, userId: string): Promise { - await memberApi.remove(repoId, userId) + async function remove(projectId: string, userId: string): Promise { + await memberApi.remove(projectId, userId) members.value = members.value.filter((m) => m.user.id !== userId) } diff --git a/frontend/src/stores/my-task.store.ts b/frontend/src/stores/my-task.store.ts index 7f04137..01dec1d 100644 --- a/frontend/src/stores/my-task.store.ts +++ b/frontend/src/stores/my-task.store.ts @@ -47,10 +47,10 @@ function toAssignedRow(api: ApiMyTaskRow): MyTaskRow { const isDone = api.status === 'done' return { id: api.id, - repoId: api.repoId, + projectId: api.projectId, title: api.title, status: api.status, - repoName: api.repoName, + projectName: api.projectName, checklist: total > 0 ? api.checklist : undefined, metaText: `지시 · ${api.issuer?.name ?? '알 수 없음'}`, dueLabel: isDone ? due.listLabel : due.dateLabel || undefined, @@ -71,10 +71,10 @@ function toIssuedRow(api: ApiMyTaskRow): MyTaskRow { const names = api.assignees.map((a) => a.name).join(', ') || '미지정' return { id: api.id, - repoId: api.repoId, + projectId: api.projectId, title: api.title, status: api.status, - repoName: api.repoName, + projectName: api.projectName, checklist: total > 0 ? api.checklist : undefined, assignees: api.assignees.map(toUserView), metaText: api.status === 'changes' ? `${names}에게 수정 요청` : names, diff --git a/frontend/src/stores/notification.store.ts b/frontend/src/stores/notification.store.ts index fab93c2..064e1a4 100644 --- a/frontend/src/stores/notification.store.ts +++ b/frontend/src/stores/notification.store.ts @@ -19,11 +19,14 @@ function pnum(p: Record, k: string): number | null { function toView(n: ApiNotification): NotificationView { const p = n.payload const actor = escapeHtml(pstr(p, 'actorName') || '누군가') - const repoName = escapeHtml(pstr(p, 'repoName')) + const projectName = escapeHtml(pstr(p, 'projectName')) const taskTitle = escapeHtml(pstr(p, 'taskTitle')) - const repoId = pstr(p, 'repoId') + const projectId = pstr(p, 'projectId') const taskSeq = pnum(p, 'taskSeq') - const taskTo = repoId && taskSeq !== null ? `/repos/${repoId}/tasks/${taskSeq}` : null + const taskTo = + projectId && taskSeq !== null + ? `/projects/${projectId}/tasks/${taskSeq}` + : null let tone = '' let html = '새 알림' @@ -52,7 +55,7 @@ function toView(n: ApiNotification): NotificationView { } case 'task.removed': tone = 'red' - to = repoId ? `/repos/${repoId}` : null // 업무가 삭제되므로 저장소로 이동 + to = projectId ? `/projects/${projectId}` : null // 업무가 삭제되므로 프로젝트로 이동 html = `${actor}님이 ${taskTitle} 업무를 삭제했습니다` break case 'task.approval_requested': @@ -73,20 +76,20 @@ function toView(n: ApiNotification): NotificationView { break case 'member.invited': tone = 'accent' - to = repoId ? `/repos/${repoId}` : null - html = `${actor}님이 ${repoName} 저장소에 초대했습니다` + to = projectId ? `/projects/${projectId}` : null + html = `${actor}님이 ${projectName} 프로젝트에 초대했습니다` break case 'member.role_changed': { const role = pstr(p, 'roleType') === 'admin' ? '관리자' : '멤버' tone = 'accent' - to = repoId ? `/repos/${repoId}` : null - html = `${actor}님이 ${repoName}에서 회원님의 역할을 ${role}(으)로 변경했습니다` + to = projectId ? `/projects/${projectId}` : null + html = `${actor}님이 ${projectName}에서 회원님의 역할을 ${role}(으)로 변경했습니다` break } case 'member.removed': tone = 'red' - to = repoId ? `/repos/${repoId}` : null - html = `${actor}님이 ${repoName} 저장소에서 회원님을 제외했습니다` + to = projectId ? `/projects/${projectId}` : null + html = `${actor}님이 ${projectName} 프로젝트에서 회원님을 제외했습니다` break } return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to } diff --git a/frontend/src/stores/project.store.ts b/frontend/src/stores/project.store.ts new file mode 100644 index 0000000..b279e58 --- /dev/null +++ b/frontend/src/stores/project.store.ts @@ -0,0 +1,131 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { useProject } from '@/composables/useProject' +import { DEFAULT_PAGE_SIZE } from '@/types/pagination' +import { progressOf, relativeTimeKo } from '@/shared/utils/format' +import type { ApiMember } from '@/types/repo' +import type { + ApiProject, + CreateProjectPayload, + UpdateProjectPayload, +} from '@/types/project' +import type { Project as ProjectView, User as UserView } from '@/mock/relay.mock' + +// 아바타 스택에 노출할 멤버 수(초과분은 +N 으로 표기) +const AVATAR_LIMIT = 3 + +function toUserView(member: ApiMember): UserView { + return { + id: member.id, + name: member.name, + role: member.role ?? '', + avatarUrl: member.avatarUrl, + } +} + +// API 프로젝트 → 화면용 Project(진행률/상대시간/아바타 파생) +function toProjectView(api: ApiProject): ProjectView { + const prog = progressOf(api.doneCount, api.totalCount) + const shown = api.members.slice(0, AVATAR_LIMIT).map(toUserView) + return { + id: api.id, + name: api.name, + desc: api.description ?? '', + visibility: api.visibility, + isSystem: api.isSystem, + members: shown, + moreCount: Math.max(0, api.memberCount - shown.length), + updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`, + repoCount: api.repoCount, + doneCount: api.doneCount, + totalCount: api.totalCount, + progressPct: prog.pct, + progressLevel: prog.level, + progressLabel: prog.label, + canManage: api.canManage, + isOwner: api.isOwner, + } +} + +// 프로젝트 스토어 — 목록/현재 프로젝트 상태 + CRUD 액션 +export const useProjectStore = defineStore('project', () => { + const projects = ref([]) + const current = ref(null) + const loading = ref(false) + const total = ref(0) + const page = ref(0) + const loadingMore = ref(false) + const hasMore = computed(() => projects.value.length < total.value) + + const projectApi = useProject() + + async function fetchList(): Promise { + loading.value = true + try { + const res = await projectApi.list(1, DEFAULT_PAGE_SIZE) + projects.value = res.items.map(toProjectView) + total.value = res.total + page.value = 1 + } finally { + loading.value = false + } + } + + async function loadMore(): Promise { + if (loadingMore.value || !hasMore.value) return + loadingMore.value = true + try { + const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE) + projects.value.push(...res.items.map(toProjectView)) + total.value = res.total + page.value += 1 + } finally { + loadingMore.value = false + } + } + + async function fetchOne(id: string): Promise { + loading.value = true + try { + current.value = toProjectView(await projectApi.get(id)) + return current.value + } finally { + loading.value = false + } + } + + async function create(payload: CreateProjectPayload): Promise { + return toProjectView(await projectApi.create(payload)) + } + + async function update( + id: string, + payload: UpdateProjectPayload, + ): Promise { + const view = toProjectView(await projectApi.update(id, payload)) + current.value = view + return view + } + + async function remove(id: string): Promise { + await projectApi.remove(id) + projects.value = projects.value.filter((p) => p.id !== id) + total.value = Math.max(0, total.value - 1) + if (current.value?.id === id) current.value = null + } + + return { + projects, + current, + loading, + total, + hasMore, + loadingMore, + fetchList, + loadMore, + fetchOne, + create, + update, + remove, + } +}) diff --git a/frontend/src/stores/repo.store.ts b/frontend/src/stores/repo.store.ts deleted file mode 100644 index 3f3024b..0000000 --- a/frontend/src/stores/repo.store.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -import { useRepo } from '@/composables/useRepo' -import { DEFAULT_PAGE_SIZE } from '@/types/pagination' -import { progressOf, relativeTimeKo } from '@/shared/utils/format' -import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo' -import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock' - -// 아바타 스택에 노출할 멤버 수(초과분은 +N 으로 표기) -const AVATAR_LIMIT = 3 - -// API 멤버 → 화면용 User(이니셜/색상 파생) -function toUserView(member: ApiMember): UserView { - return { - id: member.id, - name: member.name, - role: member.role ?? '', - avatarUrl: member.avatarUrl, - } -} - -// API 저장소 → 화면용 Repo(진행률/상대시간/아바타 파생) -function toRepoView(api: ApiRepo): RepoView { - const prog = progressOf(api.doneCount, api.totalCount) - const shown = api.members.slice(0, AVATAR_LIMIT).map(toUserView) - return { - id: api.id, - name: api.name, - owner: api.owner, - slug: api.slug, - desc: api.desc ?? '', - visibility: api.visibility, - branch: api.branch, - members: shown, - moreCount: Math.max(0, api.memberCount - shown.length), - updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`, - doneCount: api.doneCount, - totalCount: api.totalCount, - progressPct: prog.pct, - progressLevel: prog.level, - progressLabel: prog.label, - cloneUrl: api.cloneUrl, - htmlUrl: api.htmlUrl, - giteaMissing: api.giteaMissing, - canManage: api.canManage, - isOwner: api.isOwner, - } -} - -// 저장소 스토어 — 목록/현재 저장소 상태 + CRUD 액션 -export const useRepoStore = defineStore('repo', () => { - const repos = ref([]) - const current = ref(null) - const loading = ref(false) - // 페이지네이션(더보기) 상태 - const total = ref(0) - const page = ref(0) - const loadingMore = ref(false) - const hasMore = computed(() => repos.value.length < total.value) - // 생성 폼 메타 — 소유자(GITEA_ORG)·템플릿 정보. 최초 1회 조회 후 캐시 - const owner = ref('') - const templateAvailable = ref(false) - const templateSlug = ref('') - - const repoApi = useRepo() - - // 생성 폼 메타(소유자/템플릿) 조회 — 이미 있으면 재사용 - async function fetchCreateMeta(): Promise { - if (owner.value) return - const meta = await repoApi.meta() - owner.value = meta.owner - templateAvailable.value = meta.template.available - templateSlug.value = meta.template.slug - } - - // 목록 첫 페이지 조회(리셋) - async function fetchList(): Promise { - loading.value = true - try { - const res = await repoApi.list(1, DEFAULT_PAGE_SIZE) - repos.value = res.items.map(toRepoView) - total.value = res.total - page.value = 1 - } finally { - loading.value = false - } - } - - // 다음 페이지 이어붙이기(더보기) - async function loadMore(): Promise { - if (loadingMore.value || !hasMore.value) return - loadingMore.value = true - try { - const res = await repoApi.list(page.value + 1, DEFAULT_PAGE_SIZE) - repos.value.push(...res.items.map(toRepoView)) - total.value = res.total - page.value += 1 - } finally { - loadingMore.value = false - } - } - - // 단건 조회 - async function fetchOne(id: string): Promise { - loading.value = true - try { - current.value = toRepoView(await repoApi.get(id)) - return current.value - } finally { - loading.value = false - } - } - - // 생성 - async function create(payload: CreateRepoPayload): Promise { - return toRepoView(await repoApi.create(payload)) - } - - // 수정 - async function update(id: string, payload: UpdateRepoPayload): Promise { - const view = toRepoView(await repoApi.update(id, payload)) - current.value = view - return view - } - - // 삭제 - async function remove(id: string): Promise { - await repoApi.remove(id) - repos.value = repos.value.filter((r) => r.id !== id) - total.value = Math.max(0, total.value - 1) - if (current.value?.id === id) current.value = null - } - - return { - repos, - current, - loading, - total, - hasMore, - loadingMore, - owner, - templateAvailable, - templateSlug, - fetchCreateMeta, - fetchList, - loadMore, - fetchOne, - create, - update, - remove, - } -}) diff --git a/frontend/src/stores/task.store.ts b/frontend/src/stores/task.store.ts index 363cba4..06c9939 100644 --- a/frontend/src/stores/task.store.ts +++ b/frontend/src/stores/task.store.ts @@ -170,8 +170,8 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView { const issuerId = api.issuer?.id ?? null return { id: api.id, - repoId: api.repoId, - repoName: api.repoName, + projectId: api.projectId, + projectName: api.projectName, title: api.title, status: api.status, statusLabel: taskStatusLabel(api.status), @@ -216,10 +216,10 @@ export const useTaskStore = defineStore('task', () => { const taskApi = useTask() // 목록 첫 페이지 조회(리셋) — 세그먼트/검색은 백엔드에서 필터, 카운트도 응답에서 수신 - async function fetchList(repoId: string, query: TaskListQuery = {}): Promise { + async function fetchList(projectId: string, query: TaskListQuery = {}): Promise { loading.value = true try { - const res = await taskApi.list(repoId, query, 1, DEFAULT_PAGE_SIZE) + const res = await taskApi.list(projectId, query, 1, DEFAULT_PAGE_SIZE) tasks.value = res.items.map(toRepoTaskView) total.value = res.total counts.value = res.counts @@ -230,11 +230,11 @@ export const useTaskStore = defineStore('task', () => { } // 다음 페이지 이어붙이기(더보기) — 같은 세그먼트/검색 조건 유지 - async function loadMore(repoId: string, query: TaskListQuery = {}): Promise { + async function loadMore(projectId: string, query: TaskListQuery = {}): Promise { if (loadingMore.value || !hasMore.value) return loadingMore.value = true try { - const res = await taskApi.list(repoId, query, page.value + 1, DEFAULT_PAGE_SIZE) + const res = await taskApi.list(projectId, query, page.value + 1, DEFAULT_PAGE_SIZE) tasks.value.push(...res.items.map(toRepoTaskView)) total.value = res.total counts.value = res.counts @@ -245,10 +245,10 @@ export const useTaskStore = defineStore('task', () => { } // 단건 상세 조회 - async function fetchOne(repoId: string, taskId: number): Promise { + async function fetchOne(projectId: string, taskId: number): Promise { loading.value = true try { - current.value = toTaskDetailView(await taskApi.get(repoId, taskId)) + current.value = toTaskDetailView(await taskApi.get(projectId, taskId)) return current.value } finally { loading.value = false @@ -256,26 +256,26 @@ export const useTaskStore = defineStore('task', () => { } // 생성 — 생성된 상세 반환 - async function create(repoId: string, payload: CreateTaskPayload): Promise { - const view = toTaskDetailView(await taskApi.create(repoId, payload)) + async function create(projectId: string, payload: CreateTaskPayload): Promise { + const view = toTaskDetailView(await taskApi.create(projectId, payload)) current.value = view return view } // 수정 async function update( - repoId: string, + projectId: string, taskId: number, payload: UpdateTaskPayload, ): Promise { - const view = toTaskDetailView(await taskApi.update(repoId, taskId, payload)) + const view = toTaskDetailView(await taskApi.update(projectId, taskId, payload)) current.value = view return view } // 삭제 — 목록에서 제외하고 총계도 보정(세그먼트 카운트는 목록 재진입 시 재조회로 갱신) - async function remove(repoId: string, taskId: number): Promise { - await taskApi.remove(repoId, taskId) + async function remove(projectId: string, taskId: number): Promise { + await taskApi.remove(projectId, taskId) const before = tasks.value.length tasks.value = tasks.value.filter((t) => t.id !== taskId) if (tasks.value.length < before) total.value = Math.max(0, total.value - 1) @@ -284,18 +284,18 @@ export const useTaskStore = defineStore('task', () => { // 상태 변경 async function changeStatus( - repoId: string, + projectId: string, taskId: number, status: TaskDetailView['status'], ): Promise { - const view = toTaskDetailView(await taskApi.changeStatus(repoId, taskId, status)) + const view = toTaskDetailView(await taskApi.changeStatus(projectId, taskId, status)) current.value = view return view } // 부속 변경 후 상세를 다시 받아 일관된 뷰로 갱신하는 공통 헬퍼 - async function refresh(repoId: string, taskId: number): Promise { - const view = toTaskDetailView(await taskApi.get(repoId, taskId)) + async function refresh(projectId: string, taskId: number): Promise { + const view = toTaskDetailView(await taskApi.get(projectId, taskId)) current.value = view return view } @@ -303,68 +303,68 @@ export const useTaskStore = defineStore('task', () => { /* ===================== 댓글 / 답글 (5단계) ===================== */ async function addComment( - repoId: string, + projectId: string, taskId: number, text: string, ): Promise { - await taskApi.addComment(repoId, taskId, text) - return refresh(repoId, taskId) + await taskApi.addComment(projectId, taskId, text) + return refresh(projectId, taskId) } async function addReply( - repoId: string, + projectId: string, taskId: number, commentId: string, text: string, ): Promise { - await taskApi.addReply(repoId, taskId, commentId, text) - return refresh(repoId, taskId) + await taskApi.addReply(projectId, taskId, commentId, text) + return refresh(projectId, taskId) } /* ===================== 파일 첨부 (5단계) ===================== */ async function uploadFile( - repoId: string, + projectId: string, taskId: number, file: File, ): Promise { - await taskApi.uploadFile(repoId, taskId, file) - return refresh(repoId, taskId) + await taskApi.uploadFile(projectId, taskId, file) + return refresh(projectId, taskId) } async function removeFile( - repoId: string, + projectId: string, taskId: number, fileId: string, ): Promise { - await taskApi.removeFile(repoId, taskId, fileId) - return refresh(repoId, taskId) + await taskApi.removeFile(projectId, taskId, fileId) + return refresh(projectId, taskId) } /* ===================== 승인 워크플로우 (5단계) ===================== */ async function requestApproval( - repoId: string, + projectId: string, taskId: number, note?: string, ): Promise { - const view = toTaskDetailView(await taskApi.requestApproval(repoId, taskId, note)) + const view = toTaskDetailView(await taskApi.requestApproval(projectId, taskId, note)) current.value = view return view } - async function approve(repoId: string, taskId: number): Promise { - const view = toTaskDetailView(await taskApi.approve(repoId, taskId)) + async function approve(projectId: string, taskId: number): Promise { + const view = toTaskDetailView(await taskApi.approve(projectId, taskId)) current.value = view return view } async function requestChanges( - repoId: string, + projectId: string, taskId: number, note: string, ): Promise { - const view = toTaskDetailView(await taskApi.requestChanges(repoId, taskId, note)) + const view = toTaskDetailView(await taskApi.requestChanges(projectId, taskId, note)) current.value = view return view } diff --git a/frontend/src/types/activity.ts b/frontend/src/types/activity.ts index 6488e94..32624ee 100644 --- a/frontend/src/types/activity.ts +++ b/frontend/src/types/activity.ts @@ -4,9 +4,11 @@ import type { ApiMember } from '@/types/repo' // 활동 이벤트 타입(백엔드 ActivityType 과 동일 문자열) export type ApiActivityType = - | 'repo.created' - | 'repo.renamed' - | 'repo.visibility_changed' + | 'project.created' + | 'project.renamed' + | 'project.visibility_changed' + | 'repo.linked' + | 'repo.unlinked' | 'member.invited' | 'member.role_changed' | 'member.removed' diff --git a/frontend/src/types/my-task.ts b/frontend/src/types/my-task.ts index 0d7f855..546cb05 100644 --- a/frontend/src/types/my-task.ts +++ b/frontend/src/types/my-task.ts @@ -5,9 +5,9 @@ import type { ApiMember } from '@/types/repo' // 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생 export interface ApiMyTaskRow { - id: number // 저장소 내 순번(seq) - repoId: string // slug (라우팅용) - repoName: string + id: number // 프로젝트 내 순번(seq) + projectId: string // projectId (라우팅용) + projectName: string title: string status: TaskStatus dueDate: string | null diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts new file mode 100644 index 0000000..8c8ad0d --- /dev/null +++ b/frontend/src/types/project.ts @@ -0,0 +1,36 @@ +// 프로젝트 도메인 타입 — 백엔드 ProjectService 응답 / DTO 와 1:1 + +import type { Visibility } from '@/mock/relay.mock' +import type { ApiMember } from '@/types/repo' + +// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1 +export interface ApiProject { + id: string // UUID (라우팅 식별자) + name: string + description: string | null + visibility: Visibility + isSystem: boolean // "미분류" 시스템 프로젝트 + members: ApiMember[] + memberCount: number + repoCount: number // 연동 저장소 수 + doneCount: number + totalCount: number + updatedAt: string + // 단건 조회에서만 — 현재 사용자 권한 + canManage?: boolean // admin (설정 변경/탭 노출) + isOwner?: boolean // 소유자 (프로젝트 삭제) +} + +// 프로젝트 생성 요청 +export interface CreateProjectPayload { + name: string + description?: string + visibility?: Visibility +} + +// 프로젝트 수정 요청 +export interface UpdateProjectPayload { + name?: string + description?: string + visibility?: Visibility +} diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 6f47078..d0012e7 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -7,7 +7,7 @@ import type { Paginated } from '@/types/pagination' // 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1 export interface ApiRepoTask { - id: number // 저장소 내 순번(seq) + id: number // 프로젝트 내 순번(seq) title: string status: TaskStatus dueDate: string | null @@ -63,8 +63,8 @@ export interface ApiApproval { // 업무 상세(API 원시) — 백엔드 TaskDetailResponse 와 1:1 (activities 는 6단계) export interface ApiTaskDetail { id: number - repoId: string - repoName: string + projectId: string + projectName: string title: string status: TaskStatus content: string[]
{{ cloneUrl }}
+ 프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후 + 연동할 수 있습니다. +
- Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고 - 담당자에게 전달할 수 있습니다. -