diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 420b648..9708651 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import AdminSidebar from './AdminSidebar'; import AdminPlaceholder from './AdminPlaceholder'; import { findMenuLabel } from './adminMenuConfig'; @@ -19,9 +19,10 @@ import MonitorVesselPanel from './MonitorVesselPanel'; import CollectHrPanel from './CollectHrPanel'; import MonitorForecastPanel from './MonitorForecastPanel'; import VesselMaterialsPanel from './VesselMaterialsPanel'; +import DeidentifyPanel from './DeidentifyPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ -const PANEL_MAP: Record JSX.Element> = { +const PANEL_MAP: Record React.JSX.Element> = { users: () => , permissions: () => , menus: () => , @@ -42,6 +43,7 @@ const PANEL_MAP: Record JSX.Element> = { 'monitor-vessel': () => , 'collect-hr': () => , 'monitor-forecast': () => , + deidentify: () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/DeidentifyPanel.tsx b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx new file mode 100644 index 0000000..e5a579f --- /dev/null +++ b/frontend/src/tabs/admin/components/DeidentifyPanel.tsx @@ -0,0 +1,1241 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────── + +type TaskStatus = '완료' | '진행중' | '대기' | '오류'; + +interface AuditLogEntry { + id: string; + time: string; + operator: string; + operatorId: string; + action: string; + targetData: string; + result: string; + resultType: '성공' | '실패' | '거부' | '진행중'; + ip: string; + browser: string; + detail: { + dataCount: number; + rulesApplied: string; + processedCount: number; + errorCount: number; + }; +} + +interface DeidentifyTask { + id: string; + name: string; + target: string; + status: TaskStatus; + startTime: string; + progress: number; + createdBy: string; +} + +type SourceType = 'db' | 'file' | 'api'; +type ProcessMode = 'immediate' | 'scheduled' | 'oneshot'; +type RepeatType = 'daily' | 'weekly' | 'monthly'; +type DeidentifyTechnique = + | '마스킹' + | '삭제' + | '범주화' + | '암호화' + | '샘플링' + | '가명처리' + | '유지'; + +interface FieldConfig { + name: string; + dataType: string; + technique: DeidentifyTechnique; + configValue: string; + selected: boolean; +} + +interface DbConfig { + host: string; + port: string; + database: string; + tableName: string; +} + +interface ApiConfig { + url: string; + method: 'GET' | 'POST'; +} + +interface ScheduleConfig { + hour: string; + repeatType: RepeatType; + weekday: string; + startDate: string; + notifyOnComplete: boolean; + notifyOnError: boolean; +} + +interface OneshotConfig { + date: string; + hour: string; +} + +interface WizardState { + step: number; + taskName: string; + sourceType: SourceType; + dbConfig: DbConfig; + apiConfig: ApiConfig; + fields: FieldConfig[]; + processMode: ProcessMode; + scheduleConfig: ScheduleConfig; + oneshotConfig: OneshotConfig; + saveAsTemplate: boolean; + applyTemplate: string; + confirmed: boolean; +} + +// ─── Mock 데이터 ──────────────────────────────────────────── + +const MOCK_TASKS: DeidentifyTask[] = [ + { id: '001', name: 'customer_2024', target: '선박/운항 - 선장·선원 성명', status: '완료', startTime: '2026-04-10 14:30', progress: 100, createdBy: '관리자' }, + { id: '002', name: 'transaction_04', target: '사고 현장 - 현장사진, 영상내 인물', status: '진행중', startTime: '2026-04-10 14:15', progress: 82, createdBy: '김담당' }, + { id: '003', name: 'employee_info', target: '인사정보 - 계정, 로그인 정보', status: '대기', startTime: '2026-04-10 22:00', progress: 0, createdBy: '이담당' }, + { id: '004', name: 'vendor_data', target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처', status: '오류', startTime: '2026-04-09 13:45', progress: 45, createdBy: '관리자' }, + { id: '005', name: 'partner_contacts', target: '시스템 운영 - 관리자, 운영자 접속로그', status: '완료', startTime: '2026-04-08 09:00', progress: 100, createdBy: '박담당' }, +]; + +const DEFAULT_FIELDS: FieldConfig[] = [ + { name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true }, + { name: '이름', dataType: '문자열', technique: '마스킹', configValue: '*로 치환', selected: true }, + { name: '휴대폰', dataType: '문자열', technique: '마스킹', configValue: '010-****-****', selected: true }, + { name: '주소', dataType: '문자열', technique: '범주화', configValue: '시/도만 표시', selected: true }, + { name: '이메일', dataType: '문자열', technique: '가명처리', configValue: '키: random_001', selected: true }, + { name: '생년월일', dataType: '날짜', technique: '범주화', configValue: '연도만 표시', selected: true }, + { name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true }, +]; + +const TECHNIQUES: DeidentifyTechnique[] = ['마스킹', '삭제', '범주화', '암호화', '샘플링', '가명처리', '유지']; + +const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + +const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일']; + +const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터']; + +const MOCK_AUDIT_LOGS: Record = { + '001': [ + { id: 'LOG_20260410_001', time: '2026-04-10 14:30:45', operator: '김철수', operatorId: 'user_12345', action: '처리완료', targetData: 'customer_2024', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 15240, errorCount: 0 } }, + { id: 'LOG_20260410_002', time: '2026-04-10 14:15:10', operator: '김철수', operatorId: 'user_12345', action: '처리시작', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '마스킹 3, 범주화 2, 삭제 2', processedCount: 0, errorCount: 0 } }, + { id: 'LOG_20260410_003', time: '2026-04-10 14:10:30', operator: '김철수', operatorId: 'user_12345', action: '규칙설정', targetData: 'customer_2024', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 } }, + ], + '002': [ + { id: 'LOG_20260410_004', time: '2026-04-10 14:15:22', operator: '이영희', operatorId: 'user_23456', action: '처리시작', targetData: 'transaction_04', result: '진행중 (82%)', resultType: '진행중', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 8920, rulesApplied: '마스킹 2, 암호화 1, 삭제 3', processedCount: 7314, errorCount: 0 } }, + ], + '003': [ + { id: 'LOG_20260410_005', time: '2026-04-10 13:45:30', operator: '박민준', operatorId: 'user_34567', action: '규칙수정', targetData: 'employee_info', result: '성공', resultType: '성공', ip: '192.168.1.102', browser: 'Chrome 123.0', detail: { dataCount: 3200, rulesApplied: '마스킹 4, 가명처리 1', processedCount: 0, errorCount: 0 } }, + ], + '004': [ + { id: 'LOG_20260409_001', time: '2026-04-09 13:45:30', operator: '관리자', operatorId: 'user_admin', action: '처리오류', targetData: 'vendor_data', result: '오류 (45%)', resultType: '실패', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '마스킹 2, 범주화 1, 삭제 1', processedCount: 2295, errorCount: 12 } }, + { id: 'LOG_20260409_002', time: '2026-04-09 13:40:15', operator: '김철수', operatorId: 'user_12345', action: '규칙조회', targetData: 'vendor_data', result: '성공', resultType: '성공', ip: '192.168.1.100', browser: 'Chrome 123.0', detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 } }, + { id: 'LOG_20260409_003', time: '2026-04-09 09:25:00', operator: '이영희', operatorId: 'user_23456', action: '삭제시도', targetData: 'vendor_data', result: '거부 (권한부족)', resultType: '거부', ip: '192.168.1.101', browser: 'Firefox 124.0', detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 } }, + ], + '005': [ + { id: 'LOG_20260408_001', time: '2026-04-08 09:15:00', operator: '박담당', operatorId: 'user_45678', action: '처리완료', targetData: 'partner_contacts', result: '성공 (100%)', resultType: '성공', ip: '192.168.1.103', browser: 'Edge 122.0', detail: { dataCount: 1850, rulesApplied: '마스킹 2, 유지 3', processedCount: 1850, errorCount: 0 } }, + ], +}; + +function fetchTasks(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_TASKS), 300); + }); +} + +// ─── 상태 뱃지 ───────────────────────────────────────────── + +function getStatusBadgeClass(status: TaskStatus): string { + switch (status) { + case '완료': return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': return 'text-cyan-400 bg-cyan-500/10'; + case '대기': return 'text-yellow-400 bg-yellow-500/10'; + case '오류': return 'text-red-400 bg-red-500/10'; + } +} + +// ─── 진행률 바 ───────────────────────────────────────────── + +function ProgressBar({ value }: { value: number }) { + const colorClass = + value === 100 ? 'bg-emerald-500' : value > 0 ? 'bg-cyan-500' : 'bg-bg-elevated'; + return ( +
+
+
+
+ {value}% +
+ ); +} + +// ─── 작업 테이블 ──────────────────────────────────────────── + +const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션']; + +interface TaskTableProps { + rows: DeidentifyTask[]; + loading: boolean; + onAction: (action: string, task: DeidentifyTask) => void; +} + +function TaskTable({ rows, loading, onAction }: TaskTableProps) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + + + ))} + +
+ {h} +
+
+
{row.id}{row.name}{row.target} + + {row.status} + + {row.startTime} + + {row.createdBy} +
+ + + + + +
+
+
+ ); +} + +// ─── 마법사: 단계 표시기 ──────────────────────────────────── + +const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인']; + +function StepIndicator({ current }: { current: number }) { + return ( +
+ {STEP_LABELS.map((label, i) => { + const stepNum = i + 1; + const isDone = stepNum < current; + const isActive = stepNum === current; + return ( +
+
+
+ {isDone ? ( + + + + ) : ( + stepNum + )} +
+ + {stepNum}.{label} + +
+ {i < STEP_LABELS.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +// ─── 마법사: Step 1 ───────────────────────────────────────── + +interface Step1Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step1({ wizard, onChange }: Step1Props) { + const handleDbChange = (key: keyof DbConfig, value: string) => { + onChange({ dbConfig: { ...wizard.dbConfig, [key]: value } }); + }; + const handleApiChange = (key: keyof ApiConfig, value: string) => { + onChange({ apiConfig: { ...wizard.apiConfig, [key]: value } }); + }; + + return ( +
+
+ + onChange({ taskName: e.target.value })} + placeholder="작업 이름을 입력하세요" + className="w-full px-3 py-2 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+ +
+ +
+ {([ + ['db', '데이터베이스 연결'], + ['file', '파일 업로드'], + ['api', 'API 호출'], + ] as [SourceType, string][]).map(([val, label]) => ( + + ))} +
+
+ + {wizard.sourceType === 'db' && ( +
+ {( + [ + ['host', '호스트', 'localhost'], + ['port', '포트', '5432'], + ['database', '데이터베이스', 'wing'], + ['tableName', '테이블명', 'public.customers'], + ] as [keyof DbConfig, string, string][] + ).map(([key, labelText, placeholder]) => ( +
+ + handleDbChange(key, e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+ ))} +
+ )} + + {wizard.sourceType === 'file' && ( +
+ + + +

파일을 드래그하거나 클릭하여 업로드

+

CSV, XLSX, JSON 지원 (최대 500MB)

+
+ )} + + {wizard.sourceType === 'api' && ( +
+
+ + handleApiChange('url', e.target.value)} + placeholder="https://api.example.com/data" + className="w-full px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500" + /> +
+
+ + +
+
+ )} +
+ ); +} + +// ─── 마법사: Step 2 ───────────────────────────────────────── + +interface Step2Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step2({ wizard, onChange }: Step2Props) { + const toggleField = (idx: number) => { + const updated = wizard.fields.map((f, i) => + i === idx ? { ...f, selected: !f.selected } : f, + ); + onChange({ fields: updated }); + }; + + return ( +
+
+ {[ + { label: '총 데이터 건수', value: '15,240건', color: 'text-t1' }, + { label: '중복', value: '0건', color: 'text-emerald-400' }, + { label: '누락값', value: '23건', color: 'text-yellow-400' }, + ].map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ +
+

스키마 분석 결과 — 포함 필드 선택

+
+ + + + + + + + + + {wizard.fields.map((field, idx) => ( + + + + + + ))} + +
+ f.selected)} + onChange={(e) => + onChange({ fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })) }) + } + className="accent-cyan-500" + /> + 필드명데이터 타입
+ toggleField(idx)} + className="accent-cyan-500" + /> + {field.name}{field.dataType}
+
+

+ {wizard.fields.filter((f) => f.selected).length}개 선택됨 (전체 {wizard.fields.length}개) +

+
+
+ ); +} + +// ─── 마법사: Step 3 ───────────────────────────────────────── + +interface Step3Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step3({ wizard, onChange }: Step3Props) { + const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => { + const updated = wizard.fields.map((f, i) => + i === idx ? { ...f, [key]: value } : f, + ); + onChange({ fields: updated }); + }; + + const selectedFields = wizard.fields.filter((f) => f.selected); + + return ( +
+
+ + + + + + + + + + + {selectedFields.map((field) => { + const globalIdx = wizard.fields.findIndex((f) => f.name === field.name); + return ( + + + + + + + ); + })} + +
필드명데이터타입선택된 기법설정값
{field.name}{field.dataType} + + + updateField(globalIdx, 'configValue', e.target.value)} + className="w-full px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> +
+
+ +
+ + +
+ 이전 템플릿 적용: + +
+
+
+ ); +} + +// ─── 마법사: Step 4 ───────────────────────────────────────── + +interface Step4Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step4({ wizard, onChange }: Step4Props) { + const handleScheduleChange = (key: keyof ScheduleConfig, value: string | boolean) => { + onChange({ scheduleConfig: { ...wizard.scheduleConfig, [key]: value } }); + }; + const handleOneshotChange = (key: keyof OneshotConfig, value: string) => { + onChange({ oneshotConfig: { ...wizard.oneshotConfig, [key]: value } }); + }; + + return ( +
+
+ {( + [ + ['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'], + ['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'], + ['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'], + ] as [ProcessMode, string, string][] + ).map(([val, label, desc]) => ( +
+ + + {val === 'scheduled' && wizard.processMode === 'scheduled' && ( +
+
+ + +
+
+ +
+ {( + [ + ['daily', '매일'], + ['weekly', '주 1회'], + ['monthly', '월 1회'], + ] as [RepeatType, string][] + ).map(([rt, rl]) => ( +
+ handleScheduleChange('repeatType', rt)} + className="accent-cyan-500" + /> + {rl} + {rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && ( + + )} +
+ ))} +
+
+
+ + handleScheduleChange('startDate', e.target.value)} + className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> +
+
+ + +
+
+ )} + + {val === 'oneshot' && wizard.processMode === 'oneshot' && ( +
+
+ + handleOneshotChange('date', e.target.value)} + className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> +
+
+ + +
+
+ )} +
+ ))} +
+
+ ); +} + +// ─── 마법사: Step 5 ───────────────────────────────────────── + +interface Step5Props { + wizard: WizardState; + onChange: (patch: Partial) => void; +} + +function Step5({ wizard, onChange }: Step5Props) { + const selectedCount = wizard.fields.filter((f) => f.selected).length; + const ruleCount = wizard.fields.filter((f) => f.selected && f.technique !== '유지').length; + + const processModeLabel: Record = { + immediate: '즉시 처리', + scheduled: `배치 - 정기 (${wizard.scheduleConfig.hour} / ${wizard.scheduleConfig.repeatType === 'daily' ? '매일' : wizard.scheduleConfig.repeatType === 'weekly' ? `주1회 ${wizard.scheduleConfig.weekday}요일` : '월1회'})`, + oneshot: `배치 - 일회성 (${wizard.oneshotConfig.date} ${wizard.oneshotConfig.hour})`, + }; + + const summaryRows = [ + { label: '작업명', value: wizard.taskName || '(미입력)' }, + { label: '소스', value: wizard.sourceType === 'db' ? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}` : wizard.sourceType === 'file' ? '파일 업로드' : `API: ${wizard.apiConfig.url}` }, + { label: '데이터 건수', value: '15,240건' }, + { label: '선택 필드 수', value: `${selectedCount}개` }, + { label: '비식별화 규칙 수', value: `${ruleCount}개` }, + { label: '처리 방식', value: processModeLabel[wizard.processMode] }, + { label: '예상 처리시간', value: '약 3~5분' }, + ]; + + return ( +
+
+ + + {summaryRows.map(({ label, value }) => ( + + + + + ))} + +
{label}{value}
+
+ + +
+ ); +} + +// ─── 마법사 모달 ───────────────────────────────────────────── + +const INITIAL_WIZARD: WizardState = { + step: 1, + taskName: '', + sourceType: 'db', + dbConfig: { host: '', port: '5432', database: '', tableName: '' }, + apiConfig: { url: '', method: 'GET' }, + fields: DEFAULT_FIELDS, + processMode: 'immediate', + scheduleConfig: { + hour: '02:00', + repeatType: 'daily', + weekday: '월', + startDate: '', + notifyOnComplete: true, + notifyOnError: true, + }, + oneshotConfig: { date: '', hour: '02:00' }, + saveAsTemplate: false, + applyTemplate: '', + confirmed: false, +}; + +// ─── 감사로그 모달 ───────────────────────────────────────── + +function getAuditResultClass(type: AuditLogEntry['resultType']): string { + switch (type) { + case '성공': return 'text-emerald-400 bg-emerald-500/10'; + case '진행중': return 'text-cyan-400 bg-cyan-500/10'; + case '실패': return 'text-red-400 bg-red-500/10'; + case '거부': return 'text-yellow-400 bg-yellow-500/10'; + } +} + +interface AuditLogModalProps { + task: DeidentifyTask; + onClose: () => void; +} + +function AuditLogModal({ task, onClose }: AuditLogModalProps) { + const logs = MOCK_AUDIT_LOGS[task.id] ?? []; + const [selectedLog, setSelectedLog] = useState(null); + const [filterOperator, setFilterOperator] = useState('모두'); + const [startDate, setStartDate] = useState('2026-04-01'); + const [endDate, setEndDate] = useState('2026-04-11'); + + const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))]; + const filteredLogs = logs.filter((l) => { + if (filterOperator !== '모두' && l.operator !== filterOperator) return false; + return true; + }); + + return ( +
+
+ {/* 헤더 */} +
+

+ 감시 감독 (감사로그) — {task.name} +

+ +
+ + {/* 필터 바 */} +
+ 기간: + setStartDate(e.target.value)} + className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> + ~ + setEndDate(e.target.value)} + className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500" + /> + 작업자: + +
+ + {/* 로그 테이블 */} +
+ + + + {['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => ( + + ))} + + + + {filteredLogs.length === 0 ? ( + + + + ) : ( + filteredLogs.map((log) => ( + setSelectedLog(log)} + > + + + + + + + + )) + )} + +
+ {h} +
+ 감사로그가 없습니다. +
{log.time.split(' ')[1]}{log.operator}{log.action}{log.targetData} + + {log.result} + + + +
+
+ + {/* 로그 상세 정보 */} + {selectedLog && ( +
+

로그 상세 정보

+
+
로그ID: {selectedLog.id}
+
타임스탬프: {selectedLog.time}
+
작업자: {selectedLog.operator} ({selectedLog.operatorId})
+
작업 유형: {selectedLog.action}
+
대상: {selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)
+
적용 규칙: {selectedLog.detail.rulesApplied}
+
결과: {selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, 오류: {selectedLog.detail.errorCount})
+
IP 주소: {selectedLog.ip}
+
브라우저: {selectedLog.browser}
+
+
+ )} + + {/* 하단 버튼 */} +
+ + + +
+
+
+ ); +} + +// ─── 마법사 모달 ─────────────────────────────────────────── + +interface WizardModalProps { + onClose: () => void; + onSubmit: (wizard: WizardState) => void; +} + +function WizardModal({ onClose, onSubmit }: WizardModalProps) { + const [wizard, setWizard] = useState(INITIAL_WIZARD); + + const patch = useCallback((update: Partial) => { + setWizard((prev) => ({ ...prev, ...update })); + }, []); + + const handleNext = () => { + if (wizard.step < 5) patch({ step: wizard.step + 1 }); + }; + const handlePrev = () => { + if (wizard.step > 1) patch({ step: wizard.step - 1 }); + }; + const handleSubmit = () => { + onSubmit(wizard); + onClose(); + }; + + const canProceed = () => { + if (wizard.step === 1) return wizard.taskName.trim().length > 0; + if (wizard.step === 2) return wizard.fields.some((f) => f.selected); + if (wizard.step === 5) return wizard.confirmed; + return true; + }; + + return ( +
+
+ {/* 모달 헤더 */} +
+

새 비식별화 작업

+ +
+ + {/* 단계 표시기 */} + + + {/* 단계 내용 */} +
+ {wizard.step === 1 && } + {wizard.step === 2 && } + {wizard.step === 3 && } + {wizard.step === 4 && } + {wizard.step === 5 && } +
+ + {/* 푸터 버튼 */} +
+ +
+ + {wizard.step < 5 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +// ─── 메인 패널 ────────────────────────────────────────────── + +type FilterStatus = '모두' | TaskStatus; + +export default function DeidentifyPanel() { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [showWizard, setShowWizard] = useState(false); + const [auditTask, setAuditTask] = useState(null); + const [searchName, setSearchName] = useState(''); + const [filterStatus, setFilterStatus] = useState('모두'); + const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30'); + + const loadTasks = useCallback(async () => { + setLoading(true); + const data = await fetchTasks(); + setTasks(data); + setLoading(false); + }, []); + + useEffect(() => { + let isMounted = true; + if (tasks.length === 0) { + void Promise.resolve().then(() => { + if (isMounted) void loadTasks(); + }); + } + return () => { + isMounted = false; + }; + }, [tasks.length, loadTasks]); + + const handleAction = useCallback((action: string, task: DeidentifyTask) => { + // TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체 + if (action === 'delete') { + setTasks((prev) => prev.filter((t) => t.id !== task.id)); + } else if (action === 'audit') { + setAuditTask(task); + } + }, []); + + const handleWizardSubmit = useCallback((wizard: WizardState) => { + const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name); + const newTask: DeidentifyTask = { + id: String(tasks.length + 1).padStart(3, '0'), + name: wizard.taskName, + target: selectedFields.join(', ') || '-', + status: wizard.processMode === 'immediate' ? '진행중' : '대기', + startTime: new Date().toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).replace(/\. /g, '-').replace('.', ''), + progress: 0, + createdBy: '관리자', + }; + setTasks((prev) => [newTask, ...prev]); + }, [tasks.length]); + + const filteredTasks = tasks.filter((t) => { + if (searchName && !t.name.includes(searchName)) return false; + if (filterStatus !== '모두' && t.status !== filterStatus) return false; + return true; + }); + + const completedCount = tasks.filter((t) => t.status === '완료').length; + const inProgressCount = tasks.filter((t) => t.status === '진행중').length; + const errorCount = tasks.filter((t) => t.status === '오류').length; + + return ( +
+ {/* 헤더 */} +
+

비식별화조치

+ +
+ + {/* 상태 요약 */} +
+ + + 완료 {completedCount}건 + + + + 진행중 {inProgressCount}건 + + {errorCount > 0 && ( + + + 오류 {errorCount}건 + + )} + 전체 {tasks.length}건 +
+ + {/* 검색/필터 */} +
+ setSearchName(e.target.value)} + placeholder="작업명 검색" + className="px-2.5 py-1.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 placeholder:text-t3 focus:outline-none focus:border-cyan-500 w-40" + /> + + +
+ + {/* 테이블 */} +
+ +
+ + {/* 감사로그 모달 */} + {auditTask && ( + setAuditTask(null)} /> + )} + + {/* 마법사 모달 */} + {showWizard && ( + setShowWizard(false)} + onSubmit={handleWizardSubmit} + /> + )} +
+ ); +} diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts index d719756..3494ced 100644 --- a/frontend/src/tabs/admin/components/adminMenuConfig.ts +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -91,6 +91,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [ { id: 'monitor-vessel', label: '선박위치정보' }, ], }, + { id: 'deidentify', label: '비식별화조치' }, ], }, ];