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 @@
+
+
+
+
+
+
+
+
+
+ {{ current.title }}
+
+
+ {{ current.desc }}
+
+
+
+
+
+
+
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 }
+})