Merge pull request 'release: 2026-04-16.5 (Phase 1-B admin DS)' (#61) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
This commit is contained in:
커밋
d0c8a88f21
@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-16.5]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>` → `<Button>` 컴포넌트 교체. 미사용 import 정리
|
||||||
|
|
||||||
## [2026-04-16.4]
|
## [2026-04-16.4]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -41,11 +41,11 @@ const AGENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const AGENT_KPI = [
|
const AGENT_KPI = [
|
||||||
{ label: '활성 Agent', value: '4', color: 'text-green-400', bg: 'bg-green-500/10' },
|
{ label: '활성 Agent', value: '4', color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
{ label: '등록 Tool', value: '26', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: '등록 Tool', value: '26', color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
{ label: '24h 호출', value: '2,656', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '24h 호출', value: '2,656', color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
{ label: '차단 건수', value: '3', color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: '차단 건수', value: '3', color: 'text-heading', bg: 'bg-red-500/10' },
|
||||||
{ label: '승인 대기', value: '0', color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
{ label: '승인 대기', value: '0', color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 화이트리스트 도구 ──────────────────
|
// ─── 화이트리스트 도구 ──────────────────
|
||||||
@ -110,7 +110,7 @@ export function AIAgentSecurityPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Bot}
|
icon={Bot}
|
||||||
iconColor="text-orange-400"
|
iconColor="text-heading"
|
||||||
title="AI Agent 구축·운영 보안"
|
title="AI Agent 구축·운영 보안"
|
||||||
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
|
description="SER-11 | AI Agent 화이트리스트·자동중단·민감명령 승인·MCP 최소권한·감사로그"
|
||||||
demo
|
demo
|
||||||
@ -192,7 +192,7 @@ export function AIAgentSecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{KILL_SWITCH_RULES.map(r => (
|
{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">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
|
||||||
<div className="text-[9px] text-hint">{r.desc}</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 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-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-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>
|
<td className="py-2 text-center"><Badge intent="success" size="sm">{c.status}</Badge></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
@ -232,7 +232,7 @@ export function AIAgentSecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{IDENTITY_POLICIES.map(p => (
|
{IDENTITY_POLICIES.map(p => (
|
||||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||||
<div className="text-[9px] text-hint">{p.desc}</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 ──────────────────
|
// ─── 보안 현황 KPI ──────────────────
|
||||||
const SECURITY_KPI = [
|
const SECURITY_KPI = [
|
||||||
{ label: '데이터 수집 보안', value: '정상', score: 95, icon: Database, color: 'text-green-400', bg: 'bg-green-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-blue-400', bg: 'bg-blue-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-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '입출력 필터링', value: '정상', score: 98, icon: Lock, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
{ label: '경계 보안', value: '주의', score: 85, icon: Server, color: 'text-label', bg: 'bg-yellow-500/10' },
|
||||||
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
|
{ label: '취약점 점검', value: '정상', score: 90, icon: Search, color: 'text-label', bg: 'bg-cyan-500/10' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 데이터 수집 보안 ──────────────────
|
// ─── 데이터 수집 보안 ──────────────────
|
||||||
@ -111,7 +111,7 @@ export function AISecurityPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
iconColor="text-red-400"
|
iconColor="text-heading"
|
||||||
title="AI 보안 관리"
|
title="AI 보안 관리"
|
||||||
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
|
description="SER-10 | AI 데이터 수집·학습·입출력·경계 보안 및 취약점 관리"
|
||||||
demo
|
demo
|
||||||
@ -159,7 +159,7 @@ export function AISecurityPage() {
|
|||||||
['복구 계획', '3/3 활성', '완료'],
|
['복구 계획', '3/3 활성', '완료'],
|
||||||
].map(([k, v, s]) => (
|
].map(([k, v, s]) => (
|
||||||
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
|
<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-heading flex-1">{k}</span>
|
||||||
<span className="text-hint">{v}</span>
|
<span className="text-hint">{v}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -218,7 +218,7 @@ export function AISecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{CONTAMINATION_CHECKS.map(c => (
|
{CONTAMINATION_CHECKS.map(c => (
|
||||||
<div key={c.check} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{c.check}</div>
|
<div className="text-[11px] text-heading font-medium">{c.check}</div>
|
||||||
<div className="text-[9px] text-hint">{c.desc}</div>
|
<div className="text-[9px] text-hint">{c.desc}</div>
|
||||||
@ -239,7 +239,7 @@ export function AISecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{TRAINING_POLICIES.map(p => (
|
{TRAINING_POLICIES.map(p => (
|
||||||
<div key={p.policy} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
<div className="text-[11px] text-heading font-medium">{p.policy}</div>
|
||||||
<div className="text-[9px] text-hint">{p.desc}</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 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-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"><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.total.toLocaleString()}</td>
|
||||||
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
|
<td className="py-2.5 text-center text-muted-foreground">{(f.blocked / f.total * 100).toFixed(2)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -283,7 +283,7 @@ export function AISecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{BOUNDARY_ITEMS.map(b => (
|
{BOUNDARY_ITEMS.map(b => (
|
||||||
<div key={b.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{b.item}</div>
|
<div className="text-[11px] text-heading font-medium">{b.item}</div>
|
||||||
<div className="text-[9px] text-hint">{b.desc}</div>
|
<div className="text-[9px] text-hint">{b.desc}</div>
|
||||||
@ -298,7 +298,7 @@ export function AISecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{EXPLAINABILITY.map(e => (
|
{EXPLAINABILITY.map(e => (
|
||||||
<div key={e.item} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{e.item}</div>
|
<div className="text-[11px] text-heading font-medium">{e.item}</div>
|
||||||
<div className="text-[9px] text-hint">{e.desc}</div>
|
<div className="text-[9px] text-hint">{e.desc}</div>
|
||||||
@ -339,7 +339,7 @@ export function AISecurityPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{RECOVERY_PLANS.map(r => (
|
{RECOVERY_PLANS.map(r => (
|
||||||
<div key={r.plan} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
<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="flex-1">
|
||||||
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
|
<div className="text-[11px] text-heading font-medium">{r.plan}</div>
|
||||||
<div className="text-[9px] text-hint">{r.desc}</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(() => [
|
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
||||||
{ key: 'userAcnt', label: '계정', width: '90px',
|
{ 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,
|
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
|
||||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
{ key: 'rnkpNm', label: '직급', width: '60px',
|
{ key: 'rnkpNm', label: '직급', width: '60px',
|
||||||
@ -133,7 +133,7 @@ export function AccessControl() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
{ 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',
|
{ key: 'authProvider', label: '인증', width: '70px',
|
||||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||||
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
||||||
@ -147,12 +147,12 @@ export function AccessControl() {
|
|||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button type="button" onClick={() => setAssignTarget(row)}
|
<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" />
|
<UserCog className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{row.userSttsCd === 'LOCKED' && (
|
{row.userSttsCd === 'LOCKED' && (
|
||||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
<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" />
|
<Key className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -166,7 +166,7 @@ export function AccessControl() {
|
|||||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
||||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
|
||||||
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
{ 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,
|
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
||||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||||
{ key: 'resourceType', label: '리소스', width: '110px',
|
{ key: 'resourceType', label: '리소스', width: '110px',
|
||||||
@ -180,24 +180,24 @@ export function AccessControl() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'failReason', label: '실패 사유',
|
{ 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 (
|
return (
|
||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Shield}
|
icon={Shield}
|
||||||
iconColor="text-blue-400"
|
iconColor="text-label"
|
||||||
title={t('accessControl.title')}
|
title={t('accessControl.title')}
|
||||||
description={t('accessControl.desc')}
|
description={t('accessControl.desc')}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{userStats && (
|
{userStats && (
|
||||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
<UserCheck className="w-3.5 h-3.5 text-label" />
|
||||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
활성 <span className="text-label font-bold">{userStats.active}</span>명
|
||||||
<span className="mx-1">|</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="mx-1">|</span>
|
||||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -237,7 +237,7 @@ export function AccessControl() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 매트릭스) ── */}
|
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
|
||||||
{tab === 'roles' && <PermissionsPanel />}
|
{tab === 'roles' && <PermissionsPanel />}
|
||||||
@ -249,8 +249,8 @@ export function AccessControl() {
|
|||||||
{userStats && (
|
{userStats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
||||||
<StatCard label="활성" value={userStats.active} color="text-green-400" />
|
<StatCard label="활성" value={userStats.active} color="text-label" />
|
||||||
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
|
<StatCard label="잠금" value={userStats.locked} color="text-heading" />
|
||||||
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -278,9 +278,9 @@ export function AccessControl() {
|
|||||||
{auditStats && (
|
{auditStats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
||||||
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
|
<StatCard label="24시간" value={auditStats.last24h} color="text-label" />
|
||||||
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
|
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-heading" />
|
||||||
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
|
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -292,7 +292,7 @@ export function AccessControl() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{auditStats.byAction.map((a) => (
|
{auditStats.byAction.map((a) => (
|
||||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
|
<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>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export function AccessLogs() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title="접근 이력"
|
title="접근 이력"
|
||||||
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
||||||
actions={
|
actions={
|
||||||
@ -50,10 +50,10 @@ export function AccessLogs() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||||
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
|
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-heading" />
|
||||||
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
|
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-heading" />
|
||||||
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
|
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ export function AccessLogs() {
|
|||||||
{stats.topPaths.map((p) => (
|
{stats.topPaths.map((p) => (
|
||||||
<tr key={p.path} className="border-t border-border">
|
<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-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>
|
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -83,7 +83,7 @@ export function AccessLogs() {
|
|||||||
</Card>
|
</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>}
|
{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">
|
<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-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-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-purple-400 font-mono">{it.httpMethod}</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-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<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]) => (
|
{[['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>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
{[['SSL 인증서', '2027-03-15 만료'], ['방화벽', '정상 동작'], ['IDS/IPS', '실시간 감시중'], ['백업', '금일 03:00 완료']].map(([k, v]) => (
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function AuditLogs() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={FileSearch}
|
icon={FileSearch}
|
||||||
iconColor="text-blue-400"
|
iconColor="text-label"
|
||||||
title="감사 로그"
|
title="감사 로그"
|
||||||
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
||||||
actions={
|
actions={
|
||||||
@ -50,9 +50,9 @@ export function AuditLogs() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
<MetricCard label="24시간 내" value={stats.last24h} color="text-label" />
|
||||||
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
|
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-heading" />
|
||||||
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
|
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-heading" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ export function AuditLogs() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stats.byAction.map((a) => (
|
{stats.byAction.map((a) => (
|
||||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
|
<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>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +72,7 @@ export function AuditLogs() {
|
|||||||
</Card>
|
</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>}
|
{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">
|
<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-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-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-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 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@ -107,7 +107,7 @@ export function AuditLogs() {
|
|||||||
{it.result || '-'}
|
{it.result || '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</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]">{it.ipAddress || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
|
||||||
{it.detail ? JSON.stringify(it.detail) : '-'}
|
{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 { 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 { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
import { TabBar, TabButton } from '@shared/components/ui/tabs';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
|
||||||
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
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 {
|
function jobStatusIntent(s: string): BadgeIntent {
|
||||||
if (s === '수행중') return 'success';
|
if (s === '수행중') return 'success';
|
||||||
if (s === '대기중') return 'warning';
|
if (s === '대기중') return 'warning';
|
||||||
if (s === '장애발생') return 'critical';
|
if (s === '장애발생') return 'critical';
|
||||||
return 'muted';
|
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: 통합데이터 허브 수집·연계 관리
|
* SFR-03: 통합데이터 허브 수집·연계 관리
|
||||||
@ -115,7 +113,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
|||||||
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
render: (v) => <span className="text-hint font-mono">{v as string}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'system', label: '정보시스템명', width: '100px', sortable: true,
|
{ 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: 'linkInfo', label: '연계정보', width: '65px' },
|
||||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
{ 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) => {
|
render: (v) => {
|
||||||
const s = v as string;
|
const s = v as string;
|
||||||
return s === '수신대기중'
|
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>;
|
: <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="flex items-center gap-3">
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<div className="w-16 shrink-0 text-right">
|
<div className="w-16 shrink-0 text-right">
|
||||||
<div className="text-[11px] font-bold" style={{
|
<div className="text-[11px] font-bold text-label">{source.name}</div>
|
||||||
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-[10px] text-hint">{source.rate}%</div>
|
<div className="text-[10px] text-hint">{source.rate}%</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 타임라인 바 */}
|
{/* 타임라인 바 */}
|
||||||
@ -232,20 +224,21 @@ const collectColumns: DataColumn<CollectJob>[] = [
|
|||||||
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
{ key: 'successRate', label: '성공률', width: '70px', align: 'right', sortable: true,
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const n = v as number;
|
const n = v as number;
|
||||||
const c = n >= 90 ? 'text-green-400' : n >= 70 ? 'text-yellow-400' : n > 0 ? 'text-red-400' : 'text-hint';
|
if (n === 0) return <span className="font-bold text-[11px] text-hint">-</span>;
|
||||||
return <span className={`font-bold text-[11px] ${c}`}>{n > 0 ? `${n}%` : '-'}</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,
|
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{row.status === '정지' ? (
|
{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 !== '장애발생' ? (
|
) : 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}
|
) : 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-label" 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="이력"><FileText className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -291,9 +284,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
|
|||||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex items-center gap-0.5">
|
<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-label" 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="이력"><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-heading" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -387,7 +380,7 @@ export function DataHub() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Database}
|
icon={Database}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title={t('dataHub.title')}
|
title={t('dataHub.title')}
|
||||||
description={t('dataHub.desc')}
|
description={t('dataHub.desc')}
|
||||||
demo
|
demo
|
||||||
@ -402,11 +395,11 @@ export function DataHub() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: '전체 채널', value: CHANNELS.length, icon: Layers, color: 'text-label', bg: 'bg-muted' },
|
{ 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: 'ON', value: onCount, icon: Wifi, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: 'OFF', value: offCount, icon: WifiOff, color: 'text-heading', bg: 'bg-red-500/10' },
|
||||||
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-green-400', bg: 'bg-green-500/10' },
|
{ label: '평균 수신율', value: '86.5%', icon: BarChart3, color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '데이터 소스', value: '5종', icon: Radio, color: 'text-heading', bg: 'bg-purple-500/10' },
|
||||||
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
{ label: '연계 방식', value: 'KAFKA', icon: Server, color: 'text-heading', bg: 'bg-orange-500/10' },
|
||||||
].map((kpi) => (
|
].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 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}`}>
|
<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-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasPartialOff ? (
|
{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})` : '전체 정상'}
|
{hasPartialOff ? `일부 OFF (${offCount}/${CHANNELS.length})` : '전체 정상'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -653,9 +646,9 @@ export function DataHub() {
|
|||||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
|
<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>
|
<span className="text-[9px] text-hint">작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}</span>
|
||||||
<div className="flex gap-0.5">
|
<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-label" 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-heading" 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-heading" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export function LoginHistoryView() {
|
|||||||
<PageContainer size="lg">
|
<PageContainer size="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={LogIn}
|
icon={LogIn}
|
||||||
iconColor="text-green-400"
|
iconColor="text-label"
|
||||||
title="로그인 이력"
|
title="로그인 이력"
|
||||||
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
||||||
actions={
|
actions={
|
||||||
@ -55,10 +55,10 @@ export function LoginHistoryView() {
|
|||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
||||||
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
|
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-label" />
|
||||||
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
|
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-label" />
|
||||||
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
|
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-heading" />
|
||||||
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
|
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-label" suffix="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ export function LoginHistoryView() {
|
|||||||
<CardContent className="px-4 pb-4 space-y-1">
|
<CardContent className="px-4 pb-4 space-y-1">
|
||||||
{stats.byUser.map((u) => (
|
{stats.byUser.map((u) => (
|
||||||
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
|
<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>
|
<span className="text-heading font-bold">{u.count}회</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -86,9 +86,9 @@ export function LoginHistoryView() {
|
|||||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
||||||
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span className="text-green-400">성공 {d.success}</span>
|
<span className="text-label">성공 {d.success}</span>
|
||||||
<span className="text-orange-400">실패 {d.failed}</span>
|
<span className="text-label">실패 {d.failed}</span>
|
||||||
<span className="text-red-400">잠금 {d.locked}</span>
|
<span className="text-heading">잠금 {d.locked}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -98,7 +98,7 @@ export function LoginHistoryView() {
|
|||||||
</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>}
|
{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">
|
<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-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-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">
|
<td className="px-3 py-2">
|
||||||
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
||||||
</td>
|
</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">{it.authProvider || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import { useAuth } from '@/app/auth/AuthContext';
|
import { useAuth } from '@/app/auth/AuthContext';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import {
|
import {
|
||||||
Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar,
|
Bell, Plus, Edit2, Trash2, Eye,
|
||||||
Users, Megaphone, AlertTriangle, Info, Search, Filter,
|
Megaphone, AlertTriangle, Info,
|
||||||
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
Clock, Pin, Monitor, MessageSquare, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
|
||||||
import { toDateParam } from '@shared/utils/dateFormat';
|
import { toDateParam } from '@shared/utils/dateFormat';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
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 }[] = [
|
const TYPE_OPTIONS: { key: NoticeType; label: string; icon: React.ElementType; color: string }[] = [
|
||||||
{ key: 'info', label: '정보', icon: Info, color: 'text-blue-400' },
|
{ key: 'info', label: '정보', icon: Info, color: 'text-label' },
|
||||||
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-yellow-400' },
|
{ key: 'warning', label: '경고', icon: AlertTriangle, color: 'text-label' },
|
||||||
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-red-400' },
|
{ key: 'urgent', label: '긴급', icon: Bell, color: 'text-label' },
|
||||||
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-orange-400' },
|
{ key: 'maintenance', label: '점검', icon: Megaphone, color: 'text-label' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
const DISPLAY_OPTIONS: { key: NoticeDisplay; label: string; icon: React.ElementType }[] = [
|
||||||
@ -146,7 +145,7 @@ export function NoticeManagement() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
iconColor="text-yellow-400"
|
iconColor="text-label"
|
||||||
title={t('notices.title')}
|
title={t('notices.title')}
|
||||||
description={t('notices.desc')}
|
description={t('notices.desc')}
|
||||||
demo
|
demo
|
||||||
@ -161,9 +160,9 @@ export function NoticeManagement() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ label: '전체 알림', count: notices.length, icon: Bell, color: 'text-label', bg: 'bg-muted' },
|
{ 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: activeCount, icon: Eye, color: 'text-label', bg: 'bg-green-500/10' },
|
||||||
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: '예약됨', count: scheduledCount, icon: Clock, color: 'text-label', bg: 'bg-blue-500/10' },
|
||||||
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: '긴급 알림', count: urgentCount, icon: AlertTriangle, color: 'text-label', bg: 'bg-red-500/10' },
|
||||||
].map((kpi) => (
|
].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 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}`}>
|
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
|
||||||
@ -238,14 +237,14 @@ export function NoticeManagement() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-1.5 text-center">
|
<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>
|
||||||
<td className="px-1 py-1.5">
|
<td className="px-1 py-1.5">
|
||||||
<div className="flex items-center justify-center gap-0.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" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</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" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -327,7 +326,7 @@ export function NoticeManagement() {
|
|||||||
onClick={() => setForm({ ...form, display: opt.key })}
|
onClick={() => setForm({ ...form, display: opt.key })}
|
||||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||||
form.display === opt.key
|
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'
|
: 'text-hint hover:bg-surface-overlay'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -375,7 +374,7 @@ export function NoticeManagement() {
|
|||||||
onClick={() => toggleRole(role)}
|
onClick={() => toggleRole(role)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||||
form.targetRoles.includes(role)
|
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'
|
: 'text-hint border border-slate-700/30 hover:bg-surface-overlay'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -359,13 +359,13 @@ export function PermissionsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button type="button" onClick={load}
|
<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" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
{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">
|
<div className="flex items-center gap-1">
|
||||||
{canCreateRole && (
|
{canCreateRole && (
|
||||||
<button type="button" onClick={() => setShowCreate(!showCreate)}
|
<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" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
|
||||||
<button type="button" onClick={handleDeleteRole}
|
<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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -442,7 +442,7 @@ export function PermissionsPanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
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="색상 변경"
|
title="색상 변경"
|
||||||
>
|
>
|
||||||
●
|
●
|
||||||
@ -479,9 +479,9 @@ export function PermissionsPanel() {
|
|||||||
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-hint mt-0.5">
|
<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-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-gray-500 ml-1">× 강제 거부</span> /
|
||||||
<span className="text-hint ml-1">· 미지정</span>
|
<span className="text-hint ml-1">· 미지정</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import { Button } from '@shared/components/ui/button';
|
|||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
import type { BadgeIntent } from '@lib/theme/variants';
|
import type { BadgeIntent } from '@lib/theme/variants';
|
||||||
import {
|
import {
|
||||||
Settings, Database, Search, ChevronDown, ChevronRight,
|
Settings, Database, Search,
|
||||||
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
Map, Fish, Anchor, Ship, Globe, BarChart3, Download,
|
||||||
Filter, RefreshCw, BookOpen, Layers, Hash, Info,
|
Filter, RefreshCw, Hash, Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
AREA_CODES, SPECIES_CODES, FISHERY_CODES, VESSEL_TYPE_CODES,
|
||||||
@ -149,7 +149,7 @@ export function SystemConfig() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Database}
|
icon={Database}
|
||||||
iconColor="text-cyan-400"
|
iconColor="text-label"
|
||||||
title={t('systemConfig.title')}
|
title={t('systemConfig.title')}
|
||||||
description={t('systemConfig.desc')}
|
description={t('systemConfig.desc')}
|
||||||
demo
|
demo
|
||||||
@ -168,11 +168,11 @@ export function SystemConfig() {
|
|||||||
{/* KPI 카드 */}
|
{/* KPI 카드 */}
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<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: Map, label: '해역분류', count: CODE_STATS.areas, color: 'text-label', bg: 'bg-blue-500/10', desc: '해양경찰청 관할 기준' },
|
||||||
{ icon: Fish, label: '어종 코드', count: CODE_STATS.species, color: 'text-green-400', bg: 'bg-green-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-purple-400', bg: 'bg-purple-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-orange-400', bg: 'bg-orange-500/10', desc: 'MDA 5개출처 통합' },
|
{ 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-cyan-400', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
{ icon: Globe, label: '전체 코드', count: CODE_STATS.total, color: 'text-label', bg: 'bg-cyan-500/10', desc: '공통코드 총계' },
|
||||||
].map((kpi) => (
|
].map((kpi) => (
|
||||||
<Card key={kpi.label} className="bg-surface-raised border-border">
|
<Card key={kpi.label} className="bg-surface-raised border-border">
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
@ -270,7 +270,7 @@ export function SystemConfig() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pagedData as AreaCode[]).map((a) => (
|
{(pagedData as AreaCode[]).map((a) => (
|
||||||
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
<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">
|
<td className="px-4 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
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"
|
className="border-b border-border hover:bg-surface-overlay cursor-pointer"
|
||||||
onClick={() => setExpandedRow(expandedRow === s.code ? null : s.code)}
|
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">
|
<td className="px-4 py-2">
|
||||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||||
</td>
|
</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-muted-foreground text-[10px]">{s.area}</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{s.active
|
{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>
|
: <span className="text-hint text-[9px]">N</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{s.fishing && <span className="text-yellow-400">★</span>}
|
{s.fishing && <span className="text-label">★</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -357,7 +357,7 @@ export function SystemConfig() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{(pagedData as FisheryCode[]).map((f) => (
|
{(pagedData as FisheryCode[]).map((f) => (
|
||||||
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
<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">
|
<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';
|
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>
|
<tbody>
|
||||||
{(pagedData as VesselTypeCode[]).map((v) => (
|
{(pagedData as VesselTypeCode[]).map((v) => (
|
||||||
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
<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">
|
<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';
|
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-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 text-heading font-medium">{v.name}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`text-[9px] font-mono ${
|
<span className="text-[9px] font-mono text-label">{v.source}</span>
|
||||||
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>
|
|
||||||
</td>
|
</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.tonnage}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{v.purpose}</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 && (
|
{tab !== 'settings' && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage(Math.max(0, page - 1))}
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
disabled={page === 0}
|
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">
|
<span className="text-[11px] text-hint">
|
||||||
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
||||||
</span>
|
</span>
|
||||||
<button type="button"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||||
disabled={page >= totalPages - 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -466,7 +462,7 @@ export function SystemConfig() {
|
|||||||
<Card key={section} className="bg-surface-raised border-border">
|
<Card key={section} className="bg-surface-raised border-border">
|
||||||
<CardHeader className="px-4 pt-3 pb-2">
|
<CardHeader className="px-4 pt-3 pb-2">
|
||||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
<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}
|
{meta.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -474,9 +470,7 @@ export function SystemConfig() {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
<div key={item.key} className="flex justify-between items-center text-[11px]">
|
||||||
<span className="text-hint">{item.label}</span>
|
<span className="text-hint">{item.label}</span>
|
||||||
<span className={`font-medium ${
|
<span className="font-medium text-label">{item.value}</span>
|
||||||
item.value === '활성' ? 'text-green-400' : 'text-label'
|
|
||||||
}`}>{item.value}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user