Update visit request form layout
This commit is contained in:
30
docs/ACS-visit-form-ui-update.md
Normal file
30
docs/ACS-visit-form-ui-update.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# ACS 출입 신청 화면 UI 수정
|
||||
|
||||
> 작성일: 2026-07-14
|
||||
> 대상 화면: `/visit-requests/new`
|
||||
> 요청자: 양팀장님
|
||||
|
||||
## 반영 내용
|
||||
|
||||
1. 화면 콘텐츠 폭을 브라우저 창 기준으로 넓게 사용하도록 조정했다.
|
||||
2. 출입 신청 화면의 입력 항목과 레이블을 한 줄 배치로 변경했다.
|
||||
3. 필수 입력 `*` 표시를 빨간색으로 변경했다.
|
||||
4. 방문자 연락처와 현장감시자2 연락처에 11자리 숫자 입력 시 `000-0000-0000` 형식으로 자동 변환되도록 했다.
|
||||
5. 입력 영역을 `방문자`, `출입통제담당자`, `현장감시자1과2` 테두리 박스로 구분했다.
|
||||
6. 범례를 수정했다.
|
||||
- 출입통제담당자 연락처: `내선번호/휴대폰번호`
|
||||
- 현장감시자2 연락처: `내선번호/휴대폰번호`
|
||||
7. 레이블과 개인정보 동의 문구를 수정했다.
|
||||
- `퇴실 일시 *` -> `퇴실예정일시 *`
|
||||
- `[개인정보 수집·이용 동의]` -> `[개인정보 수집·이용 동의 확인]`
|
||||
- 개인정보 동의 거부 안내 문구 수정
|
||||
|
||||
## 검증
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| `npm run typecheck` | 성공 |
|
||||
| `npm --prefix frontend run build` | 성공 |
|
||||
|
||||
프론트 빌드는 샌드박스 안에서 `spawn EPERM`으로 한 차례 실패했으며, 승인 권한으로 재실행하여 성공을 확인했다.
|
||||
|
||||
@@ -31,6 +31,14 @@ const isPastDate = (iso: string): boolean => {
|
||||
return day < todayStart;
|
||||
};
|
||||
|
||||
const formatPhoneLike = (value: string): string => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (digits.length === 11) {
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const VisitRequestFormPage: React.FC = () => {
|
||||
const [form, setForm] = useState({
|
||||
visitorName: '',
|
||||
@@ -60,6 +68,10 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => setForm({ ...form, [k]: formatPhoneLike(e.target.value) });
|
||||
|
||||
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
@@ -103,7 +115,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
await createVisitRequest({
|
||||
visitorName: form.visitorName.trim(),
|
||||
company: form.company.trim() || undefined,
|
||||
contact: form.contact.trim(),
|
||||
contact: formatPhoneLike(form.contact.trim()),
|
||||
email: form.email.trim() || undefined,
|
||||
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||
serverRooms: form.serverRooms,
|
||||
@@ -112,7 +124,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
workName: form.workName.trim() || undefined,
|
||||
watcher2Name: form.watcher2Name.trim() || undefined,
|
||||
watcher2Team: form.watcher2Team.trim() || undefined,
|
||||
watcher2Contact: form.watcher2Contact.trim() || undefined,
|
||||
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
|
||||
visitFrom: form.visitFrom,
|
||||
visitTo: form.visitTo,
|
||||
});
|
||||
@@ -129,9 +141,12 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<div className="page-head"><h2>출입 신청</h2></div>
|
||||
|
||||
{/* noValidate: use our Korean messages instead of the browser's native popups */}
|
||||
<form className="card form-grid" onSubmit={onSubmit} noValidate>
|
||||
<form className="card form-grid visit-form" onSubmit={onSubmit} noValidate>
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>방문자</legend>
|
||||
<div className="group-grid">
|
||||
<label className="field">
|
||||
<span>방문자 이름 *</span>
|
||||
<span>방문자 이름 <b className="required">*</b></span>
|
||||
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -139,8 +154,8 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<input value={form.company} onChange={update('company')} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처 * <em className="hint-inline">: 입력형식은 010-0000-0000 로 작성해 주세요.</em></span>
|
||||
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" />
|
||||
<span>연락처 <b className="required">*</b><em className="hint-inline">: 입력형식은 010-0000-0000 로 작성해 주세요.</em></span>
|
||||
<input type="tel" value={form.contact} onChange={updateFormattedContact('contact')} placeholder="010-0000-0000" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>이메일</span>
|
||||
@@ -177,13 +192,13 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
{form.room === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 구역 입력 *</span>
|
||||
<span>기타 구역 입력 <b className="required">*</b></span>
|
||||
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
|
||||
<label className="field">
|
||||
<span>출입 목적 *</span>
|
||||
<span>출입 목적 <b className="required">*</b></span>
|
||||
<select value={form.purpose} onChange={update('purpose')}>
|
||||
<option value="">선택하세요</option>
|
||||
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
@@ -191,7 +206,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
{form.purpose === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 목적 입력 *</span>
|
||||
<span>기타 목적 입력 <b className="required">*</b></span>
|
||||
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
@@ -202,7 +217,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>출입 일시 *</span>
|
||||
<span>출입 일시 <b className="required">*</b></span>
|
||||
<DateTimePicker
|
||||
value={form.visitFrom}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
||||
@@ -210,17 +225,19 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>퇴실 일시 *</span>
|
||||
<span>퇴실예정일시 <b className="required">*</b></span>
|
||||
<DateTimePicker
|
||||
value={form.visitTo}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
||||
placeholder="퇴실 일시 선택"
|
||||
placeholder="퇴실예정일시 선택"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-section span-2">
|
||||
출입통제담당자 <em className="hint-inline">: 로그인한 본인 정보로 자동 등록됩니다.</em>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>출입통제담당자</legend>
|
||||
<div className="group-grid">
|
||||
<label className="field">
|
||||
<span>직원명</span>
|
||||
<input value={user?.fullName ?? ''} readOnly />
|
||||
@@ -230,12 +247,16 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<input value={user?.department ?? ''} readOnly />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<span>연락처 <em className="hint-inline">: 내선번호/휴대폰번호</em></span>
|
||||
<input value={user?.email ?? ''} readOnly />
|
||||
</label>
|
||||
<div />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="form-section span-2">현장감시자1 <em className="hint-inline">: 고정 인원 (자동 지정)</em></div>
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>현장감시자1과2</legend>
|
||||
<div className="subsection-title">현장감시자1 <em className="hint-inline">: 고정 인원 (자동 지정)</em></div>
|
||||
<div className="group-grid">
|
||||
<label className="field">
|
||||
<span>이름</span>
|
||||
<input value={FIXED_WATCHER1.name} readOnly />
|
||||
@@ -248,9 +269,10 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
<span>연락처</span>
|
||||
<input value={FIXED_WATCHER1.contact} readOnly />
|
||||
</label>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="form-section span-2">현장감시자2 <em className="hint-inline">: 작업을 입회할 상주직원</em></div>
|
||||
<div className="subsection-title">현장감시자2 <em className="hint-inline">: 작업을 입회할 상주직원</em></div>
|
||||
<div className="group-grid">
|
||||
<label className="field">
|
||||
<span>이름</span>
|
||||
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
|
||||
@@ -263,20 +285,21 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<input value={form.watcher2Contact} onChange={update('watcher2Contact')} placeholder="내선/휴대번호" />
|
||||
<span>연락처 <em className="hint-inline">: 내선번호/휴대폰번호</em></span>
|
||||
<input value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
||||
</label>
|
||||
<div />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="span-2 consent-box">
|
||||
<label className="consent-label">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
||||
<span>
|
||||
<b>[개인정보 수집·이용 동의]</b><br />
|
||||
<b>[개인정보 수집·이용 동의 확인]</b><br />
|
||||
· 수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||
· 수집·이용 목적: IT센터 출입 신청 접수 및 출입자 관리<br />
|
||||
· 보유·이용 기간: 수집일로부터 1년 (기간 경과 시 지체 없이 파기)<br />
|
||||
· 귀하는 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됩니다.
|
||||
· 방문자 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됨을 고지
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ a { color: inherit; text-decoration: none; }
|
||||
padding: 2px 6px; border-radius: 6px;
|
||||
}
|
||||
|
||||
.content { max-width: 1080px; margin: 0 auto; padding: 28px 24px; }
|
||||
.content { width: 100%; max-width: none; margin: 0; padding: 28px; }
|
||||
|
||||
/* ===== Center (auth) ===== */
|
||||
.center-screen {
|
||||
@@ -173,6 +173,52 @@ a { color: inherit; text-decoration: none; }
|
||||
gap: 0 18px;
|
||||
}
|
||||
.form-grid .span-2 { grid-column: 1 / -1; }
|
||||
.visit-form { gap: 16px; }
|
||||
.form-group {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 18px 18px 4px;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.form-group > legend {
|
||||
padding: 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(360px, 1fr));
|
||||
gap: 0 18px;
|
||||
}
|
||||
.group-grid .span-2 { grid-column: 1 / -1; }
|
||||
.visit-form .field {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
.visit-form .field > span {
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.visit-form .field.span-2 {
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
}
|
||||
.required { color: var(--red); }
|
||||
.subsection-title {
|
||||
margin: 4px 0 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
.form-group .subsection-title:first-of-type {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
|
||||
/* Inline validation message shown at the left of the action row, beside the 취소 button. */
|
||||
.form-actions .form-error { margin-right: auto; align-self: center; color: #b91c1c; font-size: 14px; font-weight: 600; }
|
||||
@@ -313,6 +359,12 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
@media (max-width: 720px) {
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.group-grid { grid-template-columns: 1fr; }
|
||||
.visit-form .field,
|
||||
.visit-form .field.span-2 {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
.nav { display: none; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user