/** * 모선 워크플로우 API 클라이언트. * - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정) * - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID) */ const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; export interface ParentResolution { id: number; groupKey: string; subClusterId: number; status: 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED'; selectedParentMmsi: string | null; rejectedCandidateMmsi: string | null; approvedBy: string | null; approvedAt: string | null; rejectedAt: string | null; manualComment: string | null; createdAt: string; updatedAt: string; } export interface CandidateExclusion { id: number; scopeType: 'GROUP' | 'GLOBAL'; groupKey: string | null; subClusterId: number | null; excludedMmsi: string; reason: string | null; actor: string | null; actorAcnt: string | null; createdAt: string; releasedAt: string | null; releasedByAcnt: string | null; } export interface LabelSession { id: number; groupKey: string; subClusterId: number; labelParentMmsi: string; status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED'; activeFrom: string; activeUntil: string | null; createdByAcnt: string | null; cancelledAt: string | null; cancelReason: string | null; createdAt: string; } export interface ReviewLog { id: number; groupKey: string; subClusterId: number | null; action: string; selectedParentMmsi: string | null; actorAcnt: string | null; comment: string | null; createdAt: string; } export interface PageResponse { content: T[]; totalElements: number; totalPages: number; number: number; size: number; } async function apiRequest(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...init, }); if (!res.ok) { let errBody = ''; try { errBody = await res.text(); } catch { /* ignore */ } throw new Error(`API ${res.status}: ${path} ${errBody}`); } return res.json(); } // ============================================================================ // Resolution // ============================================================================ export function fetchReviewList(status?: string, page = 0, size = 20) { const qs = new URLSearchParams(); if (status) qs.set('status', status); qs.set('page', String(page)); qs.set('size', String(size)); return apiRequest>(`/parent-inference/review?${qs}`); } export function reviewParent( groupKey: string, subClusterId: number, payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string }, ) { return apiRequest( `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/review`, { method: 'POST', body: JSON.stringify(payload) }, ); } // ============================================================================ // Exclusions // ============================================================================ export function fetchExclusions(scopeType?: 'GROUP' | 'GLOBAL', page = 0, size = 20) { const qs = new URLSearchParams(); if (scopeType) qs.set('scopeType', scopeType); qs.set('page', String(page)); qs.set('size', String(size)); return apiRequest>(`/parent-inference/exclusions?${qs}`); } export function excludeForGroup( groupKey: string, subClusterId: number, payload: { excludedMmsi: string; reason?: string }, ) { return apiRequest( `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`, { method: 'POST', body: JSON.stringify(payload) }, ); } export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) { return apiRequest(`/parent-inference/exclusions/global`, { method: 'POST', body: JSON.stringify(payload), }); } export function releaseExclusion(exclusionId: number, reason?: string) { return apiRequest(`/parent-inference/exclusions/${exclusionId}/release`, { method: 'POST', body: JSON.stringify({ reason }), }); } // ============================================================================ // Label Sessions // ============================================================================ export function fetchLabelSessions(status?: string, page = 0, size = 20) { const qs = new URLSearchParams(); if (status) qs.set('status', status); qs.set('page', String(page)); qs.set('size', String(size)); return apiRequest>(`/parent-inference/label-sessions?${qs}`); } export function createLabelSession( groupKey: string, subClusterId: number, payload: { labelParentMmsi: string; anchorSnapshot?: Record }, ) { return apiRequest( `/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`, { method: 'POST', body: JSON.stringify(payload) }, ); } export function cancelLabelSession(sessionId: number, reason?: string) { return apiRequest(`/parent-inference/label-sessions/${sessionId}/cancel`, { method: 'POST', body: JSON.stringify({ reason }), }); } // ============================================================================ // Review Logs (도메인 액션 이력) // ============================================================================ export function fetchReviewLogs(groupKey?: string, page = 0, size = 50) { const qs = new URLSearchParams(); if (groupKey) qs.set('groupKey', groupKey); qs.set('page', String(page)); qs.set('size', String(size)); return apiRequest>(`/parent-inference/review-logs?${qs}`); }