feat: 정렬·필터 서버 측 동작 구현 및 커스텀 드롭다운 적용

프로젝트 목록·업무 목록·내 업무에 정렬/검색/프로젝트 필터를 백엔드 쿼리 파라미터로 추가(페이지네이션과 일관). 활동 멤버 필터는 전량 로드 피드 기준 클라이언트 처리.
모든 정렬/필터 및 멤버 초대 역할 선택을 네이티브 select → 공통 DropdownSelect 컴포넌트로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:06:17 +09:00
parent 71040f94c8
commit 6767316cdb
21 changed files with 656 additions and 272 deletions
@@ -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<PaginatedResult<ProjectResponse>> {
return this.projectService.findAll(page, size);
return this.projectService.findAll(page, size, { sort, q });
}
@Post()
+13 -3
View File
@@ -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<PaginatedResult<ProjectResponse>> {
const pg = resolvePage(page, size);
const q = opts.q?.trim();
// 정렬: 이름순 / 최근 생성순 / 최근 업데이트순(기본)
const order: FindOptionsOrder<Project> =
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,
});
+24 -4
View File
@@ -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<PaginatedResult<MyTaskRowResponse>> {
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<PaginatedResult<MyTaskRowResponse>> {
return this.taskService.listIssued(user.id, page, size);
return this.taskService.listIssued(user.id, page, size, {
q,
projectId,
sort,
});
}
}
+2 -1
View File
@@ -72,10 +72,11 @@ export class TaskController {
@Query('segment') segment?: string,
@Query('q') q?: string,
@Query('assignee') assignee?: string,
@Query('sort') sort?: string,
): Promise<ProjectTaskListResult> {
return this.taskService.list(
projectId,
{ segment, q, assignee },
{ segment, q, assignee, sort },
page,
size,
);
+55 -15
View File
@@ -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<ProjectTaskListResult> {
@@ -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<Task>,
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<PaginatedResult<MyTaskRowResponse>> {
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<PaginatedResult<MyTaskRowResponse>> {
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();