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
+1 -21
View File
@@ -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. 카드 / 목록 컨테이너
+241
View File
@@ -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>
+14 -6
View File
@@ -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>
}
+8 -2
View File
@@ -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>
}
+4
View File
@@ -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
+93 -60
View File
@@ -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 {
+33 -19
View File
@@ -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;
+57 -26
View File
@@ -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>
+14 -52
View File
@@ -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%;
+3 -1
View File
@@ -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),
}
}
+10 -7
View File
@@ -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
+8 -3
View File
@@ -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
+10
View File
@@ -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)
+9
View File
@@ -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
+5 -1
View File
@@ -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)