연계관리 하위에 비식별화조치 메뉴를 추가하고, 작업 관리 그리드·5단계 마법사·감사로그 모달을 구현 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1242 lines
55 KiB
TypeScript
1242 lines
55 KiB
TypeScript
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<string, AuditLogEntry[]> = {
|
|
'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<DeidentifyTask[]> {
|
|
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 (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all ${colorClass}`} style={{ width: `${value}%` }} />
|
|
</div>
|
|
<span className="text-t3 w-8 text-right">{value}%</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 작업 테이블 ────────────────────────────────────────────
|
|
|
|
const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션'];
|
|
|
|
interface TaskTableProps {
|
|
rows: DeidentifyTask[];
|
|
loading: boolean;
|
|
onAction: (action: string, task: DeidentifyTask) => void;
|
|
}
|
|
|
|
function TaskTable({ rows, loading, onAction }: TaskTableProps) {
|
|
return (
|
|
<div className="overflow-auto">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
|
{TABLE_HEADERS.map((h) => (
|
|
<th
|
|
key={h}
|
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && rows.length === 0
|
|
? Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
|
{TABLE_HEADERS.map((_, j) => (
|
|
<td key={j} className="px-3 py-2">
|
|
<div className="h-3 bg-bg-elevated rounded w-14" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
: rows.map((row) => (
|
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
|
<td className="px-3 py-2 text-t3 font-mono">{row.id}</td>
|
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate" title={row.target}>{row.target}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}>
|
|
{row.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.startTime}</td>
|
|
<td className="px-3 py-2 min-w-[120px]">
|
|
<ProgressBar value={row.progress} />
|
|
</td>
|
|
<td className="px-3 py-2 text-t2">{row.createdBy}</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<button
|
|
onClick={() => onAction('detail', row)}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
|
|
>
|
|
상세보기
|
|
</button>
|
|
<button
|
|
onClick={() => onAction('download', row)}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
|
|
>
|
|
결과다운로드
|
|
</button>
|
|
<button
|
|
onClick={() => onAction('retry', row)}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
|
|
>
|
|
재실행
|
|
</button>
|
|
<button
|
|
onClick={() => onAction('delete', row)}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-red-400 transition-colors whitespace-nowrap"
|
|
>
|
|
삭제
|
|
</button>
|
|
<button
|
|
onClick={() => onAction('audit', row)}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
|
|
>
|
|
감사로그
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: 단계 표시기 ────────────────────────────────────
|
|
|
|
const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인'];
|
|
|
|
function StepIndicator({ current }: { current: number }) {
|
|
return (
|
|
<div className="flex items-center justify-center gap-0 px-6 py-4 border-b border-stroke-1">
|
|
{STEP_LABELS.map((label, i) => {
|
|
const stepNum = i + 1;
|
|
const isDone = stepNum < current;
|
|
const isActive = stepNum === current;
|
|
return (
|
|
<div key={label} className="flex items-center">
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold transition-colors ${
|
|
isDone
|
|
? 'bg-emerald-500 text-white'
|
|
: isActive
|
|
? 'bg-cyan-500 text-white'
|
|
: 'bg-bg-elevated text-t3'
|
|
}`}
|
|
>
|
|
{isDone ? (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
stepNum
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`mt-1 text-label-2 whitespace-nowrap ${
|
|
isActive ? 'text-cyan-400' : isDone ? 'text-emerald-400' : 'text-t3'
|
|
}`}
|
|
>
|
|
{stepNum}.{label}
|
|
</span>
|
|
</div>
|
|
{i < STEP_LABELS.length - 1 && (
|
|
<div
|
|
className={`w-10 h-px mx-1 mb-4 ${i + 1 < current ? 'bg-emerald-500' : 'bg-stroke-1'}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: Step 1 ─────────────────────────────────────────
|
|
|
|
interface Step1Props {
|
|
wizard: WizardState;
|
|
onChange: (patch: Partial<WizardState>) => 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 (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="block text-xs font-medium text-t2 mb-1">작업명 *</label>
|
|
<input
|
|
type="text"
|
|
value={wizard.taskName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-t2 mb-2">소스 유형 *</label>
|
|
<div className="flex flex-col gap-2">
|
|
{([
|
|
['db', '데이터베이스 연결'],
|
|
['file', '파일 업로드'],
|
|
['api', 'API 호출'],
|
|
] as [SourceType, string][]).map(([val, label]) => (
|
|
<label key={val} className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="sourceType"
|
|
value={val}
|
|
checked={wizard.sourceType === val}
|
|
onChange={() => onChange({ sourceType: val })}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t1">{label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{wizard.sourceType === 'db' && (
|
|
<div className="grid grid-cols-2 gap-3 p-4 rounded bg-bg-surface border border-stroke-1">
|
|
{(
|
|
[
|
|
['host', '호스트', 'localhost'],
|
|
['port', '포트', '5432'],
|
|
['database', '데이터베이스', 'wing'],
|
|
['tableName', '테이블명', 'public.customers'],
|
|
] as [keyof DbConfig, string, string][]
|
|
).map(([key, labelText, placeholder]) => (
|
|
<div key={key}>
|
|
<label className="block text-label-2 text-t3 mb-1">{labelText}</label>
|
|
<input
|
|
type="text"
|
|
value={wizard.dbConfig[key]}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{wizard.sourceType === 'file' && (
|
|
<div className="p-8 rounded border-2 border-dashed border-stroke-1 bg-bg-surface flex flex-col items-center gap-2 text-center">
|
|
<svg className="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
<p className="text-xs text-t2">파일을 드래그하거나 클릭하여 업로드</p>
|
|
<p className="text-label-2 text-t3">CSV, XLSX, JSON 지원 (최대 500MB)</p>
|
|
</div>
|
|
)}
|
|
|
|
{wizard.sourceType === 'api' && (
|
|
<div className="p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
|
|
<div>
|
|
<label className="block text-label-2 text-t3 mb-1">API URL</label>
|
|
<input
|
|
type="text"
|
|
value={wizard.apiConfig.url}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-label-2 text-t3 mb-1">메서드</label>
|
|
<select
|
|
value={wizard.apiConfig.method}
|
|
onChange={(e) => handleApiChange('method', 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"
|
|
>
|
|
<option value="GET">GET</option>
|
|
<option value="POST">POST</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: Step 2 ─────────────────────────────────────────
|
|
|
|
interface Step2Props {
|
|
wizard: WizardState;
|
|
onChange: (patch: Partial<WizardState>) => 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 (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{[
|
|
{ label: '총 데이터 건수', value: '15,240건', color: 'text-t1' },
|
|
{ label: '중복', value: '0건', color: 'text-emerald-400' },
|
|
{ label: '누락값', value: '23건', color: 'text-yellow-400' },
|
|
].map((stat) => (
|
|
<div key={stat.label} className="p-3 rounded bg-bg-surface border border-stroke-1">
|
|
<p className="text-label-2 text-t3 mb-1">{stat.label}</p>
|
|
<p className={`text-sm font-semibold ${stat.color}`}>{stat.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="text-xs font-medium text-t2 mb-2">스키마 분석 결과 — 포함 필드 선택</h4>
|
|
<div className="rounded border border-stroke-1 overflow-hidden">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-t3">
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1 w-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={wizard.fields.every((f) => f.selected)}
|
|
onChange={(e) =>
|
|
onChange({ fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })) })
|
|
}
|
|
className="accent-cyan-500"
|
|
/>
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">필드명</th>
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">데이터 타입</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{wizard.fields.map((field, idx) => (
|
|
<tr key={field.name} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.selected}
|
|
onChange={() => toggleField(idx)}
|
|
className="accent-cyan-500"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2 font-medium text-t1">{field.name}</td>
|
|
<td className="px-3 py-2 text-t3">{field.dataType}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p className="mt-1.5 text-label-2 text-t3">
|
|
{wizard.fields.filter((f) => f.selected).length}개 선택됨 (전체 {wizard.fields.length}개)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: Step 3 ─────────────────────────────────────────
|
|
|
|
interface Step3Props {
|
|
wizard: WizardState;
|
|
onChange: (patch: Partial<WizardState>) => 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 (
|
|
<div className="space-y-4">
|
|
<div className="rounded border border-stroke-1 overflow-hidden">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-t3">
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">필드명</th>
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">데이터타입</th>
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">선택된 기법</th>
|
|
<th className="px-3 py-2 text-left font-medium border-b border-stroke-1">설정값</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{selectedFields.map((field) => {
|
|
const globalIdx = wizard.fields.findIndex((f) => f.name === field.name);
|
|
return (
|
|
<tr key={field.name} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
|
<td className="px-3 py-2 font-medium text-t1">{field.name}</td>
|
|
<td className="px-3 py-2 text-t3">{field.dataType}</td>
|
|
<td className="px-3 py-2">
|
|
<select
|
|
value={field.technique}
|
|
onChange={(e) => updateField(globalIdx, 'technique', 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"
|
|
>
|
|
{TECHNIQUES.map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="text"
|
|
value={field.configValue}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-4 pt-1">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={wizard.saveAsTemplate}
|
|
onChange={(e) => onChange({ saveAsTemplate: e.target.checked })}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t2">템플릿으로 저장</span>
|
|
</label>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-t3">이전 템플릿 적용:</span>
|
|
<select
|
|
value={wizard.applyTemplate}
|
|
onChange={(e) => onChange({ applyTemplate: e.target.value })}
|
|
className="px-2.5 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
|
>
|
|
<option value="">선택 안 함</option>
|
|
{TEMPLATES.map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: Step 4 ─────────────────────────────────────────
|
|
|
|
interface Step4Props {
|
|
wizard: WizardState;
|
|
onChange: (patch: Partial<WizardState>) => 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 (
|
|
<div className="space-y-4">
|
|
<div className="space-y-3">
|
|
{(
|
|
[
|
|
['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'],
|
|
['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'],
|
|
['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'],
|
|
] as [ProcessMode, string, string][]
|
|
).map(([val, label, desc]) => (
|
|
<div key={val}>
|
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="processMode"
|
|
value={val}
|
|
checked={wizard.processMode === val}
|
|
onChange={() => onChange({ processMode: val })}
|
|
className="mt-0.5 accent-cyan-500"
|
|
/>
|
|
<div>
|
|
<span className="text-xs font-medium text-t1">{label}</span>
|
|
<p className="text-label-2 text-t3 mt-0.5">{desc}</p>
|
|
</div>
|
|
</label>
|
|
|
|
{val === 'scheduled' && wizard.processMode === 'scheduled' && (
|
|
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs text-t3 w-16 shrink-0">실행 시간</label>
|
|
<select
|
|
value={wizard.scheduleConfig.hour}
|
|
onChange={(e) => handleScheduleChange('hour', 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"
|
|
>
|
|
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-start gap-3">
|
|
<label className="text-xs text-t3 w-16 shrink-0 mt-1">반복</label>
|
|
<div className="flex flex-col gap-2">
|
|
{(
|
|
[
|
|
['daily', '매일'],
|
|
['weekly', '주 1회'],
|
|
['monthly', '월 1회'],
|
|
] as [RepeatType, string][]
|
|
).map(([rt, rl]) => (
|
|
<div key={rt} className="flex items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="repeatType"
|
|
value={rt}
|
|
checked={wizard.scheduleConfig.repeatType === rt}
|
|
onChange={() => handleScheduleChange('repeatType', rt)}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t1">{rl}</span>
|
|
{rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && (
|
|
<select
|
|
value={wizard.scheduleConfig.weekday}
|
|
onChange={(e) => handleScheduleChange('weekday', e.target.value)}
|
|
className="ml-1 px-2 py-0.5 text-xs rounded bg-bg-elevated border border-stroke-1 text-t1 focus:outline-none focus:border-cyan-500"
|
|
>
|
|
{WEEKDAYS.map((d) => <option key={d} value={d}>{d}요일</option>)}
|
|
</select>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs text-t3 w-16 shrink-0">시작일</label>
|
|
<input
|
|
type="date"
|
|
value={wizard.scheduleConfig.startDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 mt-1">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={wizard.scheduleConfig.notifyOnComplete}
|
|
onChange={(e) => handleScheduleChange('notifyOnComplete', e.target.checked)}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t2">처리 완료 시 이메일 알림</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={wizard.scheduleConfig.notifyOnError}
|
|
onChange={(e) => handleScheduleChange('notifyOnError', e.target.checked)}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t2">오류 발생 시 즉시 알림</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{val === 'oneshot' && wizard.processMode === 'oneshot' && (
|
|
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke-1 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs text-t3 w-16 shrink-0">실행 날짜</label>
|
|
<input
|
|
type="date"
|
|
value={wizard.oneshotConfig.date}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs text-t3 w-16 shrink-0">실행 시간</label>
|
|
<select
|
|
value={wizard.oneshotConfig.hour}
|
|
onChange={(e) => handleOneshotChange('hour', 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"
|
|
>
|
|
{HOURS.map((h) => <option key={h} value={h}>{h}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사: Step 5 ─────────────────────────────────────────
|
|
|
|
interface Step5Props {
|
|
wizard: WizardState;
|
|
onChange: (patch: Partial<WizardState>) => 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<ProcessMode, string> = {
|
|
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 (
|
|
<div className="space-y-4">
|
|
<div className="rounded border border-stroke-1 overflow-hidden">
|
|
<table className="w-full text-xs border-collapse">
|
|
<tbody>
|
|
{summaryRows.map(({ label, value }) => (
|
|
<tr key={label} className="border-b border-stroke-1 last:border-b-0">
|
|
<td className="px-4 py-2.5 text-t3 bg-bg-elevated w-36 font-medium">{label}</td>
|
|
<td className="px-4 py-2.5 text-t1">{value}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer p-3 rounded border border-stroke-1 bg-bg-surface">
|
|
<input
|
|
type="checkbox"
|
|
checked={wizard.confirmed}
|
|
onChange={(e) => onChange({ confirmed: e.target.checked })}
|
|
className="accent-cyan-500"
|
|
/>
|
|
<span className="text-xs text-t1 font-medium">위 정보를 확인했습니다.</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사 모달 ─────────────────────────────────────────────
|
|
|
|
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<AuditLogEntry | null>(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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
|
<h3 className="text-sm font-semibold text-t1">
|
|
감시 감독 (감사로그) — {task.name}
|
|
</h3>
|
|
<button onClick={onClose} className="text-t3 hover:text-t1 transition-colors text-lg leading-none">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* 필터 바 */}
|
|
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-stroke-1 shrink-0 bg-bg-base">
|
|
<span className="text-xs text-t3">기간:</span>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-xs text-t3">~</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-xs text-t3 ml-2">작업자:</span>
|
|
<select
|
|
value={filterOperator}
|
|
onChange={(e) => setFilterOperator(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"
|
|
>
|
|
{operators.map((op) => (
|
|
<option key={op} value={op}>{op}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* 로그 테이블 */}
|
|
<div className="flex-1 overflow-auto px-5 py-3">
|
|
<table className="w-full text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
|
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
|
|
<th key={h} className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap">
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredLogs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
|
감사로그가 없습니다.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredLogs.map((log) => (
|
|
<tr
|
|
key={log.id}
|
|
className={`border-b border-stroke-1 hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
|
|
onClick={() => setSelectedLog(log)}
|
|
>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.time.split(' ')[1]}</td>
|
|
<td className="px-3 py-2 text-t1 whitespace-nowrap">{log.operator}</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{log.action}</td>
|
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{log.targetData}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}>
|
|
{log.result}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
|
|
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-cyan-400 transition-colors whitespace-nowrap"
|
|
>
|
|
보기
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 로그 상세 정보 */}
|
|
{selectedLog && (
|
|
<div className="px-5 py-3 border-t border-stroke-1 shrink-0 bg-bg-base">
|
|
<h4 className="text-xs font-semibold text-t1 mb-2">로그 상세 정보</h4>
|
|
<div className="bg-bg-elevated border border-stroke-1 rounded p-3 text-xs grid grid-cols-2 gap-x-6 gap-y-1.5">
|
|
<div><span className="text-t3">로그ID:</span> <span className="text-t1 font-mono">{selectedLog.id}</span></div>
|
|
<div><span className="text-t3">타임스탬프:</span> <span className="text-t1 font-mono">{selectedLog.time}</span></div>
|
|
<div><span className="text-t3">작업자:</span> <span className="text-t1">{selectedLog.operator} ({selectedLog.operatorId})</span></div>
|
|
<div><span className="text-t3">작업 유형:</span> <span className="text-t1">{selectedLog.action}</span></div>
|
|
<div><span className="text-t3">대상:</span> <span className="text-t1">{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()}건)</span></div>
|
|
<div><span className="text-t3">적용 규칙:</span> <span className="text-t1">{selectedLog.detail.rulesApplied}</span></div>
|
|
<div><span className="text-t3">결과:</span> <span className="text-t1">{selectedLog.result} (처리: {selectedLog.detail.processedCount.toLocaleString()}, 오류: {selectedLog.detail.errorCount})</span></div>
|
|
<div><span className="text-t3">IP 주소:</span> <span className="text-t1 font-mono">{selectedLog.ip}</span></div>
|
|
<div><span className="text-t3">브라우저:</span> <span className="text-t1">{selectedLog.browser}</span></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-stroke-1 shrink-0">
|
|
<button className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
|
|
상세내용 다운로드 (암호화됨)
|
|
</button>
|
|
<button className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
|
|
기간별 보고서 생성
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 마법사 모달 ───────────────────────────────────────────
|
|
|
|
interface WizardModalProps {
|
|
onClose: () => void;
|
|
onSubmit: (wizard: WizardState) => void;
|
|
}
|
|
|
|
function WizardModal({ onClose, onSubmit }: WizardModalProps) {
|
|
const [wizard, setWizard] = useState<WizardState>(INITIAL_WIZARD);
|
|
|
|
const patch = useCallback((update: Partial<WizardState>) => {
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<div className="bg-bg-card border border-stroke-1 rounded-lg shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[90vh]">
|
|
{/* 모달 헤더 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke-1 shrink-0">
|
|
<h3 className="text-sm font-semibold text-t1">새 비식별화 작업</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 rounded text-t3 hover:text-t1 hover:bg-bg-elevated transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 단계 표시기 */}
|
|
<StepIndicator current={wizard.step} />
|
|
|
|
{/* 단계 내용 */}
|
|
<div className="flex-1 overflow-auto px-6 py-5">
|
|
{wizard.step === 1 && <Step1 wizard={wizard} onChange={patch} />}
|
|
{wizard.step === 2 && <Step2 wizard={wizard} onChange={patch} />}
|
|
{wizard.step === 3 && <Step3 wizard={wizard} onChange={patch} />}
|
|
{wizard.step === 4 && <Step4 wizard={wizard} onChange={patch} />}
|
|
{wizard.step === 5 && <Step5 wizard={wizard} onChange={patch} />}
|
|
</div>
|
|
|
|
{/* 푸터 버튼 */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-stroke-1 shrink-0">
|
|
<button
|
|
onClick={handlePrev}
|
|
disabled={wizard.step === 1}
|
|
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
이전
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
|
|
>
|
|
취소
|
|
</button>
|
|
{wizard.step < 5 ? (
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={!canProceed()}
|
|
className="px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
다음
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!canProceed()}
|
|
className="px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
처리 시작
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 패널 ──────────────────────────────────────────────
|
|
|
|
type FilterStatus = '모두' | TaskStatus;
|
|
|
|
export default function DeidentifyPanel() {
|
|
const [tasks, setTasks] = useState<DeidentifyTask[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showWizard, setShowWizard] = useState(false);
|
|
const [auditTask, setAuditTask] = useState<DeidentifyTask | null>(null);
|
|
const [searchName, setSearchName] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<FilterStatus>('모두');
|
|
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 (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
|
<h2 className="text-sm font-semibold text-t1">비식별화조치</h2>
|
|
<button
|
|
onClick={() => setShowWizard(true)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-cyan-600 hover:bg-cyan-700 text-white transition-colors"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
새 작업
|
|
</button>
|
|
</div>
|
|
|
|
{/* 상태 요약 */}
|
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
|
완료 {completedCount}건
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-cyan-500/10 text-cyan-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />
|
|
진행중 {inProgressCount}건
|
|
</span>
|
|
{errorCount > 0 && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
|
오류 {errorCount}건
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-t3">전체 {tasks.length}건</span>
|
|
</div>
|
|
|
|
{/* 검색/필터 */}
|
|
<div className="flex items-center gap-2 px-5 py-2.5 shrink-0 border-b border-stroke-1">
|
|
<input
|
|
type="text"
|
|
value={searchName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
|
|
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"
|
|
>
|
|
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={filterPeriod}
|
|
onChange={(e) => setFilterPeriod(e.target.value as '7' | '30' | '90')}
|
|
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"
|
|
>
|
|
<option value="7">최근 7일</option>
|
|
<option value="30">최근 30일</option>
|
|
<option value="90">최근 90일</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="flex-1 overflow-auto p-5">
|
|
<TaskTable rows={filteredTasks} loading={loading} onAction={handleAction} />
|
|
</div>
|
|
|
|
{/* 감사로그 모달 */}
|
|
{auditTask && (
|
|
<AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />
|
|
)}
|
|
|
|
{/* 마법사 모달 */}
|
|
{showWizard && (
|
|
<WizardModal
|
|
onClose={() => setShowWizard(false)}
|
|
onSubmit={handleWizardSubmit}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|