generated from ttipo/project-base
docs: 8단계(주소록) 계약과 디자인
주소록(내 연락처 · 그룹 · 사내 목록)과 메일 연동의 계약(3절)을 정하고, 확정한 캔버스 보드 셋(Contacts · ContactsEdit · ContactsMail)을 둔다. 리뷰(서버 · 화면)로 정한 것도 함께 적었다 — 되돌리기는 서버가 원본을 5분 기억했다가 그대로 다시 만들고, 고치기는 PATCH(바꾼 칸만 · 칸 안의 남의 속성을 지킨다), 받는 사람 추천의 q 는 쓰기 화면과 같은 맞추기, 주소록 크기 한도 800만(캐시가 통째로 기억할 수 있게), excludeGroupId · 사내 항목 하나 · q 254자, 쓰기 화면 채우기는 연 직원에게 묶는다. 엔진이 한 요청의 destroy 에 같은 id 가 두 번 있으면 통째로 serverUnavailable 로 거절하는 것도 적었다(2026-10-29 확인). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>주소록</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67;color:#FFFFFF}
|
||||
.ghost{background:transparent;color:#344054}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.icon-btn{background:transparent;color:#4A5363}
|
||||
.icon-btn:hover{background:#EEF0F3;color:#171C26}
|
||||
.row:hover{background:#F8F9FB}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="min-height: 100vh; display: flex; background: #FFFFFF; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<nav aria-label="사내 시스템" style="flex: 0 0 72px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 10px 0 12px; background: #0B3640">
|
||||
<a href="Main.dc.html" style="display: inline-flex; align-items: center; justify-content: center; width: 60px; min-height: 44px; border-radius: 10px; text-decoration: none">
|
||||
<img src="/_blob/2b9ef4349333debc969432c859eaf1bf" alt="ARA SYSTEM" width="48" height="17" style="display: block; width: 48px; height: auto">
|
||||
</a>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<li>
|
||||
<a href="Main.dc.html" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span>메일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
<span>캘린더</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>파일</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path></svg>
|
||||
<span>ERP</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="Contacts.dc.html" aria-current="page" style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; background: #155A64; color: #FFFFFF; font-size: 11px; font-weight: 600; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>주소록</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="#" style="margin-top: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; width: 60px; min-height: 56px; border-radius: 10px; color: #A3C2C6; font-size: 11px; font-weight: 500; text-decoration: none">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
<span>설정</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column">
|
||||
|
||||
<header style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; box-sizing: border-box; min-height: 56px; padding: 6px 12px 6px 20px; border-bottom: 1px solid #E6E8EC">
|
||||
<h1 style="margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em">주소록</h1>
|
||||
<div style="flex: 1 1 280px; max-width: 520px; display: flex; align-items: center; gap: 8px; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #8A94A3; border-radius: 8px; background: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="주소록에서 찾기" placeholder="이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)" value="{{ q }}" onInput="{{ setQ }}" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit; color: #171C26">
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 10px">
|
||||
<span aria-hidden="true" style="display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 13px; font-weight: 600">김</span>
|
||||
<div style="display: flex; flex-direction: column; line-height: 1.3">
|
||||
<span style="font-weight: 600">김아라</span>
|
||||
<span style="font-size: 12px; color: #667085">ara.kim@arasystem.it.kr</span>
|
||||
</div>
|
||||
<a href="Login.dc.html" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; color: #4A5363; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="flex: 1 1 auto; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<nav aria-label="주소록" style="flex: 1 1 208px; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 14px 10px; background: #F8F9FB; border-right: 1px solid #E6E8EC">
|
||||
<a href="ContactsEdit.dc.html" class="primary" style="display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border-radius: 8px; font-size: 13.5px; font-weight: 600; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
||||
<span>연락처 추가</span>
|
||||
</a>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; padding: 0 10px 4px; font-size: 12px; font-weight: 600; color: #667085">내 주소록</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ mine }}" as="s" hint-placeholder-count="1">
|
||||
<li>
|
||||
<button type="button" onClick="{{ s.pick }}" aria-current="{{ s.current }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-weight: {{ s.fw }}; text-align: left; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
|
||||
<span style="flex: 1 1 auto">{{ s.label }}</span>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ s.count }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; padding: 0 10px 4px; font-size: 12px; font-weight: 600; color: #667085">그룹</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ groups }}" as="s" hint-placeholder-count="2">
|
||||
<li style="display: flex; align-items: center; border-radius: 7px; background: {{ s.bg }}">
|
||||
<button type="button" onClick="{{ s.pick }}" aria-current="{{ s.current }}" style="flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 4px 0 10px; border: 0; border-radius: 7px; background: transparent; color: {{ s.fg }}; font: inherit; font-weight: {{ s.fw }}; text-align: left; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{{ s.label }}</span>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ s.count }}</span>
|
||||
</button>
|
||||
<a href="ContactsEdit.dc.html" class="icon-btn" aria-label="{{ s.menuLabel }}" style="flex: 0 0 auto; width: 32px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.6"></circle><circle cx="12" cy="12" r="1.6"></circle><circle cx="19" cy="12" r="1.6"></circle></svg>
|
||||
</a>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
<a href="ContactsEdit.dc.html" class="ghost" style="display: flex; align-items: center; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border-radius: 7px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>새 그룹</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; padding: 0 10px 4px; font-size: 12px; font-weight: 600; color: #667085">사내</h2>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<sc-for list="{{ company }}" as="s" hint-placeholder-count="2">
|
||||
<li>
|
||||
<button type="button" onClick="{{ s.pick }}" aria-current="{{ s.current }}" style="width: 100%; display: flex; align-items: center; gap: 10px; min-height: 40px; box-sizing: border-box; padding: 0 10px; border: 0; border-radius: 7px; background: {{ s.bg }}; color: {{ s.fg }}; font: inherit; font-weight: {{ s.fw }}; text-align: left; cursor: pointer">
|
||||
<sc-if value="{{ s.isStaff }}" hint-placeholder-val="{{ true }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg></sc-if>
|
||||
<sc-if value="{{ s.isShared }}" hint-placeholder-val="{{ false }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg></sc-if>
|
||||
<span style="flex: 1 1 auto">{{ s.label }}</span>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ s.count }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main style="flex: 999 1 560px; min-width: 0; display: flex; flex-wrap: wrap; align-items: stretch">
|
||||
|
||||
<section aria-labelledby="contacts-list-title" style="flex: 1 1 384px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; border-right: 1px solid #E6E8EC">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-height: 52px; box-sizing: border-box; padding: 4px 8px 4px 16px; border-bottom: 1px solid #E6E8EC">
|
||||
<h2 id="contacts-list-title" style="margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em">{{ listTitle }}</h2>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">{{ countText }}</span>
|
||||
<sc-if value="{{ isGroup }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="margin-left: auto; display: flex; gap: 2px">
|
||||
<a href="ContactsEdit.dc.html" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>구성원 넣기</span>
|
||||
</a>
|
||||
<a href="Compose.dc.html" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 10px; border-radius: 8px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>그룹에 메일 쓰기</span>
|
||||
</a>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ isDirectory }}" hint-placeholder-val="{{ false }}">
|
||||
<p style="margin: 0; padding: 8px 16px; border-bottom: 1px solid #EEF0F3; background: #F8F9FB; font-size: 12.5px; color: #4A5363">{{ directoryNote }}</p>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ hasItems }}" hint-placeholder-val="{{ true }}">
|
||||
<ul style="list-style: none; margin: 0; padding: 0">
|
||||
<sc-for list="{{ items }}" as="item" hint-placeholder-count="7">
|
||||
<li style="border-bottom: 1px solid #EEF0F3; background: {{ item.bg }}">
|
||||
<button type="button" class="row" onClick="{{ item.pick }}" aria-current="{{ item.current }}" style="width: 100%; min-height: 56px; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 7px 16px; border: 0; background: transparent; font: inherit; color: inherit; text-align: left; cursor: pointer">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: {{ item.avatarBg }}; color: {{ item.avatarFg }}; font-size: 13px; font-weight: 600">{{ item.initial }}</span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.4">
|
||||
<span style="display: flex; align-items: baseline; gap: 8px; min-width: 0">
|
||||
<span style="flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600">{{ item.name }}</span>
|
||||
<span style="flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #667085">{{ item.meta }}</span>
|
||||
</span>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">{{ item.email }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</sc-for>
|
||||
</ul>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ noItems }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 80px 24px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">{{ emptyText }}</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
</section>
|
||||
|
||||
<div style="flex: 999 1 480px; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column">
|
||||
<sc-if value="{{ hasSel }}" hint-placeholder-val="{{ true }}">
|
||||
<article aria-labelledby="contact-name" style="display: flex; flex-direction: column">
|
||||
<div style="border-bottom: 1px solid #E6E8EC">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 52px; max-width: 800px; box-sizing: border-box; padding: 4px 24px 4px 22px">
|
||||
<a href="Compose.dc.html" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>메일 쓰기</span>
|
||||
</a>
|
||||
<sc-if value="{{ d.isMine }}" hint-placeholder-val="{{ true }}">
|
||||
<a href="ContactsEdit.dc.html" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
<span>고치기</span>
|
||||
</a>
|
||||
<a href="ContactsEdit.dc.html" class="icon-btn" aria-label="더 보기" style="margin-left: auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; text-decoration: none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.7"></circle><circle cx="12" cy="12" r="1.7"></circle><circle cx="19" cy="12" r="1.7"></circle></svg>
|
||||
</a>
|
||||
</sc-if>
|
||||
<sc-if value="{{ d.isDirectory }}" hint-placeholder-val="{{ false }}">
|
||||
<a href="ContactsEdit.dc.html" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 0 10px; border-radius: 8px; font-weight: 500; text-decoration: none">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>내 주소록에 추가</span>
|
||||
</a>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px; max-width: 800px; box-sizing: border-box; padding: 22px 32px 36px">
|
||||
<div style="display: flex; align-items: center; gap: 14px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 18px; font-weight: 600">{{ d.initial }}</span>
|
||||
<div style="min-width: 0; display: flex; flex-direction: column; gap: 2px">
|
||||
<h1 id="contact-name" style="margin: 0; font-size: 20px; line-height: 1.35; font-weight: 650; letter-spacing: -0.015em">{{ d.name }}</h1>
|
||||
<span style="font-size: 13px; color: #4A5363">{{ d.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl style="display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 12px 16px; margin: 0">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">이메일</dt>
|
||||
<dd style="margin: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<sc-for list="{{ d.emails }}" as="e" hint-placeholder-count="1">
|
||||
<span style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<a href="Compose.dc.html" style="font-weight: 500; text-decoration: none">{{ e.address }}</a>
|
||||
<span style="font-size: 12px; color: #667085">{{ e.label }}</span>
|
||||
</span>
|
||||
</sc-for>
|
||||
</dd>
|
||||
<sc-if value="{{ d.hasPhones }}" hint-placeholder-val="{{ true }}">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">전화</dt>
|
||||
<dd style="margin: 0; display: flex; flex-direction: column; gap: 4px">
|
||||
<sc-for list="{{ d.phones }}" as="p" hint-placeholder-count="1">
|
||||
<span style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px">
|
||||
<span style="font-variant-numeric: tabular-nums">{{ p.number }}</span>
|
||||
<span style="font-size: 12px; color: #667085">{{ p.label }}</span>
|
||||
</span>
|
||||
</sc-for>
|
||||
</dd>
|
||||
</sc-if>
|
||||
<sc-if value="{{ d.hasOrg }}" hint-placeholder-val="{{ true }}">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">소속</dt>
|
||||
<dd style="margin: 0">{{ d.org }}</dd>
|
||||
</sc-if>
|
||||
<sc-if value="{{ d.hasTitle }}" hint-placeholder-val="{{ true }}">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">직책</dt>
|
||||
<dd style="margin: 0">{{ d.title }}</dd>
|
||||
</sc-if>
|
||||
<sc-if value="{{ d.hasGroups }}" hint-placeholder-val="{{ true }}">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">그룹</dt>
|
||||
<dd style="margin: 0; display: flex; flex-wrap: wrap; gap: 2px 12px">
|
||||
<sc-for list="{{ d.groups }}" as="g" hint-placeholder-count="1">
|
||||
<button type="button" onClick="{{ g.pick }}" style="padding: 0; border: 0; background: transparent; color: #0E7C86; font: inherit; font-weight: 500; cursor: pointer">{{ g.label }}</button>
|
||||
</sc-for>
|
||||
</dd>
|
||||
</sc-if>
|
||||
<sc-if value="{{ d.hasNote }}" hint-placeholder-val="{{ false }}">
|
||||
<dt style="font-size: 12.5px; font-weight: 600; color: #667085; padding-top: 1px">메모</dt>
|
||||
<dd style="margin: 0; white-space: pre-line; color: #1F2733">{{ d.note }}</dd>
|
||||
</sc-if>
|
||||
</dl>
|
||||
|
||||
<sc-if value="{{ d.isDirectory }}" hint-placeholder-val="{{ false }}">
|
||||
<p style="margin: 0; padding-top: 14px; border-top: 1px solid #EEF0F3; font-size: 12.5px; color: #667085">{{ d.directoryHint }}</p>
|
||||
</sc-if>
|
||||
</div>
|
||||
</article>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ noSel }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="flex: 1 1 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 520px; padding: 24px; text-align: center">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
<p style="margin: 6px 0 0; font-size: 14px; font-weight: 600">연락처를 고르세요</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">목록에서 고르면 주소 · 전화번호가 여기에 보입니다.</p>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":900}}'>
|
||||
class Component extends DCLogic {
|
||||
state = { source: 'all', sel: 'c2', q: '' };
|
||||
|
||||
contacts() {
|
||||
return [
|
||||
{ id: 'c1', name: '강서윤', company: '(주)한빛상사', dept: '구매팀', title: '과장', emails: [{ address: 'seoyun.kang@hanbit.example', label: '회사' }], phones: [{ number: '010-2481-3307', label: '휴대전화' }, { number: '02-555-0147', label: '회사' }], groups: ['g1'], note: '' },
|
||||
{ id: 'c2', name: '박지훈', company: '(주)한빛상사', dept: '영업팀', title: '팀장', emails: [{ address: 'jihoon.park@hanbit.example', label: '회사' }, { address: 'jihoon.p@mail.example', label: '개인' }], phones: [{ number: '010-3192-5540', label: '휴대전화' }], groups: ['g1', 'g2'], note: '견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.\n월말 마감 주간에는 회신이 늦다.' },
|
||||
{ id: 'c3', name: '오세훈', company: '그린클라우드', dept: '기술팀', title: '책임', emails: [{ address: 'sehun.oh@greencloud.example', label: '회사' }], phones: [], groups: ['g2'], note: '' },
|
||||
{ id: 'c4', name: '윤태호', company: '서린디자인', dept: '', title: '대표', emails: [{ address: 'taeho@seorin.example', label: '회사' }], phones: [{ number: '010-7714-2093', label: '휴대전화' }], groups: ['g2'], note: '' },
|
||||
{ id: 'c5', name: '이수빈', company: '바른회계법인', dept: '', title: '회계사', emails: [{ address: 'subin.lee@bareun.example', label: '회사' }], phones: [{ number: '02-6203-1180', label: '회사' }], groups: [], note: '' },
|
||||
{ id: 'c6', name: '정민호', company: '(주)한빛상사', dept: '기술지원팀', title: '', emails: [{ address: 'minho.jung@hanbit.example', label: '회사' }], phones: [], groups: ['g1'], note: '' },
|
||||
{ id: 'c7', name: '최유진', company: '그린클라우드', dept: '영업팀', title: '매니저', emails: [{ address: 'yujin.choi@greencloud.example', label: '회사' }], phones: [{ number: '010-5520-8812', label: '휴대전화' }], groups: ['g1', 'g2'], note: '' },
|
||||
{ id: 'c8', name: '한도경', company: '', dept: '', title: '', emails: [{ address: 'dokyung.han@mail.example', label: '개인' }], phones: [{ number: '010-9034-6621', label: '휴대전화' }], groups: [], note: '대학 동기' },
|
||||
];
|
||||
}
|
||||
|
||||
staff() {
|
||||
return [
|
||||
{ id: 's1', name: '김아라', email: 'ara.kim@arasystem.it.kr' },
|
||||
{ id: 's2', name: '박민수', email: 'minsu.park@arasystem.it.kr' },
|
||||
{ id: 's3', name: '박서준', email: 'seojun.park@arasystem.it.kr' },
|
||||
{ id: 's4', name: '박준호', email: 'junho.park@arasystem.it.kr' },
|
||||
{ id: 's5', name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
{ id: 's6', name: '정하늘', email: 'haneul.jung@arasystem.it.kr' },
|
||||
{ id: 's7', name: '최서연', email: 'seoyeon.choi@arasystem.it.kr' },
|
||||
];
|
||||
}
|
||||
|
||||
shared() {
|
||||
return [
|
||||
{ id: 'p1', name: '보안팀', email: 'security@arasystem.it.kr' },
|
||||
{ id: 'p2', name: '인사팀', email: 'hr@arasystem.it.kr' },
|
||||
{ id: 'p3', name: '인프라팀', email: 'infra@arasystem.it.kr' },
|
||||
{ id: 'p4', name: '전체 구성원', email: 'all@arasystem.it.kr' },
|
||||
{ id: 'p5', name: '총무팀', email: 'ga@arasystem.it.kr' },
|
||||
];
|
||||
}
|
||||
|
||||
renderVals() {
|
||||
const st = this.state;
|
||||
const GROUPS = [{ id: 'g1', name: '거래처' }, { id: 'g2', name: '프로젝트 알파' }];
|
||||
const CHO = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
|
||||
const initials = (text) => Array.from(text).map((ch) => {
|
||||
const code = ch.charCodeAt(0) - 0xac00;
|
||||
return code >= 0 && code <= 11171 ? CHO[Math.floor(code / 588)] : ch;
|
||||
}).join('');
|
||||
const q = st.q.trim().toLowerCase();
|
||||
const choOnly = q.length > 0 && Array.from(q).every((ch) => CHO.includes(ch));
|
||||
const matches = (fields, name) => {
|
||||
if (!q) return true;
|
||||
if (choOnly) return initials(name).includes(q);
|
||||
const hay = fields.join(' ').toLowerCase();
|
||||
return hay.includes(q) || hay.replace(/-/g, '').includes(q.replace(/-/g, ''));
|
||||
};
|
||||
|
||||
const people = this.contacts();
|
||||
const staff = this.staff();
|
||||
const shared = this.shared();
|
||||
const pickSource = (id) => () => this.setState({ source: id, sel: null });
|
||||
const navItem = (id, label, count, extra) => {
|
||||
const active = st.source === id;
|
||||
return {
|
||||
label,
|
||||
count,
|
||||
current: active ? 'page' : 'false',
|
||||
bg: active ? '#E3F2F3' : 'transparent',
|
||||
fg: active ? '#0A5F67' : '#344054',
|
||||
fw: active ? '600' : '500',
|
||||
pick: pickSource(id),
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
const mine = [navItem('all', '전체 연락처', people.length, {})];
|
||||
const groups = GROUPS.map((g) => navItem(g.id, g.name, people.filter((c) => c.groups.includes(g.id)).length, { menuLabel: `‘${g.name}’ 그룹 메뉴` }));
|
||||
const company = [
|
||||
navItem('staff', '직원', staff.length, { isStaff: true, isShared: false }),
|
||||
navItem('shared', '공용 주소', shared.length, { isStaff: false, isShared: true }),
|
||||
];
|
||||
|
||||
const directory = st.source === 'staff' || st.source === 'shared';
|
||||
let rows;
|
||||
if (directory) {
|
||||
const list = st.source === 'staff' ? staff : shared;
|
||||
rows = list
|
||||
.filter((x) => matches([x.name, x.email], x.name))
|
||||
.map((x) => ({ id: x.id, name: x.name, meta: st.source === 'staff' ? '' : '공용 주소', email: x.email, kind: st.source, raw: x }));
|
||||
} else {
|
||||
const inGroup = st.source === 'all' ? people : people.filter((c) => c.groups.includes(st.source));
|
||||
rows = inGroup
|
||||
.filter((c) => matches([c.name, c.company, c.dept, c.title, ...c.emails.map((e) => e.address), ...c.phones.map((p) => p.number)], c.name))
|
||||
.map((c) => ({ id: c.id, name: c.name, meta: [c.company, c.dept].filter(Boolean).join(' · '), email: c.emails[0] ? c.emails[0].address : '', kind: 'mine', raw: c }));
|
||||
}
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
||||
const sel = rows.find((r) => r.id === st.sel) || rows[0] || null;
|
||||
|
||||
const items = rows.map((r) => {
|
||||
const selected = sel && r.id === sel.id;
|
||||
return {
|
||||
name: r.name,
|
||||
meta: r.meta,
|
||||
email: r.email,
|
||||
initial: r.name.replace('(주)', '').slice(0, 1),
|
||||
current: selected ? 'true' : 'false',
|
||||
bg: selected ? '#E3F2F3' : '#FFFFFF',
|
||||
avatarBg: r.kind === 'shared' ? '#EEF0F3' : '#E3F2F3',
|
||||
avatarFg: r.kind === 'shared' ? '#344054' : '#0A5F67',
|
||||
pick: () => this.setState({ sel: r.id }),
|
||||
};
|
||||
});
|
||||
|
||||
const titles = { all: '전체 연락처', staff: '직원', shared: '공용 주소' };
|
||||
const group = GROUPS.find((g) => g.id === st.source);
|
||||
const listTitle = group ? group.name : titles[st.source];
|
||||
const unit = st.source === 'shared' ? '개' : '명';
|
||||
const countText = q ? `찾은 결과 ${rows.length}${unit}` : `${rows.length}${unit}`;
|
||||
|
||||
let d = { isMine: false, isDirectory: false, name: '', subtitle: '', initial: '', emails: [], phones: [], hasPhones: false, org: '', hasOrg: false, title: '', hasTitle: false, groups: [], hasGroups: false, note: '', hasNote: false, directoryHint: '' };
|
||||
if (sel && sel.kind === 'mine') {
|
||||
const c = sel.raw;
|
||||
const org = [c.company, c.dept].filter(Boolean).join(' ');
|
||||
d = {
|
||||
isMine: true,
|
||||
isDirectory: false,
|
||||
name: c.name,
|
||||
subtitle: [c.company, c.dept, c.title].filter(Boolean).join(' · ') || '소속 없음',
|
||||
initial: c.name.slice(0, 1),
|
||||
emails: c.emails,
|
||||
phones: c.phones,
|
||||
hasPhones: c.phones.length > 0,
|
||||
org,
|
||||
hasOrg: org.length > 0,
|
||||
title: c.title,
|
||||
hasTitle: c.title.length > 0,
|
||||
groups: c.groups.map((id) => {
|
||||
const g = GROUPS.find((x) => x.id === id);
|
||||
return { label: g.name, pick: () => this.setState({ source: g.id, sel: c.id }) };
|
||||
}),
|
||||
hasGroups: c.groups.length > 0,
|
||||
note: c.note,
|
||||
hasNote: c.note.length > 0,
|
||||
directoryHint: '',
|
||||
};
|
||||
} else if (sel) {
|
||||
const x = sel.raw;
|
||||
d = {
|
||||
isMine: false,
|
||||
isDirectory: true,
|
||||
name: x.name,
|
||||
subtitle: sel.kind === 'staff' ? '사내 직원' : '공용 주소',
|
||||
initial: x.name.slice(0, 1),
|
||||
emails: [{ address: x.email, label: '' }],
|
||||
phones: [],
|
||||
hasPhones: false,
|
||||
org: '',
|
||||
hasOrg: false,
|
||||
title: '',
|
||||
hasTitle: false,
|
||||
groups: [],
|
||||
hasGroups: false,
|
||||
note: '',
|
||||
hasNote: false,
|
||||
directoryHint: '사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
q: st.q,
|
||||
setQ: (event) => this.setState({ q: event.target.value }),
|
||||
mine,
|
||||
groups,
|
||||
company,
|
||||
listTitle,
|
||||
countText,
|
||||
isGroup: Boolean(group),
|
||||
isDirectory: directory,
|
||||
directoryNote: st.source === 'staff' ? '메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다.' : '여러 사람이 함께 받는 사내 주소입니다.',
|
||||
items,
|
||||
hasItems: items.length > 0,
|
||||
noItems: items.length === 0,
|
||||
emptyText: q ? `‘${st.q.trim()}’에 맞는 ${directory ? '주소가' : '연락처가'} 없습니다` : '연락처가 없습니다',
|
||||
hasSel: Boolean(sel),
|
||||
noSel: !sel,
|
||||
d,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,371 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>연락처 추가 · 고치기 · 그룹 · 상태</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.danger{background:transparent;color:#B42318}
|
||||
.danger:hover{background:#FEF3F2;color:#912018}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.field:focus{outline:none;border-color:#0E7C86;box-shadow:0 0 0 1px #0E7C86}
|
||||
.field.is-error{border-color:#B42318}
|
||||
.icon-btn{background:transparent;color:#4A5363}
|
||||
.icon-btn:hover{background:#F2F4F7;color:#171C26}
|
||||
.menu-item{background:transparent;color:#344054}
|
||||
.menu-item:hover{background:#EEF0F3}
|
||||
.toast-action{background:transparent;color:#9ED6DB}
|
||||
.toast-action:hover{background:rgba(255,255,255,0.1)}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1640px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 900px">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">연락처 추가 · 고치기 · 그룹 · 상태</h1>
|
||||
<p style="margin: 0; font-size: 13.5px; color: #4A5363">내 주소록은 메일 서버(엔진)의 주소록에 저장됩니다. 창의 모양 · 단추 · 나가기 확인은 필터 고치기 창과 같습니다. 오류 문구는 에러 코드 사전에서 가져옵니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 620px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="ct-add-title" style="box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 16px">
|
||||
<h2 id="ct-add-title" style="margin: 0; font-size: 15px; font-weight: 700">연락처 추가</h2>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="ct-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름</label>
|
||||
<input id="ct-name" class="field" type="text" value="박지훈" maxlength="100" style="height: 44px; padding: 0 12px">
|
||||
</div>
|
||||
|
||||
<fieldset style="display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">이메일</legend>
|
||||
<div style="display: grid; grid-template-columns: 112px minmax(0, 1fr) 36px; align-items: center; gap: 8px">
|
||||
<select class="field" aria-label="첫째 이메일의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option selected>회사</option>
|
||||
<option>개인</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
<input class="field" type="email" aria-label="첫째 이메일" value="jihoon.park@hanbit.example" maxlength="254" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
<button type="button" class="icon-btn" aria-label="첫째 이메일 빼기" style="width: 36px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
<select class="field" aria-label="둘째 이메일의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option>회사</option>
|
||||
<option selected>개인</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
<input class="field" type="email" aria-label="둘째 이메일" value="jihoon.p@mail.example" maxlength="254" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
<button type="button" class="icon-btn" aria-label="둘째 이메일 빼기" style="width: 36px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 500; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>이메일 더하기</span>
|
||||
</button>
|
||||
<span style="font-size: 12px; color: #667085">5개까지 · 받는 사람 칸에는 맨 위 주소가 먼저 나옵니다</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 6px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">전화번호</legend>
|
||||
<div style="display: grid; grid-template-columns: 112px minmax(0, 1fr) 36px; align-items: center; gap: 8px">
|
||||
<select class="field" aria-label="첫째 전화번호의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option selected>휴대전화</option>
|
||||
<option>회사</option>
|
||||
<option>집</option>
|
||||
<option>기타</option>
|
||||
</select>
|
||||
<input class="field" type="tel" aria-label="첫째 전화번호" value="010-3192-5540" maxlength="40" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
<button type="button" class="icon-btn" aria-label="첫째 전화번호 빼기" style="width: 36px; height: 40px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<button type="button" class="ghost" style="display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 7px; font: inherit; font-weight: 500; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<span>전화번호 더하기</span>
|
||||
</button>
|
||||
<span style="font-size: 12px; color: #667085">5개까지</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px">
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="ct-company" style="font-size: 12.5px; font-weight: 600; color: #4A5363">회사</label>
|
||||
<input id="ct-company" class="field" type="text" value="(주)한빛상사" maxlength="100" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="ct-dept" style="font-size: 12.5px; font-weight: 600; color: #4A5363">부서</label>
|
||||
<input id="ct-dept" class="field" type="text" value="영업팀" maxlength="100" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="ct-title" style="font-size: 12.5px; font-weight: 600; color: #4A5363">직책</label>
|
||||
<input id="ct-title" class="field" type="text" value="팀장" maxlength="100" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<div style="display: flex; justify-content: space-between; gap: 8px">
|
||||
<label for="ct-note" style="font-size: 12.5px; font-weight: 600; color: #4A5363">메모</label>
|
||||
<span style="font-size: 12px; color: #667085; font-variant-numeric: tabular-nums">38 / 1,000</span>
|
||||
</div>
|
||||
<textarea id="ct-note" class="field" rows="2" maxlength="1000" style="padding: 8px 10px; resize: vertical; line-height: 1.5">견적 문의는 박지훈 팀장 → 강서윤 과장 차례로.
|
||||
월말 마감 주간에는 회신이 늦다.</textarea>
|
||||
</div>
|
||||
|
||||
<fieldset style="display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; border: 0">
|
||||
<legend style="margin: 0 0 4px; padding: 0; font-size: 12.5px; font-weight: 600; color: #4A5363">그룹</legend>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0 20px">
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: pointer">
|
||||
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span>거래처</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; min-height: 40px; cursor: pointer">
|
||||
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86; cursor: pointer">
|
||||
<span>프로젝트 알파</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p style="margin: -4px 0 0; font-size: 12.5px; color: #667085">이름이나 이메일 가운데 하나는 있어야 합니다.</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px">
|
||||
<div style="margin-left: auto; display: flex; gap: 8px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">저장</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">연락처 고치기 — 칸 아래의 안내</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">[저장]을 누를 때 보고, 첫 잘못된 칸에 초점을 둡니다(알림 없음)</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">이메일</span>
|
||||
<div style="display: grid; grid-template-columns: 112px minmax(0, 1fr); align-items: center; gap: 8px">
|
||||
<select class="field" aria-label="이메일의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option selected>회사</option>
|
||||
</select>
|
||||
<input class="field is-error" type="email" aria-label="이메일" aria-invalid="true" aria-describedby="ct-err-email" value="jihoon.park@hanbit" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
<p id="ct-err-email" style="margin: 0 0 0 120px; font-size: 12.5px; color: #B42318">이메일 형식이 올바르지 않습니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<div style="display: grid; grid-template-columns: 112px minmax(0, 1fr); align-items: center; gap: 8px">
|
||||
<select class="field" aria-label="이메일의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option selected>회사</option>
|
||||
</select>
|
||||
<input class="field" type="email" aria-label="이메일" aria-describedby="ct-warn-dup" value="seoyun.kang@hanbit.example" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
<p id="ct-warn-dup" style="margin: 0 0 0 120px; font-size: 12.5px; color: #7A5200">이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. <a href="Contacts.dc.html">그 연락처 보기</a></p>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid #EEF0F3">
|
||||
<button type="button" class="danger" style="display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
<span>이 연락처 지우기</span>
|
||||
</button>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">저장</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">지우기는 확인 없이 하고 알림의 [되돌리기]로 돌립니다(10초). 고친 채 [취소] · Esc · 다른 화면으로 가면 "저장하지 않은 연락처가 있습니다" — 필터 창의 나가기 확인과 같습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">⋯ 메뉴 — 그룹 · 연락처</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">왼쪽 그룹 줄의 ⋯ · 연락처 상세의 ⋯</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start">
|
||||
<div role="menu" aria-label="‘거래처’ 그룹 메뉴" style="width: 220px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
<span>이름 바꾸기</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
<span>그룹에 메일 쓰기</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; color: #B42318; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
<span>그룹 지우기</span>
|
||||
</button>
|
||||
</div>
|
||||
<div role="menu" aria-label="연락처 메뉴" style="width: 220px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
<span>주소 복사</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; color: #B42318; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
<span>연락처 지우기</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">그룹을 지워도 연락처는 그대로 남습니다. 그룹 넣기 · 빼기는 연락처 고치기 창의 「그룹」이나 그룹의 [구성원 넣기]로 합니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 400px 620px minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 20px">
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="grp-new-title" style="box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 id="grp-new-title" style="margin: 0; font-size: 15px; font-weight: 700">새 그룹</h2>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="grp-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">그룹 이름</label>
|
||||
<input id="grp-name" class="field is-error" type="text" value="거래처" maxlength="50" aria-invalid="true" aria-describedby="grp-name-err" style="height: 44px; padding: 0 12px">
|
||||
<p id="grp-name-err" style="margin: 0; font-size: 12.5px; color: #B42318">같은 이름의 그룹이 있습니다.</p>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">만들기</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">이름 바꾸기도 같은 창입니다(제목 "그룹 이름 바꾸기", 단추 [저장]). 그룹은 100개까지, 이름은 50자까지입니다.</p>
|
||||
</div>
|
||||
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="grp-add-title" style="box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 id="grp-add-title" style="margin: 0; font-size: 15px; font-weight: 700">‘거래처’에 구성원 넣기</h2>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<div role="radiogroup" aria-label="어디에서 고를지" style="display: inline-flex; gap: 2px; padding: 2px; border: 1px solid #D0D5DD; border-radius: 8px">
|
||||
<button type="button" role="radio" aria-checked="true" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; background: #E3F2F3; color: #0A5F67; font: inherit; font-weight: 600; cursor: pointer">내 연락처</button>
|
||||
<button type="button" role="radio" aria-checked="false" style="min-height: 36px; padding: 0 12px; border: 0; border-radius: 6px; background: transparent; color: #344054; font: inherit; font-weight: 500; cursor: pointer">사내 직원</button>
|
||||
</div>
|
||||
<div style="flex: 1 1 auto; display: flex; align-items: center; gap: 8px; height: 40px; box-sizing: border-box; padding: 0 10px; border: 1px solid #D0D5DD; border-radius: 8px">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#667085" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" aria-label="구성원으로 넣을 연락처 찾기" placeholder="이름 · 주소 — 초성도 됩니다" style="flex: 1 1 auto; min-width: 0; border: 0; outline: none; background: transparent; font: inherit">
|
||||
</div>
|
||||
</div>
|
||||
<ul style="list-style: none; margin: 0; padding: 0; border: 1px solid #EEF0F3; border-radius: 8px">
|
||||
<li style="border-bottom: 1px solid #EEF0F3">
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; cursor: pointer">
|
||||
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="font-weight: 600">오세훈 <span style="font-weight: 400; font-size: 12px; color: #667085">그린클라우드 · 기술팀</span></span>
|
||||
<span style="font-size: 12px; color: #4A5363">sehun.oh@greencloud.example</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li style="border-bottom: 1px solid #EEF0F3">
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; cursor: pointer">
|
||||
<input type="checkbox" checked style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="font-weight: 600">윤태호 <span style="font-weight: 400; font-size: 12px; color: #667085">서린디자인</span></span>
|
||||
<span style="font-size: 12px; color: #4A5363">taeho@seorin.example</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li style="border-bottom: 1px solid #EEF0F3">
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; cursor: pointer">
|
||||
<input type="checkbox" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="font-weight: 600">이수빈 <span style="font-weight: 400; font-size: 12px; color: #667085">바른회계법인</span></span>
|
||||
<span style="font-size: 12px; color: #4A5363">subin.lee@bareun.example</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label style="display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 0 12px; cursor: pointer">
|
||||
<input type="checkbox" style="width: 18px; height: 18px; margin: 0; accent-color: #0E7C86">
|
||||
<span style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; line-height: 1.35">
|
||||
<span style="font-weight: 600">한도경</span>
|
||||
<span style="font-size: 12px; color: #4A5363">dokyung.han@mail.example</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">이미 이 그룹에 있는 연락처는 보이지 않습니다. 「사내 직원」에서 고르면 그 직원을 내 주소록에도 추가합니다(이름 · 주소).</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span role="status" style="font-size: 12.5px; color: #4A5363">2명 고름</span>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">넣기</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 10px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">알림</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">오른쪽 아래 · 단추가 있으면 10초</p>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">연락처를 저장했습니다.</span>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<span style="flex: 1 1 auto; font-weight: 500">‘박지훈’ 연락처를 지웠습니다.</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 6px 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<span style="flex: 1 1 auto; font-weight: 500">‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다.</span>
|
||||
<button type="button" class="toast-action" style="min-height: 36px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer">되돌리기</button>
|
||||
</div>
|
||||
<div role="status" style="display: flex; align-items: center; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 6px 14px; border-radius: 10px; background: #0B3640; color: #FFFFFF">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ED6DB" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span style="flex: 1 1 auto; font-weight: 500">2명을 ‘거래처’ 그룹에 넣었습니다.</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: stretch">
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 24px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; text-align: center">
|
||||
<span style="align-self: flex-start; font-size: 12px; font-weight: 600; color: #667085">처음 — 연락처가 없을 때</span>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
|
||||
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">아직 연락처가 없습니다</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다.</p>
|
||||
<button type="button" class="primary" style="margin-top: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">연락처 추가</button>
|
||||
<p style="margin: 4px 0 0; font-size: 12px; color: #667085">메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 24px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px; text-align: center">
|
||||
<span style="align-self: flex-start; font-size: 12px; font-weight: 600; color: #667085">그룹이 비었을 때</span>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#98A2B3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<p style="margin: 4px 0 0; font-size: 14px; font-weight: 600">‘협력사 담당’ 그룹에 아직 아무도 없습니다</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다.</p>
|
||||
<button type="button" class="primary" style="margin-top: 6px; min-height: 44px; padding: 0 16px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">구성원 넣기</button>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px 24px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<span style="font-size: 12px; font-weight: 600; color: #667085">사내 목록을 받지 못했을 때 · 한도</span>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 0 12px; border-bottom: 1px solid #EEF0F3; text-align: center">
|
||||
<p style="margin: 0; font-size: 14px; font-weight: 600">사내 목록을 불러오지 못했습니다</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다.</p>
|
||||
<button type="button" class="ghost" style="margin-top: 4px; min-height: 44px; padding: 0 14px; border: 1px solid #D0D5DD; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">다시 시도</button>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<button type="button" aria-disabled="true" aria-describedby="ct-limit-note" style="min-height: 44px; border: 0; border-radius: 8px; background: #D0D5DD; color: #FFFFFF; font: inherit; font-weight: 600; cursor: not-allowed">연락처 추가</button>
|
||||
<p id="ct-limit-note" style="margin: 0; font-size: 12.5px; color: #4A5363">연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1640}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>메일과 함께 — 받는 사람 추천 · 주소록에 추가</title>
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<style>
|
||||
@font-face{font-family:'Pretendard';src:url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2-variations'),url('/_blob/16067b9aa5137abf808e3dbef30c42e0') format('woff2');font-weight:45 920;font-style:normal;font-display:swap}
|
||||
body{margin:0}
|
||||
a{color:#0E7C86}a:hover{color:#0A5F67}
|
||||
:focus-visible{outline:2px solid #0E7C86;outline-offset:2px}
|
||||
.primary{background:#0E7C86;color:#FFFFFF}
|
||||
.primary:hover{background:#0A5F67}
|
||||
.ghost{background:transparent;color:#4A5363}
|
||||
.ghost:hover{background:#F2F4F7;color:#171C26}
|
||||
.field{box-sizing:border-box;border:1px solid #D0D5DD;border-radius:8px;background:#FFFFFF;font:inherit;color:#171C26}
|
||||
.menu-item{background:transparent;color:#344054}
|
||||
.menu-item:hover{background:#EEF0F3}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="width: 1440px; height: 1180px; box-sizing: border-box; padding: 48px; display: flex; flex-direction: column; gap: 28px; background: #F6F7F9; color: #171C26; font-family: Pretendard, -apple-system, 'Apple SD Gothic Neo', 'Malgun Gothic', system-ui, sans-serif; font-size: 13px; line-height: 1.5">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; max-width: 900px">
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -0.02em">메일과 함께 — 받는 사람 추천 · 주소록에 추가</h1>
|
||||
<p style="margin: 0; font-size: 13.5px; color: #4A5363">주소록은 메일 쓰기 · 읽기 화면과 이어집니다. 받는 사람 추천의 사내 묶음은 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 모두입니다.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; align-items: start">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">받는 사람 추천 — 네 묶음</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">주소록 → 그룹 → 사내 → 최근 주고받은 주소 · 최대 8개</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 12px; border-bottom: 2px solid #0E7C86; background: #FFFFFF">
|
||||
<label for="sg-to" style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
|
||||
<input id="sg-to" type="text" value="박" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="sg-list" aria-activedescendant="sg-opt-1" style="flex: 1 1 80px; min-width: 60px; min-height: 34px; border: 0; outline: none; font: inherit; color: #171C26; background: transparent">
|
||||
</div>
|
||||
<div id="sg-list" role="listbox" aria-label="받는 사람 추천" style="display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<div role="group" aria-label="주소록" style="display: flex; flex-direction: column">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">주소록</span>
|
||||
<div id="sg-opt-1" role="option" aria-selected="true" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px; background: #E3F2F3">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>지훈</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">jihoon.park@hanbit.example · (주)한빛상사</span>
|
||||
</div>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>지훈</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">jihoon.p@mail.example · 개인</span>
|
||||
</div>
|
||||
</div>
|
||||
<div role="group" aria-label="사내" style="display: flex; flex-direction: column; margin-top: 4px; padding-top: 4px; border-top: 1px solid #EEF0F3">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">사내</span>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>준호</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">junho.park@arasystem.it.kr</span>
|
||||
</div>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>서준</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">seojun.park@arasystem.it.kr</span>
|
||||
</div>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>민수</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">minsu.park@arasystem.it.kr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div role="group" aria-label="최근 주고받은 주소" style="display: flex; flex-direction: column; margin-top: 4px; padding-top: 4px; border-top: 1px solid #EEF0F3">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">최근 주고받은 주소</span>
|
||||
<div role="option" aria-selected="false" style="display: flex; align-items: baseline; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px">
|
||||
<span style="flex: 0 0 auto"><strong>박</strong>소영</span>
|
||||
<span style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #4A5363">soyoung.park@print.example</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 4px 8px 2px; font-size: 12px; color: #667085">↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">주소가 여럿인 연락처는 주소마다 한 줄입니다(맨 위 주소가 먼저). 두 묶음에 다 있는 주소는 앞 묶음에 한 번만 나옵니다 — 사내 직원을 내 주소록에도 넣었으면 주소록에 나옵니다.</p>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">그룹으로 한꺼번에</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">그룹 이름을 치면 그룹 줄이 나옵니다</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 4px">
|
||||
<div style="display: flex; align-items: center; gap: 6px 12px; min-height: 44px; box-sizing: border-box; padding: 4px 12px; border-bottom: 2px solid #0E7C86; background: #FFFFFF">
|
||||
<label for="sg-to2" style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</label>
|
||||
<input id="sg-to2" type="text" value="거래" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="sg-list2" aria-activedescendant="sg-grp-1" style="flex: 1 1 80px; min-width: 60px; min-height: 34px; border: 0; outline: none; font: inherit; color: #171C26; background: transparent">
|
||||
</div>
|
||||
<div id="sg-list2" role="listbox" aria-label="받는 사람 추천" style="display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<div role="group" aria-label="그룹" style="display: flex; flex-direction: column">
|
||||
<span aria-hidden="true" style="padding: 4px 8px 2px; font-size: 12px; font-weight: 600; color: #667085">그룹</span>
|
||||
<div id="sg-grp-1" role="option" aria-selected="true" style="display: flex; align-items: center; gap: 8px; min-height: 40px; box-sizing: border-box; padding: 9px 8px; border-radius: 6px; background: #E3F2F3">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#0A5F67" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex: 0 0 auto"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span style="flex: 0 0 auto"><strong>거래</strong>처</span>
|
||||
<span style="font-size: 12px; color: #4A5363">4명 · 고르면 한꺼번에 넣습니다</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; padding-top: 6px">
|
||||
<span style="font-size: 12px; font-weight: 600; color: #667085">고른 뒤</span>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 6px 12px; border-bottom: 1px solid #D0D5DD; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7">강서윤</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7">박지훈</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7">정민호</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7">최유진</span>
|
||||
</div>
|
||||
<p role="status" style="margin: 0; font-size: 12.5px; color: #4A5363">‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다.</p>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #4A5363">이메일이 없는 구성원은 빼고, 이미 넣은 주소는 다시 넣지 않습니다 — "주소가 없는 1명은 뺐습니다"처럼 함께 알립니다. 구성원마다 맨 위 주소를 씁니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">주소록에서 메일 쓰기</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">연락처 · 그룹 · 사내 어디에서나</p>
|
||||
</div>
|
||||
<ul style="margin: 0; padding: 0 0 0 18px; display: flex; flex-direction: column; gap: 6px; font-size: 12.5px; color: #344054">
|
||||
<li>연락처 상세의 [메일 쓰기] · 주소 링크 → 새 메일, 받는 사람에 그 주소(주소마다 링크가 따로 있다).</li>
|
||||
<li>그룹의 [그룹에 메일 쓰기] → 받는 사람에 구성원 모두(맨 위 주소) — 위 「그룹으로 한꺼번에」와 같은 규칙.</li>
|
||||
<li>사내 직원 · 공용 주소의 [메일 쓰기]도 같다.</li>
|
||||
<li>쓰다 만 메일이 있어도 새 쓰기 화면을 연다 — 쓰던 메일은 임시 저장 규칙대로 남는다.</li>
|
||||
</ul>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 8px; background: #F8F9FB">
|
||||
<span style="font-size: 12px; font-weight: 600; color: #667085">쓰기 화면이 열린 모습</span>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 44px; box-sizing: border-box; padding: 6px 12px; border-bottom: 1px solid #D0D5DD; background: #FFFFFF">
|
||||
<span style="flex: 0 0 auto; font-size: 12.5px; font-weight: 600; color: #4A5363">받는 사람</span>
|
||||
<span style="display: inline-flex; align-items: center; min-height: 28px; padding: 0 8px; border-radius: 4px; background: #F2F4F7">박지훈</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; min-height: 44px; box-sizing: border-box; padding: 4px 12px; border-bottom: 1px solid #D0D5DD; background: #FFFFFF; color: #98A2B3">제목</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 20px; align-items: start">
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 14px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">읽기 화면 ⋯ — 보낸 사람을 주소록에</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">⋯ 메뉴의 둘째 묶음 · 「이런 메일로 필터 만들기」 위</p>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start">
|
||||
<div style="flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 10px">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid #EEF0F3; border-radius: 10px">
|
||||
<span aria-hidden="true" style="flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: #E3F2F3; color: #0A5F67; font-size: 14px; font-weight: 600">박</span>
|
||||
<div style="flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px">
|
||||
<span style="font-size: 13.5px; font-weight: 600">박지훈 <span style="font-weight: 400; font-size: 12.5px; color: #667085">jihoon.park@hanbit.example</span></span>
|
||||
<span style="font-size: 12.5px; color: #4A5363">받는 사람 김아라 <ara.kim@arasystem.it.kr></span>
|
||||
</div>
|
||||
<button type="button" aria-label="더 보기" aria-expanded="true" style="flex: 0 0 auto; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 8px; background: #EEF0F3; color: #171C26; cursor: pointer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.7"></circle><circle cx="12" cy="12" r="1.7"></circle><circle cx="19" cy="12" r="1.7"></circle></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div role="menu" aria-label="메일 메뉴" style="align-self: flex-end; width: 280px; display: flex; flex-direction: column; padding: 6px; border: 1px solid #E6E8EC; border-radius: 10px; background: #FFFFFF; box-shadow: 0 10px 28px rgba(16, 24, 28, 0.12)">
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
<span>원문 내려받기 (.eml)</span>
|
||||
</button>
|
||||
<div aria-hidden="true" style="height: 1px; margin: 4px 6px; background: #EEF0F3"></div>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; background: #EEF0F3; font: inherit; font-weight: 600; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
|
||||
<span>보낸 사람을 주소록에 추가</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="menu-item" style="display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 10px; border: 0; border-radius: 6px; font: inherit; text-align: left; cursor: pointer">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
<span>이런 메일로 필터 만들기</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="sg-add-title" style="flex: 1 1 300px; min-width: 0; box-sizing: border-box; padding: 18px 20px 14px; border: 1px solid #E6E8EC; border-radius: 12px; background: #FFFFFF; box-shadow: 0 14px 36px rgba(16, 24, 28, 0.14); display: flex; flex-direction: column; gap: 12px">
|
||||
<h3 id="sg-add-title" style="margin: 0; font-size: 15px; font-weight: 700">연락처 추가</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<label for="sg-add-name" style="font-size: 12.5px; font-weight: 600; color: #4A5363">이름</label>
|
||||
<input id="sg-add-name" class="field" type="text" value="박지훈" style="height: 44px; padding: 0 12px">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px">
|
||||
<span style="font-size: 12.5px; font-weight: 600; color: #4A5363">이메일</span>
|
||||
<div style="display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 8px">
|
||||
<select class="field" aria-label="이메일의 종류" style="height: 40px; padding: 0 8px">
|
||||
<option selected>회사</option>
|
||||
</select>
|
||||
<input class="field" type="email" aria-label="이메일" value="jihoon.park@hanbit.example" style="height: 40px; padding: 0 10px; min-width: 0">
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin: 0; font-size: 12.5px; color: #667085">보낸 사람의 표시 이름과 주소를 채워 엽니다 — 나머지 칸은 「연락처 추가」 창과 같습니다(보드 「연락처 추가 · 고치기」).</p>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<button type="button" class="primary" style="min-height: 44px; padding: 0 18px; border: 0; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer">저장</button>
|
||||
<button type="button" class="ghost" style="min-height: 44px; padding: 0 14px; border: 0; border-radius: 8px; font: inherit; font-weight: 500; cursor: pointer">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="display: flex; flex-direction: column; gap: 12px; padding: 20px; background: #FFFFFF; border: 1px solid #E6E8EC; border-radius: 12px">
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<h2 style="margin: 0; font-size: 14px; font-weight: 700">⋯ 항목이 바뀌는 때</h2>
|
||||
<p style="margin: 0; font-size: 12px; color: #667085">보낸 사람 주소로 판단합니다</p>
|
||||
</div>
|
||||
<dl style="display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 10px 16px; margin: 0; font-size: 12.5px">
|
||||
<dt style="font-weight: 600; color: #344054">내 주소록에 없는 주소</dt>
|
||||
<dd style="margin: 0; color: #4A5363">「보낸 사람을 주소록에 추가」 → 채운 창</dd>
|
||||
<dt style="font-weight: 600; color: #344054">내 주소록에 있는 주소</dt>
|
||||
<dd style="margin: 0; color: #4A5363">「주소록에서 보기」 → 주소록의 그 연락처(목록에서 고른 채)</dd>
|
||||
<dt style="font-weight: 600; color: #344054">사내 직원의 주소</dt>
|
||||
<dd style="margin: 0; color: #4A5363">「주소록에 추가」로 그대로 둔다 — 사내 목록은 이름 · 주소만이라 전화번호 · 메모를 적으려면 내 주소록에 넣는다</dd>
|
||||
<dt style="font-weight: 600; color: #344054">보낸 사람 주소가 없는 메일</dt>
|
||||
<dd style="margin: 0; color: #4A5363">항목이 없다(필터 만들기와 같다)</dd>
|
||||
<dt style="font-weight: 600; color: #344054">연락처가 3,000개</dt>
|
||||
<dd style="margin: 0; color: #4A5363">항목을 끄고(aria-disabled) 이름 아래에 까닭 한 줄</dd>
|
||||
</dl>
|
||||
<p style="margin: 0; padding-top: 10px; border-top: 1px solid #EEF0F3; font-size: 12.5px; color: #4A5363">메뉴를 열 때 주소록을 알림 없이 받아 판단합니다(받아 둔 것이 있으면 그것으로). 결과가 오기 전에는 「보낸 사람을 주소록에 추가」로 둡니다 — 저장할 때 같은 주소가 있으면 창 안에 「이미 주소록에 있는 주소」가 보입니다.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":1440,"height":1180}}'>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -409,6 +409,63 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
"켜 둔 자동 응답이 지금은 동작하지 않습니다. 메일 서버의 규칙이 다른 곳에서 바뀌었습니다 — 아래 [저장]을 누르면 다시 켜집니다."
|
||||
- 단추는 없다 — 저장 단추는 늘 누를 수 있다. 꺼 두었으면 보이지 않는다. 저장하면 필터도 함께 다시 켜진다.
|
||||
|
||||
## 8단계 — 주소록 (2026-10-29 확정 — `Contacts` · `ContactsEdit` · `ContactsMail`)
|
||||
|
||||
레일의 주소록 모듈을 켠다(`app-modules.ts` 의 `contacts`). 내 주소록(연락처 · 그룹)과 사내 목록(직원 · 공용 주소)이 있고, 메일 쓰기 · 읽기 화면과 이어진다.
|
||||
규칙은 계약 3절 "주소록". 셸 · 색 · 단추 · 창 · 알림은 메일 모듈과 같다.
|
||||
|
||||
### 주소록 화면 (`Contacts`)
|
||||
|
||||
- 자리: 레일 · 상단 바 · 모듈 패널(208) · 목록(384) · 상세 — 메일의 분할 보기와 같다. 상단 바의 찾기 칸은 **지금 보는 목록 안에서** 찾는다
|
||||
("이름 · 주소 · 회사 · 전화번호 — 초성도 됩니다 (ㅂㅈㅎ)").
|
||||
- 패널: [연락처 추가](채운 단추) → 「내 주소록」 전체 연락처(수) → 「그룹」 그룹마다 한 줄(수 · ⋯ 32×40 — 메일함 ⋯ 와 같은 밀도 예외) + [새 그룹] → 「사내」 직원(수) · 공용 주소(수).
|
||||
- 목록 머리: 제목 · "N명"(공용 주소는 "N개", 찾으면 "찾은 결과 N명"). 그룹이면 오른쪽에 [구성원 넣기] [그룹에 메일 쓰기].
|
||||
사내면 머리 아래 회색 줄 — 직원 "메일 서버에 계정이 있는 직원입니다 — 이름과 주소만 보입니다." · 공용 주소 "여러 사람이 함께 받는 사내 주소입니다."
|
||||
- 목록 줄(56): 아바타 32(이름 첫 글자 — 공용 주소는 회색) · 이름(굵게) + 회사 · 부서(작게) / 대표 주소. 고른 줄은 청록 바탕. 이름 차례(한국어), 100명씩 받고 끝에 [더 보기].
|
||||
- 상세 머리 줄: [메일 쓰기] [고치기] … [⋯] — 사내는 [메일 쓰기] [내 주소록에 추가]. 아래로 아바타 48 · 이름(20) · "회사 · 부서 · 직책"(없으면 "소속 없음", 사내는 "사내 직원" · "공용 주소").
|
||||
- 상세 칸(왼쪽 이름 88): 이메일(주소마다 링크 — 누르면 그 주소로 새 메일, 옆에 종류) · 전화(번호 · 종류) · 소속 · 직책 · 그룹(누르면 그 그룹의 목록) · 메모(줄바꿈 그대로). 빈 칸은 줄째 뺀다.
|
||||
사내 상세 아래: "사내 목록은 메일 서버의 계정에서 옵니다 — 이름과 주소만 보이고 여기서 고칠 수 없습니다. 전화번호 · 메모를 적어 두려면 내 주소록에 추가하세요."
|
||||
- 빈 상태: 찾은 것이 없으면 "‘…’에 맞는 연락처가 없습니다"(사내는 "주소가") + "이름 · 주소 · 회사 · 전화번호의 일부나 이름의 초성으로 찾습니다." · 고른 것이 없으면 "연락처를 고르세요".
|
||||
|
||||
### 연락처 추가 · 고치기 (`ContactsEdit`)
|
||||
|
||||
- 모달 620 — 필터 고치기 창과 같은 틀이고 나가기 확인도 같다("저장하지 않은 연락처가 있습니다" / [계속 고치기] [닫기]).
|
||||
- 칸: 이름(100자) · 이메일(종류 회사 · 개인 · 기타 + 주소 + ✕, [이메일 더하기] 5개까지 — "받는 사람 칸에는 맨 위 주소가 먼저 나옵니다") ·
|
||||
전화번호(휴대전화 · 회사 · 집 · 기타 + 번호 + ✕, 5개까지) · 회사 · 부서 · 직책(한 줄에 셋) · 메모(1,000자 — 셈 "38 / 1,000") · 그룹(체크 칸).
|
||||
맨 아래 "이름이나 이메일 가운데 하나는 있어야 합니다." · [저장] [취소], 고치기만 왼쪽에 [이 연락처 지우기](빨간 글).
|
||||
- 잘못은 [저장]을 누를 때 칸 아래 빨간 글로만(첫 잘못된 칸에 초점, 알림 없음) — "이메일 형식이 올바르지 않습니다." 등.
|
||||
- 같은 주소: 이메일 칸을 떠날 때 내 주소록에 같은 주소(다른 연락처)가 있으면 칸 아래 갈색 글
|
||||
"이미 주소록에 있는 주소입니다 — 강서윤(거래처). 그래도 저장할 수 있습니다. [그 연락처 보기]" — 막지 않는다.
|
||||
- 지우기는 확인 없이: 창을 닫고 "‘박지훈’ 연락처를 지웠습니다." + [되돌리기](10초).
|
||||
|
||||
### 그룹 (`ContactsEdit`)
|
||||
|
||||
- 새 그룹 · 이름 바꾸기: 모달 400, 칸 하나(50자) · [만들기] / [저장] [취소]. 같은 이름이면 칸 아래 "같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요."(`CONTACT_001` 사전 문구 — 알림 없이. 보드는 앞 문장만). 그룹은 100개까지.
|
||||
- 그룹 ⋯: 이름 바꾸기 · 그룹에 메일 쓰기 · 그룹 지우기(빨간 글). 지우기는 확인 없이 "‘거래처’ 그룹을 지웠습니다 — 연락처 4명은 그대로 있습니다." + [되돌리기].
|
||||
- 연락처 ⋯(상세): 주소 복사 · 연락처 지우기.
|
||||
- 구성원 넣기: 모달 620 — 「내 연락처 | 사내 직원」 전환(36 — `.segmented` 밀도 예외) + 찾기(초성), 체크 줄 48(이름 · 회사 · 주소). 이미 구성원인 연락처는 보이지 않는다.
|
||||
"N명 고름" · [넣기] [취소]. 사내 직원을 고르면 내 주소록에도 이름 · 주소로 추가한다(같은 주소의 연락처가 이미 있으면 그 연락처를 넣는다). 끝나면 "2명을 ‘거래처’ 그룹에 넣었습니다."
|
||||
- 빈 그룹: "‘…’ 그룹에 아직 아무도 없습니다" · "구성원을 넣으면 그룹 이름으로 한 번에 메일을 보낼 수 있습니다." + [구성원 넣기].
|
||||
- 그룹에 넣기 · 빼기는 연락처 고치기 창의 「그룹」으로, 여러 사람을 한꺼번에는 [구성원 넣기]로.
|
||||
|
||||
### 상태 (`ContactsEdit`)
|
||||
|
||||
- 처음(연락처 없음): "아직 연락처가 없습니다" · "자주 메일을 주고받는 사람을 넣어 두면 받는 사람 칸에서 바로 찾습니다." · [연락처 추가] ·
|
||||
"메일을 읽다가 ⋯ › 「보낸 사람을 주소록에 추가」로도 넣을 수 있습니다."
|
||||
- 사내 목록을 받지 못함: "사내 목록을 불러오지 못했습니다" · "잠시 후 다시 시도해 주세요. 내 주소록은 그대로 쓸 수 있습니다." · [다시 시도].
|
||||
- 한도: 연락처가 3,000개면 [연락처 추가]를 끄고(`aria-disabled`) 아래에 "연락처는 3,000개까지 둘 수 있습니다. 쓰지 않는 연락처를 지운 뒤 추가해 주세요."
|
||||
- 알림: "연락처를 저장했습니다." · "‘…’ 연락처를 지웠습니다." · "‘…’ 그룹을 지웠습니다 — 연락처 N명은 그대로 있습니다." · "N명을 ‘…’ 그룹에 넣었습니다." (단추가 있으면 10초)
|
||||
|
||||
### 메일과 함께 (`ContactsMail`)
|
||||
|
||||
- **받는 사람 추천**: 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소(모두 합쳐 최대 8개). 주소록은 주소마다 한 줄 — 대표 주소는 "주소 · 회사", 그 밖의 주소는 "주소 · 개인"처럼 종류.
|
||||
사내는 이제 로그인한 적 있는 직원이 아니라 메일 서버에 계정이 있는 직원 · 공용 주소 모두다. 여러 묶음에 있는 주소는 앞 묶음에 한 번만.
|
||||
- **그룹 줄**: 아이콘 + 그룹 이름(맞는 글자 굵게) + "N명 · 고르면 한꺼번에 넣습니다". 고르면 구성원의 대표 주소를 칩으로 넣고 칸 아래 "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다."
|
||||
이메일이 없는 구성원 · 이미 넣은 주소는 빼고, 뺀 것이 있으면 "주소가 없는 1명은 뺐습니다"를 덧붙인다.
|
||||
- **주소록에서 메일 쓰기**: 연락처 · 그룹 · 사내의 [메일 쓰기]와 주소 링크 → 새 쓰기 화면, 받는 사람을 채워서(그룹은 구성원 모두 — 받는 사람 칸). 쓰다 만 메일은 임시 저장 규칙대로 남는다.
|
||||
- **읽기 화면 ⋯**: 둘째 묶음(「이런 메일로 필터 만들기」 위)에 「보낸 사람을 주소록에 추가」 → 이름 · 주소를 채운 연락처 추가 창. 이미 내 주소록에 있으면 「주소록에서 보기」(그 연락처를 고른 주소록).
|
||||
사내 직원의 주소도 「보낸 사람을 주소록에 추가」. 보낸 사람 주소가 없는 메일은 항목이 없고, 연락처가 3,000개면 항목을 끄고 이름 아래에 까닭 한 줄.
|
||||
|
||||
## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다)
|
||||
|
||||
- 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다).
|
||||
@@ -587,3 +644,51 @@ API 와 서버 규칙은 계약 2절 "실시간 알림".
|
||||
- **닫기 확인과 고치기 창**: 브라우저가 Esc 를 거듭 누르면 고치기 창을 막지 못하고 스스로 닫는다 — [계속 고치기]면 창을 다시 띄우고 초점은 닫히기 전에 있던 칸으로 돌려준다.
|
||||
닫기 확인이 떠 있는 동안 다른 화면으로 가려 하면(뒤로 가기) 같은 확인 창이 "이 화면을 나가면 …" [나가기]로 바뀐다. 저장 · 지우기를 보내는 중에는 묻지 않는다(결과는 알림으로).
|
||||
- **부재중**: 메일함 패널 아래의 부재중 안내는 그대로 둔다(저장한 값으로 보인다 — 동작하지 않음은 설정 화면에서만 알린다).
|
||||
|
||||
### 8단계 구현하며 정한 보완 (2026-10-29 — 캔버스에는 아직 없다)
|
||||
|
||||
- **받는 사람 추천**: 주소록 줄의 설명은 주소 종류로 정한다 — 회사 주소는 회사 이름(없으면 "회사"), 개인 주소는 "개인", 그 밖은 "기타".
|
||||
계약에 대표 표시가 없어 보드("대표 주소는 회사")와 두 경우에서 다르다 — 대표 주소가 개인이면 "· 개인", 대표가 아닌 회사 주소에는 회사 이름이 나온다.
|
||||
구성원이 0명인 그룹은 추천하지 않는다. 초성 · 조합 중 글자도 받아 둔 것으로 모자라면 서버에 묻는다(서버가 같은 맞추기를 쓴다 — 계약 2절).
|
||||
그룹을 고른 뒤 칸 아래 글(`role=status`)은 불러오는 중 "‘G’ 그룹의 구성원을 불러오는 중…" →
|
||||
"‘G’ 그룹의 N명을 받는 사람에 넣었습니다." / "‘G’ 그룹에서 받는 사람에 더 넣을 주소가 없습니다." + 뺀 것이 있으면 "주소가 없는 N명 · 이미 넣은 N명은 뺐습니다." — 칸에 다시 치면 지운다.
|
||||
그룹을 이어 고르면 그룹마다 따로 받아 끝나는 대로 넣는다 — 칸 아래 글은 받는 중인 그룹이 있으면 마지막으로 고른 그룹의 "불러오는 중", 없으면 마지막에 끝난 그룹의 결과.
|
||||
- **쓰기 화면을 받는 사람을 채워 열기**: 새 메일에만 쓰고(라우터 상태 `composeTo` — 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다), 잘못된 주소는 버린다.
|
||||
채운 받는 사람은 "바꾼 것"이 아니다 — 손대지 않으면 자동 저장 · 나가기 확인이 없다. 초점은 제목 칸으로.
|
||||
새 탭으로 연 쓰기 링크(가운데 클릭)는 빈 쓰기 화면이다 — 라우터 상태는 새 탭으로 가지 않는다(한계).
|
||||
- **그룹**: [그룹에 메일 쓰기]는 받는 동안 바쁜 표시(`aria-busy`)이고 거듭 누르면 무시한다. 받는 사이 다른 목록 · 화면으로 갔거나 세션이 바뀌면 쓰기 화면으로 가지 않는다.
|
||||
주소가 있는 구성원이 없으면 오류 알림 "‘G’ 그룹에는 메일 주소가 있는 구성원이 없습니다." — 빈 그룹이면 목록 머리 단추와 그룹 ⋯ 의 같은 항목이 함께 꺼지고(`aria-disabled`) 까닭은 "아직 구성원이 없습니다.".
|
||||
그룹이 100개면 [새 그룹]을 끄고 "그룹은 100개까지 만들 수 있습니다.". [되돌리기]는 서버가 기억한 원본으로 다시 만든다(계약 3절 "되돌리기").
|
||||
- **구성원 넣기 창**: 서버가 이미 구성원인 사람을 빼고 준다(연락처 · 사내 둘 다, [더 보기]도 — `excludeGroupId`). 한 번에 100명까지 — 다 고르면 나머지 칸을 끄고 알린다(`role=status`).
|
||||
전환은 눌림 단추 둘(`role=group` + `aria-pressed` — 보드의 radiogroup 대신, 새 `.segmented--outline`). 목록은 높이 18rem 안에서 넘기고 [더 보기]가 있다. 아바타는 없다(보드대로).
|
||||
넣은 뒤 알림의 수는 서버가 넣었다고 한 수(`addedCount`)다 — 0명이면 "‘G’ 그룹에 새로 넣은 사람이 없습니다. 이미 구성원이거나 그룹에 넣을 수 없는 연락처입니다."(다른 프로그램이 uid 없이 만든 카드 — 계약 3절).
|
||||
- **연락처 창(추가 · 고치기)**:
|
||||
- [고치기]는 그 연락처를 새로 받아 연다(받는 동안 `aria-busy`, 거듭 누르면 무시, 받는 사이 다른 연락처를 골랐으면 열지 않는다 — 다른 탭 · 휴대폰에서 바꾼 것을 덮지 않게).
|
||||
찾을 수 없으면(`RES_001`) 알림 없이 목록에서 빼고 상세가 "찾을 수 없음" + [목록으로]를, 그 밖의 실패는 알림만 띄운다(창을 열지 않는다).
|
||||
- 저장은 **바꾼 칸만** 보낸다 — 보낼 값으로 견주어 앞뒤 공백 · 빈 줄 · 제어 문자 · 그룹의 차례는 바꾼 것이 아니다. 바꾼 것이 없으면 보내지 않고 닫는다. 나가기 확인도 같은 기준이다.
|
||||
- 검사도 바꾼 칸만 — 손대지 않은 남의 값(21번째 주소 · "내선"이 든 번호 · 긴 메모 · 100자 넘는 한 줄 칸)은 그대로 둔다. 이메일 · 전화번호는 고치면 그 칸 전부를 본다.
|
||||
보드에 없던 문구: "이메일은 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요." · "전화번호는 20개까지 넣을 수 있습니다. 몇 개를 빼 주세요."([더하기] 아래 — 초점은 그 묶음의 첫 줄) ·
|
||||
"100자까지 쓸 수 있습니다."(그 칸 아래) · "같은 주소를 두 번 넣었습니다." · 전화번호 형식 · "메모는 1,000자까지 쓸 수 있습니다."(고쳤을 때만).
|
||||
- 이메일은 서버보다 좁게 본다 — @ 앞은 dot-atom 64자, 도메인 칸마다 앞뒤 하이픈 없이 63자 · 끝 점 없음 · 마지막 칸은 글자 2개 이상이거나 `xn--`, 한글 도메인은 받는다, 254자(필터의 전달 주소와 같은 검사).
|
||||
이메일 칸은 `type="text"` + `inputmode="email"` — 브라우저가 한글 도메인을 punycode 로 바꾸지 않게(받는 사람 칸과 같다).
|
||||
- 같은 주소 경고의 괄호는 그 연락처의 첫 그룹(없으면 회사)이다. 칸을 고치면 경고를 내리고, 채워 연 창(읽기 화면 · 사내 목록)은 열 때 바로 본다. 늦게 뜨는 안내는 늘 있는 `role=status` 영역 안에 뜬다.
|
||||
- 필수 안내("이름이나 이메일 가운데 하나는…")는 빨간 글로 바뀌고 이름 칸에 초점을 둔다. 서버의 형식 거절(`VAL_001`)은 단추 줄 위에 사전 문구로.
|
||||
저장 중에 창을 닫았으면(또는 다시 열었으면) 창 안에 보일 거절도 알림으로 알린다(그룹 이름 창도 같다).
|
||||
- **보이지 않는 제어 문자**는 거절하지 않고 고친다 — 한 줄 칸의 탭 · 제어 문자는 띄어쓰기로, 메모의 CR LF · CR 은 줄바꿈으로. 열 때 · 보낼 때 고치고,
|
||||
고친 것만으로는 바꾼 것이 아니다(표에서 붙여 넣은 탭 · 다른 프로그램의 CR LF 는 사용자에게 보이지 않아 고칠 수 없기 때문).
|
||||
- **크기 · 제목**: 그룹 체크 줄 · [더 보기] · [새 그룹]은 44(보드 40 — 밀도 예외를 새로 만들지 않는다). 패널의 그룹 ⋯ 는 마우스를 올리거나 초점이 갈 때 보인다(메일함 행과 같다).
|
||||
제목 수준은 메일 화면과 같다 — 패널 묶음 이름은 글 + `aria-labelledby`, 목록 제목은 h1, 상세 이름은 h2.
|
||||
- **알림 문구**(보드에 없던 것): "연락처를 되돌렸습니다." · "그룹을 되돌렸습니다." · "‘X’ 그룹을 만들었습니다." · "그룹 이름을 바꿨습니다." ·
|
||||
"주소를 복사했습니다." / "주소를 복사하지 못했습니다." · 구성원 없는 그룹을 지웠으면 "‘X’ 그룹을 지웠습니다.". 서버가 원본을 기억하지 못한 지우기(표 없음)는 같은 문구를 [되돌리기] 없이 띄운다.
|
||||
- **지운 뒤**: 보던 연락처를 지우면 그 목록으로(고른 것 없이, 초점은 목록), 보던 그룹을 지우면 전체 연락처로. 이미 지워진 것(`RES_001`)은 지운 것으로 본다. 주소 복사는 대표 주소만(주소가 없으면 항목을 끄고 까닭).
|
||||
- **목록 맞추기**: 만들기 · 고치기 · 되돌리기는 응답을 서버의 차례(frontend `compareContacts` — backend `contact-search.ts` 와 짝)로 받은 쪽 안의 자리에 넣는다(받은 쪽 끝보다 뒤면 넣지 않고 수만 고친다).
|
||||
응답으로 자리를 알 수 없을 때만 첫 쪽을 한 번 다시 받는다 — 찾은 목록에 새 연락처가 생겼을 때 · 구성원 넣기로 보는 그룹이 바뀌었을 때 · 사내 직원을 넣어 전체 목록에 새 연락처가 생겼을 때.
|
||||
- **찾기**: 목록을 바꾸면 찾기를 푼다. 사내 목록은 칸 안내 · 빈 문구가 따로("사내 직원이 없습니다" · "공용 주소가 없습니다"). Enter 는 바로 찾고, Esc · ✕ 는 푼다.
|
||||
글자와 자모가 섞인 조합 중 글은 보내지 않는다(초성만이면 보낸다). 찾는 글은 254자까지(서버와 같다).
|
||||
- **상세**: 연락처를 찾을 수 없으면(`RES_001`) [목록으로], 그 밖의 실패는 [다시 시도]. 목록을 받지 못해도 고른 연락처는 따로 받는다. 주소로 연 사내 항목은 그 항목 하나를 받는다.
|
||||
[메일 쓰기]는 주소가 없으면 꺼지고(`aria-disabled`) 까닭 "메일 주소가 없는 연락처입니다.", 사내 [내 주소록에 추가]는 연락처가 3,000개면 꺼지고 3,000개 안내.
|
||||
- **읽기 화면 ⋯**: 원문 아래에 구분선을 둔다(보드대로). 「주소록에서 보기」 아이콘은 책. 보낸 사람이 바뀌면(다른 메일) 다시 판단한다 — 254자 넘는 주소면 묻지 않고 "없음"으로.
|
||||
연락처 추가 창은 **메일 레이아웃에 둔다** — 메일을 바꾸거나 목록으로 돌아가도 쓴 그대로 남고, 메일 화면을 떠날 때만 창의 나가기 확인이 묻는다. 닫으면 ⋯ 가 아직 있을 때만 초점을 돌려준다. 채우는 주소 종류는 회사.
|
||||
- **창의 자리**: 주소록의 연락처 · 그룹 · 구성원 창은 주소록 화면의 틀에 둔다 — 주소록 안에서 옮겨 다녀도 닫히지 않고, 주소록을 떠나면 나가기 확인을 묻고, 세션이 바뀌면 닫는다(메일 화면의 창도 같다).
|
||||
- **서버 쪽에서 생긴 한계**(계약 3절): 엔진이 만든 뒤 uid 를 바꾸지 못하게 해서 다른 프로그램이 uid 없이 만든 카드는 그룹에 넣을 수 없다 — 고치기 창에서 그 저장은 창 안 사전 문구.
|
||||
다른 프로그램이 넣은 값이 우리 규칙 밖이면 그 칸을 고칠 때만 그 값도 규칙에 맞춰야 한다(다른 칸은 그대로 고칠 수 있다).
|
||||
|
||||
@@ -26,9 +26,12 @@
|
||||
"SettingsFilter.dc.html": { "x": 0, "y": 10680, "w": 1440, "h": 900, "title": "설정 · 필터", "expand": "fill", "is_interactive": true },
|
||||
"MailFilterStates.dc.html": { "x": 1520, "y": 10680, "w": 1440, "h": 1420, "title": "메일 필터 상태 · 메일에서 바로 만들기" },
|
||||
"MailFilterApply.dc.html": { "x": 0, "y": 12700, "w": 1440, "h": 900, "title": "설정 · 필터 · 이미 받은 메일에 적용", "expand": "fill", "is_interactive": true },
|
||||
"MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" }
|
||||
"MailFilterApplyStates.dc.html": { "x": 1520, "y": 12700, "w": 1440, "h": 1280, "title": "필터 적용 진행 · 결과 · 나가기 확인 · 부재중 안내" },
|
||||
"Contacts.dc.html": { "x": 0, "y": 14580, "w": 1440, "h": 900, "title": "주소록 · 내 연락처 · 그룹 · 사내", "expand": "fill", "is_interactive": true },
|
||||
"ContactsEdit.dc.html": { "x": 1520, "y": 14580, "w": 1440, "h": 1640, "title": "연락처 추가 · 고치기 · 그룹 · 상태" },
|
||||
"ContactsMail.dc.html": { "x": 3040, "y": 14580, "w": 1440, "h": 1180, "title": "메일과 함께 — 받는 사람 추천 · 주소록에 추가" }
|
||||
},
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html"],
|
||||
"order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html"],
|
||||
"notes": {
|
||||
"flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 },
|
||||
"phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 },
|
||||
@@ -36,7 +39,8 @@
|
||||
"phase4": { "x": 0, "y": 6340, "text": "4단계 화면 — 계정 · 메일 설정 (비밀번호 · 부재중 응답 · 메일함 관리 · 스팸 · 원문) — 2026-10-28 확정", "kind": "title1", "maxW": 6000 },
|
||||
"phase5": { "x": 0, "y": 8260, "text": "5단계 화면 — 실시간 새 메일 · 바탕화면 알림 — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
|
||||
"phase6": { "x": 0, "y": 10380, "text": "6단계 화면 — 메일 필터 (설정 › 메일 › 필터 · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」) — 2026-10-28 확정", "kind": "title1", "maxW": 2960 },
|
||||
"phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }
|
||||
"phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 },
|
||||
"phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }
|
||||
},
|
||||
"designSystems": []
|
||||
}
|
||||
|
||||
+198
-7
@@ -8,7 +8,7 @@
|
||||
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `SYS_001`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `CONTACT_001` `CONTACT_002` `SYS_001`
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
@@ -515,23 +515,31 @@ interface SignatureImage {
|
||||
interface RecipientSuggestion {
|
||||
name: string | null
|
||||
email: string
|
||||
source: 'directory' | 'recent' // directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소
|
||||
source: 'contact' | 'directory' | 'recent' // contact = 내 주소록(8단계), directory = 사내(8단계부터 엔진의 사내 목록 — 3절), recent = 최근 주고받은 주소
|
||||
company: string | null // contact 만 — 그 연락처의 회사 (그 밖에는 null)
|
||||
emailLabel: 'work' | 'personal' | 'other' | null // contact 만 — 그 주소의 종류 (그 밖에는 null)
|
||||
}
|
||||
```
|
||||
|
||||
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
|
||||
- `q` 가 있으면 이름·주소에 그 글자가 든 것만 준다(대소문자 무시). 8단계부터 쓰기 화면과 **같은 맞추기**다 — 이름은 초성 · 조합 중 글자를 섞어도 맞고
|
||||
(`ㄷㅇ` · `도ㅇ` → 이도윤, 윤을 치는 중인 `도유` · `동` → 도윤), 주소는 그 글자가 든 것(완전한 주소를 쳤으면 같은 아이디도). 규칙의 짝은 frontend `shared/utils/recipient-match.ts` 이고
|
||||
두 쪽 테스트가 같은 표본 표를 쓴다. 응답은 커서 페이지네이션 규칙 그대로(`{ items, pageInfo }`) —
|
||||
커서는 받은 그 `q` 로만 쓴다(다른 `q` 와 함께 보내면 400 `VAL_001`).
|
||||
- **순서**: 사내 → 최근 주고받은 주소, 각 묶음 안에서는 주고받은 횟수와 최근성으로. 두 쪽에 다 있는 주소는 사내로 한 번만 나온다.
|
||||
- **순서**: 주소록(8단계) → 사내 → 최근 주고받은 주소. 주소록은 이름 차례(3절), 사내 · 최근은 주고받은 횟수와 최근성으로.
|
||||
여러 묶음에 있는 주소는 앞 묶음으로 한 번만 나온다 — 3절 "메일과 함께".
|
||||
- **최근 주고받은 주소**는 그 사람의 보낸편지함 최근 500통(받는 사람·참조·숨은참조)과 받은편지함 최근 500통(보낸 사람)에서 모은다 —
|
||||
다른 직원의 메일함은 읽지 않는다. 나·메일 엔진 관리자·게이트웨이·막은 계정·noreply 류는 뺀다.
|
||||
- **사내 목록**은 로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다 — 메일 엔진의 계정 목록 권한은 쓰지 않는다(일부러 막아 두었다).
|
||||
- **사내 목록**은 8단계부터 엔진의 사내 목록(3절 "사내 목록")이다. 그것을 읽지 못할 때만 아래 옛 규칙으로 대신한다 —
|
||||
로그인에 성공한 직원(주소·이름)을 서버가 기록해 만든다(직원 역할에는 엔진의 계정 목록 권한이 없다).
|
||||
한 번도 로그인하지 않은 직원은 그 사람과 메일을 주고받기 전까지 나오지 않는다. 최근 90일 안에 로그인한 직원만 사내로 추천한다
|
||||
(퇴사자가 계속 맨 앞에 나와 반송되지 않게 — 웹메일 세션은 최대 30일이라 쓰는 사람은 그 안에 다시 로그인한다).
|
||||
- **알려진 한계**: 최근 주고받은 주소의 이름은 받은 메일의 보낸 사람 표시 이름에서 온다 — 바깥 사람이 동료 이름을 표시 이름으로 쓰면 그 이름으로 추천된다.
|
||||
추천 줄은 늘 주소를 함께 보이고, 사내와 최근 묶음을 나눠 보인다.
|
||||
- 응답은 `Cache-Control: private, no-store`. 서버는 사용자마다 10분 캐시하고(서버 메모리, 최대 500명), 그 사람이 메일을 보내면 다시 모은다.
|
||||
같은 사람의 동시 요청은 한 번만 모은다. 로그에는 개수만 남긴다.
|
||||
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자랄 때만 `q` 로 다시 묻는다(250ms 기다린 뒤).
|
||||
- **클라이언트**: 쓰기 화면을 열 때 `q` 없이 `limit=100` 으로 한 번 받아 두고 치는 대로 거른다(초성 포함). 맞는 것이 모자라고 더 있을 때만 `q` 로 다시 묻는다(250ms 기다린 뒤) —
|
||||
8단계부터는 초성 · 조합 중 글자도 묻는다(주소록이 크면 받아 둔 100줄이 주소록으로 차서 사내 · 최근 주소가 그 안에 없다). 맞추기가 같아서 받아 둔 것에서 8개를 찾았으면 서버에 물어도 같은 8개다.
|
||||
두 쪽 모두 유니코드 정규형(NFC)으로 맞추지 않는다 — 화면이 원래 글자 위치로 강조하기 때문이다(다른 정규형으로 저장된 이름은 그 글자로 맞지 않는다).
|
||||
|
||||
### 새 메일 확인 (`/api/mail-state`) — 3단계
|
||||
|
||||
@@ -972,5 +980,188 @@ interface MailFilterRun {
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
탭을 모두 닫아도 오는 알림(웹 푸시 — 엔진은 `webpush-vapid` 를 지원한다), 알림 소리(운영체제 설정을 따른다), 휴대폰 푸시, 본문 안 이미지(인라인 cid — 붙여 넣은 이미지는 첨부로, 서명 그림만 예외), 외부 이미지 보기(이미지 중계),
|
||||
여러 개의 서명, 엔진 계정 목록 기반의 사내 주소록, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
|
||||
여러 개의 서명, 자체 검색 색인(붙여 쓴 말 · 앞부분 · 제목 말머리로 찾기), 조건 검색(보낸 사람 · 기간 · 첨부 있음), 검색어 강조,
|
||||
끌어다 놓아 옮기기, 메일함 옮기기(다른 상위로) · 하위 메일함까지 한꺼번에 지우기, 앱 비밀번호 · 2단계 인증(엔진은 지원한다), 부재중 응답의 서식 본문 · 사내/외부 구분, 인쇄.
|
||||
|
||||
---
|
||||
|
||||
## 3. 주소록 — 8단계 (모두 로그인 필요 — 없으면 401 `AUTH_001`)
|
||||
|
||||
직원마다의 **내 주소록**(연락처 · 그룹)과 모두가 함께 보는 **사내 목록**(직원 · 공용 주소)이다. 화면은 디자인 README "8단계"(디자인 원본 `Contacts` · `ContactsEdit` · `ContactsMail`).
|
||||
|
||||
- **내 주소록은 메일 엔진의 주소록에 둔다** — 직원마다 엔진의 기본 주소록 하나(JMAP Contacts · JSContact 카드). 서버는 게이트웨이 대리 접근으로 그 직원의 주소록만 읽고 쓴다.
|
||||
같은 주소록을 나중에 휴대폰 · 메일 프로그램(CardDAV)에서도 쓸 수 있다 — 그래서 서버는 우리가 다루지 않는 칸(주소 · 생일 · 사진 등)을 고칠 때 그대로 둔다.
|
||||
- **사내 목록은 엔진의 계정 목록(JMAP Principal)에서 이름 · 주소만 읽는다**. 직원 역할은 계정 목록을 볼 수 없게 막아 두었으므로(1단계 setup)
|
||||
setup 이 만든 전용 계정으로만 읽는다(아래 "사내 목록").
|
||||
|
||||
### 타입
|
||||
|
||||
```ts
|
||||
type ContactEmailLabel = 'work' | 'personal' | 'other' // 회사 · 개인 · 기타
|
||||
type ContactPhoneLabel = 'mobile' | 'work' | 'home' | 'other' // 휴대전화 · 회사 · 집 · 기타
|
||||
|
||||
interface ContactEmail { address: string; label: ContactEmailLabel }
|
||||
interface ContactPhone { number: string; label: ContactPhoneLabel }
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
name: string | null // 표시 이름(엔진 카드의 name.full — 없으면 이름 조각을 이어 붙인 것)
|
||||
emails: ContactEmail[] // 앞의 것이 대표 주소 — 받는 사람 추천의 첫 줄 · 그룹으로 보낼 때 쓴다
|
||||
phones: ContactPhone[]
|
||||
company: string | null
|
||||
department: string | null
|
||||
title: string | null // 직책
|
||||
note: string | null // 메모 (줄바꿈 있음)
|
||||
groupIds: string[] // 속한 그룹 — 이 주소록의 그룹만
|
||||
}
|
||||
|
||||
interface ContactInput { // POST 본문(고치기는 아래 ContactPatch) — 서버는 앞뒤 공백을 빼고 저장한다(빈 글은 null)
|
||||
name: string | null // 100자까지
|
||||
emails: ContactEmail[] // 20개까지(화면은 5개까지 더한다 — 다른 프로그램이 넣은 것은 그대로 보이고 저장된다), 주소 254자 · 메일 주소 형식 · 같은 연락처 안에서 겹치지 않게(대소문자 무시)
|
||||
phones: ContactPhone[] // 20개까지, 번호 40자 — 숫자가 하나 이상이고 숫자 · 띄어쓰기 · + - ( ) . # * 만
|
||||
company: string | null // 100자
|
||||
department: string | null // 100자
|
||||
title: string | null // 100자
|
||||
note: string | null // 1,000자 — 줄바꿈(\n)은 되고 그 밖의 제어 문자는 안 된다
|
||||
groupIds: string[] // 100개까지, 겹치지 않게
|
||||
}
|
||||
// 이름이나 이메일 가운데 하나는 있어야 한다(둘 다 없으면 400 VAL_001). 글자 수는 JS 문자열 길이(UTF-16) — 웹의 .length 와 같다.
|
||||
|
||||
type ContactPatch = Partial<ContactInput> // PATCH 본문 — 바꾼 칸만(하나 이상 — 비면 400 VAL_001). 보낸 칸만 위 규칙으로 보고, "이름이나 이메일"은 합친 결과로 본다.
|
||||
// emails · phones · groupIds 는 보내면 그 칸이 통째로 바뀐다. 보내지 않은 칸은 검사하지도 바꾸지도 않는다 — 아래 "고치기".
|
||||
|
||||
interface ContactDeleted { // 연락처 지우기의 응답
|
||||
contact: Contact // 지운 연락처
|
||||
restoreToken: string | null // [되돌리기] 표 — 아래 "되돌리기" (서버가 기억하지 못했으면 null — 화면은 [되돌리기] 없이 알린다)
|
||||
}
|
||||
|
||||
interface ContactGroup {
|
||||
id: string
|
||||
name: string // 1~50자, 이 주소록 안에서 겹치지 않는다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다)
|
||||
memberCount: number // 이 주소록에 있는 연락처 가운데 구성원 수
|
||||
}
|
||||
|
||||
interface ContactGroupDeleted { // 그룹 지우기의 응답
|
||||
id: string
|
||||
name: string
|
||||
memberIds: string[]
|
||||
restoreToken: string | null // [되돌리기] 표 — 아래 "되돌리기"
|
||||
}
|
||||
|
||||
type ContactRestored = // 되돌리기의 응답 — 다시 만든 것(id 는 새로 받는다)
|
||||
| { kind: 'contact'; contact: Contact }
|
||||
| { kind: 'group'; group: ContactGroup }
|
||||
|
||||
interface ContactGroupMembersAdded {
|
||||
group: ContactGroup
|
||||
addedCount: number // 이번에 그룹에 들어간 수 (이미 구성원이던 것은 세지 않는다)
|
||||
createdCount: number // 사내 목록에서 골라 내 주소록에 새로 만든 연락처 수
|
||||
}
|
||||
|
||||
interface DirectoryEntry {
|
||||
id: string // 엔진의 계정 id
|
||||
name: string // 엔진의 표시 이름 — 없으면 주소의 @ 앞
|
||||
email: string
|
||||
kind: 'person' | 'shared' // 직원(개인 계정) · 공용 주소(그룹 계정)
|
||||
}
|
||||
```
|
||||
|
||||
### 엔드포인트
|
||||
|
||||
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/contacts` | `q`(선택, 1~254자) · `groupId`(선택 — 그 그룹의 구성원만) · `excludeGroupId`(선택 — 그 그룹의 구성원을 빼고. `groupId` 와 함께 쓰지 않는다) · `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: Contact[], pageInfo }` — 이름 차례(아래), `total` 포함 | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001` |
|
||||
| `GET /api/contacts/{id}` | — | 200 `Contact` | 404 `RES_001`, 503 `MAIL_001` |
|
||||
| `POST /api/contacts` | `ContactInput` | 201 `Contact` | 400 `VAL_001`, 404 `RES_001`(없는 groupId), 409 `CONTACT_002`(연락처 3,000개 · 그룹 구성원 500명 · 크기), 503 `MAIL_001` |
|
||||
| `PATCH /api/contacts/{id}` | `ContactPatch` — 바꾼 칸만(보내지 않은 칸 · 엔진 카드의 다른 칸은 그대로 — 아래 "고치기") | 200 `Contact` | 400 `VAL_001`, 404 `RES_001`, 409 `CONTACT_002`(그룹 구성원 500명 · 크기), 503 `MAIL_001` |
|
||||
| `DELETE /api/contacts/{id}` | — | 200 `ContactDeleted` — 지운 연락처(그룹에서도 뺀다)와 되돌리기 표 | 404 `RES_001`, 503 `MAIL_001` |
|
||||
| `GET /api/contact-groups` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ContactGroup[], pageInfo }` — 이름 차례 | 503 `MAIL_001` |
|
||||
| `POST /api/contact-groups` | `{ name }` | 201 `ContactGroup` | 400 `VAL_001`, 409 `CONTACT_001`(같은 이름) · `CONTACT_002`(그룹 100개), 503 `MAIL_001` |
|
||||
| `PATCH /api/contact-groups/{id}` | `{ name }` | 200 `ContactGroup` | 400 `VAL_001`, 404 `RES_001`, 409 `CONTACT_001`, 503 `MAIL_001` |
|
||||
| `DELETE /api/contact-groups/{id}` | — | 200 `ContactGroupDeleted` — 연락처는 그대로 | 404 `RES_001`, 503 `MAIL_001` |
|
||||
| `POST /api/contact-restorations` | `{ token }` — 지우기 응답의 `restoreToken` | 201 `ContactRestored` — 지운 그대로 다시 만든 연락처 · 그룹(아래 "되돌리기") | 400 `VAL_001`, 404 `RES_001`(표가 없음 · 지남 · 이미 씀), 409 `CONTACT_001` · `CONTACT_002`, 503 `MAIL_001` |
|
||||
| `POST /api/contact-groups/{id}/members` | `{ contactIds?, directoryIds? }` — 합쳐 1~100개 | 200 `ContactGroupMembersAdded` | 400 `VAL_001`, 404 `RES_001`(없는 그룹 · 연락처 · 사내 목록 id), 409 `CONTACT_002`, 503 `MAIL_001` |
|
||||
| `GET /api/directory-entries` | `q`(선택, 1~254자) · `kind`(선택 — `person` · `shared`) · `excludeGroupId`(선택 — 그 그룹 구성원의 주소를 뺀다. 구성원 넣기 창) · `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: DirectoryEntry[], pageInfo }` — 이름 차례, `total` 포함 | 400 `VAL_001`, 404 `RES_001`(없는 그룹), 503 `MAIL_001`(사내 목록을 읽지 못함 — 아래 "사내 목록") |
|
||||
| `GET /api/directory-entries/{id}` | — | 200 `DirectoryEntry` | 404 `RES_001`(없거나 빼는 계정), 503 `MAIL_001` |
|
||||
|
||||
- **에러 코드(8단계에 더함)**: `CONTACT_001` 409 — 같은 이름의 그룹이 있다(만들기 · 이름 바꾸기 — 화면은 칸 아래에, 알림 없이).
|
||||
`CONTACT_002` 409 — 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명 · 아래 "크기")를 넘는다 — 아무것도 바꾸지 않는다
|
||||
(사전 문구에는 앞의 세 한도가 나온다 — 크기는 보통 쓰임으로는 닿지 않는다).
|
||||
- `cursor` 는 불투명 문자열이고 받은 그 `q` · `groupId` · `excludeGroupId` · `kind` 로만 쓴다(다르면 400 `VAL_001`). 차례 사이에 연락처가 바뀌면 다음 쪽에서 한두 개가 겹치거나 빠질 수 있다.
|
||||
- **차례**: 이름(한국어 사전 차례 — `Intl.Collator('ko')`), 이름이 없으면 대표 주소로 견준다. 같으면 대표 주소 → id.
|
||||
- **찾기(`q`)**: 초성만 친 글(ㄱ~ㅎ, 겹자음 포함)이면 이름의 초성과 견준다(ㅂㅈㅎ → 박지훈). 그 밖에는 이름 · 주소 · 회사 · 부서 · 직책 · 전화번호에 그 글이 들어 있으면 맞다
|
||||
(ASCII 대소문자 무시). 전화번호는 숫자만 견준다(`01031925540` · `3192` 가 `010-3192-5540` 에 맞는다). 사내 목록은 이름 · 주소로 같은 규칙.
|
||||
엔진의 주소록 검색은 낱말 단위이고 이름 정렬을 하지 못해서(2026-10-29 확인 — '김아라'로 못 찾고 `name` 정렬은 unsupportedSort) 서버가 주소록 전체를 읽어 찾고 정렬한다.
|
||||
- **엔진에 두는 모양(서버 책임)**: 연락처는 JSContact `kind: individual`, 그룹은 `kind: group`(구성원은 `members` — 카드의 `uid` 로 잇는다).
|
||||
만들 때 서버가 `uid`(`urn:uuid:…`)를 넣는다 — 넣지 않으면 엔진이 `uid` 를 주지 않아 그룹에 이을 수 없다(2026-10-29 확인).
|
||||
엔진은 만든 뒤 `uid` 를 바꾸지 못하게 한다("You cannot change the UID of a contact.") — 그래서 다른 프로그램이 `uid` 없이 만든 카드는 그룹에 넣을 수 없다:
|
||||
`PATCH` 의 `groupIds` 에 새 그룹을 넣으면 400 `VAL_001`, 구성원 넣기에서는 건너뛰고 세지 않는다(CardDAV 프로그램은 늘 UID 를 넣는다 — 드물다).
|
||||
그룹 구성원을 바꿀 때는 `members` 를 통째로 쓴다(엔진이 구성원 없는 그룹의 `members/<uid>` 패치를 받지 않는다).
|
||||
칸: `name.full` · `emails`(종류는 `contexts` — work · private, 대표 주소는 `pref: 1`) · `phones`(휴대전화는 `features.mobile`, 회사 · 집은 `contexts` work · private) ·
|
||||
`organizations`(이름 · `units` 첫째가 부서) · `titles`(`kind: title`) · `notes`. 고치기는 이 칸만 패치한다 — 다른 칸(주소 · 생일 · 사진 · 다른 프로그램의 칸)은 그대로 둔다.
|
||||
칸 안에서도 우리가 정하지 않는 것은 그대로 둔다(아래 "고치기").
|
||||
엔진에 주소록이 여럿이면 기본 주소록(`isDefault`)만 쓴다. 그룹 구성원 수는 이 주소록에 있는 연락처만 센다(지워진 카드를 가리키는 `uid` 는 세지 않는다).
|
||||
- **캐시**: 주소록 전체 읽기는 300장에 0.4초 남짓이라(개발 엔진) 서버는 직원마다 주소록을 엔진의 상태(`state`)와 함께 기억하고, 요청마다 상태만 물어(`ContactCard/get` 의 빈 ids — 3ms) 바뀌지 않았으면 다시 읽지 않는다
|
||||
(서버 메모리 — 200명 · 모두 합쳐 2,400만, 한 사람 800만까지(아래 "크기"와 같은 잣대) — "크기" 안의 주소록은 늘 통째로 기억할 수 있다.
|
||||
그보다 큰 주소록(다른 프로그램이 크게 만든 것)은 기억하지 않고 요청마다 다시 읽는다. 30분 쓰지 않으면 잊는다. 다른 프로그램에서 바꾼 것도 상태가 바뀌어 곧 보인다 — 그때는 주소록 전체를 다시 읽는다, 3,000장에 3초 남짓).
|
||||
엔진에서 한 번에 읽는 상한은 카드 5,000장 · 계정 10,000개다(넘으면 앞쪽만 — warn 로그).
|
||||
- **쓰기**: 같은 직원의 바꾸기는 차례로 한다. 엔진에는 읽은 상태 그대로일 때만 쓰고(`ifInState`), 그사이 바뀌면 다시 읽어 다시 정한다 — 세 번 어긋나면 503 `MAIL_001`.
|
||||
한도 · 같은 이름 검사도 같은 판으로 본다. 엔진에는 바뀐 칸만 보낸다(회사 · 부서는 한 조직이라 하나만 바뀌어도 함께).
|
||||
엔진이 `serverUnavailable` 로 거절하면(메서드 오류든 항목 오류든) 판을 다시 읽어 다시 한다(위 세 번 안에서 — 일부만 들어갔을 수 있어 만들기 · 되돌리기는 같은 uid 로 이미 들어간 카드를 찾는다).
|
||||
⚠️ 엔진은 한 요청의 `destroy` 에 같은 id 가 두 번 있으면 요청 전체를 `serverUnavailable`(`store.assert-value-failed`)로 거절한다(2026-10-29 확인 — 몇 번을 다시 해도 같다) — 서버는 한 요청에 같은 카드를 두 번 넣지 않는다.
|
||||
판은 읽기 전에 물은 것으로 잡는다 — 읽는 사이 다른 곳에서 만든 카드를 빠뜨린 채 "최신"으로 기억하지 않게.
|
||||
- **크기**: 서버가 재는 주소록 크기(연락처 · 그룹에서 우리가 다루는 칸의 글자 수 + 항목마다 조금 — 위 캐시가 쓰는 잣대)를 **800만** 넘게 하는 쓰기
|
||||
(만들기 · 고치기 · 구성원 넣기 · 되돌리기)는 409 `CONTACT_002` 다 — 줄이는 쓰기와 지우기는 늘 된다. 연락처 3,000개에 그룹 100개를 구성원 500명씩 다 채워도 연락처마다
|
||||
1,000자 넘게 남아 보통 쓰임으로는 닿지 않는다. 캐시가 주소록 하나를 통째로 기억할 수 있게 둔 한도다
|
||||
(2026-10-29 리뷰: 칸마다 최대인 연락처 3,000장은 2,400만이라 기억하지 못하고, 요청마다 엔진에서 다시 읽어 1초씩 멈췄다).
|
||||
- **고치기(`PATCH`)**: 화면은 창을 열 때 그 연락처를 새로 받고(`GET /api/contacts/{id}`), 연 때와 달라진 칸만 보낸다 — 창을 연 사이 다른 탭 · 휴대폰에서 바꾼 다른 칸을 덮지 않는다.
|
||||
같은 칸을 양쪽에서 바꿨으면 나중에 저장한 쪽이 이긴다(판을 묻지 않는다). 보내지 않은 칸은 검사하지 않는다 — 다른 프로그램이 우리 규칙 밖의 값(21번째 주소 · "내선"이 든 번호 ·
|
||||
1,000자 넘는 메모)을 넣었어도 다른 칸은 고칠 수 있다(그 칸을 고칠 때는 그 값도 규칙에 맞춰야 한다).
|
||||
칸 안의 남의 정보도 지킨다 — 서버는 보낸 주소를 지금 카드의 같은 주소(대소문자 무시)와, 번호는 보인 글이 같은 것 먼저 · 그다음 숫자가 같은 것과 짝지어
|
||||
그 항목의 다른 속성(`label` · 다른 `contexts` · `fax` 같은 `features` · `pref`)을 남기고, 종류를 바꾼 것만 우리 값(`contexts` work · private, `features.mobile`)으로 고친다.
|
||||
대표 주소(`pref: 1`)는 첫 주소에만 둔다 — 다른 항목의 `pref` 는 남기되 1 이거나 읽는 차례(`pref` → 넣은 차례)를 보낸 차례와 다르게 만들면 뺀다.
|
||||
직책은 `role` 이 아닌 첫 항목만 고치고 지우면 그 항목만 뺀다(다른 직책 항목이 있으면 그것이 보인다). 회사 · 부서는 첫 조직의 이름과 첫 `units` 만 고친다
|
||||
(다른 조직 · 다른 부서는 그대로 — 둘 다 비워 그 조직에 남는 것이 없으면 조직째 뺀다). 메모는 하나로 쓰고(여러 개였으면 이어 보인 글을 고친 것이라) 첫 항목의 다른 속성은 남긴다.
|
||||
이름을 고치면 `{ full }` 로 쓴다 — 이름 조각(`name.components`)과 거기 딸린 칸은 지운다(옛 조각이 다른 프로그램에 옛 이름으로 보이지 않게).
|
||||
엔진은 받은 값을 조금 손봐 저장한다(객체 안 키 차례 · `title` 을 `role` 앞에 · `role` 에 `organizationId` · 주소에 `isOrdered` — 2026-10-29 확인) — 응답과 캐시는 서버가 보낸 카드를 다시 읽은 모양이다.
|
||||
- **응답**: `GET` 응답에는 `Cache-Control: private, no-store` 를 붙인다. 그룹 목록의 `pageInfo.total` 은 그룹 수다.
|
||||
- **지우기**: 연락처를 지우면 그 연락처를 가리키는 그룹 구성원도 함께 뺀다. 영구 삭제다(엔진 주소록에는 휴지통이 없다) — 화면은 확인 없이 지우고 10초 [되돌리기]로 다시 만든다.
|
||||
- **되돌리기**(`POST /api/contact-restorations`): 서버는 지우기 직전에 엔진 카드 전체(다른 프로그램의 칸 · `uid` 포함)와 그때 속한 그룹(연락처) · 구성원(그룹)을 읽어 직원마다
|
||||
**5분** 기억하고(서버 메모리 — 직원마다 최근 20개 · 모두 합쳐 32MB, 카드 하나가 256KB 를 넘으면 기억하지 않는다 — 표 `null`) 지우기 응답에 표를 준다. 되돌리기는 그 원본을 **그대로** 다시 만든다 —
|
||||
같은 `uid`(그사이 이 주소록의 다른 카드가 그 `uid` 를 쓰면 새 `uid` — 엔진은 같은 주소록 안의 같은 `uid` 를 거절한다, 다른 주소록이면 받는다), 우리 규칙 밖의 값도 그대로.
|
||||
`uid` 가 없던 원본은 새 `uid` 로 만든다(되돌린 뒤에는 그룹에 넣을 수 있다). 엔진이 다시 만들기를 받지 않으면 503 `MAIL_001` 이다(드묾 — 엔진은 공급자 전용 칸(`example.com:custom` 꼴)이 든 카드를 만들 때 거절한다). 연락처는 그때 속했고 지금도 있는 그룹에 다시 넣고(구성원 500명이 찬 그룹은 건너뛴다),
|
||||
그룹은 그때의 구성원 가운데 지금도 있는 연락처로 다시 만든다(같은 이름의 그룹이 그사이 생겼으면 409 `CONTACT_001`, 그룹 100개 · 연락처 3,000개 · 크기는 409 `CONTACT_002`).
|
||||
id 는 새로 받는다. 표는 한 번만 쓴다(성공하면 지운다 — 실패하면 5분 안에 다시 쓸 수 있다). 표가 없거나 지났거나 이미 썼거나 다른 직원의 것이면 404 `RES_001`(서버를 다시 띄우면 표는 사라진다).
|
||||
⚠️ 서버를 여러 대로 늘리면 표도 공유 저장소로 옮겨야 한다(다른 인스턴스로 간 되돌리기는 404).
|
||||
- **로그**: 연락처의 이름 · 주소 · 전화번호 · 메모는 남기지 않는다(개수 · id 만). `q` 는 요청 URL 로그에서 길이만 남는다(`maskUrlForLog`). 되돌리기 표도 남기지 않는다.
|
||||
- **알려진 한계 · CardDAV 를 열기 전에 할 일**: 지금은 공개 443 이 잠겨(1단계 setup) 엔진 주소록에 쓰는 길이 우리 API 뿐이다. 휴대폰 · 메일 프로그램(CardDAV)에 열기 전에
|
||||
엔진 응답에 바이트 상한을 두고(지금은 카드 수 5,000장만 막는다 — 사진이 든 카드 500장 한 쪽이 수백 MB 일 수 있다) 위 "크기"를 넘는 주소록의 읽기 비용을 다시 본다.
|
||||
|
||||
### 사내 목록 (`/api/directory-entries`)
|
||||
|
||||
- 엔진의 계정 목록 — 개인 계정은 직원(`person`), 그룹 계정은 공용 주소(`shared`). 이름은 엔진의 표시 이름이다(계정의 설명 — 관리 화면에서 넣는 이름).
|
||||
- 빼는 계정: 엔진 관리자 · 게이트웨이 · 사내 목록 계정 · 막은 계정(`MAIL_BLOCKED_ACCOUNTS`) · setup 이 만든 계정(설명에 `[setup.mjs]`) — 웹메일 로그인의 정적 목록과 같다.
|
||||
로그인 때 엔진에 묻는 관리 권한 확인은 여기에 없다(엔진의 계정 목록에는 역할이 없다) — 엔진 관리자 아이디 밖의 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣어야 빠진다.
|
||||
개인 · 그룹이 아닌 계정(자원 등)도 뺀다.
|
||||
주소가 `MAIL_DOMAIN` 이 아닌 계정과 주소가 없는 계정도 뺀다.
|
||||
- 읽는 길: setup 이 만든 **사내 목록 계정**(`<MAIL_DIRECTORY_LOCAL_PART>@<MAIL_DOMAIN>` — 기본 `mail-directory`). 그 계정의 역할에는 로그인과 계정 목록 읽기
|
||||
(`authenticate` · `jmapPrincipalGet` · `jmapPrincipalQuery`)만 있고 비밀번호가 없다 — 서버만 게이트웨이 대리 접근으로 쓴다. 엔진이 주는 칸은 id · 종류 · 로그인 이름 · 표시 이름 · 주소뿐이다
|
||||
(비밀번호 같은 칸은 없다 — 2026-10-29 확인). 이 권한은 엔진 관리자가 내주지도 빼지도 못해 setup 이 **복구 관리자로 실행될 때** 만든다(개발은 늘, 운영은 첫 배포의 `--recovery` — mail/README.md "사내 목록 계정").
|
||||
이 계정은 메일을 받지 않는다 — 그 주소(+태그 꼴 포함)로 오는 메일은 받는 쪽 RCPT 단계에서 없는 주소처럼 550 이다(setup 이 관리자 · 게이트웨이와 함께 맞춘다 —
|
||||
받아 놓고 보낸 사람에게 반송하지 않게, mail/README.md "보안"). 그 뒤의 막으로 역할에 메일 받기 권한이 없고 저장 한도도 1MiB 다.
|
||||
- 서버는 목록을 10분 기억한다(모든 직원이 함께 — 서버 메모리). 사내 목록 계정이 없거나 읽지 못하면 503 `MAIL_001` 이고, 받는 사람 추천의 "사내"는 전처럼 로그인한 직원 기록으로 대신한다.
|
||||
- 한 항목(`GET /api/directory-entries/{id}`)은 그 기억한 목록에서 찾는다 — 화면이 주소로 연 사내 항목. `excludeGroupId` 는 그 직원 주소록의 그 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다.
|
||||
|
||||
### 메일과 함께 (2절과 이어지는 것)
|
||||
|
||||
- **받는 사람 추천**(2절 `/api/recipient-suggestions`)에 내 주소록을 더한다 — `source: 'contact'`(주소마다 한 줄, 대표 주소가 먼저. 나와 시스템 계정(엔진 관리자 · 게이트웨이 ·
|
||||
사내 목록 계정 · 막은 계정)과 주소 모양(`아이디@도메인`)이 아닌 것만 빼고, noreply 류도 직원이 넣은 것이라 그대로 둔다). 순서는 주소록 → 사내 → 최근 주고받은 주소이고,
|
||||
여러 묶음에 있는 주소는 앞 묶음에 한 번만 나온다. "사내"는 사내 목록(위)의 직원 · 공용 주소다 — 사내 목록을 읽지 못하면 전처럼 로그인한 직원 기록(최근 90일).
|
||||
주소록 묶음은 추천 캐시(10분)에 넣지 않고 요청마다 그 직원의 주소록(위의 상태로 확인한 캐시)에서 붙인다 — 연락처를 바꾸면 다음 추천에 바로 보인다.
|
||||
주소록을 읽지 못하면 그 묶음만 빼고 답한다(warn 로그).
|
||||
- **그룹**은 추천 응답에 넣지 않는다 — 클라이언트가 쓰기 화면을 열 때 `GET /api/contact-groups` 를 한 번 받아 그룹 이름으로 찾고 "주소록" 다음 묶음으로 보인다.
|
||||
그룹을 고르면 `GET /api/contacts?groupId=…&limit=100` 으로 구성원을 받아(100명 넘으면 다음 쪽까지) 대표 주소를 받는 사람에 넣는다 — 이메일이 없는 구성원 · 이미 넣은 주소는 뺀다.
|
||||
- **쓰기 화면을 받는 사람을 채워 연다**(연락처 · 그룹 · 사내의 [메일 쓰기]): 주소는 화면 주소(URL)에 싣지 않고 라우터 상태로 넘긴다 — 브라우저 기록 · 서버 로그에 남지 않게.
|
||||
상태에는 연 직원의 주소를 함께 싣고, 지금 로그인한 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기로 돌아온 경우 — 2026-10-29 리뷰).
|
||||
- **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
|
||||
판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
|
||||
|
||||
Reference in New Issue
Block a user