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:
@@ -0,0 +1,47 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiRepoMember,
|
||||
InviteMemberPayload,
|
||||
UpdateMemberPayload,
|
||||
} from '@/types/member'
|
||||
|
||||
// 저장소 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/members
|
||||
export function useMember() {
|
||||
const api = useApi()
|
||||
|
||||
// 멤버 목록 (멤버 또는 공개 저장소)
|
||||
async function list(repoId: string): Promise<ApiRepoMember[]> {
|
||||
return (await api.get(`/repos/${repoId}/members`)) as unknown as ApiRepoMember[]
|
||||
}
|
||||
|
||||
// 멤버 초대 (admin) — 가입된 사용자만 이메일로 초대
|
||||
async function invite(
|
||||
repoId: string,
|
||||
payload: InviteMemberPayload,
|
||||
): Promise<ApiRepoMember> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/members`,
|
||||
payload,
|
||||
)) as unknown as ApiRepoMember
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 (admin, owner 제외)
|
||||
async function update(
|
||||
repoId: string,
|
||||
userId: string,
|
||||
payload: UpdateMemberPayload,
|
||||
): Promise<ApiRepoMember> {
|
||||
return (await api.patch(
|
||||
`/repos/${repoId}/members/${userId}`,
|
||||
payload,
|
||||
)) as unknown as ApiRepoMember
|
||||
}
|
||||
|
||||
// 멤버 제거 (admin, owner 제외)
|
||||
async function remove(repoId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/repos/${repoId}/members/${userId}`)
|
||||
}
|
||||
|
||||
return { list, invite, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useApi } from './useApi'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 사용자 도메인 API Composable — 멤버 초대 자동완성용 검색/목록
|
||||
// 응답은 PublicUser(= ApiMember: { id, email, name, role }) 배열
|
||||
export function useUser() {
|
||||
const api = useApi()
|
||||
|
||||
// 사용자 검색 — q 가 있으면 이메일/이름 부분일치, 없으면 전체 목록
|
||||
async function search(q?: string): Promise<ApiMember[]> {
|
||||
const params = q && q.trim() ? { q: q.trim() } : {}
|
||||
return (await api.get('/users', { params })) as unknown as ApiMember[]
|
||||
}
|
||||
|
||||
return { search }
|
||||
}
|
||||
@@ -56,6 +56,12 @@ export interface Repo {
|
||||
progressLevel: 'done' | 'mid' | 'low'
|
||||
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
|
||||
progressLabel: string
|
||||
/** Gitea HTTPS clone URL (미연동 시 null/undefined) */
|
||||
cloneUrl?: string | null
|
||||
/** Gitea 웹 UI URL (미연동 시 null/undefined) */
|
||||
htmlUrl?: string | null
|
||||
/** Gitea 조직에서 사라진 저장소 표시(역방향 동기화로 갱신) */
|
||||
giteaMissing?: boolean
|
||||
}
|
||||
|
||||
/** 저장소 상세의 업무 목록 행 */
|
||||
@@ -744,8 +750,6 @@ export function countActive(groups: MyTaskGroup[]): number {
|
||||
export interface RepoMember {
|
||||
user: User
|
||||
email: string
|
||||
/** 저장소 내 직무 표기(예: 리드, 콘텐츠 디자인) */
|
||||
subRole: string
|
||||
/** 담당 업무 수 */
|
||||
taskCount: number
|
||||
/** 권한 역할 */
|
||||
@@ -758,11 +762,11 @@ export interface RepoMember {
|
||||
|
||||
export const REPO_MEMBERS: Record<string, RepoMember[]> = {
|
||||
'q3-launch-campaign': [
|
||||
{ user: USERS.jung, email: 'tkjung@acme.co', subRole: '리드', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
|
||||
{ user: USERS.kim, email: 'sykim@acme.co', subRole: '콘텐츠 디자인', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.park, email: 'jhpark@acme.co', subRole: '퍼포먼스 마케팅', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.lee, email: 'dylee@acme.co', subRole: '카피라이터', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.choi, email: 'yjchoi@acme.co', subRole: 'UX 디자인', taskCount: 1, roleType: 'member' },
|
||||
{ user: USERS.jung, email: 'tkjung@acme.co', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
|
||||
{ user: USERS.kim, email: 'sykim@acme.co', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.park, email: 'jhpark@acme.co', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.lee, email: 'dylee@acme.co', taskCount: 2, roleType: 'member' },
|
||||
{ user: USERS.choi, email: 'yjchoi@acme.co', taskCount: 1, roleType: 'member' },
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useMember } from '@/composables/useMember'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { avatarColor, initialOf } from '@/shared/utils/format'
|
||||
import type {
|
||||
ApiRepoMember,
|
||||
InviteMemberPayload,
|
||||
UpdateMemberPayload,
|
||||
} from '@/types/member'
|
||||
import type { RepoMember as MemberView } from '@/mock/relay.mock'
|
||||
|
||||
// API 멤버 → 화면용 RepoMember(이니셜/색상 파생 + isYou 산출)
|
||||
function toMemberView(api: ApiRepoMember, currentUserId: string | null): MemberView {
|
||||
return {
|
||||
user: {
|
||||
id: api.user.id,
|
||||
name: api.user.name,
|
||||
role: api.user.role ?? '',
|
||||
initial: initialOf(api.user.name),
|
||||
color: avatarColor(api.user.id),
|
||||
},
|
||||
email: api.email,
|
||||
taskCount: api.taskCount,
|
||||
roleType: api.roleType,
|
||||
isYou: api.user.id === currentUserId,
|
||||
owner: api.owner,
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 멤버 스토어 — 현재 저장소의 멤버 목록 상태 + 초대/역할변경/제거 액션
|
||||
export const useMemberStore = defineStore('member', () => {
|
||||
const members = ref<MemberView[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const memberApi = useMember()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 현재 로그인 사용자 id (isYou 산출용)
|
||||
function currentUserId(): string | null {
|
||||
return authStore.user?.id ?? null
|
||||
}
|
||||
|
||||
// 멤버 목록 조회
|
||||
async function fetchList(repoId: string): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await memberApi.list(repoId)
|
||||
members.value = list.map((m) => toMemberView(m, currentUserId()))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 멤버 초대 — 성공 시 목록에 추가
|
||||
async function invite(repoId: string, payload: InviteMemberPayload): Promise<void> {
|
||||
const created = await memberApi.invite(repoId, payload)
|
||||
members.value.push(toMemberView(created, currentUserId()))
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 — 성공 시 해당 행 교체
|
||||
async function update(
|
||||
repoId: string,
|
||||
userId: string,
|
||||
payload: UpdateMemberPayload,
|
||||
): Promise<void> {
|
||||
const updated = await memberApi.update(repoId, userId, payload)
|
||||
const view = toMemberView(updated, currentUserId())
|
||||
const idx = members.value.findIndex((m) => m.user.id === userId)
|
||||
if (idx >= 0) members.value[idx] = view
|
||||
}
|
||||
|
||||
// 멤버 제거 — 성공 시 목록에서 제외
|
||||
async function remove(repoId: string, userId: string): Promise<void> {
|
||||
await memberApi.remove(repoId, userId)
|
||||
members.value = members.value.filter((m) => m.user.id !== userId)
|
||||
}
|
||||
|
||||
return { members, loading, fetchList, invite, update, remove }
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
// 저장소 멤버 도메인 타입 — 백엔드 RepoMemberResponse / DTO 와 1:1
|
||||
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 권한 역할
|
||||
export type MemberRoleType = 'admin' | 'member'
|
||||
|
||||
// 멤버(API 원시) — 백엔드 RepoMemberResponse 와 1:1 (isYou 는 프론트가 산출)
|
||||
export interface ApiRepoMember {
|
||||
user: ApiMember // { id, email, name, role }
|
||||
email: string
|
||||
taskCount: number
|
||||
roleType: MemberRoleType
|
||||
owner: boolean // 소유자(isOwner) — 역할 변경/제거 불가
|
||||
}
|
||||
|
||||
// 멤버 초대 요청 — 이미 가입된 사용자를 이메일로 초대
|
||||
export interface InviteMemberPayload {
|
||||
email: string
|
||||
roleType: MemberRoleType
|
||||
}
|
||||
|
||||
// 멤버 역할 변경 요청
|
||||
export interface UpdateMemberPayload {
|
||||
roleType?: MemberRoleType
|
||||
}
|
||||
Reference in New Issue
Block a user