diff --git a/backend/src/modules/activity/activity.controller.ts b/backend/src/modules/activity/activity.controller.ts index dace81c..7fb0516 100644 --- a/backend/src/modules/activity/activity.controller.ts +++ b/backend/src/modules/activity/activity.controller.ts @@ -1,11 +1,15 @@ import { Controller, + DefaultValuePipe, Get, Param, + ParseIntPipe, ParseUUIDPipe, + Query, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import type { PaginatedResult } from '../../common/pagination/pagination'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ActivityService, type ActivityResponse } from './activity.service'; @@ -17,11 +21,13 @@ export class ActivityController { constructor(private readonly activityService: ActivityService) {} @Get() - @ApiOperation({ summary: '프로젝트 활동 피드 (최신순)' }) + @ApiOperation({ summary: '프로젝트 활동 피드 (최신순, 페이지네이션)' }) @ApiResponse({ status: 200, description: '조회 성공' }) list( @Param('projectId', ParseUUIDPipe) projectId: string, - ): Promise { - return this.activityService.listForProject(projectId); + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.activityService.listForProject(projectId, page, size); } } diff --git a/backend/src/modules/activity/activity.service.ts b/backend/src/modules/activity/activity.service.ts index c9b720c..908c489 100644 --- a/backend/src/modules/activity/activity.service.ts +++ b/backend/src/modules/activity/activity.service.ts @@ -1,10 +1,18 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; import { UserService, type PublicUser } from '../user/user.service'; import { Project } from '../project/entities/project.entity'; import { Activity, type ActivityType } from './entities/activity.entity'; +// 업무 상세 활동 탭 조회 상한 — 매우 활발한 업무에서 전량 렌더링을 방지(최신 N건) +const TASK_ACTIVITY_LIMIT = 100; + // 활동 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립 export interface ActivityResponse { id: string; @@ -54,18 +62,29 @@ export class ActivityService { } } - // 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 최대 200건). 날짜 그룹핑은 프론트. - async listForProject(projectId: string): Promise { - const rows = await this.activityRepo.find({ + // 프로젝트 활동 피드 — projectId(uuid)로 조회(최신순, 오프셋 페이지네이션). 날짜 그룹핑은 프론트. + async listForProject( + projectId: string, + page?: number, + size?: number, + ): Promise> { + const pg = resolvePage(page, size); + const [rows, total] = await this.activityRepo.findAndCount({ where: { project: { id: projectId } }, relations: ['actor'], order: { createdAt: 'DESC' }, - take: 200, + skip: pg.skip, + take: pg.take, }); - return rows.map((a) => this.toResponse(a)); + return paginated( + rows.map((a) => this.toResponse(a)), + total, + pg, + ); } // 업무 활동 — projectId(uuid) + 업무 순번(seq) 기준(최신순). 업무 상세 활동 탭용. + // 전량 렌더링 방지를 위해 최신 TASK_ACTIVITY_LIMIT 건으로 제한한다. async listForTask( projectId: string, taskSeq: number, @@ -74,6 +93,7 @@ export class ActivityService { where: { project: { id: projectId }, taskSeq }, relations: ['actor'], order: { createdAt: 'DESC' }, + take: TASK_ACTIVITY_LIMIT, }); return rows.map((a) => this.toResponse(a)); } diff --git a/frontend/src/composables/useActivity.ts b/frontend/src/composables/useActivity.ts index 2d54c42..c7e5097 100644 --- a/frontend/src/composables/useActivity.ts +++ b/frontend/src/composables/useActivity.ts @@ -1,13 +1,20 @@ import { useApi } from './useApi' +import { DEFAULT_PAGE_SIZE, type Paginated } from '@/types/pagination' import type { ApiActivity } from '@/types/activity' // 활동 피드 API Composable — 인터셉터가 success/data 를 언래핑 export function useActivity() { const api = useApi() - // 프로젝트 활동 피드(최신순) - async function list(projectId: string): Promise { - return (await api.get(`/projects/${projectId}/activity`)) as unknown as ApiActivity[] + // 프로젝트 활동 피드(최신순, 페이지네이션) + async function list( + projectId: string, + page = 1, + size = DEFAULT_PAGE_SIZE, + ): Promise> { + return (await api.get(`/projects/${projectId}/activity`, { + params: { page, size }, + })) as unknown as Paginated } return { list } diff --git a/frontend/src/pages/relay/ProjectActivityPage.vue b/frontend/src/pages/relay/ProjectActivityPage.vue index 8a7b099..38a053b 100644 --- a/frontend/src/pages/relay/ProjectActivityPage.vue +++ b/frontend/src/pages/relay/ProjectActivityPage.vue @@ -242,6 +242,21 @@ const ActIcon = defineComponent({ + + +
+ +
@@ -373,6 +388,30 @@ const ActIcon = defineComponent({ margin-left: 0.75rem; } +/* 더 보기 */ +.more-row { + display: flex; + justify-content: center; + margin-top: 0.875rem; +} +.more-btn { + border: 1px solid var(--border); + background: var(--panel); + border-radius: 0.5rem; + padding: 0.5rem 1.25rem; + font-size: 0.813rem; + font-weight: 600; + color: var(--text-2); + cursor: pointer; +} +.more-btn:hover:not(:disabled) { + background: #f4f5f7; +} +.more-btn:disabled { + opacity: 0.6; + cursor: default; +} + /* 로딩/빈 상태 */ .feed-state { background: var(--panel); diff --git a/frontend/src/stores/activity.store.ts b/frontend/src/stores/activity.store.ts index 6b0cf44..4f98f81 100644 --- a/frontend/src/stores/activity.store.ts +++ b/frontend/src/stores/activity.store.ts @@ -1,6 +1,7 @@ -import { ref } from 'vue' +import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { useActivity } from '@/composables/useActivity' +import { DEFAULT_PAGE_SIZE } from '@/types/pagination' import { dayGroupKo, escapeHtml, @@ -116,33 +117,68 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem { } } -// 활동 스토어 — 프로젝트 활동 피드(날짜 그룹) +// 활동 스토어 — 프로젝트 활동 피드(페이지네이션 누적 → 날짜 그룹) export const useActivityStore = defineStore('activity', () => { - const days = ref([]) + const items = ref([]) + const total = ref(0) + const page = ref(0) + const currentProjectId = ref(null) const loading = ref(false) + const loadingMore = ref(false) const activityApi = useActivity() - // 프로젝트 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속) + // 더 불러올 페이지가 남았는지(누적 건수 < 전체) + const hasMore = computed(() => items.value.length < total.value) + + // 누적 items → 날짜 그룹(최신순이라 같은 날은 연속) + const days = computed(() => { + const groups: ProjectActivityDay[] = [] + let current: ProjectActivityDay | null = null + for (const a of items.value) { + const day = dayGroupKo(a.createdAt) + if (!current || current.day !== day) { + current = { day, items: [] } + groups.push(current) + } + current.items.push(toProjectActivityItem(a)) + } + return groups + }) + + // 첫 페이지(리셋) 로드 async function fetch(projectId: string): Promise { loading.value = true + currentProjectId.value = projectId try { - const list = await activityApi.list(projectId) - const groups: ProjectActivityDay[] = [] - let current: ProjectActivityDay | null = null - for (const a of list) { - const day = dayGroupKo(a.createdAt) - if (!current || current.day !== day) { - current = { day, items: [] } - groups.push(current) - } - current.items.push(toProjectActivityItem(a)) - } - days.value = groups + const res = await activityApi.list(projectId, 1, DEFAULT_PAGE_SIZE) + items.value = res.items + total.value = res.total + page.value = 1 } finally { loading.value = false } } - return { days, loading, fetch } + // 다음 페이지 이어붙이기(더보기) + async function loadMore(): Promise { + if (loadingMore.value || !hasMore.value || !currentProjectId.value) return + loadingMore.value = true + try { + const res = await activityApi.list( + currentProjectId.value, + page.value + 1, + DEFAULT_PAGE_SIZE, + ) + // 그 사이 신규 적재로 중복될 수 있는 id 는 제외하고 append + const known = new Set(items.value.map((x) => x.id)) + items.value.push(...res.items.filter((a) => !known.has(a.id))) + total.value = res.total + page.value += 1 + } finally { + loadingMore.value = false + } + } + + return { days, loading, loadingMore, hasMore, fetch, loadMore } })