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
+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>
<!-- 저장소를 찾지 못한 경우 -->