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