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,86 @@
// 받는 사람 맞추기 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다)
// 쓰기 화면의 맞추기(shared/utils/recipient-match.ts 의 matchName · matchEmail)와 서버의 q 맞추기(계약 2절 "받는 사람 추천")를 같은 표로 본다.
/**
* 표본 표 — [찾는 글(q), 이름(없으면 null), 주소, 맞는가]. 답은 화면의 matchName · matchEmail 로 만들었다(손으로 적지 않았다).
* ⚠️ backend `src/modules/mail/recipient-match.spec.ts` 의 RECIPIENT_MATCH_SAMPLES 와 **같은 표**다(행과 순서까지 그대로).
* 한쪽을 고치면 둘 다 고친다 — 맞추기 규칙을 바꾸면 두 recipient-match.ts 와 두 표를 함께 (서버가 다르게 맞추면
* 쓰기 화면이 받아 둔 것에서 찾은 것과 서버의 답이 어긋난다). 서버도 NFC 로 맞추지 않는다 — 화면의 강조 범위가 원래 글 기준이라
*/
export const RECIPIENT_MATCH_SAMPLES: readonly (readonly [
query: string,
name: string | null,
email: string,
matches: boolean,
])[] = [
['도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['Lee', 'Doyun Lee', 'd.lee@example.com', true],
['lEE', 'Doyun Lee', 'd.lee@example.com', true],
['윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['박', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false],
['ㄲ', '꽃님', 'flower@example.com', true],
['ㄱ', '꽃님', 'flower@example.com', false],
['ㅃ', '빵집', 'bread@example.com', true],
['도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['이동', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['돈', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도요', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['유', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['윤', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['박지', '박지훈', 'jihoon.park@hanbit.co.kr', true],
['닭', '달걀', 'egg@example.com', true],
['넋', '너서', 'neo@example.com', false],
['값', '가비', 'gabi@example.com', false],
['값', '갑시다', 'gapsi@example.com', true],
['갑', '갑시다', 'gapsi@example.com', true],
['ㄳ', '넋', 'neok@example.com', false],
['ㄱ', '넋', 'neok@example.com', false],
['김 아', '김 아라', 'ara.kim@arasystem.it.kr', true],
['김아', '김 아라', 'ara.kim@arasystem.it.kr', false],
['ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true],
['ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false],
[' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true],
['doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false],
['@arasystem', '', 'doyun.lee@arasystem.it.kr', true],
['lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true],
['a@b@c', '', 'a@x.example', true],
['Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true],
['kr', null, 'doyun.lee@arasystem.it.kr', true],
['istanbul', null, 'İstanbul@example.com', false],
['example', null, 'İstanbul@example.com', false],
['도윤', null, 'doyun.lee@arasystem.it.kr', false],
['도윤', '', 'x@example.com', false],
['😀', '팀😀', 'team@example.com', true],
['팀😀', '팀😀', 'team@example.com', true],
['😀팀', '팀😀', 'team@example.com', false],
['𠀋', '𠀋田', 'kanji@example.com', true],
['ㅏ', '아라', 'ara@example.com', false],
['ㅇㅏ', '아라', 'ara@example.com', false],
['2팀', '영업2팀', 'sales2@example.com', true],
['(주)', '(주)한빛', 'hanbit@example.com', true],
['도윤'.normalize('NFD'), '이도윤', 'doyun.lee@arasystem.it.kr', false],
['도윤', '이도윤'.normalize('NFD'), 'x@example.com', false],
['ㄱ'.repeat(33), 'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가', 'long@example.com', true],
['가'.repeat(32) + '나', '가'.repeat(40) + '나', 'long@example.com', true],
['가'.repeat(31) + '나다', '가'.repeat(40) + '나다', 'long@example.com', true],
['가'.repeat(31) + '나라', '가'.repeat(40) + '나다', 'long@example.com', false],
]
+22
View File
@@ -66,6 +66,28 @@ export function createTestRouter(): Router {
component: Empty,
meta: { requiresAuth: true },
},
// 주소록 (8단계 — router/routes.ts 와 같은 이름 · 모양)
{ path: '/contacts', name: 'contacts', component: Empty, meta: { requiresAuth: true } },
{ path: '/contacts/all/:contactId', name: 'contacts-contact', component: Empty, meta: { requiresAuth: true } },
{ path: '/contacts/groups/:groupId', name: 'contacts-group', component: Empty, meta: { requiresAuth: true } },
{
path: '/contacts/groups/:groupId/:contactId',
name: 'contacts-group-contact',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/contacts/directory/:kind(people|shared)',
name: 'contacts-directory',
component: Empty,
meta: { requiresAuth: true },
},
{
path: '/contacts/directory/:kind(people|shared)/:entryId',
name: 'contacts-directory-entry',
component: Empty,
meta: { requiresAuth: true },
},
],
})
}