방문자 사전신청·승인, 입·출입 체크인/아웃, QR 배지, 재실현황, 블랙리스트, 대시보드 통계, 방문 리포트(엑셀)까지 7단계 전 기능 구현. - backend: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드), 세션 인증, JPA, H2/PostgreSQL, POI, ZXing, Flyway - frontend: React 19 / Vite 6 / TypeScript - infra: Docker Compose (db·app·web nginx), Flyway V1__init, Python 사용자 시드 - docs: 워크플로우 / 시퀀스 다이어그램(Mermaid) / 이슈·유의사항 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
import {
|
|
AccessAction,
|
|
AccessRecord,
|
|
ApiResponse,
|
|
BlacklistCreate,
|
|
BlacklistItem,
|
|
ChangePasswordRequest,
|
|
CurrentUser,
|
|
ExcelImportResult,
|
|
InsideVisitor,
|
|
LoginRequest,
|
|
StatsSummary,
|
|
PublicPass,
|
|
VisitRequestCreate,
|
|
VisitRequestView,
|
|
Zone,
|
|
} from './types';
|
|
|
|
const BASE = '/api';
|
|
|
|
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
|
|
const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/'];
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
credentials: 'include',
|
|
...init,
|
|
});
|
|
// Session expired / not authenticated on a protected call → send to login.
|
|
if (
|
|
res.status === 401 &&
|
|
!NO_REDIRECT_ON_401.some((p) => path.startsWith(p)) &&
|
|
typeof window !== 'undefined' &&
|
|
!['/login', '/kiosk'].includes(window.location.pathname) &&
|
|
!window.location.pathname.startsWith('/pass/')
|
|
) {
|
|
window.location.assign('/login');
|
|
}
|
|
let body: ApiResponse<T> | null = null;
|
|
try {
|
|
body = (await res.json()) as ApiResponse<T>;
|
|
} catch {
|
|
// non-JSON (shouldn't happen with our envelope)
|
|
}
|
|
if (!res.ok || !body) {
|
|
throw new Error(body?.message || `HTTP ${res.status}`);
|
|
}
|
|
return body.data as T;
|
|
}
|
|
|
|
function jsonInit(method: string, payload: unknown): RequestInit {
|
|
return {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
};
|
|
}
|
|
|
|
// ===== Auth =====
|
|
export const login = (req: LoginRequest) =>
|
|
request<CurrentUser>('/auth/login', jsonInit('POST', req));
|
|
|
|
export const logout = () =>
|
|
request<string>('/auth/logout', { method: 'POST' });
|
|
|
|
export const getCurrentUser = () => request<CurrentUser>('/auth/me');
|
|
|
|
export const changePassword = (req: ChangePasswordRequest) =>
|
|
request<string>('/auth/change-password', jsonInit('POST', req));
|
|
|
|
// ===== Zones =====
|
|
export const listZones = () => request<Zone[]>('/zones');
|
|
|
|
// ===== Visit requests =====
|
|
export const listVisitRequests = () =>
|
|
request<VisitRequestView[]>('/visit-requests');
|
|
|
|
export const listPendingRequests = () =>
|
|
request<VisitRequestView[]>('/visit-requests/pending');
|
|
|
|
export const getVisitRequest = (id: number) =>
|
|
request<VisitRequestView>(`/visit-requests/${id}`);
|
|
|
|
export const createVisitRequest = (req: VisitRequestCreate) =>
|
|
request<VisitRequestView>('/visit-requests', jsonInit('POST', req));
|
|
|
|
export const cancelVisitRequest = (id: number) =>
|
|
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
|
|
|
export const uploadVisitRequests = (file: File) => {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
return request<ExcelImportResult>('/visit-requests/upload', {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
};
|
|
|
|
// ===== Approvals =====
|
|
export const approveRequest = (id: number, comment?: string) =>
|
|
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
|
|
|
|
export const rejectRequest = (id: number, comment?: string) =>
|
|
request<VisitRequestView>(`/approvals/${id}/reject`, jsonInit('POST', { comment }));
|
|
|
|
// ===== Access console (check-in / out, currently inside) =====
|
|
export const searchApprovedForCheckIn = (q: string) =>
|
|
request<VisitRequestView[]>(`/access/search?q=${encodeURIComponent(q)}`);
|
|
|
|
export const checkIn = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
|
request<AccessAction>('/access/check-in', jsonInit('POST', payload));
|
|
|
|
export const checkOut = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
|
|
request<AccessAction>('/access/check-out', jsonInit('POST', payload));
|
|
|
|
export const listInside = () => request<InsideVisitor[]>('/access/inside');
|
|
export const listTodayAccess = () => request<AccessRecord[]>('/access/today');
|
|
|
|
// ===== Passes / badge =====
|
|
export const getPass = (id: number) => request<VisitRequestView>(`/passes/${id}`);
|
|
export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`;
|
|
|
|
// ===== Public pass + self-service kiosk (no login, token-based) =====
|
|
export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${token}`);
|
|
export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`;
|
|
export const publicCheckIn = (token: string) =>
|
|
request<AccessAction>(`/public/passes/${token}/check-in`, { method: 'POST' });
|
|
export const publicCheckOut = (token: string) =>
|
|
request<AccessAction>(`/public/passes/${token}/check-out`, { method: 'POST' });
|
|
|
|
// ===== Stats =====
|
|
export const getStatsSummary = () => request<StatsSummary>('/stats/summary');
|
|
|
|
// ===== Blacklist (ADMIN) =====
|
|
export const listBlacklist = () => request<BlacklistItem[]>('/blacklist');
|
|
export const addBlacklist = (req: BlacklistCreate) =>
|
|
request<BlacklistItem>('/blacklist', jsonInit('POST', req));
|
|
export const deleteBlacklist = (id: number) =>
|
|
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
|
|
|
|
// ===== Reports =====
|
|
export const reportDownloadUrl = (from: string, to: string) =>
|
|
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|