diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index 1a9b50d..0306aff 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -814,3 +814,18 @@ - `npm.cmd run typecheck` 성공. - `npm.cmd test` 성공. - `npm.cmd run build` 성공. + +- 2026-07-16 사내 SMS API 가이드 반영 및 일시 선택 UI 개선: + - `C:\ai-bok\SMS API가이드.pdf`를 로컬에서만 확인했고, PDF 원문 및 민감 자료는 저장소/서버 배포물에 포함하지 않음. + - 사내 메시지 발송은 네이버 API 직접 호출이 아니라 DMZ 보안 API 호출 방식임을 확인. + - Node SMS 어댑터에 `ACS_SMS_PROVIDER=hanbank`/`bok` provider를 추가: + - `POST /sens/sms` + - 요청 필드: `receive_number`, `content`, `msg_type=LMS`, `reserve_time` + - 응답 `statusCode=202` 또는 `statusName=success`를 성공으로 처리. + - 기존 `ACS_SMS_PROVIDER=dev`는 로컬/서버 모의 발송 검증용으로 유지. + - 출입신청 일시 선택 UI는 입력칸 옆 외부 `적용` 버튼을 제거하고, 캘린더 팝업 우측 상단 `닫기` 버튼으로 닫도록 변경. + - 검증: + - `npm.cmd run typecheck` 성공. + - `npm.cmd run build:frontend` 성공. + - `npm.cmd run build:server` 성공. + - `npm.cmd test` 성공. diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index 9897dab..bd3c975 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useRef } from 'react'; import DatePicker, { registerLocale } from 'react-datepicker'; import { ko } from 'date-fns/locale'; import 'react-datepicker/dist/react-datepicker.css'; @@ -23,38 +23,40 @@ function fromLocalString(value: string): Date | null { } export const DateTimePicker: React.FC = ({ value, onChange, placeholder }) => { - const [draft, setDraft] = useState(fromLocalString(value)); - - useEffect(() => { - setDraft(fromLocalString(value)); - }, [value]); - - const apply = () => { - if (draft) { - onChange(toLocalString(draft)); - } - }; + const pickerRef = useRef(null); return ( -
- setDraft(d)} - showTimeSelect - timeIntervals={5} - timeCaption="시간" - timeFormat="a h:mm" - dateFormat="yyyy.MM.dd (eee) a h:mm" - dateFormatCalendar="yyyy.MM" - locale="ko" - shouldCloseOnSelect={false} - placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} - className="dt-input" - popperClassName="acs-datepicker" - /> - -
+ d && onChange(toLocalString(d))} + showTimeSelect + timeIntervals={5} + timeCaption="시간" + timeFormat="a h:mm" + dateFormat="yyyy.MM.dd (eee) a h:mm" + dateFormatCalendar="yyyy.MM" + locale="ko" + shouldCloseOnSelect={false} + placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} + className="dt-input" + popperClassName="acs-datepicker" + renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => ( +
+ + {date.getFullYear()}.{pad(date.getMonth() + 1)} +
+ + +
+
+ )} + /> ); }; diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index d7d0729..d83b179 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -128,11 +128,15 @@ a { color: inherit; text-decoration: none; } /* react-datepicker: make the input fill the field like the native ones */ .field .react-datepicker-wrapper { width: 100%; } .field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; } -/* clearer popup border + shadow, and a stable field-side apply button */ +/* clearer popup border + shadow */ .acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } -.dt-apply-row { display: flex; align-items: center; gap: 8px; } -.dt-apply-row .react-datepicker-wrapper { flex: 1; min-width: 0; } -.dt-apply-button { flex: 0 0 auto; min-height: 40px; padding-inline: 14px; } +.dt-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 8px 4px; } +.dt-picker-title { font-weight: 700; color: var(--text); } +.dt-picker-header-actions { display: flex; align-items: center; gap: 4px; } +.dt-picker-nav, +.dt-picker-close { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); font-size: 18px; line-height: 1; cursor: pointer; } +.dt-picker-nav:disabled { opacity: 0.4; cursor: not-allowed; } +.dt-picker-close { color: #b42318; font-size: 20px; } /* In-app modal dialog (replaces window.prompt/confirm) */ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; } diff --git a/server/services/sms.test.ts b/server/services/sms.test.ts new file mode 100644 index 0000000..7e2d1e2 --- /dev/null +++ b/server/services/sms.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +test('hanbank provider posts the internal SMS API payload shape', async () => { + process.env.ACS_SMS_PROVIDER = 'hanbank'; + process.env.ACS_SMS_API_URL = 'http://sms.example.test:8000'; + + const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + calls.push({ input, init }); + return new Response(JSON.stringify({ statusCode: '202', statusName: 'success' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const { sendSms } = await import('./sms.js'); + const result = await sendSms({ + to: '010-1234-5678', + text: '[ACS] QR 출입증 링크', + }); + + assert.equal(result.status, 'SENT'); + assert.equal(result.channel, 'HANBANK'); + assert.equal(String(calls[0]?.input), 'http://sms.example.test:8000/sens/sms'); + assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), { + receive_number: '01012345678', + content: '[ACS] QR 출입증 링크', + msg_type: 'LMS', + reserve_time: '', + }); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/server/services/sms.ts b/server/services/sms.ts index ba9ea4c..bfe5960 100644 --- a/server/services/sms.ts +++ b/server/services/sms.ts @@ -12,8 +12,15 @@ export interface SmsMessage { text: string; } +type SmsApiResponse = { + requestId?: string; + requestTime?: string; + statusCode?: string | number; + statusName?: string; +}; + function normalizePhone(value: string | null | undefined): string { - return String(value ?? '').replace(/[^\d+]/g, ''); + return String(value ?? '').replace(/\D/g, ''); } function fail(channel: string, recipient: string, error: unknown): SmsSendResult { @@ -26,8 +33,29 @@ function fail(channel: string, recipient: string, error: unknown): SmsSendResult }; } +function smsProvider(): string { + return process.env.ACS_SMS_PROVIDER ?? env.smsProvider; +} + +function smsApiUrl(): string | undefined { + return process.env.ACS_SMS_API_URL ?? env.smsApiUrl; +} + +function smsApiKey(): string | undefined { + return process.env.ACS_SMS_API_KEY ?? env.smsApiKey; +} + +function smsSender(): string | undefined { + return process.env.ACS_SMS_SENDER ?? env.smsSender; +} + +function smsTimeoutMs(): number { + return Number(process.env.ACS_SMS_TIMEOUT_MS ?? env.smsTimeoutMs); +} + export async function sendSms(message: SmsMessage): Promise { - const provider = env.smsProvider.toLowerCase(); + const configuredProvider = smsProvider(); + const provider = configuredProvider.toLowerCase(); const recipient = normalizePhone(message.to); if (!recipient) { @@ -39,27 +67,33 @@ export async function sendSms(message: SmsMessage): Promise { return { channel: 'DEV', recipient, status: 'SENT', lastError: null }; } - if (provider !== 'http') { - return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${env.smsProvider}`); + if (provider === 'hanbank' || provider === 'bok') { + return sendHanbankSms(recipient, message.text); } - if (!env.smsApiUrl) { + if (provider !== 'http') { + return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${configuredProvider}`); + } + + const apiUrl = smsApiUrl(); + if (!apiUrl) { return fail('SMS', recipient, 'ACS_SMS_API_URL이 설정되지 않았습니다.'); } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), env.smsTimeoutMs); + const timeout = setTimeout(() => controller.abort(), smsTimeoutMs()); try { - const response = await fetch(env.smsApiUrl, { + const apiKey = smsApiKey(); + const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(env.smsApiKey ? { Authorization: `Bearer ${env.smsApiKey}` } : {}), + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify({ to: recipient, - from: env.smsSender, + from: smsSender(), message: message.text, }), signal: controller.signal, @@ -77,3 +111,60 @@ export async function sendSms(message: SmsMessage): Promise { clearTimeout(timeout); } } + +function resolveHanbankSmsUrl(): string { + const baseUrl = smsApiUrl() ?? 'http://210.104.132.59:8000'; + if (/\/sens\/sms\/?$/i.test(baseUrl)) { + return baseUrl; + } + return `${baseUrl.replace(/\/+$/, '')}/sens/sms`; +} + +function isSuccessResponse(response: SmsApiResponse): boolean { + const statusCode = String(response.statusCode ?? '').trim(); + const statusName = String(response.statusName ?? '').trim().toLowerCase(); + return statusCode === '202' || statusName === 'success'; +} + +async function sendHanbankSms(recipient: string, text: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), smsTimeoutMs()); + + try { + const response = await fetch(resolveHanbankSmsUrl(), { + method: 'POST', + headers: { + accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + receive_number: recipient, + content: text, + msg_type: 'LMS', + reserve_time: '', + }), + signal: controller.signal, + }); + + const rawBody = await response.text().catch(() => ''); + let body: SmsApiResponse = {}; + if (rawBody) { + try { + body = JSON.parse(rawBody) as SmsApiResponse; + } catch { + body = {}; + } + } + + if (!response.ok || (body.statusCode !== undefined && !isSuccessResponse(body))) { + const detail = rawBody ? `: ${rawBody.slice(0, 300)}` : ''; + throw new Error(`사내 SMS API 발송 실패 HTTP ${response.status}${detail}`); + } + + return { channel: 'HANBANK', recipient, status: 'SENT', lastError: null }; + } catch (error) { + return fail('HANBANK', recipient, error); + } finally { + clearTimeout(timeout); + } +}