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();
|
||||
|
||||
@@ -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. 카드 / 목록 컨테이너
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
|
||||
// 사용처: 프로젝트 목록·업무 목록 정렬, 내 업무 프로젝트/정렬, 활동 멤버 필터.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
interface Option {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
options: Option[]
|
||||
// 메뉴 정렬 기준(트리거 좌/우 끝)
|
||||
align?: 'left' | 'right'
|
||||
// 툴바에서 오른쪽으로 밀어 붙일지(기존 margin-left:auto 대체)
|
||||
pushRight?: boolean
|
||||
// 폼 필드처럼 전체 너비로 펼칠지(라벨이 늘어나고 chevron 이 오른쪽 끝에 붙음)
|
||||
block?: boolean
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{ align: 'left', pushRight: false, block: false, ariaLabel: undefined },
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
// 현재 선택된 옵션 라벨(트리거에 표시)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
|
||||
)
|
||||
|
||||
function toggle(): void {
|
||||
open.value = !open.value
|
||||
}
|
||||
function select(value: T): void {
|
||||
emit('update:modelValue', value)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
// 바깥 클릭 / Esc 로 닫기
|
||||
function onDocClick(e: MouseEvent): void {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (open.value && e.key === 'Escape') open.value = false
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="dd"
|
||||
:class="{ push: pushRight, block }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dd-trigger"
|
||||
:class="{ open }"
|
||||
:aria-label="ariaLabel"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- 선행 아이콘(사용처별 SVG) -->
|
||||
<span
|
||||
v-if="$slots.icon"
|
||||
class="dd-ico"
|
||||
><slot name="icon" /></span>
|
||||
<span class="dd-label">{{ selectedLabel }}</span>
|
||||
<svg
|
||||
class="dd-chev"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
v-if="open"
|
||||
class="dd-menu"
|
||||
:class="align === 'right' ? 'right' : 'left'"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
class="dd-opt"
|
||||
:class="{ active: opt.value === modelValue }"
|
||||
role="option"
|
||||
:aria-selected="opt.value === modelValue"
|
||||
@click="select(opt.value)"
|
||||
>
|
||||
<span class="dd-opt-label">{{ opt.label }}</span>
|
||||
<svg
|
||||
v-if="opt.value === modelValue"
|
||||
class="dd-check"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dd {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.dd.push {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 폼 필드용 전체 너비 변형 */
|
||||
.dd.block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.dd.block .dd-trigger {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.dd.block .dd-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 트리거 — 기존 정렬/필터 pill 과 동일한 외형 */
|
||||
.dd-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2.25rem;
|
||||
max-width: 14rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.625rem;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dd-trigger:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.dd-trigger.open {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.dd-ico {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.dd-ico :deep(svg) {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.dd-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.dd-chev {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.dd-trigger.open .dd-chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 메뉴 */
|
||||
.dd-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
z-index: 60;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.14);
|
||||
}
|
||||
.dd-menu.left {
|
||||
left: 0;
|
||||
}
|
||||
.dd-menu.right {
|
||||
right: 0;
|
||||
}
|
||||
.dd-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.4375rem 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dd-opt:hover {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.dd-opt.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.dd-opt-label {
|
||||
flex: 1;
|
||||
}
|
||||
.dd-check {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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<Paginated<ApiMyTaskRow>> {
|
||||
async function assigned(
|
||||
page = 1,
|
||||
size = 20,
|
||||
query: MyTaskListQuery = {},
|
||||
): Promise<Paginated<ApiMyTaskRow>> {
|
||||
return (await api.get('/tasks/assigned', {
|
||||
params: { page, size },
|
||||
params: { ...query, page, size },
|
||||
})) as unknown as Paginated<ApiMyTaskRow>
|
||||
}
|
||||
|
||||
// 내가 지시한 업무
|
||||
async function issued(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
|
||||
async function issued(
|
||||
page = 1,
|
||||
size = 20,
|
||||
query: MyTaskListQuery = {},
|
||||
): Promise<Paginated<ApiMyTaskRow>> {
|
||||
return (await api.get('/tasks/issued', {
|
||||
params: { page, size },
|
||||
params: { ...query, page, size },
|
||||
})) as unknown as Paginated<ApiMyTaskRow>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Paginated<ApiProject>> {
|
||||
// 목록(검색/정렬 + 페이지네이션)
|
||||
async function list(
|
||||
page = 1,
|
||||
size = 20,
|
||||
query: ProjectListQuery = {},
|
||||
): Promise<Paginated<ApiProject>> {
|
||||
return (await api.get('/projects', {
|
||||
params: { page, size },
|
||||
params: { ...query, page, size },
|
||||
})) as unknown as Paginated<ApiProject>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,18 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록 (API 연동)
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 검색/프로젝트/정렬은 서버에서 처리(담당·지시 양쪽에 적용, 페이지네이션과 일관)
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import { useMyTaskStore } from '@/stores/my-task.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
import type { MyTaskListQuery, MyTaskSort } from '@/types/my-task'
|
||||
|
||||
type View = 'assigned' | 'issued'
|
||||
const view = ref<View>('assigned')
|
||||
|
||||
// 검색/프로젝트/정렬 필터
|
||||
const keyword = ref('')
|
||||
const projectFilter = ref<string>('all')
|
||||
const sort = ref<MyTaskSort>('due')
|
||||
const SORT_OPTIONS: { value: MyTaskSort; label: string }[] = [
|
||||
{ value: 'due', label: '마감 임박순' },
|
||||
{ value: 'recent', label: '최근 등록순' },
|
||||
]
|
||||
|
||||
const myTaskStore = useMyTaskStore()
|
||||
|
||||
// 프로젝트 필터 드롭다운 옵션 — 프로젝트 목록을 불러와 채운다('모든 프로젝트' + 각 프로젝트)
|
||||
const projectStore = useProjectStore()
|
||||
const { projects } = storeToRefs(projectStore)
|
||||
const projectOptions = computed(() => [
|
||||
{ value: 'all', label: '모든 프로젝트' },
|
||||
...projects.value.map((p) => ({ value: p.id, label: p.name })),
|
||||
])
|
||||
|
||||
// 현재 검색/프로젝트/정렬 조건 → 내 업무 쿼리
|
||||
function currentQuery(): MyTaskListQuery {
|
||||
const q = keyword.value.trim()
|
||||
return {
|
||||
...(q ? { q } : {}),
|
||||
...(projectFilter.value !== 'all' ? { projectId: projectFilter.value } : {}),
|
||||
sort: sort.value,
|
||||
}
|
||||
}
|
||||
function loadAll() {
|
||||
void myTaskStore.fetchAll(currentQuery())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void myTaskStore.fetchAll()
|
||||
void projectStore.fetchList()
|
||||
loadAll()
|
||||
})
|
||||
|
||||
// 프로젝트/정렬 변경 시 즉시 재조회
|
||||
watch([projectFilter, sort], loadAll)
|
||||
|
||||
// 검색어는 디바운스 후 재조회(연속 입력 시 호출 폭주 방지)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(keyword, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadAll, 250)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
|
||||
@@ -95,47 +145,51 @@ function loadMore() {
|
||||
/><path d="m21 21-4-4" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="업무 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="repofilter">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
모든 프로젝트
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-model="projectFilter"
|
||||
:options="projectOptions"
|
||||
align="left"
|
||||
aria-label="프로젝트 필터"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</template>
|
||||
</DropdownSelect>
|
||||
<DropdownSelect
|
||||
v-model="sort"
|
||||
:options="SORT_OPTIONS"
|
||||
push-right
|
||||
align="right"
|
||||
aria-label="정렬"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
</template>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
|
||||
<!-- 승인 대기 강조 배너 (지시 뷰) -->
|
||||
@@ -459,27 +513,6 @@ function loadMore() {
|
||||
.toolbar .search {
|
||||
width: 16.25rem;
|
||||
}
|
||||
.repofilter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
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;
|
||||
}
|
||||
.repofilter svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 승인 대기 강조 배너 */
|
||||
.pending-note {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Project, ActivityIcon } from '@/mock/relay.mock'
|
||||
@@ -34,13 +35,35 @@ async function load() {
|
||||
watch(projectId, load, { immediate: true })
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
// 멤버 필터(행위자 id, 'all' = 전체). 피드는 전량 로드되므로 클라이언트에서 정확히 거른다.
|
||||
const memberFilter = ref<string>('all')
|
||||
|
||||
// 필터 적용 후 빈 날짜 그룹은 제거
|
||||
// 피드에 등장한 행위자 목록(중복 제거) — 멤버 필터 드롭다운 옵션
|
||||
const actorOptions = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const d of activityStore.days) {
|
||||
for (const it of d.items) {
|
||||
if (it.actorId) map.set(it.actorId, it.actorName ?? '알 수 없음')
|
||||
}
|
||||
}
|
||||
return Array.from(map, ([id, name]) => ({ id, name }))
|
||||
})
|
||||
// 멤버 필터 드롭다운 옵션('모든 멤버' + 행위자들)
|
||||
const memberOptions = computed(() => [
|
||||
{ value: 'all', label: '모든 멤버' },
|
||||
...actorOptions.value.map((a) => ({ value: a.id, label: a.name })),
|
||||
])
|
||||
|
||||
// 유형/멤버 필터 적용 후 빈 날짜 그룹은 제거
|
||||
const days = computed(() =>
|
||||
activityStore.days
|
||||
.map((d) => ({
|
||||
day: d.day,
|
||||
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
|
||||
items: d.items.filter(
|
||||
(it) =>
|
||||
(filter.value === 'all' || it.tone === filter.value) &&
|
||||
(memberFilter.value === 'all' || it.actorId === memberFilter.value),
|
||||
),
|
||||
}))
|
||||
.filter((d) => d.items.length > 0),
|
||||
)
|
||||
@@ -142,33 +165,30 @@ const ActIcon = defineComponent({
|
||||
설정
|
||||
</button>
|
||||
</div>
|
||||
<div class="mfilter">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="12"
|
||||
cy="7"
|
||||
r="4"
|
||||
/>
|
||||
</svg>
|
||||
모든 멤버
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-model="memberFilter"
|
||||
:options="memberOptions"
|
||||
push-right
|
||||
align="right"
|
||||
aria-label="멤버 필터"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="12"
|
||||
cy="7"
|
||||
r="4"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
@@ -237,27 +257,6 @@ const ActIcon = defineComponent({
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.mfilter {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
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;
|
||||
}
|
||||
.mfilter svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 활동 피드 */
|
||||
.feed {
|
||||
|
||||
@@ -8,9 +8,10 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskListQuery, TaskSegment } from '@/types/task'
|
||||
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
|
||||
import type { Project, TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -31,11 +32,17 @@ async function loadProject() {
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<TaskSegment>('all')
|
||||
const sort = ref<TaskSort>('latest')
|
||||
const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'due', label: '마감 임박순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
]
|
||||
|
||||
// 현재 세그먼트/검색 조건 → 목록 쿼리
|
||||
// 현재 세그먼트/검색/정렬 조건 → 목록 쿼리
|
||||
function currentQuery(): TaskListQuery {
|
||||
const q = keyword.value.trim()
|
||||
return { segment: segment.value, ...(q ? { q } : {}) }
|
||||
return { segment: segment.value, sort: sort.value, ...(q ? { q } : {}) }
|
||||
}
|
||||
|
||||
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
|
||||
@@ -66,8 +73,8 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 세그먼트 변경 시 목록만 리셋 로드(프로젝트 재조회 불필요)
|
||||
watch(segment, loadTasks)
|
||||
// 세그먼트/정렬 변경 시 목록만 리셋 로드(프로젝트 재조회 불필요)
|
||||
watch([segment, sort], loadTasks)
|
||||
|
||||
// 검색어는 디바운스 후 리셋 로드(연속 입력 시 호출 폭주 방지)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -207,19 +214,26 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
완료 <span class="n">{{ counts.done }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-model="sort"
|
||||
:options="SORT_OPTIONS"
|
||||
push-right
|
||||
align="right"
|
||||
aria-label="정렬"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
</template>
|
||||
</DropdownSelect>
|
||||
<RouterLink
|
||||
class="btn primary new-task"
|
||||
:to="`/projects/${repo.id}/tasks/new`"
|
||||
@@ -471,7 +485,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
width: 15rem;
|
||||
}
|
||||
.new-task {
|
||||
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
|
||||
/* 정렬 드롭다운(push-right)의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
|
||||
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 목록 — 검색/필터하여 목록으로 표시
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// 프로젝트 목록 — 검색/정렬은 서버에서 처리(페이지네이션과 일관)
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { ProjectListQuery, ProjectSort } from '@/types/project'
|
||||
|
||||
const keyword = ref('')
|
||||
const sort = ref<ProjectSort>('recent')
|
||||
const SORT_OPTIONS: { value: ProjectSort; label: string }[] = [
|
||||
{ value: 'recent', label: '최근 업데이트순' },
|
||||
{ value: 'name', label: '이름순' },
|
||||
{ value: 'created', label: '최근 생성순' },
|
||||
]
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const { projects, loading, total, hasMore, loadingMore } = storeToRefs(projectStore)
|
||||
|
||||
onMounted(() => {
|
||||
void projectStore.fetchList()
|
||||
// 현재 검색/정렬 조건 → 목록 쿼리(서버에서 필터·정렬)
|
||||
function currentQuery(): ProjectListQuery {
|
||||
const q = keyword.value.trim()
|
||||
return { sort: sort.value, ...(q ? { q } : {}) }
|
||||
}
|
||||
function loadList() {
|
||||
void projectStore.fetchList(currentQuery())
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
|
||||
// 정렬 변경 시 즉시 재조회
|
||||
watch(sort, loadList)
|
||||
|
||||
// 검색어는 디바운스 후 재조회(연속 입력 시 호출 폭주 방지)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(keyword, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadList, 250)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
})
|
||||
|
||||
// 검색어 필터 적용
|
||||
const filteredProjects = computed(() =>
|
||||
projects.value.filter(
|
||||
(project) => !keyword.value || project.name.includes(keyword.value),
|
||||
),
|
||||
)
|
||||
// 검색어가 있으면 '검색 결과 없음', 없으면 '프로젝트 없음' 문구 분기
|
||||
const isFiltered = computed(() => keyword.value.trim() !== '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -61,19 +85,26 @@ const filteredProjects = computed(() =>
|
||||
placeholder="프로젝트 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="sort">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
최근 업데이트순
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-model="sort"
|
||||
:options="SORT_OPTIONS"
|
||||
push-right
|
||||
align="right"
|
||||
aria-label="정렬"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
|
||||
</svg>
|
||||
</template>
|
||||
</DropdownSelect>
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
to="/projects/new"
|
||||
@@ -97,7 +128,7 @@ const filteredProjects = computed(() =>
|
||||
<!-- 목록 -->
|
||||
<div class="list">
|
||||
<RouterLink
|
||||
v-for="repo in filteredProjects"
|
||||
v-for="repo in projects"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
:to="`/projects/${repo.id}`"
|
||||
@@ -179,10 +210,10 @@ const filteredProjects = computed(() =>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredProjects.length === 0"
|
||||
v-else-if="projects.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
{{ projects.length === 0 ? '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
{{ isFiltered ? '검색 결과가 없습니다.' : '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
@@ -114,6 +115,10 @@ async function removeMember(userId: string, name: string) {
|
||||
const showInvite = ref(false)
|
||||
const inviteEmail = ref('')
|
||||
const inviteRole = ref<MemberRoleType>('member')
|
||||
const ROLE_OPTIONS: { value: MemberRoleType; label: string }[] = [
|
||||
{ value: 'member', label: '멤버' },
|
||||
{ value: 'admin', label: '관리자' },
|
||||
]
|
||||
const inviteBusy = ref(false)
|
||||
|
||||
// --- 사용자 자동완성(타입어헤드) ---
|
||||
@@ -548,26 +553,13 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
<div class="m-field">
|
||||
<label class="m-label">역할</label>
|
||||
<div class="select-wrap">
|
||||
<select v-model="inviteRole">
|
||||
<option value="member">
|
||||
멤버
|
||||
</option>
|
||||
<option value="admin">
|
||||
관리자
|
||||
</option>
|
||||
</select>
|
||||
<span class="caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<DropdownSelect
|
||||
v-model="inviteRole"
|
||||
:options="ROLE_OPTIONS"
|
||||
block
|
||||
class="role-select"
|
||||
aria-label="역할"
|
||||
/>
|
||||
</div>
|
||||
<div class="role-hint">
|
||||
<b>멤버</b>는 담당자로 배정되어 업무를 수행하고, <b>관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.
|
||||
@@ -1010,42 +1002,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.invite-modal .select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.invite-modal .select-wrap select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
/* 역할 선택 드롭다운(DropdownSelect) — 모달 폼 입력 높이/서체에 맞춤 */
|
||||
.invite-modal .role-select :deep(.dd-trigger) {
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2.125rem 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.invite-modal .select-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.invite-modal .select-wrap .caret {
|
||||
position: absolute;
|
||||
right: 0.6875rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.invite-modal .select-wrap .caret svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.invite-modal .m-input {
|
||||
width: 100%;
|
||||
|
||||
@@ -19,9 +19,11 @@ function pstr(payload: Record<string, unknown>, key: string): string {
|
||||
// 행위자/시간 등 공통 필드
|
||||
function baseOf(
|
||||
api: ApiActivity,
|
||||
): Pick<ProjectActivityItem, 'avatarUrl' | 'time'> {
|
||||
): Pick<ProjectActivityItem, 'avatarUrl' | 'actorId' | 'actorName' | 'time'> {
|
||||
return {
|
||||
avatarUrl: api.actor?.avatarUrl ?? null,
|
||||
actorId: api.actor?.id ?? null,
|
||||
actorName: api.actor?.name ?? null,
|
||||
time: relativeTimeKo(api.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMyTask } from '@/composables/useMyTask'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiMyTaskRow } from '@/types/my-task'
|
||||
import type { ApiMyTaskRow, MyTaskListQuery } from '@/types/my-task'
|
||||
import type {
|
||||
MyTaskGroup,
|
||||
MyTaskRow,
|
||||
@@ -122,6 +122,8 @@ export const useMyTaskStore = defineStore('myTask', () => {
|
||||
const issuedPage = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
// 검색/프로젝트/정렬 조건 — 더보기에서 같은 조건으로 다음 페이지를 잇기 위해 보관
|
||||
const query = ref<MyTaskListQuery>({})
|
||||
|
||||
const myTaskApi = useMyTask()
|
||||
|
||||
@@ -134,13 +136,14 @@ export const useMyTaskStore = defineStore('myTask', () => {
|
||||
const assignedHasMore = computed(() => assignedRaw.value.length < assignedTotal.value)
|
||||
const issuedHasMore = computed(() => issuedRaw.value.length < issuedTotal.value)
|
||||
|
||||
// 첫 페이지(리셋) — 담당/지시 동시 조회
|
||||
async function fetchAll(): Promise<void> {
|
||||
// 첫 페이지(리셋) — 담당/지시 동시 조회. 검색/프로젝트/정렬 조건 적용
|
||||
async function fetchAll(q: MyTaskListQuery = {}): Promise<void> {
|
||||
query.value = q
|
||||
loading.value = true
|
||||
try {
|
||||
const [a, i] = await Promise.all([
|
||||
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE),
|
||||
myTaskApi.issued(1, DEFAULT_PAGE_SIZE),
|
||||
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE, query.value),
|
||||
myTaskApi.issued(1, DEFAULT_PAGE_SIZE, query.value),
|
||||
])
|
||||
assignedRaw.value = a.items
|
||||
assignedTotal.value = a.total
|
||||
@@ -158,7 +161,7 @@ export const useMyTaskStore = defineStore('myTask', () => {
|
||||
if (loadingMore.value || !assignedHasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE)
|
||||
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE, query.value)
|
||||
assignedRaw.value.push(...a.items)
|
||||
assignedTotal.value = a.total
|
||||
assignedPage.value += 1
|
||||
@@ -172,7 +175,7 @@ export const useMyTaskStore = defineStore('myTask', () => {
|
||||
if (loadingMore.value || !issuedHasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE)
|
||||
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE, query.value)
|
||||
issuedRaw.value.push(...i.items)
|
||||
issuedTotal.value = i.total
|
||||
issuedPage.value += 1
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ApiMember } from '@/types/repo'
|
||||
import type {
|
||||
ApiProject,
|
||||
CreateProjectPayload,
|
||||
ProjectListQuery,
|
||||
UpdateProjectPayload,
|
||||
} from '@/types/project'
|
||||
import type { Project as ProjectView, User as UserView } from '@/mock/relay.mock'
|
||||
@@ -56,10 +57,14 @@ export const useProjectStore = defineStore('project', () => {
|
||||
|
||||
const projectApi = useProject()
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
// 검색/정렬 조건을 보관 — 더보기에서 같은 조건으로 다음 페이지를 잇기 위함
|
||||
const query = ref<ProjectListQuery>({})
|
||||
|
||||
async function fetchList(q: ProjectListQuery = {}): Promise<void> {
|
||||
query.value = q
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.list(1, DEFAULT_PAGE_SIZE)
|
||||
const res = await projectApi.list(1, DEFAULT_PAGE_SIZE, query.value)
|
||||
projects.value = res.items.map(toProjectView)
|
||||
total.value = res.total
|
||||
page.value = 1
|
||||
@@ -72,7 +77,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE, query.value)
|
||||
projects.value.push(...res.items.map(toProjectView))
|
||||
total.value = res.total
|
||||
page.value += 1
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 내 업무 정렬 — 마감 임박순/최근 등록순 (백엔드 sort 쿼리와 1:1)
|
||||
export type MyTaskSort = 'due' | 'recent'
|
||||
|
||||
// 내 업무 필터 — 검색/프로젝트/정렬 (백엔드 assigned·issued 쿼리와 1:1)
|
||||
export interface MyTaskListQuery {
|
||||
q?: string
|
||||
projectId?: string
|
||||
sort?: MyTaskSort
|
||||
}
|
||||
|
||||
// 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생
|
||||
export interface ApiMyTaskRow {
|
||||
id: number // 프로젝트 내 순번(seq)
|
||||
|
||||
@@ -17,6 +17,15 @@ export interface ApiProject {
|
||||
isOwner?: boolean // 소유자 (프로젝트 삭제)
|
||||
}
|
||||
|
||||
// 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)
|
||||
export type ProjectSort = 'recent' | 'name' | 'created'
|
||||
|
||||
// 프로젝트 목록 필터 — 검색/정렬 (백엔드 findAll 쿼리와 1:1)
|
||||
export interface ProjectListQuery {
|
||||
q?: string
|
||||
sort?: ProjectSort
|
||||
}
|
||||
|
||||
// 프로젝트 생성 요청
|
||||
export interface CreateProjectPayload {
|
||||
name: string
|
||||
|
||||
@@ -94,11 +94,15 @@ export interface TaskSegmentCounts {
|
||||
done: number
|
||||
}
|
||||
|
||||
// 업무 목록 필터 — 세그먼트/검색/담당자 (백엔드 list 쿼리와 1:1)
|
||||
// 업무 목록 정렬 — 최신순/마감 임박순/오래된순 (백엔드 sort 쿼리와 1:1)
|
||||
export type TaskSort = 'latest' | 'due' | 'oldest'
|
||||
|
||||
// 업무 목록 필터 — 세그먼트/검색/담당자/정렬 (백엔드 list 쿼리와 1:1)
|
||||
export interface TaskListQuery {
|
||||
segment?: TaskSegment
|
||||
q?: string
|
||||
assignee?: string
|
||||
sort?: TaskSort
|
||||
}
|
||||
|
||||
// 업무 목록 응답 — 페이지네이션 + 세그먼트 카운트 동봉 (백엔드 ProjectTaskListResult 와 1:1)
|
||||
|
||||
Reference in New Issue
Block a user