wing-ops/frontend/src/tabs/admin/components/DeidentifyPanel.tsx
Nan Kyung Lee 1142e0cc46 feat(admin): 비식별화조치 메뉴 및 패널 추가
연계관리 하위에 비식별화조치 메뉴를 추가하고, 작업 관리 그리드·5단계 마법사·감사로그 모달을 구현

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 07:04:20 +09:00

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>
);
}