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
+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%;