feat: apply datetime and self-approve admin requests
This commit is contained in:
@@ -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` 성공.
|
||||||
|
|||||||
@@ -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,12 +22,24 @@ 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 }) => {
|
||||||
|
const [draft, setDraft] = useState<Date | null>(fromLocalString(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(fromLocalString(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (draft) {
|
||||||
|
onChange(toLocalString(draft));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-apply-row">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={fromLocalString(value)}
|
selected={draft}
|
||||||
onChange={(d: Date | null) => {
|
onChange={(d: Date | null) => setDraft(d)}
|
||||||
if (d) onChange(toLocalString(d));
|
|
||||||
}}
|
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
timeIntervals={5}
|
timeIntervals={5}
|
||||||
timeCaption="시간"
|
timeCaption="시간"
|
||||||
@@ -40,4 +52,9 @@ export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }
|
|||||||
className="dt-input"
|
className="dt-input"
|
||||||
popperClassName="acs-datepicker"
|
popperClassName="acs-datepicker"
|
||||||
/>
|
/>
|
||||||
);
|
<button type="button" className="btn-ghost dt-apply-button" onClick={apply} disabled={!draft}>
|
||||||
|
적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user