feat(frontend): 주소록 화면 · 메일 연동

레일의 주소록 모듈 — 내 연락처 · 그룹 · 사내 목록(직원 · 공용 주소), 초성 찾기, 연락처 추가 · 고치기 창, 그룹 · 구성원 넣기 —
과 메일 연동(받는 사람 추천의 주소록 · 그룹, 연락처 · 그룹 · 사내의 [메일 쓰기], 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」)을 더한다.
디자인 README "8단계", 계약 3절.

리뷰로 고친 것:
- 쓰기 화면을 채우는 라우터 상태에 연 직원을 싣는다 — 같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가면 앞 직원의 받는 사람이 채워졌다.
- [고치기]는 창을 열 때 새로 받고 바꾼 칸만 보낸다 — 받아 둔 값으로 다른 탭 · 휴대폰의 변경을 덮었다. 검사도 바꾼 칸만.
- [되돌리기]는 서버의 되돌리기 표로 — 다시 만들기로는 다른 프로그램의 칸 · uid 를 잃었다.
- 읽기 화면에서 연 연락처 추가 창을 메일 레이아웃으로 옮겼다 — 메일을 바꾸면 쓴 것이 확인 없이 사라졌다.
- 그룹을 이어 고르면 앞 그룹이 빠지던 것, 저장 중 창을 닫으면 거절이 묻히던 것, 구성원 넣기의 첫 쪽이 모두 구성원이면 막히던 것(excludeGroupId).
- 받는 사람 추천은 초성 · 조합 중 글자도 서버에 묻는다(서버가 같은 맞추기 — 같은 표본 표를 두 쪽 테스트에 둔다).
- 바꿀 때마다 불러온 쪽을 모두 다시 받지 않고 응답을 서버 차례로 그 자리에 넣는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:13:20 +09:00
parent 08cf31fbe6
commit e2c41d38bb
65 changed files with 11130 additions and 214 deletions
@@ -0,0 +1,43 @@
<script setup lang="ts">
// 주소록 모듈 레이아웃 (8단계 — 디자인 원본 Contacts · README "8단계 — 주소록") — 전역 셸 + 모듈 패널(내 주소록 · 그룹 · 사내) + 하위 화면(목록 · 상세)
// - 상단 바의 찾기 칸은 "주소록에서 찾기" — 지금 보는 목록 안에서 찾는다 (ContactsSearchBox)
// - 들어올 때마다 그룹 · 연락처 수 · 사내 목록 수를 새로 받는다 (다른 탭 · 휴대폰 · 메일 프로그램에서 바꿨을 수 있다)
// - 창(연락처 · 그룹 이름 · 구성원 넣기)은 여기서 그린다 — 주소록 안에서 목록 · 고른 연락처가 바뀌어도 닫히지 않고,
// 주소록을 떠날 때만 나가기 확인이 묻는다. 세션이 바뀌면(로그아웃 · 만료) 닫는다 — 세션 만료 대화상자와 겹치지 않게
import { provide, watch } from 'vue'
import ContactsDialogs from '@/components/contacts/ContactsDialogs.vue'
import ContactsPanel from '@/components/contacts/ContactsPanel.vue'
import ContactsSearchBox from '@/components/contacts/ContactsSearchBox.vue'
import AppShell from '@/components/shell/AppShell.vue'
import { CONTACTS_DIALOGS, createContactsDialogs } from '@/composables/useContactsDialogs'
import { useAuthStore } from '@/stores/auth.store'
import { useContactsStore } from '@/stores/contacts.store'
const authStore = useAuthStore()
const contactsStore = useContactsStore()
const dialogs = createContactsDialogs()
provide(CONTACTS_DIALOGS, dialogs)
contactsStore.enterModule()
watch(
() => authStore.generation,
() => dialogs.close(),
)
</script>
<template>
<AppShell module-id="contacts">
<template #search>
<ContactsSearchBox />
</template>
<ContactsPanel />
<RouterView />
</AppShell>
<!-- 창은 셸 밖에 둔다 — 열리면 맨 위 층에 뜬다 -->
<ContactsDialogs
:request="dialogs.request.value"
@close="dialogs.close()"
/>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
<script setup lang="ts">
// 주소록 목록 · 상세 화면 (/contacts · /contacts/all/:contactId · /contacts/groups/:groupId[/:contactId] · /contacts/directory/:kind[/:entryId], 모두 ?q=)
// 화면 상태는 주소가 기준이다: 주소의 목록(전체 · 그룹 · 사내) · 찾을 글이 바뀌면 그 목록을 새로 받고, 고른 항목이 바뀌면 상세만 바꾼다.
// - 고른 연락처 · 사내 항목이 받은 목록에 없으면(주소로 바로 들어옴 · 다음 쪽 · 목록을 못 받음 · 그사이 목록에서 빠짐) 그것만 따로 받는다
// (GET /contacts/{id} · /directory-entries/{id})
// - 목록(ContactsList) 384 · 상세(ContactDetail) — 메일의 분할 보기와 같다 (넓은 화면에서는 칸마다 스크롤)
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import ContactDetail from '@/components/contacts/ContactDetail.vue'
import ContactsList from '@/components/contacts/ContactsList.vue'
import { contactsRouteStateOf, contactsSourceKey, isContactSource } from '@/shared/utils/contacts-route'
import { useContactsStore } from '@/stores/contacts.store'
const route = useRoute()
const store = useContactsStore()
const state = computed(() => contactsRouteStateOf(route))
/** 고른 항목이 받아 둔 것(목록 · 따로 받은 연락처)에 있다 */
const selectedPresent = computed(() => {
const current = state.value
if (!current?.selectedId) return false
return isContactSource(current.source)
? store.contactById(current.selectedId) !== null
: store.entryById(current.selectedId) !== null
})
// 목록 · 찾을 글이 바뀔 때만 다시 받는다 — 연락처를 고르는 것으로는 다시 받지 않는다 (값마다 견준다)
watch(
[() => contactsSourceKey(state.value?.source ?? null), () => state.value?.q ?? null],
() => {
const current = state.value
if (current) void store.openList(current.source, current.q)
},
{ immediate: true },
)
// 고른 항목이 받아 둔 것에 없으면 그것만 따로 받는다(목록을 못 받았어도) — 목록을 받는 동안은 기다린다(첫 쪽에 있을 수 있다)
watch(
[() => state.value?.selectedId ?? null, () => store.listStatus, selectedPresent],
([selectedId, listStatus, present]) => {
const current = state.value
if (!current || !selectedId) {
store.closeDetail()
store.closeEntry()
return
}
if (present || listStatus === 'loading' || listStatus === 'idle') return
if (isContactSource(current.source)) void store.openDetail(selectedId)
else void store.openEntry(selectedId)
},
{ immediate: true },
)
</script>
<template>
<main class="contacts-view">
<template v-if="state">
<ContactsList :state="state" />
<ContactDetail :state="state" />
</template>
</main>
</template>
<style scoped>
.contacts-view {
flex: 999 1 35rem;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
/* 넓은 화면에서는 목록 · 상세가 한 줄에 놓이고 칸마다 스크롤한다 (메일 화면과 같은 이유로 줄바꿈을 끈다) */
@media (min-width: 70rem) {
.contacts-view {
flex-wrap: nowrap;
min-height: 0;
}
}
</style>
@@ -18,6 +18,7 @@ import {
} from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import type { Mailbox, MessageDetail, RejectedRecipient } from '@/shared/types/mail'
import { composePrefillState } from '@/shared/utils/compose-prefill'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -1126,6 +1127,67 @@ describe('ComposePage', () => {
wrapper.unmount()
})
it('주소록에서 받는 사람을 채워 연 새 메일(라우터 상태 — 주소는 URL 에 없다) — 칩으로 채우고 제목에 초점, 손대지 않으면 저장하지도 묻지도 않는다', async () => {
const requests = installMockApi(savable(replyApi))
const router = createTestRouter()
await router.push({
name: 'mail-compose',
state: composePrefillState(
[
{ name: '박지훈', email: 'jihoon.park@hanbit.example' },
{ name: '최유진', email: 'yujin.choi@greencloud.example' },
// 상태는 브라우저가 기억하는 값이라 다시 본다 — 형식이 틀린 주소는 넣지 않는다
{ name: null, email: 'not-an-address' },
],
USER.email,
),
})
await router.isReady()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose')
const wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
await editorLoaded(wrapper)
expect(wrapper.get('h1').text()).toBe('새 메일')
expect(chips(wrapper, 'compose-to')).toEqual([
'박지훈 <jihoon.park@hanbit.example>',
'최유진 <yujin.choi@greencloud.example>',
])
expect(document.activeElement?.id).toBe('compose-subject')
expect(blocksUnload()).toBe(false)
await router.push('/mail/inbox')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(dialogOpen(wrapper)).toBe(false)
expect(draftSaves(requests)).toEqual([])
})
it('채운 받는 사람을 실은 직원이 지금 직원과 다르면(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기) 채우지 않는다 — 빈 새 메일, 초점은 받는 사람', async () => {
installMockApi(savable(replyApi))
const router = createTestRouter()
await router.push({
name: 'mail-compose',
state: composePrefillState([{ name: '박지훈', email: 'jihoon.park@hanbit.example' }], 'doyun.lee@arasystem.it.kr'),
})
await router.isReady()
const wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: screenProvide(router) },
})
mountedWrappers.push(wrapper)
await flushPromises()
await editorLoaded(wrapper)
expect(chips(wrapper, 'compose-to')).toEqual([])
expect(document.activeElement?.id).toBe('compose-to')
})
it('손대지 않은 전달 초안(원본 첨부 포함)은 저장하지도 묻지도 않는다 — 원본 첨부를 빼면 바꾼 것이라 떠나기 전에 저장한다', async () => {
const first = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
expect(blocksUnload()).toBe(false)
+11 -1
View File
@@ -3,6 +3,8 @@
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 원문 인용을 채운다.
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 인용 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
// 이어 쓰기: ?draft=<임시 저장본 id> → 저장본을 불러와 채운다 (서명은 다시 넣지 않는다). 제목은 답장 · 전달 · 새 메일 중 하나.
// 받는 사람을 채운 새 메일(8단계 — 주소록의 [메일 쓰기] · 주소 링크 · [그룹에 메일 쓰기]): 주소는 URL 이 아니라 라우터 상태로 온다(compose-prefill.ts).
// 채운 받는 사람은 "바꿈"이 아니다(답장처럼 — 손대지 않고 닫으면 저장하지 않는다). 초점은 제목으로 (받는 사람은 이미 있다)
// 서명: 열 때 설정의 서명을 보통 글로 넣어 둔다 — 새 메일은 본문 끝(빈 줄 하나 뒤), 답장 · 전달은 인용 위 (디자인 README "2단계 › 서명").
// 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장", useDraftSaving):
// - 바꾼 내용이 있으면 입력이 3초 멈출 때(10초에 한 번까지 — 쉬지 않고 쓰면 처음 바꾼 뒤 30초에) · 「임시 저장」을 누를 때 ·
@@ -89,6 +91,7 @@ import {
snapshotComposeForm,
type ComposeFormSnapshot,
} from '@/shared/utils/compose-changes'
import { composePrefillOf } from '@/shared/utils/compose-prefill'
import { isImageFile } from '@/shared/utils/editor-files'
import {
createIdempotencyKey,
@@ -973,8 +976,15 @@ watch(
} else if (id && next !== 'new' && next !== 'draft') {
void prepareFrom(id, next)
} else {
// 주소록에서 받는 사람을 채워 연 새 메일 — 처음 채운 받는 사람은 바꿈이 아니다 (저장한 양식으로 둔다).
// 상태를 실은 직원이 지금 직원과 다르면 쓰지 않는다 (같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기 — compose-prefill.ts)
const prefilled = next === 'new' ? composePrefillOf(router.options.history.state, authStore.user?.email) : []
if (prefilled.length > 0) {
to.value = prefilled.map(tokenFromAddress)
savedForm.value = snapshotForm()
}
void prepareNew()
void nextTick(() => toField.value?.focus())
void nextTick(() => (prefilled.length > 0 ? subjectInputRef.value?.focus() : toField.value?.focus()))
}
},
{ immediate: true },
+7
View File
@@ -4,10 +4,15 @@
// 메일 화면에 있는 동안: 상단 검색 칸은 "메일 검색", 새 메일 · 다른 창의 변화를 맞춘다(실시간 소식 — 끊겼으면 1분 확인),
// 탭 제목 앞에 받은편지함의 안 읽은 수 (디자인 README "3단계" · "5단계 › 실시간 반영"). 실시간 연결 자체는 앱 전체에서 돈다(App.vue)
// 들어올 때 부재중 자동 응답을 한 번 받는다 — 켜져 있으면 메일함 패널 아래에 안내 (계약 2절 "부재중 자동 응답")
// 읽기 화면 ⋯ 「보낸 사람을 주소록에 추가」의 창은 여기에 둔다 — 메일이 바뀌어도 사라지지 않게 (useMailContactDialog)
import { provide } from 'vue'
import AppShell from '@/components/shell/AppShell.vue'
import MailContactDialogHost from '@/components/mail/MailContactDialogHost.vue'
import MailSearchBox from '@/components/mail/MailSearchBox.vue'
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
import { useClock } from '@/composables/useClock'
import { createMailContactDialog, MAIL_CONTACT_DIALOG } from '@/composables/useMailContactDialog'
import { useMailStatePolling } from '@/composables/useMailStatePolling'
import { useMailTabTitle } from '@/composables/useMailTabTitle'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
@@ -15,6 +20,7 @@ import { useMailVacationStore } from '@/stores/mail-vacation.store'
import { useMailStore } from '@/stores/mail.store'
const mailStore = useMailStore()
provide(MAIL_CONTACT_DIALOG, createMailContactDialog())
// 하위 화면(목록·본문)보다 먼저 요청을 보낸다 — 메일함 개수가 본문 읽음 처리보다 늦게 도착하는 일을 줄인다
void mailStore.ensureMailboxes()
@@ -46,4 +52,5 @@ useMailTabTitle()
<MailboxPanel />
<RouterView />
</AppShell>
<MailContactDialogHost />
</template>