From b1afe5480cb3783e0d6d0ce7b55bfb3b30fda22e Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 21 Jun 2026 22:46:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20alert/confirm=EC=9D=84=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EB=AA=A8=EB=8B=AC=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 window.alert/confirm 을 업무 삭제 모달과 동일한 구조/디자인의 공통 모달로 대체하고, 한 곳에서 관리하도록 다이얼로그 시스템을 도입한다. - dialog.store: 알림/확인 큐 + Promise 기반 상태 관리 - useDialog(): alert/confirm 컴포저블 API - AppDialog: App 루트 1회 마운트, 전역 .modal-*/.mbtn 클래스로 렌더 (variant: info/success/warning/danger → 아이콘·버튼 색) - relay.css: modal-ico.info/.warning, mbtn.primary 변형 추가 - 교체: API 에러 알림(useApi), 검증/성공 알림(Signup/ProjectCreate/ ProjectSettings/TaskCreate), 삭제·제거 확인(ProjectSettings/ ProjectMembers/AgentChatPanel) Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/App.vue | 4 +- frontend/src/assets/styles/relay.css | 14 +- frontend/src/components/AgentChatPanel.vue | 8 +- frontend/src/components/AppDialog.vue | 127 ++++++++++++++++++ frontend/src/composables/useApi.ts | 6 +- frontend/src/composables/useDialog.ts | 15 +++ .../src/pages/relay/ProjectCreatePage.vue | 4 +- .../src/pages/relay/ProjectMembersPage.vue | 8 +- .../src/pages/relay/ProjectSettingsPage.vue | 14 +- frontend/src/pages/relay/SignupPage.vue | 10 +- frontend/src/pages/relay/TaskCreatePage.vue | 9 +- frontend/src/stores/dialog.store.ts | 81 +++++++++++ 12 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/AppDialog.vue create mode 100644 frontend/src/composables/useDialog.ts create mode 100644 frontend/src/stores/dialog.store.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5a6a315..04ab965 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,10 +1,12 @@ diff --git a/frontend/src/assets/styles/relay.css b/frontend/src/assets/styles/relay.css index de5f9cb..fc05602 100644 --- a/frontend/src/assets/styles/relay.css +++ b/frontend/src/assets/styles/relay.css @@ -488,6 +488,14 @@ body { background: var(--green-weak); color: var(--green); } +.modal-ico.info { + background: var(--accent-weak); + color: var(--accent); +} +.modal-ico.warning { + background: var(--amber-weak); + color: var(--amber); +} .modal-ico svg { width: 1.375rem; height: 1.375rem; @@ -549,13 +557,15 @@ body { .mbtn.approve:hover { background: #188a4b; } -.mbtn.request-send { +.mbtn.request-send, +.mbtn.primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4); } -.mbtn.request-send:hover { +.mbtn.request-send:hover, +.mbtn.primary:hover { background: var(--accent-hover); } .modal-textarea { diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue index 76fa87e..ee1e55b 100644 --- a/frontend/src/components/AgentChatPanel.vue +++ b/frontend/src/components/AgentChatPanel.vue @@ -4,6 +4,7 @@ import { nextTick, ref, watch } from 'vue' import { storeToRefs } from 'pinia' import { useAiStore } from '@/stores/ai.store' +import { useDialog } from '@/composables/useDialog' import { relativeTimeKo } from '@/shared/utils/format' import { renderMarkdown } from '@/shared/utils/markdown' @@ -18,6 +19,7 @@ interface Emits { const emit = defineEmits() const aiStore = useAiStore() +const dialog = useDialog() const { conversations, listLoading, messages, loading, error } = storeToRefs(aiStore) @@ -66,7 +68,11 @@ async function openConv(id: string) { } async function removeConv(id: string) { - if (!window.confirm('이 대화를 삭제할까요?')) return + const ok = await dialog.confirm('이 대화를 삭제할까요?', { + variant: 'danger', + confirmText: '삭제', + }) + if (!ok) return await aiStore.removeConversation(id) } diff --git a/frontend/src/components/AppDialog.vue b/frontend/src/components/AppDialog.vue new file mode 100644 index 0000000..a5accfd --- /dev/null +++ b/frontend/src/components/AppDialog.vue @@ -0,0 +1,127 @@ + + + diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 4b143c6..64adae2 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -3,6 +3,7 @@ import axios, { type AxiosResponse, type InternalAxiosRequestConfig, } from 'axios' +import { useDialogStore } from '@/stores/dialog.store' // axios 요청 설정 확장 — 호출 단위 옵션 declare module 'axios' { @@ -35,9 +36,10 @@ const ErrorCodeLexicon: Record = { BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', } -// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨 +// 사용자 노출용 에러 알림 — 전역 공통 다이얼로그(danger)로 표시. +// (인터셉터 실행 시점엔 pinia 가 활성화돼 있어 store 접근 가능) const showErrorUI = (message: string) => { - window.alert(message) + void useDialogStore().alert(message, { variant: 'danger' }) } // 에러 코드 → 메시지 변환 (미정의 코드는 SYS_001 로 폴백, 항상 문자열 보장) diff --git a/frontend/src/composables/useDialog.ts b/frontend/src/composables/useDialog.ts new file mode 100644 index 0000000..dd2a3e5 --- /dev/null +++ b/frontend/src/composables/useDialog.ts @@ -0,0 +1,15 @@ +import { useDialogStore, type DialogOptions } from '@/stores/dialog.store' + +// 전역 알림/확인 다이얼로그 — window.alert/confirm 대체. +// 사용 예: +// const dialog = useDialog() +// await dialog.alert('저장했습니다.', { variant: 'success' }) +// if (await dialog.confirm('삭제할까요?', { confirmText: '삭제' })) { ... } +export function useDialog() { + const store = useDialogStore() + return { + alert: (text: string, opts?: DialogOptions): Promise => + store.alert(text, opts).then(() => undefined), + confirm: (text: string, opts?: DialogOptions): Promise => store.confirm(text, opts), + } +} diff --git a/frontend/src/pages/relay/ProjectCreatePage.vue b/frontend/src/pages/relay/ProjectCreatePage.vue index 8729aa6..fd88a3e 100644 --- a/frontend/src/pages/relay/ProjectCreatePage.vue +++ b/frontend/src/pages/relay/ProjectCreatePage.vue @@ -4,9 +4,11 @@ import { ref } from 'vue' import { useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import { useProjectStore } from '@/stores/project.store' +import { useDialog } from '@/composables/useDialog' const router = useRouter() const projectStore = useProjectStore() +const dialog = useDialog() const name = ref('') const description = ref('') @@ -16,7 +18,7 @@ const submitting = ref(false) async function createProject() { if (submitting.value) return if (!name.value.trim()) { - window.alert('프로젝트 이름을 입력해 주세요.') + void dialog.alert('프로젝트 이름을 입력해 주세요.', { variant: 'warning' }) return } submitting.value = true diff --git a/frontend/src/pages/relay/ProjectMembersPage.vue b/frontend/src/pages/relay/ProjectMembersPage.vue index dd13c9d..6b0dd63 100644 --- a/frontend/src/pages/relay/ProjectMembersPage.vue +++ b/frontend/src/pages/relay/ProjectMembersPage.vue @@ -9,6 +9,7 @@ import UserAvatar from '@/components/UserAvatar.vue' import { useProjectStore } from '@/stores/project.store' import { useMemberStore } from '@/stores/member.store' import { useUser } from '@/composables/useUser' +import { useDialog } from '@/composables/useDialog' import type { Project } from '@/mock/relay.mock' import type { ApiMember } from '@/types/repo' import type { MemberRoleType } from '@/types/member' @@ -19,6 +20,7 @@ const route = useRoute() const projectId = computed(() => String(route.params.projectId)) const projectStore = useProjectStore() const memberStore = useMemberStore() +const dialog = useDialog() // 프로젝트 헤더 — API 연동 const repo = ref(null) @@ -96,7 +98,11 @@ async function changeRole(userId: string, roleType: MemberRoleType) { // --- 멤버 제거 --- async function removeMember(userId: string, name: string) { - if (!window.confirm(`${name} 님을 프로젝트에서 제거하시겠습니까?`)) return + const ok = await dialog.confirm(`${name} 님을 프로젝트에서 제거할까요?`, { + variant: 'danger', + confirmText: '제거', + }) + if (!ok) return try { await memberStore.remove(projectId.value, userId) } catch { diff --git a/frontend/src/pages/relay/ProjectSettingsPage.vue b/frontend/src/pages/relay/ProjectSettingsPage.vue index 31d42ff..fafacc1 100644 --- a/frontend/src/pages/relay/ProjectSettingsPage.vue +++ b/frontend/src/pages/relay/ProjectSettingsPage.vue @@ -6,12 +6,14 @@ import AppShell from '@/layouts/AppShell.vue' import ProjectHeader from '@/components/ProjectHeader.vue' import ProjectTabs from '@/components/ProjectTabs.vue' import { useProjectStore } from '@/stores/project.store' +import { useDialog } from '@/composables/useDialog' import { type Project } from '@/mock/relay.mock' const route = useRoute() const router = useRouter() const projectId = computed(() => String(route.params.projectId)) const projectStore = useProjectStore() +const dialog = useDialog() const repo = ref(null) @@ -56,7 +58,7 @@ async function save() { name: displayTitle.value.trim(), description: description.value.trim(), }) - window.alert('변경 사항을 저장했습니다.') + void dialog.alert('변경 사항을 저장했습니다.', { variant: 'success' }) } catch { // 인터셉터 전역 처리 } finally { @@ -67,7 +69,15 @@ async function save() { // 프로젝트 삭제 (소유자 전용) async function removeProject() { if (!repo.value || !isOwner.value) return - if (!window.confirm('프로젝트와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return + const ok = await dialog.confirm( + '프로젝트와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다.', + { + title: '프로젝트를 삭제할까요?', + variant: 'danger', + confirmText: '삭제', + }, + ) + if (!ok) return try { await projectStore.remove(projectId.value) await router.push('/projects') diff --git a/frontend/src/pages/relay/SignupPage.vue b/frontend/src/pages/relay/SignupPage.vue index 887da74..a2cd6b5 100644 --- a/frontend/src/pages/relay/SignupPage.vue +++ b/frontend/src/pages/relay/SignupPage.vue @@ -3,8 +3,10 @@ import { computed, ref } from 'vue' import { RouterLink } from 'vue-router' import { useAuthStore } from '@/stores/auth.store' +import { useDialog } from '@/composables/useDialog' const authStore = useAuthStore() +const dialog = useDialog() // 폼 상태 const name = ref('') @@ -30,19 +32,19 @@ async function submit() { // 클라이언트 1차 검증 (서버 DTO 가 최종 검증) if (!name.value.trim() || !email.value.trim() || !password.value) { - window.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.') + void dialog.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.', { variant: 'warning' }) return } if (password.value.length < 8) { - window.alert('비밀번호는 최소 8자 이상이어야 합니다.') + void dialog.alert('비밀번호는 최소 8자 이상이어야 합니다.', { variant: 'warning' }) return } if (password.value !== passwordConfirm.value) { - window.alert('비밀번호가 일치하지 않습니다.') + void dialog.alert('비밀번호가 일치하지 않습니다.', { variant: 'warning' }) return } if (!agreeTerms.value) { - window.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.') + void dialog.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.', { variant: 'warning' }) return } diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index 75864d1..bdac748 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -11,6 +11,7 @@ import { useTaskStore } from '@/stores/task.store' import { useAuthStore } from '@/stores/auth.store' import { useTask } from '@/composables/useTask' import { useAi } from '@/composables/useAi' +import { useDialog } from '@/composables/useDialog' import { attachmentKindOf, fileBadge, formatBytes } from '@/shared/utils/format' import { CHECKLIST_CATEGORIES } from '@/mock/relay.mock' import type { @@ -36,6 +37,7 @@ const taskStore = useTaskStore() const authStore = useAuthStore() const taskApi = useTask() const ai = useAi() +const dialog = useDialog() // API 멤버 → 화면용 User(이니셜/색상 파생) function toUserView(m: ApiMember): User { @@ -337,7 +339,9 @@ function onPickFiles(e: Event) { const ok = picked.filter((f) => f.size <= MAX_FILE_SIZE) pendingFiles.value.push(...ok) if (ok.length < picked.length) { - window.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`) + void dialog.alert(`25MB를 초과하는 파일 ${picked.length - ok.length}개는 제외했습니다.`, { + variant: 'warning', + }) } input.value = '' // 같은 파일을 다시 선택할 수 있도록 초기화 } @@ -436,8 +440,9 @@ async function submitTask() { // 선택한 파일 업로드(업무가 생성/확정된 뒤에야 가능) — 베스트에포트 const failed = await uploadPendingFiles(tid) if (failed > 0) { - window.alert( + void dialog.alert( `파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`, + { variant: 'warning' }, ) } router.push(`/projects/${projectId.value}/tasks/${tid}`) diff --git a/frontend/src/stores/dialog.store.ts b/frontend/src/stores/dialog.store.ts new file mode 100644 index 0000000..f0e30ab --- /dev/null +++ b/frontend/src/stores/dialog.store.ts @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +// 전역 다이얼로그(알림/확인) — window.alert/confirm 을 대체하는 공통 모달의 상태 저장소. +// 디자인/구조는 업무 삭제 모달과 동일(전역 relay.css 의 .modal-* / .mbtn 클래스 사용). + +export type DialogVariant = 'info' | 'success' | 'warning' | 'danger' +export type DialogMode = 'alert' | 'confirm' + +// 호출 시 넘길 수 있는 옵션 +export interface DialogOptions { + /** 제목을 따로 지정하면 본문 텍스트는 설명(desc)으로 내려간다 */ + title?: string + /** 아이콘/버튼 색 계열 */ + variant?: DialogVariant + /** 확인 버튼 문구 */ + confirmText?: string + /** 취소 버튼 문구(confirm 전용) */ + cancelText?: string +} + +// 현재 표시 중인 다이얼로그 상태 +export interface DialogState { + id: number + mode: DialogMode + title: string + desc?: string + variant: DialogVariant + confirmText: string + cancelText: string + resolve: (ok: boolean) => void +} + +export const useDialogStore = defineStore('dialog', () => { + // 현재 떠 있는 다이얼로그(없으면 null) + const current = ref(null) + // 동시에 여러 개가 요청되면 순차 표시(큐) + const queue: DialogState[] = [] + let seq = 0 + + function enqueue( + mode: DialogMode, + text: string, + opts: DialogOptions, + defaults: { variant: DialogVariant; confirmText: string }, + ): Promise { + return new Promise((resolve) => { + const item: DialogState = { + id: ++seq, + mode, + // title 을 따로 주면 본문은 설명으로, 아니면 본문이 곧 제목 + title: opts.title ?? text, + desc: opts.title ? text : undefined, + variant: opts.variant ?? defaults.variant, + confirmText: opts.confirmText ?? defaults.confirmText, + cancelText: opts.cancelText ?? '취소', + resolve, + } + if (current.value) queue.push(item) + else current.value = item + }) + } + + // 알림(OK 한 개) — 항상 true 로 resolve + function alert(text: string, opts: DialogOptions = {}): Promise { + return enqueue('alert', text, opts, { variant: 'info', confirmText: '확인' }) + } + + // 확인(취소/확인) — 확인=true, 취소/바깥클릭=false + function confirm(text: string, opts: DialogOptions = {}): Promise { + return enqueue('confirm', text, opts, { variant: 'danger', confirmText: '확인' }) + } + + // 사용자 응답 처리 후 다음 큐로 전환 + function settle(ok: boolean) { + current.value?.resolve(ok) + current.value = queue.shift() ?? null + } + + return { current, alert, confirm, settle } +})