From bd3d933a40b30517b1e82aaad7483316c044effc Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Thu, 16 Jul 2026 14:32:36 +0900 Subject: [PATCH] feat: apply datetime and self-approve admin requests --- docs/ACS작업일지.md | 10 ++++ frontend/src/components/DateTimePicker.tsx | 57 ++++++++++++++-------- frontend/src/styles/common.css | 7 +-- server/routes/business.ts | 32 ++++++++---- 4 files changed, 74 insertions(+), 32 deletions(-) diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index ce92117..1a9b50d 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -804,3 +804,13 @@ - `npm.cmd run typecheck` 성공. - `npm.cmd run build` 성공. - `npm.cmd test` 성공. + +- 2026-07-16 출입신청 일시 적용 버튼 및 관리자 즉시승인: + - 출입일시/퇴실예정일시 입력칸 옆에 `적용` 버튼 추가. + - 캘린더에서 고른 날짜/시간은 임시값으로 유지하고, `적용` 클릭 시 실제 입력값으로 확정되도록 변경. + - ADMIN/SECURITY 권한 사용자가 직접 출입신청을 등록하는 경우 승인대기 없이 즉시 `APPROVED` 처리. + - 즉시승인 시 일반 승인과 동일하게 승인 이력 저장 및 QR 출입증 링크 SMS 발송 로직 실행. + - 검증: + - `npm.cmd run typecheck` 성공. + - `npm.cmd test` 성공. + - `npm.cmd run build` 성공. diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index 58581e2..9897dab 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import DatePicker, { registerLocale } from 'react-datepicker'; import { ko } from 'date-fns/locale'; import 'react-datepicker/dist/react-datepicker.css'; @@ -22,22 +22,39 @@ function fromLocalString(value: string): Date | null { return value ? new Date(value) : null; } -export const DateTimePicker: React.FC = ({ value, onChange, placeholder }) => ( - { - if (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" - /> -); +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)); + } + }; + + 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" + /> + +
+ ); +}; diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index cc52529..d7d0729 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -128,10 +128,11 @@ 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 full-width [입력] footer button */ +/* clearer popup border + shadow, and a stable field-side apply button */ .acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } -.dt-actions { padding: 8px; border-top: 1px solid var(--border); } -.dt-confirm { width: 100%; } +.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; } /* 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/routes/business.ts b/server/routes/business.ts index a7cad96..e511e45 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -271,6 +271,22 @@ async function retryPassDelivery(dbQuery: QueryFn, deliveryId: number) { return updated.rows[0]; } +function canSelfApprove(user: UserRow): boolean { + return user.roles.includes('ADMIN') || user.roles.includes('SECURITY'); +} + +async function approveVisit(dbQuery: QueryFn, visitId: number, actor: UserRow, comment: string | null): Promise { + await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [visitId]); + await dbQuery( + "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())", + [visitId, Number(actor.id), comment], + ); + await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', visitId, '승인'); + const visit = (await visitById(dbQuery, visitId))!; + await sendPassDelivery(dbQuery, visit); + return visit; +} + async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) { if (code) { const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>( @@ -548,7 +564,12 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.'); const created = []; for (const zone of zones) { - created.push(await createOneVisit(dbQuery, actor, req.body, zone)); + const visit = await createOneVisit(dbQuery, actor, req.body, zone); + if (canSelfApprove(actor)) { + created.push(toVisit(await approveVisit(dbQuery, visit.id, actor, '본인 등록 자동승인'))); + } else { + created.push(visit); + } } ok(res, created); })); @@ -568,14 +589,7 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout router.post('/approvals/:id/approve', asyncRoute(async (req, res) => { const actor = await requireAdmin(dbQuery, req); const id = Number(req.params.id); - await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]); - await dbQuery( - "INSERT INTO approvals (created_at, updated_at, visit_request_id, approver_id, decision, comment, decided_at) VALUES (now(), now(), $1, $2, 'APPROVED', $3, now())", - [id, Number(actor.id), req.body?.comment ? String(req.body.comment) : null], - ); - await audit(dbQuery, actor, 'APPROVE', 'VISIT_REQUEST', id, '승인'); - const visit = (await visitById(dbQuery, id))!; - await sendPassDelivery(dbQuery, visit); + const visit = await approveVisit(dbQuery, id, actor, req.body?.comment ? String(req.body.comment) : null); ok(res, toVisit(visit)); }));