feat: 멤버 관리 연동 + 사용자 검색 API (3단계)

- 멤버 목록/초대/역할변경/제거 프론트 연동(RepoMembersPage), 초대 자동완성
- GET /users 사용자 검색 API + UserController
- RepoMembersPage 역할 변경 드롭다운 잘림(overflow) 수정
- InviteMember/UpdateMember DTO, RepoMember 엔티티 정비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 18:11:46 +09:00
parent e2fe089321
commit c01f8e535c
13 changed files with 686 additions and 199 deletions
+457 -154
View File
@@ -1,18 +1,59 @@
<script setup lang="ts">
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달 (백엔드 연동)
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import RepoHeader from '@/components/RepoHeader.vue'
import RepoTabs from '@/components/RepoTabs.vue'
import { findRepo, findRepoMembers } from '@/mock/relay.mock'
import { useRepoStore } from '@/stores/repo.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
import { avatarColor, initialOf } from '@/shared/utils/format'
import type { Repo } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
import type { MemberRoleType } from '@/types/member'
type RoleFilter = 'all' | 'admin' | 'member'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
const members = computed(() => findRepoMembers(repoId.value))
const repoStore = useRepoStore()
const memberStore = useMemberStore()
// 저장소 헤더 — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null // 404/403 은 인터셉터가 알림 처리
}
}
// 멤버 목록 — API 연동
const members = computed(() => memberStore.members)
const loading = computed(() => memberStore.loading)
async function loadMembers() {
try {
await memberStore.fetchList(repoId.value)
} catch {
// 인터셉터가 알림 처리
}
}
watch(
repoId,
() => {
loadRepo()
loadMembers()
},
{ immediate: true },
)
// 현재 사용자가 이 저장소의 관리자인지(초대·역할변경·제거 권한)
const isAdmin = computed(() =>
members.value.some((m) => m.isYou && m.roleType === 'admin'),
)
// 헤더 아바타용 멤버(전체 로스터)
const headerMembers = computed(() => members.value.map((m) => m.user))
@@ -35,10 +76,122 @@ const filteredMembers = computed(() =>
}),
)
// 초대 모달
// --- 역할 변경 드롭다운 ---
const openRoleFor = ref<string | null>(null)
const roleBusy = ref(false)
function toggleRoleMenu(userId: string) {
openRoleFor.value = openRoleFor.value === userId ? null : userId
}
async function changeRole(userId: string, roleType: MemberRoleType) {
openRoleFor.value = null
roleBusy.value = true
try {
await memberStore.update(repoId.value, userId, { roleType })
} catch {
// 인터셉터가 알림 처리
} finally {
roleBusy.value = false
}
}
// --- 멤버 제거 ---
async function removeMember(userId: string, name: string) {
if (!window.confirm(`${name} 님을 저장소에서 제거하시겠습니까?`)) return
try {
await memberStore.remove(repoId.value, userId)
} catch {
// 인터셉터가 알림 처리
}
}
// --- 초대 모달 ---
const showInvite = ref(false)
const inviteEmail = ref('')
const inviteRole = ref<MemberRoleType>('member')
const inviteBusy = ref(false)
// --- 사용자 자동완성(타입어헤드) ---
const userApi = useUser()
const userResults = ref<ApiMember[]>([])
const usersLoading = ref(false)
const showUserDropdown = ref(false)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
// 현재 저장소에 이미 속한 사용자 id (자동완성에서 제외)
const memberIds = computed(() => new Set(members.value.map((m) => m.user.id)))
const userOptions = computed(() =>
userResults.value.filter((u) => !memberIds.value.has(u.id)),
)
// 서버 검색 실행 — 입력값으로 이메일/이름 부분일치, 비어 있으면 전체
async function runUserSearch() {
usersLoading.value = true
try {
userResults.value = await userApi.search(inviteEmail.value)
} catch {
userResults.value = [] // 인터셉터가 알림 처리
} finally {
usersLoading.value = false
}
}
function scheduleUserSearch() {
if (userSearchTimer) clearTimeout(userSearchTimer)
userSearchTimer = setTimeout(runUserSearch, 200)
}
function onEmailFocus() {
showUserDropdown.value = true
void runUserSearch() // 포커스 시 전체(또는 현재 입력 기준) 목록 표시
}
function onEmailInput() {
showUserDropdown.value = true
scheduleUserSearch()
}
function onEmailBlur() {
// 항목은 @mousedown.prevent 로 선택하지만, 안전하게 약간 지연 후 닫는다
setTimeout(() => (showUserDropdown.value = false), 120)
}
function selectUser(u: ApiMember) {
inviteEmail.value = u.email
showUserDropdown.value = false
}
function openInvite() {
inviteEmail.value = ''
inviteRole.value = 'member'
userResults.value = []
showUserDropdown.value = false
showInvite.value = true
}
function closeInvite() {
showInvite.value = false
showUserDropdown.value = false
}
const canSubmitInvite = computed(
() => !inviteBusy.value && inviteEmail.value.trim().length > 0,
)
async function submitInvite() {
if (!canSubmitInvite.value) return
inviteBusy.value = true
try {
await memberStore.invite(repoId.value, {
email: inviteEmail.value.trim(),
roleType: inviteRole.value,
})
closeInvite()
} catch {
// 인터셉터가 알림 처리(미가입/중복 등 BIZ_001 서버 문구 노출)
} finally {
inviteBusy.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') showInvite.value = false
if (e.key === 'Escape') {
showInvite.value = false
openRoleFor.value = null
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
@@ -70,14 +223,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<RepoHeader
:repo="repo"
meta-text="멤버 5명"
:meta-text="`멤버 ${counts.all}`"
:members="headerMembers"
:more-count="0"
/>
<RepoTabs
:repo-id="repo.id"
active="members"
:task-count="12"
:task-count="repo.totalCount"
:member-count="counts.all"
/>
@@ -141,9 +294,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</button>
</div>
<button
v-if="isAdmin"
class="btn primary invite"
type="button"
@click="showInvite = true"
@click="openInvite"
>
<svg
viewBox="0 0 24 24"
@@ -164,7 +318,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</div>
<!-- 멤버 목록 -->
<div class="list">
<div
v-if="loading"
class="state-msg"
>
멤버를 불러오는
</div>
<div
v-else-if="filteredMembers.length === 0"
class="state-msg"
>
표시할 멤버가 없습니다.
</div>
<div
v-else
class="list"
>
<div
v-for="m in filteredMembers"
:key="m.user.id"
@@ -182,7 +351,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
></span>
</div>
<div class="mem-sub">
{{ m.email }} <span class="dot">·</span> {{ m.subRole }}
{{ m.email }}
</div>
</div>
<div
@@ -191,6 +360,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
>
<b>{{ m.taskCount }}</b> 담당
</div>
<!-- 소유자: 변경 불가 고정 표시 -->
<div
v-if="m.owner"
class="role fixed admin"
@@ -211,28 +381,64 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</span>
</div>
<button
<!-- 관리자: 역할 변경 드롭다운 -->
<div
v-else-if="isAdmin"
class="role-wrap"
>
<button
class="role"
:class="{ admin: m.roleType === 'admin' }"
type="button"
:disabled="roleBusy"
@click.stop="toggleRoleMenu(m.user.id)"
>
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
<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>
</button>
<div
v-if="openRoleFor === m.user.id"
class="role-menu"
>
<button
type="button"
:class="{ on: m.roleType === 'member' }"
@click="changeRole(m.user.id, 'member')"
>
멤버
</button>
<button
type="button"
:class="{ on: m.roleType === 'admin' }"
@click="changeRole(m.user.id, 'admin')"
>
관리자
</button>
</div>
</div>
<!-- 일반 멤버 시점: 변경 불가 표시 -->
<div
v-else
class="role"
type="button"
class="role readonly"
:class="{ admin: m.roleType === 'admin' }"
>
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
<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>
</button>
</div>
<button
v-if="isAdmin && !m.owner"
class="mem-remove"
:class="{ disabled: m.owner }"
type="button"
:title="m.owner ? '소유자는 제거할 수 없습니다' : '멤버 제거'"
title="멤버 제거"
@click="removeMember(m.user.id, m.user.name)"
>
<svg
viewBox="0 0 24 24"
@@ -281,81 +487,99 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="modal-body">
<label class="m-label">초대할 사람</label>
<div class="icombo">
<div class="icombo-field">
<span class="ichip"><span class="avatar av-f mini"></span>한지민 <span class="x">×</span></span>
<span class="ichip ext">
<form
class="modal-body"
@submit.prevent="submitInvite"
>
<div class="m-field uc-field">
<label class="m-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="m-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span
class="avatar user-av"
:class="avatarColor(u.id)"
>{{ initialOf(u.name) }}</span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
<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
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" /></svg>
partner@vendor.com <span class="x">×</span>
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
<input
class="icombo-input"
placeholder="이름 또는 이메일 입력…"
>
</div>
</div>
<div class="m-row">
<div class="m-role">
<label class="m-label">역할</label>
<div class="select-wrap">
<select>
<option>멤버</option>
<option>관리자</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>
</div>
<div class="m-grow">
<label class="m-label">초대 메시지 <span class="muted">(선택)</span></label>
<input
class="m-input"
type="text"
placeholder="함께 캠페인 준비해요!"
>
</div>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</div>
</form>
<div class="modal-foot">
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="showInvite = false"
@click="closeInvite"
>
취소
</button>
<button
class="mbtn primary"
type="button"
@click="showInvite = false"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<svg
viewBox="0 0 24 24"
@@ -365,7 +589,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
초대 보내기 (2)
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
</div>
</div>
@@ -416,6 +640,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
.mem-row:first-child {
border-top: none;
border-radius: 0.625rem 0.625rem 0 0;
}
.mem-row:last-child {
border-radius: 0 0 0.625rem 0.625rem;
}
.mem-row:hover {
background: #fafbfc;
@@ -589,6 +817,71 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
opacity: 0 !important;
pointer-events: none;
}
/* 로딩/빈 상태 메시지 */
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
/* 역할 변경 드롭다운 */
/* 전역 .list 의 overflow:hidden 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
대신 컨테이너 라운드 코너 클리핑은 각 행의 첫/마지막 모서리에서 유지된다. */
.list {
overflow: visible;
}
.role-wrap {
position: relative;
}
.role:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.role.readonly {
cursor: default;
background: #f7f8fa;
}
.role-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 20;
min-width: 6.5rem;
background: #fff;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.16);
padding: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.role-menu button {
height: 2rem;
padding: 0 0.625rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
text-align: left;
cursor: pointer;
}
.role-menu button:hover {
background: #f1f2f4;
color: var(--text);
}
.role-menu button.on {
color: var(--accent);
background: var(--accent-weak);
}
</style>
<!-- 초대 모달은 Teleport body 렌더되므로 -scoped 전역 스타일 -->
@@ -599,7 +892,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
overflow: hidden;
/* overflow:hidden 제거 — 자동완성 드롭다운이 모달 밖으로 넘쳐도 잘리지 않도록 */
}
.invite-modal .modal-head {
display: flex;
@@ -644,6 +937,78 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
.invite-modal .modal-body {
padding: 0.25rem 1.5rem 0.5rem;
/* 전역 .modal-body(text-align:center) 상속 차단 — 폼은 좌측 정렬 */
text-align: left;
}
/* 모달 입력 필드(이메일/역할) — 세로 스택, 전체 너비 */
.invite-modal .m-field {
margin-top: 1.125rem;
}
.invite-modal .m-field:first-child {
margin-top: 0;
}
/* 사용자 자동완성 드롭다운 */
.invite-modal .uc-field {
position: relative;
}
.invite-modal .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 16rem;
overflow-y: auto;
}
.invite-modal .user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.invite-modal .user-dd-item:hover {
background: #f5f6f8;
}
.invite-modal .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-modal .user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-modal .user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
}
.invite-modal .m-label {
font-size: 0.781rem;
@@ -656,76 +1021,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
color: var(--text-3);
font-weight: 500;
}
.invite-modal .icombo {
position: relative;
}
.invite-modal .icombo-field {
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
box-shadow: 0 0 0 3px var(--accent-weak);
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.invite-modal .ichip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.1875rem 0.5rem 0.1875rem 0.25rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .ichip .avatar.mini {
width: 1.125rem;
height: 1.125rem;
font-size: 0.5625rem;
}
.invite-modal .ichip.ext {
background: var(--accent-weak);
border-color: var(--accent-border);
color: var(--accent);
}
.invite-modal .ichip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
}
.invite-modal .ichip .x:hover {
color: var(--red);
}
.invite-modal .icombo-input {
flex: 1;
min-width: 7.5rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.invite-modal .icombo-input::placeholder {
color: var(--text-3);
}
.invite-modal .m-row {
display: flex;
gap: 0.75rem;
margin-top: 1.125rem;
}
.invite-modal .m-role {
flex: 0 0 9.375rem;
}
.invite-modal .m-grow {
flex: 1;
}
.invite-modal .select-wrap {
position: relative;
}
@@ -800,6 +1095,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
border-top: 1px solid var(--border);
background: #fafbfc;
margin-top: 1rem;
/* overflow:hidden 제거에 따른 하단 라운드 보정 */
border-radius: 0 0 0.875rem 0.875rem;
}
.invite-modal .modal-foot .fnote {
font-size: 0.75rem;
@@ -807,6 +1104,8 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
margin-right: auto;
}
.invite-modal .mbtn {
/* 전역 .mbtn(flex:1, 확인 다이얼로그용) 상속 차단 — 버튼은 내용 너비로 우측 정렬 */
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
border-radius: var(--radius);
@@ -834,6 +1133,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
.invite-modal .mbtn.primary:hover {
background: var(--accent-hover);
}
.invite-modal .mbtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.invite-modal .mbtn svg {
width: 0.9375rem;
height: 0.9375rem;