feat: apply datetime and self-approve admin requests

This commit is contained in:
unknown
2026-07-16 14:32:36 +09:00
parent cff1a1bcad
commit bd3d933a40
4 changed files with 74 additions and 32 deletions

View File

@@ -804,3 +804,13 @@
- `npm.cmd run typecheck` 성공. - `npm.cmd run typecheck` 성공.
- `npm.cmd run build` 성공. - `npm.cmd run build` 성공.
- `npm.cmd test` 성공. - `npm.cmd test` 성공.
- 2026-07-16 출입신청 일시 적용 버튼 및 관리자 즉시승인:
- 출입일시/퇴실예정일시 입력칸 옆에 `적용` 버튼 추가.
- 캘린더에서 고른 날짜/시간은 임시값으로 유지하고, `적용` 클릭 시 실제 입력값으로 확정되도록 변경.
- ADMIN/SECURITY 권한 사용자가 직접 출입신청을 등록하는 경우 승인대기 없이 즉시 `APPROVED` 처리.
- 즉시승인 시 일반 승인과 동일하게 승인 이력 저장 및 QR 출입증 링크 SMS 발송 로직 실행.
- 검증:
- `npm.cmd run typecheck` 성공.
- `npm.cmd test` 성공.
- `npm.cmd run build` 성공.

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import DatePicker, { registerLocale } from 'react-datepicker'; import DatePicker, { registerLocale } from 'react-datepicker';
import { ko } from 'date-fns/locale'; import { ko } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
@@ -22,22 +22,39 @@ function fromLocalString(value: string): Date | null {
return value ? new Date(value) : null; return value ? new Date(value) : null;
} }
export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => ( export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => {
<DatePicker const [draft, setDraft] = useState<Date | null>(fromLocalString(value));
selected={fromLocalString(value)}
onChange={(d: Date | null) => { useEffect(() => {
if (d) onChange(toLocalString(d)); setDraft(fromLocalString(value));
}} }, [value]);
showTimeSelect
timeIntervals={5} const apply = () => {
timeCaption="시간" if (draft) {
timeFormat="a h:mm" onChange(toLocalString(draft));
dateFormat="yyyy.MM.dd (eee) a h:mm" }
dateFormatCalendar="yyyy.MM" };
locale="ko"
shouldCloseOnSelect={false} return (
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} <div className="dt-apply-row">
className="dt-input" <DatePicker
popperClassName="acs-datepicker" selected={draft}
/> onChange={(d: Date | null) => 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"
/>
<button type="button" className="btn-ghost dt-apply-button" onClick={apply} disabled={!draft}>
</button>
</div>
);
};

View File

@@ -128,10 +128,11 @@ a { color: inherit; text-decoration: none; }
/* react-datepicker: make the input fill the field like the native ones */ /* react-datepicker: make the input fill the field like the native ones */
.field .react-datepicker-wrapper { width: 100%; } .field .react-datepicker-wrapper { width: 100%; }
.field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; } .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); } .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-apply-row { display: flex; align-items: center; gap: 8px; }
.dt-confirm { width: 100%; } .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) */ /* 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; } .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; }

View File

@@ -271,6 +271,22 @@ async function retryPassDelivery(dbQuery: QueryFn, deliveryId: number) {
return updated.rows[0]; 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<VisitRow> {
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) { async function resolvePurpose(dbQuery: QueryFn, code: string | undefined, purpose: string, detail?: string) {
if (code) { if (code) {
const result = await dbQuery<{ code: string; name: string; custom_allowed: boolean; active: boolean }>( 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, '출입 구역을 선택하세요.'); if (zones.length === 0) throw new ApiError(400, '출입 구역을 선택하세요.');
const created = []; const created = [];
for (const zone of zones) { 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); ok(res, created);
})); }));
@@ -568,14 +589,7 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
router.post('/approvals/:id/approve', asyncRoute(async (req, res) => { router.post('/approvals/:id/approve', asyncRoute(async (req, res) => {
const actor = await requireAdmin(dbQuery, req); const actor = await requireAdmin(dbQuery, req);
const id = Number(req.params.id); const id = Number(req.params.id);
await dbQuery("UPDATE visit_requests SET status = 'APPROVED', updated_at = now() WHERE id = $1", [id]); const visit = await approveVisit(dbQuery, id, actor, req.body?.comment ? String(req.body.comment) : null);
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);
ok(res, toVisit(visit)); ok(res, toVisit(visit));
})); }));