refactor(frontend): admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)
129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환: - text-cyan-400 (45건) → text-label - text-green-400/500 (51건) → text-label + Badge intent="success" - text-red-400/500 (31건) → text-heading + Badge intent="critical" - text-blue-400 (33건) → text-label + Badge intent="info" - text-purple-400 (20건) → text-heading - text-yellow/orange/amber (32건) → text-heading + Badge intent="warning" raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등) 미사용 import 정리 (SaveButton/DataTable/lucide 아이콘) 대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs, AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement, PermissionsPanel, SystemConfig 검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
This commit is contained in:
부모
6f997ad796
커밋
234169d540
@ -41,11 +41,11 @@ const AGENTS = [
|
||||
];
|
||||
|
||||
const AGENT_KPI = [
|
||||
{ label: '활성 Agent', value: '4', color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: '등록 Tool', value: '26', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '24h 호출', value: '2,656', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: '차단 건수', value: '3', color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: '승인 대기', value: '0', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: '활성 Agent', value: '4', color: 'text-label', bg: 'bg-green-500/10' },
|
||||
{ label: '등록 Tool', value: '26', color: 'text-label', bg: 'bg-blue-500/10' },
|
||||
{ label: '24h 호출', value: '2,656', color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||
{ label: '차단 건수', value: '3', color: 'text-heading', bg: 'bg-red-500/10' },
|
||||
{ label: '승인 대기', value: '0', color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||
];
|
||||
|
||||
// ─── 화이트리스트 도구 ──────────────────
|
||||
@ -110,7 +110,7 @@ export function AIAgentSecurityPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Bot}
|
||||
iconColor="text-orange-400"
|
||||
iconColor="text-heading"
|
||||
title="AI Agent 구축·운영 보안"
|
||||
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
|
||||
demo
|
||||
@ -192,7 +192,7 @@ export function AIAgentSecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{KILL_SWITCH_RULES.map(r => (
|
||||
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<AlertTriangle className="w-4 h-4 text-heading" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||
@ -216,7 +216,7 @@ export function AIAgentSecurityPage() {
|
||||
<td className="py-2 px-2 text-heading font-medium">{c.command}</td>
|
||||
<td className="py-2 text-center"><Badge intent={getThreatLevelIntent(c.level)} size="sm">{c.level}</Badge></td>
|
||||
<td className="py-2 text-muted-foreground">{c.approval}</td>
|
||||
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-orange-400 mx-auto" /> : <span className="text-hint">-</span>}</td>
|
||||
<td className="py-2 text-center">{c.hitl ? <Hand className="w-3.5 h-3.5 text-label mx-auto" /> : <span className="text-hint">-</span>}</td>
|
||||
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
@ -232,7 +232,7 @@ export function AIAgentSecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{IDENTITY_POLICIES.map(p => (
|
||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Key className="w-4 h-4 text-orange-400" />
|
||||
<Key className="w-4 h-4 text-label" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||
|
||||
@ -31,11 +31,11 @@ const TABS: { key: Tab; icon: React.ComponentType<{ className?: string }>; label
|
||||
|
||||
// ─── 보안 현황 KPI ──────────────────
|
||||
const SECURITY_KPI = [
|
||||
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
||||
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-label', bg: 'bg-green-500/10' },
|
||||
{ label: 'AI 학습 보안', value: '정상', score: 92, icon: Brain, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-label', bg: 'bg-cyan-500/10' },
|
||||
];
|
||||
|
||||
// ─── 데이터 수집 보안 ──────────────────
|
||||
@ -111,7 +111,7 @@ export function AISecurityPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-red-400"
|
||||
iconColor="text-heading"
|
||||
title="AI 보안 관리"
|
||||
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
|
||||
demo
|
||||
@ -159,7 +159,7 @@ export function AISecurityPage() {
|
||||
['복구 계획', '3/3 활성', '완료'],
|
||||
].map(([k, v, s]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
||||
{s === '완료' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
|
||||
{s === '완료' ? <CheckCircle className="w-3 h-3 text-label" /> : <AlertTriangle className="w-3 h-3 text-label" />}
|
||||
<span className="text-heading flex-1">{k}</span>
|
||||
<span className="text-hint">{v}</span>
|
||||
</div>
|
||||
@ -218,7 +218,7 @@ export function AISecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{CONTAMINATION_CHECKS.map(c => (
|
||||
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<CheckCircle className="w-4 h-4 text-label" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{c.check}</div>
|
||||
<div className="text-[9px] text-hint">{c.desc}</div>
|
||||
@ -239,7 +239,7 @@ export function AISecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{TRAINING_POLICIES.map(p => (
|
||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Lock className="w-4 h-4 text-blue-400" />
|
||||
<Lock className="w-4 h-4 text-label" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||
<div className="text-[9px] text-hint">{p.desc}</div>
|
||||
@ -266,7 +266,7 @@ export function AISecurityPage() {
|
||||
<td className="py-2.5 px-2 text-heading font-medium">{f.name}</td>
|
||||
<td className="py-2.5 text-hint text-[9px]">{f.desc}</td>
|
||||
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{f.status}</Badge></td>
|
||||
<td className="py-2.5 text-center text-red-400 font-bold">{f.blocked}</td>
|
||||
<td className="py-2.5 text-center text-heading font-bold">{f.blocked}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{f.total.toLocaleString()}</td>
|
||||
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
|
||||
</tr>
|
||||
@ -283,7 +283,7 @@ export function AISecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{BOUNDARY_ITEMS.map(b => (
|
||||
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Server className="w-4 h-4 text-yellow-400" />
|
||||
<Server className="w-4 h-4 text-label" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{b.item}</div>
|
||||
<div className="text-[9px] text-hint">{b.desc}</div>
|
||||
@ -298,7 +298,7 @@ export function AISecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{EXPLAINABILITY.map(e => (
|
||||
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<Eye className="w-4 h-4 text-purple-400" />
|
||||
<Eye className="w-4 h-4 text-heading" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{e.item}</div>
|
||||
<div className="text-[9px] text-hint">{e.desc}</div>
|
||||
@ -339,7 +339,7 @@ export function AISecurityPage() {
|
||||
<div className="space-y-2">
|
||||
{RECOVERY_PLANS.map(r => (
|
||||
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<RefreshCw className="w-4 h-4 text-cyan-400" />
|
||||
<RefreshCw className="w-4 h-4 text-label" />
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
|
||||
<div className="text-[9px] text-hint">{r.desc}</div>
|
||||
|
||||
@ -104,10 +104,10 @@ export function AccessControl() {
|
||||
};
|
||||
|
||||
// ── 사용자 테이블 컬럼 ──────────────
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
||||
{ key: 'userAcnt', label: '계정', width: '90px',
|
||||
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||
render: (v) => <span className="text-label font-mono text-[11px]">{v as string}</span> },
|
||||
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
|
||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'rnkpNm', label: '직급', width: '60px',
|
||||
@ -133,7 +133,7 @@ export function AccessControl() {
|
||||
},
|
||||
},
|
||||
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
||||
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> },
|
||||
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-heading' : 'text-hint'}`}>{v as number}</span> },
|
||||
{ key: 'authProvider', label: '인증', width: '70px',
|
||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
||||
@ -147,12 +147,12 @@ export function AccessControl() {
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button type="button" onClick={() => setAssignTarget(row)}
|
||||
className="p-1 text-hint hover:text-purple-400" title="역할 배정">
|
||||
className="p-1 text-hint hover:text-heading" title="역할 배정">
|
||||
<UserCog className="w-3 h-3" />
|
||||
</button>
|
||||
{row.userSttsCd === 'LOCKED' && (
|
||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||
className="p-1 text-hint hover:text-green-400" title="잠금 해제">
|
||||
className="p-1 text-hint hover:text-label" title="잠금 해제">
|
||||
<Key className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
@ -166,7 +166,7 @@ export function AccessControl() {
|
||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
||||
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
||||
render: (v) => <span className="text-label font-mono">{(v as string) || '-'}</span> },
|
||||
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'resourceType', label: '리소스', width: '110px',
|
||||
@ -180,24 +180,24 @@ export function AccessControl() {
|
||||
},
|
||||
},
|
||||
{ key: 'failReason', label: '실패 사유',
|
||||
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> },
|
||||
render: (v) => <span className="text-heading text-[10px]">{(v as string) || '-'}</span> },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
iconColor="text-label"
|
||||
title={t('accessControl.title')}
|
||||
description={t('accessControl.desc')}
|
||||
actions={
|
||||
<>
|
||||
{userStats && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
||||
<UserCheck className="w-3.5 h-3.5 text-label" />
|
||||
활성 <span className="text-label font-bold">{userStats.active}</span>명
|
||||
<span className="mx-1">|</span>
|
||||
잠금 <span className="text-red-400 font-bold">{userStats.locked}</span>
|
||||
잠금 <span className="text-heading font-bold">{userStats.locked}</span>
|
||||
<span className="mx-1">|</span>
|
||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||
</div>
|
||||
@ -237,7 +237,7 @@ export function AccessControl() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||
|
||||
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
|
||||
{tab === 'roles' && <PermissionsPanel />}
|
||||
@ -249,8 +249,8 @@ export function AccessControl() {
|
||||
{userStats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
||||
<StatCard label="활성" value={userStats.active} color="text-green-400" />
|
||||
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
|
||||
<StatCard label="활성" value={userStats.active} color="text-label" />
|
||||
<StatCard label="잠금" value={userStats.locked} color="text-heading" />
|
||||
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
@ -278,9 +278,9 @@ export function AccessControl() {
|
||||
{auditStats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
||||
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
|
||||
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
|
||||
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
|
||||
<StatCard label="24시간" value={auditStats.last24h} color="text-label" />
|
||||
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-heading" />
|
||||
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-heading" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -292,7 +292,7 @@ export function AccessControl() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{auditStats.byAction.map((a) => (
|
||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
|
||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
||||
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -37,7 +37,7 @@ export function AccessLogs() {
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
iconColor="text-cyan-400"
|
||||
iconColor="text-label"
|
||||
title="접근 이력"
|
||||
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
||||
actions={
|
||||
@ -50,10 +50,10 @@ export function AccessLogs() {
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
||||
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
|
||||
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
|
||||
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-heading" />
|
||||
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-heading" />
|
||||
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-heading" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -73,7 +73,7 @@ export function AccessLogs() {
|
||||
{stats.topPaths.map((p) => (
|
||||
<tr key={p.path} className="border-t border-border">
|
||||
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
|
||||
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
|
||||
<td className="py-1.5 text-right text-label font-bold">{p.count}</td>
|
||||
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -83,7 +83,7 @@ export function AccessLogs() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
@ -109,8 +109,8 @@ export function AccessLogs() {
|
||||
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
||||
|
||||
@ -67,7 +67,7 @@ export function AdminPanel() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-blue-400" />데이터베이스</CardTitle>
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Database className="w-3.5 h-3.5 text-label" />데이터베이스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[['PostgreSQL', 'v15.4 운영중'], ['TimescaleDB', 'v2.12 운영중'], ['Redis 캐시', 'v7.2 운영중'], ['Kafka', 'v3.6 클러스터 3노드']].map(([k, v]) => (
|
||||
@ -77,7 +77,7 @@ export function AdminPanel() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-green-400" />보안 현황</CardTitle>
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5"><Shield className="w-3.5 h-3.5 text-label" />보안 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
||||
|
||||
@ -36,7 +36,7 @@ export function AuditLogs() {
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={FileSearch}
|
||||
iconColor="text-blue-400"
|
||||
iconColor="text-label"
|
||||
title="감사 로그"
|
||||
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
||||
actions={
|
||||
@ -50,9 +50,9 @@ export function AuditLogs() {
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
||||
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
|
||||
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
|
||||
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -64,7 +64,7 @@ export function AuditLogs() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.byAction.map((a) => (
|
||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
|
||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
||||
{a.action} <span className="text-label font-bold ml-1">{a.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@ -72,7 +72,7 @@ export function AuditLogs() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
@ -99,7 +99,7 @@ export function AuditLogs() {
|
||||
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-label">{it.userAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||
<td className="px-3 py-2">
|
||||
@ -107,7 +107,7 @@ export function AuditLogs() {
|
||||
{it.result || '-'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
||||
{it.detail ? JSON.stringify(it.detail) : '-'}
|
||||
|
||||
@ -1,29 +1,27 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
import {
|
||||
Database, RefreshCw, Wifi, WifiOff, Radio,
|
||||
Activity, Server, ArrowDownToLine, AlertTriangle,
|
||||
CheckCircle, BarChart3, Layers, Plus, Play, Square,
|
||||
Trash2, Edit2, Eye, FileText, HardDrive, FolderOpen,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */
|
||||
function jobStatusIntent(s: string): BadgeIntent {
|
||||
if (s === '수행중') return 'success';
|
||||
if (s === '대기중') return 'warning';
|
||||
if (s === '장애발생') return 'critical';
|
||||
return 'muted';
|
||||
}
|
||||
import {
|
||||
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
|
||||
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
|
||||
CheckCircle, XCircle, BarChart3, Layers, Plus, Play, Square,
|
||||
Trash2, Edit2, Eye, FileText, HardDrive, Upload, FolderOpen,
|
||||
Network, X, ChevronRight, Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
/*
|
||||
* SFR-03: 통합데이터 허브 수집·연계 관리
|
||||
@ -115,7 +113,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
||||
},
|
||||
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span>,
|
||||
render: (v) => <span className="text-label font-medium">{v as string}</span>,
|
||||
},
|
||||
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
||||
@ -126,7 +124,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
return s === '수신대기중'
|
||||
? <span className="text-orange-400 text-[9px]">{s}</span>
|
||||
? <Badge intent="warning" size="xs">{s}</Badge>
|
||||
: <span className="text-muted-foreground font-mono text-[10px]">{s}</span>;
|
||||
},
|
||||
},
|
||||
@ -157,13 +155,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 라벨 */}
|
||||
<div className="w-16 shrink-0 text-right">
|
||||
<div className="text-[11px] font-bold" style={{
|
||||
color: source.name === 'VTS' ? '#22c55e'
|
||||
: source.name === 'VTS-AIS' ? '#3b82f6'
|
||||
: source.name === 'V-PASS' ? '#a855f7'
|
||||
: source.name === 'E-NAVI' ? '#ef4444'
|
||||
: '#eab308',
|
||||
}}>{source.name}</div>
|
||||
<div className="text-[11px] font-bold text-label">{source.name}</div>
|
||||
<div className="text-[10px] text-hint">{source.rate}%</div>
|
||||
</div>
|
||||
{/* 타임라인 바 */}
|
||||
@ -232,20 +224,21 @@ const collectColumns: DataColumn<CollectJob>[] = [
|
||||
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
|
||||
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</span>;
|
||||
if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
|
||||
const intent: BadgeIntent = n >= 90 ? 'success' : n >= 70 ? 'warning' : 'critical';
|
||||
return <Badge intent={intent} size="xs">{n}%</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{row.status === '정지' ? (
|
||||
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-heading" title="시작"><Play className="w-3 h-3" /></button>
|
||||
) : row.status !== '장애발생' ? (
|
||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-heading" title="정지"><Square className="w-3 h-3" /></button>
|
||||
) : null}
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -291,9 +284,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
|
||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||
render: () => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-label" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-label" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-heading" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -387,7 +380,7 @@ export function DataHub() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-cyan-400"
|
||||
iconColor="text-label"
|
||||
title={t('dataHub.title')}
|
||||
description={t('dataHub.desc')}
|
||||
demo
|
||||
@ -402,11 +395,11 @@ export function DataHub() {
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
|
||||
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
{ label: 'ON', value: onCount, icon: Wifi, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
|
||||
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
|
||||
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', bg: 'bg-orange-500/10' },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||
@ -509,11 +502,11 @@ export function DataHub() {
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPartialOff ? (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-heading" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-label" />
|
||||
)}
|
||||
<span className={`text-[11px] font-bold ${hasPartialOff ? 'text-orange-400' : 'text-green-400'}`}>
|
||||
<span className="text-[11px] font-bold text-heading">
|
||||
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
|
||||
</span>
|
||||
</div>
|
||||
@ -653,9 +646,9 @@ export function DataHub() {
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
|
||||
<span className="text-[9px] text-hint">작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-label" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-heading" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -41,7 +41,7 @@ export function LoginHistoryView() {
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={LogIn}
|
||||
iconColor="text-green-400"
|
||||
iconColor="text-label"
|
||||
title="로그인 이력"
|
||||
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
||||
actions={
|
||||
@ -55,10 +55,10 @@ export function LoginHistoryView() {
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
|
||||
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
|
||||
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
|
||||
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
|
||||
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-label" />
|
||||
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-label" />
|
||||
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-heading" />
|
||||
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-label" suffix="%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -71,7 +71,7 @@ export function LoginHistoryView() {
|
||||
<CardContent className="px-4 pb-4 space-y-1">
|
||||
{stats.byUser.map((u) => (
|
||||
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-cyan-400 font-mono">{u.user_acnt}</span>
|
||||
<span className="text-label font-mono">{u.user_acnt}</span>
|
||||
<span className="text-heading font-bold">{u.count}회</span>
|
||||
</div>
|
||||
))}
|
||||
@ -86,9 +86,9 @@ export function LoginHistoryView() {
|
||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-green-400">성공 {d.success}</span>
|
||||
<span className="text-orange-400">실패 {d.failed}</span>
|
||||
<span className="text-red-400">잠금 {d.locked}</span>
|
||||
<span className="text-label">성공 {d.success}</span>
|
||||
<span className="text-label">실패 {d.failed}</span>
|
||||
<span className="text-heading">잠금 {d.locked}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -98,7 +98,7 @@ export function LoginHistoryView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
@ -123,11 +123,11 @@ export function LoginHistoryView() {
|
||||
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2 text-label">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-heading text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
||||
</tr>
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
import {
|
||||
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
|
||||
Users, Megaphone, AlertTriangle, Info, Search, Filter,
|
||||
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
||||
Bell, Plus, Edit2, Trash2, Eye,
|
||||
Megaphone, AlertTriangle, Info,
|
||||
Clock, Pin, Monitor, MessageSquare, X,
|
||||
} from 'lucide-react';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { toDateParam } from '@shared/utils/dateFormat';
|
||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
||||
@ -59,10 +58,10 @@ const INITIAL_NOTICES: SystemNotice[] = [
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
|
||||
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
|
||||
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
|
||||
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
|
||||
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
|
||||
{ key: 'info', label: '정보', icon: Info, color: 'text-label' },
|
||||
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
|
||||
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
|
||||
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
|
||||
];
|
||||
|
||||
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
||||
@ -146,7 +145,7 @@ export function NoticeManagement() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Bell}
|
||||
iconColor="text-yellow-400"
|
||||
iconColor="text-label"
|
||||
title={t('notices.title')}
|
||||
description={t('notices.desc')}
|
||||
demo
|
||||
@ -161,9 +160,9 @@ export function NoticeManagement() {
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
|
||||
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-green-400', bg: 'bg-green-500/10' },
|
||||
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||
{ label: '현재 노출 중', count: activeCount, icon: Eye, color: 'text-label', bg: 'bg-green-500/10' },
|
||||
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', bg: 'bg-red-500/10' },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||
@ -238,14 +237,14 @@ export function NoticeManagement() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-1 py-1.5 text-center">
|
||||
{n.pinned && <Pin className="w-3 h-3 text-yellow-400 inline" />}
|
||||
{n.pinned && <Pin className="w-3 h-3 text-label inline" />}
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
|
||||
<button type="button" onClick={() => openEdit(n)} disabled={!canUpdate} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canUpdate ? '수정' : '수정 권한이 필요합니다'}>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
|
||||
<button type="button" onClick={() => handleDelete(n.id)} disabled={!canDelete} className="p-1 text-hint hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed" title={canDelete ? '삭제' : '삭제 권한이 필요합니다'}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
@ -327,7 +326,7 @@ export function NoticeManagement() {
|
||||
onClick={() => setForm({ ...form, display: opt.key })}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||
form.display === opt.key
|
||||
? 'bg-blue-600/20 text-blue-400 font-bold'
|
||||
? 'bg-blue-600/20 text-label font-bold'
|
||||
: 'text-hint hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
@ -375,7 +374,7 @@ export function NoticeManagement() {
|
||||
onClick={() => toggleRole(role)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||
form.targetRoles.includes(role)
|
||||
? 'bg-blue-600/20 text-blue-400 border border-blue-500/30 font-bold'
|
||||
? 'bg-surface-overlay text-heading border border-border font-bold'
|
||||
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -359,13 +359,13 @@ export function PermissionsPanel() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={load}
|
||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
{error && <div className="text-xs text-heading">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
|
||||
@ -379,13 +379,13 @@ export function PermissionsPanel() {
|
||||
<div className="flex items-center gap-1">
|
||||
{canCreateRole && (
|
||||
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
||||
className="p-1 text-hint hover:text-green-400" title="신규 역할">
|
||||
className="p-1 text-hint hover:text-label" title="신규 역할">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||||
<button type="button" onClick={handleDeleteRole}
|
||||
className="p-1 text-hint hover:text-red-400" title="역할 삭제">
|
||||
className="p-1 text-hint hover:text-heading" title="역할 삭제">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
@ -442,7 +442,7 @@ export function PermissionsPanel() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||||
className="text-[8px] text-hint hover:text-blue-400"
|
||||
className="text-[8px] text-hint hover:text-label"
|
||||
title="색상 변경"
|
||||
>
|
||||
●
|
||||
@ -479,9 +479,9 @@ export function PermissionsPanel() {
|
||||
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
셀 의미: <span className="text-blue-400">✓ 명시 허용</span> /
|
||||
셀 의미: <span className="text-label">✓ 명시 허용</span> /
|
||||
<span className="text-blue-300/80 ml-1">✓ 상속 허용</span> /
|
||||
<span className="text-red-400 ml-1">— 명시 거부</span> /
|
||||
<span className="text-heading ml-1">— 명시 거부</span> /
|
||||
<span className="text-gray-500 ml-1">× 강제 거부</span> /
|
||||
<span className="text-hint ml-1">· 미지정</span>
|
||||
</div>
|
||||
|
||||
@ -6,9 +6,9 @@ import { Button } from '@shared/components/ui/button';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
import {
|
||||
Settings, Database, Search, ChevronDown, ChevronRight,
|
||||
Settings, Database, Search,
|
||||
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
||||
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
|
||||
Filter, RefreshCw, Hash, Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
||||
@ -149,7 +149,7 @@ export function SystemConfig() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-cyan-400"
|
||||
iconColor="text-label"
|
||||
title={t('systemConfig.title')}
|
||||
description={t('systemConfig.desc')}
|
||||
demo
|
||||
@ -168,11 +168,11 @@ export function SystemConfig() {
|
||||
{/* KPI 카드 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[
|
||||
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-blue-400', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
||||
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
||||
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-purple-400', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
||||
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
||||
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
||||
{ icon: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-label', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
||||
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-label', bg: 'bg-green-500/10', desc: '국립수산과학원 기준' },
|
||||
{ icon: Anchor, label: '어업유형', count: CODE_STATS.fishery, color: 'text-heading', bg: 'bg-purple-500/10', desc: '수산업법 허가·면허' },
|
||||
{ icon: Ship, label: '선박유형', count: CODE_STATS.vesselTypes, color: 'text-label', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
||||
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-label', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
||||
].map((kpi) => (
|
||||
<Card key={kpi.label} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-3">
|
||||
@ -270,7 +270,7 @@ export function SystemConfig() {
|
||||
<tbody>
|
||||
{(pagedData as AreaCode[]).map((a) => (
|
||||
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
|
||||
<td className="px-4 py-2 text-label font-mono font-medium">{a.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
{(() => {
|
||||
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
||||
@ -313,7 +313,7 @@ export function SystemConfig() {
|
||||
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
||||
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
||||
>
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
||||
<td className="px-4 py-2 text-label font-mono font-medium">{s.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||
</td>
|
||||
@ -323,12 +323,12 @@ export function SystemConfig() {
|
||||
<td className="px-4 py-2 text-muted-foreground text-[10px]">{s.area}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{s.active
|
||||
? <span className="text-green-400 text-[9px]">Y</span>
|
||||
? <span className="text-label text-[9px]">Y</span>
|
||||
: <span className="text-hint text-[9px]">N</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{s.fishing && <span className="text-yellow-400">★</span>}
|
||||
{s.fishing && <span className="text-label">★</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -357,7 +357,7 @@ export function SystemConfig() {
|
||||
<tbody>
|
||||
{(pagedData as FisheryCode[]).map((f) => (
|
||||
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
|
||||
<td className="px-4 py-2 text-label font-mono font-medium">{f.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
{(() => {
|
||||
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
|
||||
@ -399,7 +399,7 @@ export function SystemConfig() {
|
||||
<tbody>
|
||||
{(pagedData as VesselTypeCode[]).map((v) => (
|
||||
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
|
||||
<td className="px-3 py-2 text-label font-mono font-medium">{v.code}</td>
|
||||
<td className="px-3 py-2">
|
||||
{(() => {
|
||||
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
|
||||
@ -409,13 +409,7 @@ export function SystemConfig() {
|
||||
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-[9px] font-mono ${
|
||||
v.source === 'AIS' ? 'text-cyan-400'
|
||||
: v.source === 'GIC' ? 'text-green-400'
|
||||
: v.source === 'RRA' ? 'text-blue-400'
|
||||
: v.source === 'PMS' ? 'text-orange-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}>{v.source}</span>
|
||||
<span className="text-[9px] font-mono text-label">{v.source}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.tonnage}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</td>
|
||||
@ -431,23 +425,25 @@ export function SystemConfig() {
|
||||
{/* 페이지네이션 (코드 탭에서만) */}
|
||||
{tab !== 'settings' && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
</Button>
|
||||
<span className="text-[11px] text-hint">
|
||||
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
||||
</span>
|
||||
<button type="button"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -466,7 +462,7 @@ export function SystemConfig() {
|
||||
<Card key={section} className="bg-surface-raised border-border">
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<meta.icon className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<meta.icon className="w-3.5 h-3.5 text-label" />
|
||||
{meta.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -474,9 +470,7 @@ export function SystemConfig() {
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
||||
<span className="text-hint">{item.label}</span>
|
||||
<span className={`font-medium ${
|
||||
item.value === '활성' ? 'text-green-400' : 'text-label'
|
||||
}`}>{item.value}</span>
|
||||
<span className="font-medium text-label">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user