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:
@@ -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>
|
||||
|
||||
<!-- 저장소를 찾지 못한 경우 -->
|
||||
|
||||
Reference in New Issue
Block a user