feat: 화상회의 내가 만든 방 편집 기능
진행자(생성자)가 방의 이름·예약 시각·참여 제한(allow-list)을 수정할 수 있다. - 백엔드: PATCH /meetings/rooms/:roomId (updateRoom, 진행자만, 이름 중복 검사). UpdateMeetingRoomDto(생성과 동일 필드). MeetingRoomResponse 에 allowedUsers 추가 (진행자에게만 노출 — 편집 폼 사전 채움용). User repo 주입해 ID→PublicUser 해석. - 프론트: useMeeting.updateRoom, 로비 각 방에 편집 버튼(진행자), MeetingCreateForm 를 편집 모드로 재사용(현재 값 프리필·중복검사 자기제외·저장 분기), MeetingIcon edit 추가. 검증: 백엔드 build, 프론트 type-check·lint 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,10 +14,12 @@ import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
interface Props {
|
||||
// 이름 중복 즉시 검사용 현재 방 목록
|
||||
rooms: ApiMeetingRoom[]
|
||||
// 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드).
|
||||
editRoom?: ApiMeetingRoom | null
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
// 생성 성공 → 부모가 목록 갱신 + 모달 닫기
|
||||
// 생성/수정 성공 → 부모가 목록 갱신 + 모달 닫기
|
||||
(e: 'created'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
@@ -27,14 +29,26 @@ const NAME_RE = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
|
||||
const userApi = useUser()
|
||||
const meetingApi = useMeeting()
|
||||
|
||||
const name = ref('')
|
||||
const scheduledAt = ref('')
|
||||
const restrict = ref(false)
|
||||
const isEdit = computed(() => !!props.editRoom)
|
||||
|
||||
// ISO(UTC) → datetime-local 입력값('YYYY-MM-DDTHH:mm', 로컬 시간)
|
||||
function toLocalInput(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
const name = ref(props.editRoom?.name ?? '')
|
||||
const scheduledAt = ref(
|
||||
props.editRoom?.scheduledAt ? toLocalInput(props.editRoom.scheduledAt) : '',
|
||||
)
|
||||
const restrict = ref(props.editRoom?.restricted ?? false)
|
||||
const error = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
// --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) ---
|
||||
const selected = ref<ApiMember[]>([]) // 선택된 멤버(입력칸 아래 칩)
|
||||
// 편집 모드면 현재 허용 멤버로 칩을 미리 채운다(백엔드가 진행자에게 allowedUsers 제공).
|
||||
const selected = ref<ApiMember[]>([...(props.editRoom?.allowedUsers ?? [])]) // 선택된 멤버(입력칸 아래 칩)
|
||||
const query = ref('')
|
||||
const found = ref<ApiMember[]>([]) // 검색 결과
|
||||
const showDropdown = ref(false)
|
||||
@@ -75,26 +89,34 @@ async function submit() {
|
||||
error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.'
|
||||
return
|
||||
}
|
||||
if (props.rooms.some((r) => r.name === n)) {
|
||||
// 이름 중복 검사 — 편집 중인 방 자신은 제외한다.
|
||||
if (props.rooms.some((r) => r.name === n && r.id !== props.editRoom?.id)) {
|
||||
error.value = '이미 같은 이름의 방이 있습니다.'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
await meetingApi.createRoom({
|
||||
name: n,
|
||||
scheduledAt: scheduledAt.value
|
||||
? new Date(scheduledAt.value).toISOString()
|
||||
const payload = {
|
||||
name: n,
|
||||
scheduledAt: scheduledAt.value
|
||||
? new Date(scheduledAt.value).toISOString()
|
||||
: undefined,
|
||||
allowedUserIds:
|
||||
restrict.value && selected.value.length
|
||||
? selected.value.map((u) => u.id)
|
||||
: undefined,
|
||||
allowedUserIds:
|
||||
restrict.value && selected.value.length
|
||||
? selected.value.map((u) => u.id)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
try {
|
||||
if (props.editRoom) {
|
||||
await meetingApi.updateRoom(props.editRoom.id, payload)
|
||||
} else {
|
||||
await meetingApi.createRoom(payload)
|
||||
}
|
||||
emit('created')
|
||||
} catch {
|
||||
error.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
error.value = isEdit.value
|
||||
? '방 수정에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
: '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
@@ -102,11 +124,11 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="새 회의 만들기"
|
||||
:title="isEdit ? '회의 편집' : '새 회의 만들기'"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<template #subtitle>
|
||||
방을 만들면 내가 진행자가 됩니다.
|
||||
{{ isEdit ? '내가 만든 방의 이름·예약·참여 제한을 수정합니다.' : '방을 만들면 내가 진행자가 됩니다.' }}
|
||||
</template>
|
||||
|
||||
<form
|
||||
@@ -243,7 +265,15 @@ async function submit() {
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
<MeetingIcon name="plus" />{{ submitting ? '생성 중…' : '방 만들기' }}
|
||||
<MeetingIcon :name="isEdit ? 'edit' : 'plus'" />{{
|
||||
submitting
|
||||
? isEdit
|
||||
? '저장 중…'
|
||||
: '생성 중…'
|
||||
: isEdit
|
||||
? '저장'
|
||||
: '방 만들기'
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -23,6 +23,7 @@ export type MeetingIconName =
|
||||
| 'warn'
|
||||
| 'lock'
|
||||
| 'trash'
|
||||
| 'edit'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -173,5 +174,8 @@ defineProps<{ name: MeetingIconName }>()
|
||||
<template v-else-if="name === 'trash'">
|
||||
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'edit'">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const showForm = ref(false)
|
||||
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
|
||||
const editRoom = ref<ApiMeetingRoom | null>(null)
|
||||
const keyword = ref('')
|
||||
|
||||
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
|
||||
@@ -37,8 +39,23 @@ const filteredRooms = computed(() => {
|
||||
function onSearchInput() {
|
||||
if (props.joinError) emit('clear-error')
|
||||
}
|
||||
function onCreated() {
|
||||
// 새 회의 만들기(생성 모드)
|
||||
function openCreate() {
|
||||
editRoom.value = null
|
||||
showForm.value = true
|
||||
}
|
||||
// 방 편집(편집 모드) — 진행자만 노출됨
|
||||
function openEdit(room: ApiMeetingRoom) {
|
||||
showForm.value = false
|
||||
editRoom.value = room
|
||||
}
|
||||
function closeForm() {
|
||||
showForm.value = false
|
||||
editRoom.value = null
|
||||
}
|
||||
// 생성/수정 완료 → 폼 닫고 부모에 목록 갱신 요청
|
||||
function onFormDone() {
|
||||
closeForm()
|
||||
emit('created')
|
||||
}
|
||||
|
||||
@@ -78,7 +95,7 @@ function fmtSchedule(iso: string): string {
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary new-room"
|
||||
@click="showForm = true"
|
||||
@click="openCreate"
|
||||
>
|
||||
<MeetingIcon name="plus" />새 회의 만들기
|
||||
</button>
|
||||
@@ -92,10 +109,11 @@ function fmtSchedule(iso: string): string {
|
||||
</div>
|
||||
|
||||
<MeetingCreateForm
|
||||
v-if="showForm"
|
||||
v-if="showForm || editRoom"
|
||||
:rooms="rooms"
|
||||
@created="onCreated"
|
||||
@cancel="showForm = false"
|
||||
:edit-room="editRoom"
|
||||
@created="onFormDone"
|
||||
@cancel="closeForm"
|
||||
/>
|
||||
|
||||
<!-- 방 목록 -->
|
||||
@@ -152,6 +170,15 @@ function fmtSchedule(iso: string): string {
|
||||
>
|
||||
<MeetingIcon name="lock" />권한 없음
|
||||
</button>
|
||||
<button
|
||||
v-if="r.isHost"
|
||||
type="button"
|
||||
class="btn rr-edit"
|
||||
title="방 편집"
|
||||
@click="openEdit(r)"
|
||||
>
|
||||
<MeetingIcon name="edit" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.isHost"
|
||||
type="button"
|
||||
@@ -332,15 +359,21 @@ function fmtSchedule(iso: string): string {
|
||||
.rr-noaccess {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-edit,
|
||||
.rr-del {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.rr-edit svg,
|
||||
.rr-del svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.rr-edit:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
|
||||
.btn.danger {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiMeetingRoom,
|
||||
CreateMeetingRoomPayload,
|
||||
UpdateMeetingRoomPayload,
|
||||
} from '@/types/meeting'
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
@@ -41,10 +42,21 @@ export function useMeeting() {
|
||||
)) as unknown as ApiMeetingRoom
|
||||
}
|
||||
|
||||
// 방 수정(진행자만) — 이름·예약·참여 제한 변경
|
||||
async function updateRoom(
|
||||
roomId: string,
|
||||
payload: UpdateMeetingRoomPayload,
|
||||
): Promise<ApiMeetingRoom> {
|
||||
return (await api.patch(
|
||||
`/meetings/rooms/${roomId}`,
|
||||
payload,
|
||||
)) as unknown as ApiMeetingRoom
|
||||
}
|
||||
|
||||
// 방 삭제(진행자만)
|
||||
async function removeRoom(roomId: string): Promise<void> {
|
||||
await api.delete(`/meetings/rooms/${roomId}`)
|
||||
}
|
||||
|
||||
return { getToken, listRooms, createRoom, removeRoom }
|
||||
return { getToken, listRooms, createRoom, updateRoom, removeRoom }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 회의 내 역할
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
@@ -12,6 +13,8 @@ export interface ApiMeetingRoom {
|
||||
restricted: boolean // 참여 제한(allow-list) 여부
|
||||
isHost: boolean // 현재 사용자가 진행자
|
||||
canJoin: boolean // 현재 사용자가 입장 가능
|
||||
// 참여 허용 멤버 — 진행자에게만 내려옴(편집 폼 사전 채움용). 공개 방/비진행자는 null.
|
||||
allowedUsers: ApiMember[] | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -22,6 +25,9 @@ export interface CreateMeetingRoomPayload {
|
||||
allowedUserIds?: string[] // 비우면 전체 공개
|
||||
}
|
||||
|
||||
// 방 수정 요청 — 생성과 동일 필드(진행자가 이름·예약·참여 제한 변경)
|
||||
export type UpdateMeetingRoomPayload = CreateMeetingRoomPayload
|
||||
|
||||
// 회의실 채팅 메시지 — LiveKit 데이터 채널로 주고받는 인메모리 메시지(서버 저장 없음)
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user