Update visit request form layout

This commit is contained in:
unknown
2026-07-14 18:51:24 +09:00
parent 0ae4b73592
commit 7a404cd836
3 changed files with 241 additions and 136 deletions

View 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`으로 한 차례 실패했으며, 승인 권한으로 재실행하여 성공을 확인했다.

View File

@@ -31,6 +31,14 @@ const isPastDate = (iso: string): boolean => {
return day < todayStart; 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 = () => { export const VisitRequestFormPage: React.FC = () => {
const [form, setForm] = useState({ const [form, setForm] = useState({
visitorName: '', visitorName: '',
@@ -60,6 +68,10 @@ export const VisitRequestFormPage: React.FC = () => {
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => setForm({ ...form, [k]: e.target.value }); ) => 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>) => const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ setForm((f) => ({
...f, ...f,
@@ -103,7 +115,7 @@ export const VisitRequestFormPage: React.FC = () => {
await createVisitRequest({ await createVisitRequest({
visitorName: form.visitorName.trim(), visitorName: form.visitorName.trim(),
company: form.company.trim() || undefined, company: form.company.trim() || undefined,
contact: form.contact.trim(), contact: formatPhoneLike(form.contact.trim()),
email: form.email.trim() || undefined, email: form.email.trim() || undefined,
vehicleNo: form.vehicleNo.trim() || undefined, vehicleNo: form.vehicleNo.trim() || undefined,
serverRooms: form.serverRooms, serverRooms: form.serverRooms,
@@ -112,7 +124,7 @@ export const VisitRequestFormPage: React.FC = () => {
workName: form.workName.trim() || undefined, workName: form.workName.trim() || undefined,
watcher2Name: form.watcher2Name.trim() || undefined, watcher2Name: form.watcher2Name.trim() || undefined,
watcher2Team: form.watcher2Team.trim() || undefined, watcher2Team: form.watcher2Team.trim() || undefined,
watcher2Contact: form.watcher2Contact.trim() || undefined, watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
visitFrom: form.visitFrom, visitFrom: form.visitFrom,
visitTo: form.visitTo, visitTo: form.visitTo,
}); });
@@ -129,9 +141,12 @@ export const VisitRequestFormPage: React.FC = () => {
<div className="page-head"><h2> </h2></div> <div className="page-head"><h2> </h2></div>
{/* noValidate: use our Korean messages instead of the browser's native popups */} {/* 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"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus /> <input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
</label> </label>
<label className="field"> <label className="field">
@@ -139,8 +154,8 @@ export const VisitRequestFormPage: React.FC = () => {
<input value={form.company} onChange={update('company')} /> <input value={form.company} onChange={update('company')} />
</label> </label>
<label className="field"> <label className="field">
<span> * <em className="hint-inline">: 010-0000-0000 .</em></span> <span> <b className="required">*</b><em className="hint-inline">: 010-0000-0000 .</em></span>
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" /> <input type="tel" value={form.contact} onChange={updateFormattedContact('contact')} placeholder="010-0000-0000" />
</label> </label>
<label className="field"> <label className="field">
<span></span> <span></span>
@@ -177,13 +192,13 @@ export const VisitRequestFormPage: React.FC = () => {
</label> </label>
{form.room === '기타' ? ( {form.room === '기타' ? (
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" /> <input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
</label> </label>
) : <div />} ) : <div />}
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<select value={form.purpose} onChange={update('purpose')}> <select value={form.purpose} onChange={update('purpose')}>
<option value=""></option> <option value=""></option>
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)} {PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
@@ -191,7 +206,7 @@ export const VisitRequestFormPage: React.FC = () => {
</label> </label>
{form.purpose === '기타' ? ( {form.purpose === '기타' ? (
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" /> <input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
</label> </label>
) : <div />} ) : <div />}
@@ -202,7 +217,7 @@ export const VisitRequestFormPage: React.FC = () => {
</label> </label>
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<DateTimePicker <DateTimePicker
value={form.visitFrom} value={form.visitFrom}
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))} onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
@@ -210,17 +225,19 @@ export const VisitRequestFormPage: React.FC = () => {
/> />
</label> </label>
<label className="field"> <label className="field">
<span> *</span> <span> <b className="required">*</b></span>
<DateTimePicker <DateTimePicker
value={form.visitTo} value={form.visitTo}
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))} onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
placeholder="퇴실 일시 선택" placeholder="퇴실예정일시 선택"
/> />
</label> </label>
<div className="form-section span-2">
<em className="hint-inline">: .</em>
</div> </div>
</fieldset>
<fieldset className="form-group span-2">
<legend></legend>
<div className="group-grid">
<label className="field"> <label className="field">
<span></span> <span></span>
<input value={user?.fullName ?? ''} readOnly /> <input value={user?.fullName ?? ''} readOnly />
@@ -230,12 +247,16 @@ export const VisitRequestFormPage: React.FC = () => {
<input value={user?.department ?? ''} readOnly /> <input value={user?.department ?? ''} readOnly />
</label> </label>
<label className="field"> <label className="field">
<span></span> <span> <em className="hint-inline">: /</em></span>
<input value={user?.email ?? ''} readOnly /> <input value={user?.email ?? ''} readOnly />
</label> </label>
<div /> </div>
</fieldset>
<div className="form-section span-2">1 <em className="hint-inline">: ( )</em></div> <fieldset className="form-group span-2">
<legend>12</legend>
<div className="subsection-title">1 <em className="hint-inline">: ( )</em></div>
<div className="group-grid">
<label className="field"> <label className="field">
<span></span> <span></span>
<input value={FIXED_WATCHER1.name} readOnly /> <input value={FIXED_WATCHER1.name} readOnly />
@@ -248,9 +269,10 @@ export const VisitRequestFormPage: React.FC = () => {
<span></span> <span></span>
<input value={FIXED_WATCHER1.contact} readOnly /> <input value={FIXED_WATCHER1.contact} readOnly />
</label> </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"> <label className="field">
<span></span> <span></span>
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} /> <input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
@@ -263,20 +285,21 @@ export const VisitRequestFormPage: React.FC = () => {
</select> </select>
</label> </label>
<label className="field"> <label className="field">
<span></span> <span> <em className="hint-inline">: /</em></span>
<input value={form.watcher2Contact} onChange={update('watcher2Contact')} placeholder="내선/휴대번호" /> <input value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대번호" />
</label> </label>
<div /> </div>
</fieldset>
<div className="span-2 consent-box"> <div className="span-2 consent-box">
<label className="consent-label"> <label className="consent-label">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} /> <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span> <span>
<b>[ · ]</b><br /> <b>[ · ]</b><br />
· 항목: 이름, , , <br /> · 항목: 이름, , , <br />
· · 목적: IT센터 <br /> · · 목적: IT센터 <br />
· · 기간: 수집일로부터 1 ( )<br /> · · 기간: 수집일로부터 1 ( )<br />
· · , . · · ,
</span> </span>
</label> </label>
</div> </div>

View File

@@ -76,7 +76,7 @@ a { color: inherit; text-decoration: none; }
padding: 2px 6px; border-radius: 6px; 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 (auth) ===== */
.center-screen { .center-screen {
@@ -173,6 +173,52 @@ a { color: inherit; text-decoration: none; }
gap: 0 18px; gap: 0 18px;
} }
.form-grid .span-2 { grid-column: 1 / -1; } .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; } .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. */ /* 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; } .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) { @media (max-width: 720px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); } .stat-grid { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 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; } .nav { display: none; }
} }