feat: 정렬·필터 서버 측 동작 구현 및 커스텀 드롭다운 적용
프로젝트 목록·업무 목록·내 업무에 정렬/검색/프로젝트 필터를 백엔드 쿼리 파라미터로 추가(페이지네이션과 일관). 활동 멤버 필터는 전량 로드 피드 기준 클라이언트 처리. 모든 정렬/필터 및 멤버 초대 역할 선택을 네이티브 select → 공통 DropdownSelect 컴포넌트로 통일. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user