diff --git a/backend/src/modules/project/project.controller.ts b/backend/src/modules/project/project.controller.ts index 25f3cbc..6f2f431 100644 --- a/backend/src/modules/project/project.controller.ts +++ b/backend/src/modules/project/project.controller.ts @@ -36,8 +36,10 @@ export class ProjectController { findAll( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + @Query('sort') sort?: string, + @Query('q') q?: string, ): Promise> { - return this.projectService.findAll(page, size); + return this.projectService.findAll(page, size, { sort, q }); } @Post() diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index de92888..0b9d7d9 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { ILike, Repository, type FindOptionsOrder } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { paginated, @@ -45,15 +45,25 @@ export class ProjectService { private readonly userService: UserService, ) {} - // 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션. + // 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 검색/정렬 + 페이지네이션. async findAll( page?: number, size?: number, + opts: { sort?: string; q?: string } = {}, ): Promise> { const pg = resolvePage(page, size); + const q = opts.q?.trim(); + // 정렬: 이름순 / 최근 생성순 / 최근 업데이트순(기본) + const order: FindOptionsOrder = + opts.sort === 'name' + ? { name: 'ASC' } + : opts.sort === 'created' + ? { createdAt: 'DESC' } + : { updatedAt: 'DESC' }; const [projects, total] = await this.projectRepo.findAndCount({ relations: ['members', 'members.user'], - order: { updatedAt: 'DESC' }, + where: q ? { name: ILike(`%${q}%`) } : {}, + order, skip: pg.skip, take: pg.take, }); diff --git a/backend/src/modules/task/my-task.controller.ts b/backend/src/modules/task/my-task.controller.ts index 0f704f3..82662ef 100644 --- a/backend/src/modules/task/my-task.controller.ts +++ b/backend/src/modules/task/my-task.controller.ts @@ -21,24 +21,44 @@ export class MyTaskController { constructor(private readonly taskService: TaskService) {} @Get('assigned') - @ApiOperation({ summary: '내가 담당인 업무 (프로젝트 횡단, 페이지네이션)' }) + @ApiOperation({ + summary: + '내가 담당인 업무 (프로젝트 횡단, 검색/프로젝트/정렬 + 페이지네이션)', + }) @ApiResponse({ status: 200, description: '조회 성공' }) listAssigned( @CurrentUser() user: PublicUser, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + @Query('q') q?: string, + @Query('projectId') projectId?: string, + @Query('sort') sort?: string, ): Promise> { - return this.taskService.listAssigned(user.id, page, size); + return this.taskService.listAssigned(user.id, page, size, { + q, + projectId, + sort, + }); } @Get('issued') - @ApiOperation({ summary: '내가 지시한 업무 (프로젝트 횡단, 페이지네이션)' }) + @ApiOperation({ + summary: + '내가 지시한 업무 (프로젝트 횡단, 검색/프로젝트/정렬 + 페이지네이션)', + }) @ApiResponse({ status: 200, description: '조회 성공' }) listIssued( @CurrentUser() user: PublicUser, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + @Query('q') q?: string, + @Query('projectId') projectId?: string, + @Query('sort') sort?: string, ): Promise> { - return this.taskService.listIssued(user.id, page, size); + return this.taskService.listIssued(user.id, page, size, { + q, + projectId, + sort, + }); } } diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index df159e3..e93465f 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -72,10 +72,11 @@ export class TaskController { @Query('segment') segment?: string, @Query('q') q?: string, @Query('assignee') assignee?: string, + @Query('sort') sort?: string, ): Promise { return this.taskService.list( projectId, - { segment, q, assignee }, + { segment, q, assignee, sort }, page, size, ); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index e2e5689..43765a3 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, IsNull, Repository } from 'typeorm'; +import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { Project } from '../project/entities/project.entity'; @@ -198,7 +198,7 @@ export class TaskService { // 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트 async list( projectId: string, - filters: { segment?: string; q?: string; assignee?: string }, + filters: { segment?: string; q?: string; assignee?: string; sort?: string }, page?: number, size?: number, ): Promise { @@ -239,12 +239,24 @@ export class TaskService { ); } - // 정렬은 메인 엔티티(task.seq) 기준만 사용한다. + // 정렬은 메인 엔티티(task.seq/task.dueDate) 컬럼만 사용한다. // skip/take(페이지네이션)와 to-many 조인을 함께 쓰면 TypeORM 이 DISTINCT id 서브쿼리로 // 페이징하는데, 이때 조인된 to-many 컬럼(checklist.sort_order)으로 ORDER BY 하면 // 컬럼 메타데이터를 찾지 못해 터진다(createOrderByCombinedWithSelectExpression). - // 목록 응답은 체크리스트의 [완료/전체] 수만 쓰므로 항목 순서가 필요 없다. - qb.orderBy('task.seq', 'DESC'); + // 따라서 마감/순번 등 메인 엔티티 컬럼으로만 정렬한다. + const sort = filters.sort ?? 'latest'; + if (sort === 'due') { + // 마감 임박순(마감 없음은 뒤로) → 동률은 최신 순번 + qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy( + 'task.seq', + 'DESC', + ); + } else if (sort === 'oldest') { + qb.orderBy('task.seq', 'ASC'); + } else { + // 최신순(기본) + qb.orderBy('task.seq', 'DESC'); + } const [tasks, total] = await qb .skip(pg.skip) @@ -930,14 +942,41 @@ export class TaskService { // --- 7단계: 내 업무(프로젝트 횡단 집계) --- - // 내가 담당인 업무 — 마감 임박순(마감 없음은 뒤로), 오프셋 페이지네이션 + // 내 업무 공통 필터/정렬 적용 — 검색(q)·프로젝트(projectId)·정렬(sort) + // 정렬은 메인 엔티티 컬럼만 사용(to-many 조인 + skip/take 제약). + private applyMyTaskFilters( + qb: SelectQueryBuilder, + filters: { q?: string; projectId?: string; sort?: string }, + ): void { + const q = filters.q?.trim(); + if (q) { + qb.andWhere('task.title ILIKE :q', { q: `%${q}%` }); + } + if (filters.projectId) { + qb.andWhere('project.id = :pid', { pid: filters.projectId }); + } + const sort = filters.sort ?? 'due'; + if (sort === 'recent') { + // 최근 등록순(프로젝트 횡단이라 seq 대신 생성시각 기준) + qb.orderBy('task.createdAt', 'DESC'); + } else { + // 마감 임박순(기본, 마감 없음은 뒤로) + qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy( + 'task.seq', + 'DESC', + ); + } + } + + // 내가 담당인 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션 async listAssigned( userId: string, page?: number, size?: number, + filters: { q?: string; projectId?: string; sort?: string } = {}, ): Promise> { const pg = resolvePage(page, size); - const [tasks, total] = await this.taskRepo + const qb = this.taskRepo .createQueryBuilder('task') .leftJoinAndSelect('task.project', 'project') .leftJoinAndSelect('task.assignees', 'assignee') @@ -953,9 +992,9 @@ export class TaskService { .where('ta.user_id = :userId') .getQuery(), ) - .setParameter('userId', userId) - .orderBy('task.dueDate', 'ASC', 'NULLS LAST') - .addOrderBy('task.seq', 'DESC') + .setParameter('userId', userId); + this.applyMyTaskFilters(qb, filters); + const [tasks, total] = await qb .skip(pg.skip) .take(pg.take) .getManyAndCount(); @@ -966,22 +1005,23 @@ export class TaskService { ); } - // 내가 지시(생성)한 업무 — 마감 임박순, 오프셋 페이지네이션 + // 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션 async listIssued( userId: string, page?: number, size?: number, + filters: { q?: string; projectId?: string; sort?: string } = {}, ): Promise> { const pg = resolvePage(page, size); - const [tasks, total] = await this.taskRepo + const qb = this.taskRepo .createQueryBuilder('task') .leftJoinAndSelect('task.project', 'project') .leftJoinAndSelect('task.assignees', 'assignee') .leftJoinAndSelect('task.issuer', 'issuer') .leftJoinAndSelect('task.checklist', 'checklist') - .where('task.issuer = :userId', { userId }) - .orderBy('task.dueDate', 'ASC', 'NULLS LAST') - .addOrderBy('task.seq', 'DESC') + .where('task.issuer = :userId', { userId }); + this.applyMyTaskFilters(qb, filters); + const [tasks, total] = await qb .skip(pg.skip) .take(pg.take) .getManyAndCount(); diff --git a/frontend/src/assets/styles/relay.css b/frontend/src/assets/styles/relay.css index fc05602..18605c6 100644 --- a/frontend/src/assets/styles/relay.css +++ b/frontend/src/assets/styles/relay.css @@ -402,27 +402,7 @@ body { color: var(--accent); } -.sort { - margin-left: auto; - display: flex; - align-items: center; - gap: 0.375rem; - height: 2.25rem; - border: 1px solid var(--border-strong); - border-radius: var(--radius); - padding: 0 0.6875rem; - background: #fff; - font-size: 0.8125rem; - font-weight: 500; - color: var(--text-2); - cursor: pointer; - white-space: nowrap; -} -.sort svg { - width: 0.9375rem; - height: 0.9375rem; - color: var(--text-3); -} +/* 정렬/필터 드롭다운은 공통 컴포넌트 DropdownSelect 로 대체(자체 스타일 보유). */ /* ============================================================ * 9. 카드 / 목록 컨테이너 diff --git a/frontend/src/components/DropdownSelect.vue b/frontend/src/components/DropdownSelect.vue new file mode 100644 index 0000000..f16160d --- /dev/null +++ b/frontend/src/components/DropdownSelect.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/composables/useMyTask.ts b/frontend/src/composables/useMyTask.ts index 36ccf9b..3493e19 100644 --- a/frontend/src/composables/useMyTask.ts +++ b/frontend/src/composables/useMyTask.ts @@ -1,22 +1,30 @@ import { useApi } from './useApi' -import type { ApiMyTaskRow } from '@/types/my-task' +import type { ApiMyTaskRow, MyTaskListQuery } from '@/types/my-task' import type { Paginated } from '@/types/pagination' -// 내 업무 API Composable — 프로젝트 횡단 집계(담당/지시), 오프셋 페이지네이션 +// 내 업무 API Composable — 프로젝트 횡단 집계(담당/지시), 검색/프로젝트/정렬 + 오프셋 페이지네이션 export function useMyTask() { const api = useApi() // 내가 담당인 업무 - async function assigned(page = 1, size = 20): Promise> { + async function assigned( + page = 1, + size = 20, + query: MyTaskListQuery = {}, + ): Promise> { return (await api.get('/tasks/assigned', { - params: { page, size }, + params: { ...query, page, size }, })) as unknown as Paginated } // 내가 지시한 업무 - async function issued(page = 1, size = 20): Promise> { + async function issued( + page = 1, + size = 20, + query: MyTaskListQuery = {}, + ): Promise> { return (await api.get('/tasks/issued', { - params: { page, size }, + params: { ...query, page, size }, })) as unknown as Paginated } diff --git a/frontend/src/composables/useProject.ts b/frontend/src/composables/useProject.ts index 4798914..b52346b 100644 --- a/frontend/src/composables/useProject.ts +++ b/frontend/src/composables/useProject.ts @@ -2,6 +2,7 @@ import { useApi } from './useApi' import type { ApiProject, CreateProjectPayload, + ProjectListQuery, UpdateProjectPayload, } from '@/types/project' import type { Paginated } from '@/types/pagination' @@ -10,9 +11,14 @@ import type { Paginated } from '@/types/pagination' export function useProject() { const api = useApi() - async function list(page = 1, size = 20): Promise> { + // 목록(검색/정렬 + 페이지네이션) + async function list( + page = 1, + size = 20, + query: ProjectListQuery = {}, + ): Promise> { return (await api.get('/projects', { - params: { page, size }, + params: { ...query, page, size }, })) as unknown as Paginated } diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 1aa6ef8..518e6c5 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -231,6 +231,10 @@ export interface ProjectActivityItem { icon: ActivityIcon /** 행위자 프로필 이미지 URL — 없으면 placeholder */ avatarUrl?: string | null + /** 행위자 식별자(멤버 필터용) — 행위자 없는 활동이면 null */ + actorId?: string | null + /** 행위자 표시 이름(멤버 필터 드롭다운 라벨용) */ + actorName?: string | null /** 본문 HTML(내부 생성, 신뢰 가능 마크업) */ html: string time: string diff --git a/frontend/src/pages/relay/MyTasksPage.vue b/frontend/src/pages/relay/MyTasksPage.vue index bdc7ba2..4be4bb8 100644 --- a/frontend/src/pages/relay/MyTasksPage.vue +++ b/frontend/src/pages/relay/MyTasksPage.vue @@ -1,18 +1,68 @@