feat: 저장소 업무 세그먼트·검색·더보기 + 멤버 전체 로드 (8-1 페이지네이션 마무리)

- 저장소 업무 목록(RepoDetailPage): 세그먼트/검색을 백엔드로 이전(클라 필터 제거),
  응답 counts 로 세그먼트 배지, 더보기(오프셋) 추가. task.store 에 total/counts/hasMore/loadMore.
- 멤버: member.store.fetchList 가 페이지네이션 API 를 끝까지 순회해 전체 로드
  (역할/검색 카운트 전수 + TaskCreatePage 담당자 후보 보장).
- 타입 신설: TaskSegment/TaskSegmentCounts/ApiRepoTaskListResult.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:12:21 +09:00
parent c128b19599
commit ef858f6e34
5 changed files with 140 additions and 51 deletions
+8 -6
View File
@@ -3,28 +3,30 @@ import type {
ApiAttachment,
ApiComment,
ApiCommentReply,
ApiRepoTask,
ApiRepoTaskListResult,
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
} from '@/types/task'
import type { TaskStatus } from '@/mock/relay.mock'
import type { Paginated } from '@/types/pagination'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
export function useTask() {
const api = useApi()
// 업무 목록 (상태/담당자 필터 선택) — 백엔드가 페이지네이션 { items, total, ... } 반환
// 업무 목록 (세그먼트/검색/담당자 필터 + 페이지네이션) — 응답에 세그먼트 카운트 동봉
async function list(
repoId: string,
query: TaskListQuery = {},
): Promise<Paginated<ApiRepoTask>> {
page = 1,
size = DEFAULT_PAGE_SIZE,
): Promise<ApiRepoTaskListResult> {
return (await api.get(`/repos/${repoId}/tasks`, {
params: query,
})) as unknown as Paginated<ApiRepoTask>
params: { ...query, page, size },
})) as unknown as ApiRepoTaskListResult
}
// 업무 단건 상세 (taskId = 저장소 내 순번)
+58 -35
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 저장소 상세 — 저장소 헤더(공통) + 진행률 + 서브탭(공통) + 업무 목록
import { computed, ref, watch } from 'vue'
// 세그먼트/검색은 백엔드에서 필터·카운트하고, 목록은 더보기(오프셋 페이지네이션)로 점진 로드한다.
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'
@@ -8,14 +10,14 @@ import RepoTabs from '@/components/RepoTabs.vue'
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
import { useRepoStore } from '@/stores/repo.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment } from '@/types/task'
import type { Repo, TaskStatus } from '@/mock/relay.mock'
type Segment = 'all' | 'prog' | 'todo' | 'done'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const taskStore = useTaskStore()
const { tasks, counts, hasMore, loadingMore, loading } = storeToRefs(taskStore)
// 저장소 헤더/진행률 — API 연동
const repo = ref<Repo | null>(null)
@@ -27,14 +29,34 @@ async function loadRepo() {
}
}
// 업무 목록 — API 연동
const keyword = ref('')
const segment = ref<TaskSegment>('all')
// 현재 세그먼트/검색 조건 → 목록 쿼리
function currentQuery(): TaskListQuery {
const q = keyword.value.trim()
return { segment: segment.value, ...(q ? { q } : {}) }
}
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
async function loadTasks() {
try {
await taskStore.fetchList(repoId.value)
await taskStore.fetchList(repoId.value, currentQuery())
} catch {
// 인터셉터가 알림 처리
}
}
// 더보기 — 같은 조건으로 다음 페이지 이어붙이기
async function loadMoreTasks() {
try {
await taskStore.loadMore(repoId.value, currentQuery())
} catch {
// 인터셉터가 알림 처리
}
}
// 저장소 전환 시 헤더 + 목록 리셋 로드
watch(
repoId,
() => {
@@ -44,37 +66,27 @@ watch(
{ immediate: true },
)
const tasks = computed(() => taskStore.tasks)
// 세그먼트 변경 시 목록만 리셋 로드(저장소 재조회 불필요)
watch(segment, loadTasks)
// 검색어는 디바운스 후 리셋 로드(연속 입력 시 호출 폭주 방지)
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadTasks, 250)
})
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
)
const keyword = ref('')
const segment = ref<Segment>('all')
// 빈 목록이 '검색/세그먼트로 걸러져 비었는지' 판별(빈 상태 문구 분기)
const isFiltered = computed(() => keyword.value.trim() !== '' || segment.value !== 'all')
// 세그먼트별 분류 규칙
function inSegment(status: TaskStatus, seg: Segment): boolean {
if (seg === 'all') return true
if (seg === 'prog') return status === 'prog' || status === 'review'
if (seg === 'todo') return status === 'todo'
return status === 'done'
}
const counts = computed(() => ({
all: tasks.value.length,
prog: tasks.value.filter((t) => inSegment(t.status, 'prog')).length,
todo: tasks.value.filter((t) => inSegment(t.status, 'todo')).length,
done: tasks.value.filter((t) => inSegment(t.status, 'done')).length,
}))
const filteredTasks = computed(() =>
tasks.value.filter(
(t) =>
inSegment(t.status, segment.value) && (!keyword.value || t.title.includes(keyword.value)),
),
)
// 지연 건수
// 지연 건수 — 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원, 알려진 한계)
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
// 업무 상태 → 배지 라벨
@@ -234,23 +246,23 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
<!-- 업무 목록 -->
<div
v-if="taskStore.loading && tasks.length === 0"
v-if="loading && tasks.length === 0"
class="state-msg"
>
업무를 불러오는
</div>
<div
v-else-if="filteredTasks.length === 0"
v-else-if="tasks.length === 0"
class="state-msg"
>
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
{{ isFiltered ? '조건에 맞는 업무가 없습니다.' : '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' }}
</div>
<div
v-else
class="list"
>
<RouterLink
v-for="task in filteredTasks"
v-for="task in tasks"
:key="task.id"
class="task-row"
:class="{ 'is-done': task.status === 'done' }"
@@ -388,6 +400,17 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</span>
</RouterLink>
</div>
<!-- 더보기 -->
<button
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="loadMoreTasks()"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
</div>
<!-- 저장소를 찾지 못한 경우 -->
+13 -3
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { useMember } from '@/composables/useMember'
import { useAuthStore } from '@/stores/auth.store'
import { avatarColor, initialOf } from '@/shared/utils/format'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import type {
ApiRepoMember,
InviteMemberPayload,
@@ -41,12 +42,21 @@ export const useMemberStore = defineStore('member', () => {
return authStore.user?.id ?? null
}
// 멤버 목록 조회
// 멤버 목록 조회 — 백엔드는 페이지네이션이지만, 역할/검색 카운트와 담당자 후보 풀
// (TaskCreatePage)이 전수 기준이어야 하므로 모든 페이지를 순회해 전체를 로드한다.
// (멤버는 팀 규모로 한정적이라 전체 로드가 안전하다)
async function fetchList(repoId: string): Promise<void> {
loading.value = true
try {
const { items } = await memberApi.list(repoId)
members.value = items.map((m) => toMemberView(m, currentUserId()))
const acc: ApiRepoMember[] = []
let page = 1
for (;;) {
const { items, total } = await memberApi.list(repoId, page, DEFAULT_PAGE_SIZE)
acc.push(...items)
if (items.length === 0 || acc.length >= total) break
page += 1
}
members.value = acc.map((m) => toMemberView(m, currentUserId()))
} finally {
loading.value = false
}
+41 -5
View File
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useTask } from '@/composables/useTask'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import {
avatarColor,
escapeHtml,
@@ -21,6 +22,7 @@ import type {
ApiTaskDetail,
CreateTaskPayload,
TaskListQuery,
TaskSegmentCounts,
UpdateTaskPayload,
} from '@/types/task'
import type { ApiActivity } from '@/types/activity'
@@ -200,24 +202,51 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
}
// 업무 스토어 — 저장소 업무 목록 + 현재 상세 + CRUD/상태 액션
// 빈 세그먼트 카운트(초기값)
const EMPTY_COUNTS: TaskSegmentCounts = { all: 0, prog: 0, todo: 0, done: 0 }
export const useTaskStore = defineStore('task', () => {
const tasks = ref<RepoTaskView[]>([])
const current = ref<TaskDetailView | null>(null)
const loading = ref(false)
// 목록 페이지네이션(더보기) + 세그먼트 카운트 상태
const total = ref(0)
const page = ref(0)
const counts = ref<TaskSegmentCounts>({ ...EMPTY_COUNTS })
const loadingMore = ref(false)
const hasMore = computed(() => tasks.value.length < total.value)
const taskApi = useTask()
// 목록 조회
// 목록 첫 페이지 조회(리셋) — 세그먼트/검색은 백엔드에서 필터, 카운트도 응답에서 수신
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
loading.value = true
try {
const { items } = await taskApi.list(repoId, query)
tasks.value = items.map(toRepoTaskView)
const res = await taskApi.list(repoId, query, 1, DEFAULT_PAGE_SIZE)
tasks.value = res.items.map(toRepoTaskView)
total.value = res.total
counts.value = res.counts
page.value = 1
} finally {
loading.value = false
}
}
// 다음 페이지 이어붙이기(더보기) — 같은 세그먼트/검색 조건 유지
async function loadMore(repoId: string, query: TaskListQuery = {}): Promise<void> {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
const res = await taskApi.list(repoId, query, page.value + 1, DEFAULT_PAGE_SIZE)
tasks.value.push(...res.items.map(toRepoTaskView))
total.value = res.total
counts.value = res.counts
page.value += 1
} finally {
loadingMore.value = false
}
}
// 단건 상세 조회
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
loading.value = true
@@ -247,10 +276,12 @@ export const useTaskStore = defineStore('task', () => {
return view
}
// 삭제
// 삭제 — 목록에서 제외하고 총계도 보정(세그먼트 카운트는 목록 재진입 시 재조회로 갱신)
async function remove(repoId: string, taskId: number): Promise<void> {
await taskApi.remove(repoId, 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)
if (current.value?.id === taskId) current.value = null
}
@@ -345,7 +376,12 @@ export const useTaskStore = defineStore('task', () => {
tasks,
current,
loading,
total,
counts,
hasMore,
loadingMore,
fetchList,
loadMore,
fetchOne,
create,
update,
+20 -2
View File
@@ -3,6 +3,7 @@
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { ApiActivity } from '@/types/activity'
import type { Paginated } from '@/types/pagination'
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
export interface ApiRepoTask {
@@ -79,12 +80,29 @@ export interface ApiTaskDetail {
changesNote: string | null
}
// 업무 목록 필터
// 업무 목록 세그먼트(상태 묶음) — 진행 중 = prog+review
export type TaskSegment = 'all' | 'prog' | 'todo' | 'done'
// 세그먼트별 카운트(검색어 반영) — RepoDetailPage 세그먼트 배지용. 백엔드 TaskSegmentCounts 와 1:1
export interface TaskSegmentCounts {
all: number
prog: number
todo: number
done: number
}
// 업무 목록 필터 — 세그먼트/검색/담당자 (백엔드 list 쿼리와 1:1)
export interface TaskListQuery {
status?: TaskStatus
segment?: TaskSegment
q?: string
assignee?: string
}
// 업무 목록 응답 — 페이지네이션 + 세그먼트 카운트 동봉 (백엔드 RepoTaskListResult 와 1:1)
export type ApiRepoTaskListResult = Paginated<ApiRepoTask> & {
counts: TaskSegmentCounts
}
// 업무 생성 요청
export interface CreateTaskPayload {
title: string