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