Compare commits
3 커밋
7ed07093db
...
a07c745cbc
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| a07c745cbc | |||
| 5812d9dea3 | |||
| 20d6743c17 |
@ -47,15 +47,16 @@ public class PermTreeController {
|
||||
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
|
||||
return roles.stream().<Map<String, Object>>map(r -> {
|
||||
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
|
||||
return Map.of(
|
||||
"roleSn", r.getRoleSn(),
|
||||
"roleCd", r.getRoleCd(),
|
||||
"roleNm", r.getRoleNm(),
|
||||
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
|
||||
"dfltYn", r.getDfltYn(),
|
||||
"builtinYn", r.getBuiltinYn(),
|
||||
"permissions", perms
|
||||
);
|
||||
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("roleSn", r.getRoleSn());
|
||||
result.put("roleCd", r.getRoleCd());
|
||||
result.put("roleNm", r.getRoleNm());
|
||||
result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc());
|
||||
result.put("colorHex", r.getColorHex());
|
||||
result.put("dfltYn", r.getDfltYn());
|
||||
result.put("builtinYn", r.getBuiltinYn());
|
||||
result.put("permissions", perms);
|
||||
return result;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,10 @@ public class Role {
|
||||
@Column(name = "role_dc", columnDefinition = "text")
|
||||
private String roleDc;
|
||||
|
||||
/** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */
|
||||
@Column(name = "color_hex", length = 7)
|
||||
private String colorHex;
|
||||
|
||||
@Column(name = "dflt_yn", nullable = false, length = 1)
|
||||
private String dfltYn;
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ public class RoleManagementService {
|
||||
.roleCd(req.roleCd().toUpperCase())
|
||||
.roleNm(req.roleNm())
|
||||
.roleDc(req.roleDc())
|
||||
.colorHex(req.colorHex())
|
||||
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
|
||||
.builtinYn("N")
|
||||
.build();
|
||||
@ -70,6 +71,7 @@ public class RoleManagementService {
|
||||
}
|
||||
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
|
||||
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
|
||||
if (req.colorHex() != null) role.setColorHex(req.colorHex());
|
||||
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
|
||||
return roleRepository.save(role);
|
||||
}
|
||||
|
||||
@ -6,5 +6,6 @@ public record RoleCreateRequest(
|
||||
@NotBlank String roleCd,
|
||||
@NotBlank String roleNm,
|
||||
String roleDc,
|
||||
String colorHex,
|
||||
String dfltYn
|
||||
) {}
|
||||
|
||||
@ -3,5 +3,6 @@ package gc.mda.kcg.permission.dto;
|
||||
public record RoleUpdateRequest(
|
||||
String roleNm,
|
||||
String roleDc,
|
||||
String colorHex,
|
||||
String dfltYn
|
||||
) {}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
-- ============================================================================
|
||||
-- V017: auth_role.color_hex 컬럼 추가
|
||||
-- ============================================================================
|
||||
-- 역할별 UI 표기 색상을 DB에서 관리.
|
||||
-- 프론트엔드는 RoleResponse.colorHex로 받아 ColorPicker 카탈로그에 적용.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE kcg.auth_role
|
||||
ADD COLUMN IF NOT EXISTS color_hex VARCHAR(7);
|
||||
|
||||
COMMENT ON COLUMN kcg.auth_role.color_hex IS '역할 표기 색상 (#RRGGBB hex). NULL이면 프론트 기본 팔레트에서 결정.';
|
||||
|
||||
-- 빌트인 5개 역할의 기본 색상 (기존 프론트 ROLE_COLORS 정책과 매칭)
|
||||
UPDATE kcg.auth_role SET color_hex = '#ef4444' WHERE role_cd = 'ADMIN';
|
||||
UPDATE kcg.auth_role SET color_hex = '#3b82f6' WHERE role_cd = 'OPERATOR';
|
||||
UPDATE kcg.auth_role SET color_hex = '#a855f7' WHERE role_cd = 'ANALYST';
|
||||
UPDATE kcg.auth_role SET color_hex = '#22c55e' WHERE role_cd = 'FIELD';
|
||||
UPDATE kcg.auth_role SET color_hex = '#eab308' WHERE role_cd = 'VIEWER';
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deck.gl": "^9.2.11",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^26.0.3",
|
||||
@ -20,6 +21,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2824,7 +2826,7 @@
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -4907,6 +4909,16 @@
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deck.gl": "^9.2.11",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^26.0.3",
|
||||
@ -24,6 +25,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -6,12 +6,12 @@ import {
|
||||
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
|
||||
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers,
|
||||
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
|
||||
ChevronsLeft, ChevronsRight,
|
||||
Navigation, Users, EyeOff, BarChart3, Globe,
|
||||
Smartphone, Monitor, Send, Cpu, MessageSquare,
|
||||
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||
import { getRoleColorHex } from '@shared/constants/userRoles';
|
||||
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
@ -27,13 +27,6 @@ import { useSettingsStore } from '@stores/settingsStore';
|
||||
* - 모든 페이지 하단: 페이지네이션
|
||||
*/
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
ADMIN: 'text-red-400',
|
||||
OPERATOR: 'text-blue-400',
|
||||
ANALYST: 'text-purple-400',
|
||||
FIELD: 'text-green-400',
|
||||
VIEWER: 'text-yellow-400',
|
||||
};
|
||||
|
||||
const AUTH_METHOD_LABELS: Record<string, string> = {
|
||||
password: 'ID/PW',
|
||||
@ -51,7 +44,7 @@ const NAV_ENTRIES: NavEntry[] = [
|
||||
// ── 상황판·감시 ──
|
||||
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
|
||||
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
|
||||
{ to: '/events', icon: Radar, labelKey: 'nav.eventList' },
|
||||
{ to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' },
|
||||
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
|
||||
// ── 위험도·단속 ──
|
||||
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
|
||||
@ -114,40 +107,6 @@ function formatRemaining(seconds: number) {
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── 공통 페이지네이션 (간소형) ─────────────
|
||||
function PagePagination({ page, totalPages, onPageChange }: {
|
||||
page: number; totalPages: number; onPageChange: (p: number) => void;
|
||||
}) {
|
||||
if (totalPages <= 1) return null;
|
||||
const range: number[] = [];
|
||||
const maxVis = 5;
|
||||
let s = Math.max(0, page - Math.floor(maxVis / 2));
|
||||
const e = Math.min(totalPages - 1, s + maxVis - 1);
|
||||
if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1);
|
||||
for (let i = s; i <= e; i++) range.push(i);
|
||||
|
||||
const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
|
||||
{range.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
|
||||
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
|
||||
}`}
|
||||
>{p + 1}</button>
|
||||
))}
|
||||
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
|
||||
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainLayout() {
|
||||
const { t } = useTranslation('common');
|
||||
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
|
||||
@ -166,33 +125,6 @@ export function MainLayout() {
|
||||
// 공통 검색
|
||||
const [pageSearch, setPageSearch] = useState('');
|
||||
|
||||
// 공통 스크롤 페이징 (페이지 단위 스크롤)
|
||||
const [scrollPage, setScrollPage] = useState(0);
|
||||
const scrollPageSize = 800; // px per page
|
||||
|
||||
const handleScrollPageChange = (p: number) => {
|
||||
setScrollPage(p);
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// 스크롤 이벤트로 현재 페이지 추적
|
||||
const handleScroll = () => {
|
||||
if (contentRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
|
||||
const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
|
||||
const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1);
|
||||
setScrollPage(currentPage);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalScrollPages = () => {
|
||||
if (!contentRef.current) return 1;
|
||||
const { scrollHeight, clientHeight } = contentRef.current;
|
||||
return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
const el = contentRef.current;
|
||||
@ -257,7 +189,7 @@ export function MainLayout() {
|
||||
});
|
||||
|
||||
// RBAC
|
||||
const roleColor = user ? ROLE_COLORS[user.role] : null;
|
||||
const roleColor = user ? getRoleColorHex(user.role) : null;
|
||||
const isSessionWarning = sessionRemaining <= 5 * 60;
|
||||
|
||||
// SFR-02: 공통알림 데이터
|
||||
@ -310,7 +242,7 @@ export function MainLayout() {
|
||||
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lock className="w-3 h-3 text-hint" />
|
||||
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
|
||||
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
|
||||
</div>
|
||||
<div className="text-[8px] text-hint mt-0.5">
|
||||
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
||||
@ -485,7 +417,7 @@ export function MainLayout() {
|
||||
<div className="text-[8px] text-hint">{user.org}</div>
|
||||
</div>
|
||||
{roleColor && (
|
||||
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
|
||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
@ -522,7 +454,7 @@ export function MainLayout() {
|
||||
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors"
|
||||
>
|
||||
<Search className="w-3 h-3" />
|
||||
{t('action.search')}
|
||||
@ -559,19 +491,9 @@ export function MainLayout() {
|
||||
<main
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* SFR-02: 공통 페이지네이션 (하단) */}
|
||||
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
|
||||
<PagePagination
|
||||
page={scrollPage}
|
||||
totalPages={getTotalScrollPages()}
|
||||
onPageChange={handleScrollPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SFR-02: 공통알림 팝업 */}
|
||||
|
||||
@ -18,6 +18,9 @@ import {
|
||||
type AuditStats,
|
||||
} from '@/services/adminApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
||||
import { getUserAccountStatusIntent, getUserAccountStatusLabel } from '@shared/constants/userAccountStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { PermissionsPanel } from './PermissionsPanel';
|
||||
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
|
||||
|
||||
@ -31,32 +34,12 @@ import { UserRoleAssignDialog } from './UserRoleAssignDialog';
|
||||
* 4) 보안 정책 - 정적 정보
|
||||
*/
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'bg-red-500/20 text-red-400',
|
||||
OPERATOR: 'bg-blue-500/20 text-blue-400',
|
||||
ANALYST: 'bg-purple-500/20 text-purple-400',
|
||||
FIELD: 'bg-green-500/20 text-green-400',
|
||||
VIEWER: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-500/20 text-green-400',
|
||||
LOCKED: 'bg-red-500/20 text-red-400',
|
||||
INACTIVE: 'bg-gray-500/20 text-gray-400',
|
||||
PENDING: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '활성',
|
||||
LOCKED: '잠금',
|
||||
INACTIVE: '비활성',
|
||||
PENDING: '승인대기',
|
||||
};
|
||||
|
||||
type Tab = 'roles' | 'users' | 'audit' | 'policy';
|
||||
|
||||
export function AccessControl() {
|
||||
const { t } = useTranslation('admin');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [tab, setTab] = useState<Tab>('roles');
|
||||
|
||||
// 공통 상태
|
||||
@ -135,7 +118,7 @@ export function AccessControl() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{list.map((r) => (
|
||||
<Badge key={r} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
|
||||
<Badge key={r} size="sm" style={getRoleBadgeStyle(r)}>{r}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -144,7 +127,7 @@ export function AccessControl() {
|
||||
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</Badge>;
|
||||
return <Badge intent={getUserAccountStatusIntent(s)} size="sm">{getUserAccountStatusLabel(s, tc, lang)}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
||||
@ -241,7 +224,7 @@ export function AccessControl() {
|
||||
type="button"
|
||||
onClick={() => setTab(tt.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
tab === tt.key ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<tt.icon className="w-3.5 h-3.5" />
|
||||
|
||||
@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes';
|
||||
|
||||
/**
|
||||
* 접근 이력 조회 + 메트릭 카드.
|
||||
@ -30,11 +31,6 @@ export function AccessLogs() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const statusColor = (s: number) =>
|
||||
s >= 500 ? 'bg-red-500/20 text-red-400'
|
||||
: s >= 400 ? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-green-500/20 text-green-400';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -113,7 +109,7 @@ export function AccessLogs() {
|
||||
<td className="px-3 py-2 text-purple-400 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 className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
|
||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
|
||||
@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||
import { getConnectionStatusHex } from '@shared/constants/connectionStatuses';
|
||||
import {
|
||||
Database, RefreshCw, Calendar, Wifi, WifiOff, Radio,
|
||||
Activity, Server, ArrowDownToLine, Clock, AlertTriangle,
|
||||
@ -43,11 +44,7 @@ const SIGNAL_SOURCES: SignalSource[] = [
|
||||
{ name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() },
|
||||
];
|
||||
|
||||
const SIGNAL_COLORS: Record<SignalStatus, string> = {
|
||||
ok: '#22c55e',
|
||||
warn: '#eab308',
|
||||
error: '#ef4444',
|
||||
};
|
||||
// SIGNAL_COLORS는 connectionStatuses 카탈로그에서 가져옴 (getConnectionStatusHex)
|
||||
|
||||
const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}시`);
|
||||
|
||||
@ -111,7 +108,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
||||
{ key: 'linkMethod', label: '연계방식', width: '70px', align: 'center',
|
||||
render: (v) => <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v as string}</Badge>,
|
||||
render: (v) => <Badge intent="purple" size="sm">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
|
||||
render: (v) => {
|
||||
@ -129,7 +126,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
const on = v === 'ON';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-heading' : 'bg-red-500 text-heading'}`}>
|
||||
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-on-vivid' : 'bg-red-500 text-on-vivid'}`}>
|
||||
{v as string}
|
||||
</Badge>
|
||||
{row.lastUpdate && (
|
||||
@ -163,7 +160,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-5 rounded-[1px]"
|
||||
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
|
||||
style={{ backgroundColor: getConnectionStatusHex(status), minWidth: '2px' }}
|
||||
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')} — ${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
|
||||
/>
|
||||
))}
|
||||
@ -274,7 +271,7 @@ const LOAD_JOBS: LoadJob[] = [
|
||||
const loadColumns: DataColumn<LoadJob>[] = [
|
||||
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge className="bg-cyan-500/15 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'targetDb', label: 'DB', width: '70px', align: 'center' },
|
||||
{ key: 'status', label: '상태', width: '80px', align: 'center', sortable: true,
|
||||
@ -439,7 +436,7 @@ export function DataHub() {
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -541,7 +538,7 @@ export function DataHub() {
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${
|
||||
statusFilter === f
|
||||
? 'bg-cyan-600 text-heading font-bold'
|
||||
? 'bg-cyan-600 text-on-vivid font-bold'
|
||||
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||
}`}
|
||||
>
|
||||
@ -570,16 +567,16 @@ export function DataHub() {
|
||||
<span className="text-[10px] text-hint">서버 타입:</span>
|
||||
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setCollectTypeFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setCollectStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
</div>
|
||||
@ -595,14 +592,14 @@ export function DataHub() {
|
||||
<span className="text-[10px] text-hint">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setLoadStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
<FolderOpen className="w-3 h-3" />스토리지 관리
|
||||
</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
</div>
|
||||
@ -619,13 +616,13 @@ export function DataHub() {
|
||||
<span className="text-[10px] text-hint">종류:</span>
|
||||
{(['', '수집', '적재'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setAgentRoleFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setAgentStatusFilter(f)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}
|
||||
>{f || '전체'}</button>
|
||||
))}
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
|
||||
@ -4,12 +4,17 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
|
||||
import { formatDateTime, formatDate } from '@shared/utils/dateFormat';
|
||||
import { getLoginResultIntent, getLoginResultLabel } from '@shared/constants/loginResultStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 로그인 이력 조회 + 메트릭 카드.
|
||||
* 권한: admin:login-history (READ)
|
||||
*/
|
||||
export function LoginHistoryView() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [items, setItems] = useState<LoginHistory[]>([]);
|
||||
const [stats, setStats] = useState<LoginStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -30,12 +35,6 @@ export function LoginHistoryView() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const resultColor = (r: string) => {
|
||||
if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400';
|
||||
if (r === 'LOCKED') return 'bg-red-500/20 text-red-400';
|
||||
return 'bg-orange-500/20 text-orange-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -122,7 +121,7 @@ export function LoginHistoryView() {
|
||||
<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">
|
||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||
<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-muted-foreground">{it.authProvider || '-'}</td>
|
||||
|
||||
@ -152,7 +152,7 @@ export function NoticeManagement() {
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
새 알림 등록
|
||||
|
||||
@ -13,6 +13,9 @@ import {
|
||||
type Operation, type TreeNode, type PermRow,
|
||||
} from '@/lib/permission/permResolver';
|
||||
import { useAuth } from '@/app/auth/AuthContext';
|
||||
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||
import { ColorPicker } from '@shared/components/common/ColorPicker';
|
||||
import { updateRole as apiUpdateRole } from '@/services/adminApi';
|
||||
|
||||
/**
|
||||
* 트리 기반 권한 관리 패널 (wing 패턴).
|
||||
@ -34,14 +37,6 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
|
||||
*/
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
OPERATOR: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
ANALYST: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
FIELD: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
VIEWER: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
};
|
||||
|
||||
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
|
||||
|
||||
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
|
||||
@ -65,6 +60,8 @@ export function PermissionsPanel() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newRoleCd, setNewRoleCd] = useState('');
|
||||
const [newRoleNm, setNewRoleNm] = useState('');
|
||||
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
|
||||
const [editingColor, setEditingColor] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
@ -233,15 +230,26 @@ export function PermissionsPanel() {
|
||||
const handleCreateRole = async () => {
|
||||
if (!newRoleCd || !newRoleNm) return;
|
||||
try {
|
||||
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm });
|
||||
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
|
||||
setShowCreate(false);
|
||||
setNewRoleCd(''); setNewRoleNm('');
|
||||
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateColor = async (roleSn: number, hex: string) => {
|
||||
try {
|
||||
await apiUpdateRole(roleSn, { colorHex: hex });
|
||||
await load();
|
||||
setEditingColor(null);
|
||||
} catch (e: unknown) {
|
||||
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async () => {
|
||||
if (!selectedRole) return;
|
||||
if (selectedRole.builtinYn === 'Y') {
|
||||
@ -364,14 +372,15 @@ export function PermissionsPanel() {
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1">
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||||
<input value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||
placeholder="ROLE_CD (대문자)"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<input value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
placeholder="역할 이름"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<div className="flex gap-1">
|
||||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||||
<div className="flex gap-1 pt-1">
|
||||
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
|
||||
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded">생성</button>
|
||||
<button type="button" onClick={() => setShowCreate(false)}
|
||||
@ -383,26 +392,55 @@ export function PermissionsPanel() {
|
||||
<div className="space-y-1">
|
||||
{roles.map((r) => {
|
||||
const selected = r.roleSn === selectedRoleSn;
|
||||
const isEditingColor = editingColor === String(r.roleSn);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={r.roleSn}
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded border transition-colors ${
|
||||
className={`px-2 py-1.5 rounded border transition-colors ${
|
||||
selected
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-heading'
|
||||
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border text-[9px]`}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
title="역할 선택"
|
||||
>
|
||||
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
{canUpdatePerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||||
className="text-[8px] text-hint hover:text-blue-400"
|
||||
title="색상 변경"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
{isEditingColor && (
|
||||
<div className="mt-2 p-2 bg-background rounded border border-border">
|
||||
<ColorPicker
|
||||
label="배지 색상"
|
||||
value={r.colorHex}
|
||||
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -203,7 +203,7 @@ export function SystemConfig() {
|
||||
key={t.key}
|
||||
onClick={() => changeTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -321,7 +321,7 @@ export function SystemConfig() {
|
||||
>
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{s.major}</Badge>
|
||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
|
||||
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
|
||||
|
||||
@ -2,14 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { X, Check, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'bg-red-500/20 text-red-400',
|
||||
OPERATOR: 'bg-blue-500/20 text-blue-400',
|
||||
ANALYST: 'bg-purple-500/20 text-purple-400',
|
||||
FIELD: 'bg-green-500/20 text-green-400',
|
||||
VIEWER: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
|
||||
|
||||
interface Props {
|
||||
user: AdminUser;
|
||||
@ -91,7 +84,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
|
||||
</div>
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px]`}>
|
||||
<Badge size="md" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
<div className="text-left">
|
||||
|
||||
@ -144,7 +144,7 @@ export function AIAssistant() {
|
||||
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
|
||||
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
|
||||
/>
|
||||
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-heading rounded-xl transition-colors">
|
||||
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts';
|
||||
import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/*
|
||||
* SFR-04: AI 불법조업 예측 모델 관리
|
||||
@ -237,6 +239,8 @@ const ALARM_SEVERITY = [
|
||||
|
||||
export function AIModelManagement() {
|
||||
const { t } = useTranslation('ai');
|
||||
const { t: tcCommon } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [tab, setTab] = useState<Tab>('registry');
|
||||
const [rules, setRules] = useState(defaultRules);
|
||||
|
||||
@ -301,7 +305,7 @@ export function AIModelManagement() {
|
||||
{ key: 'api' as Tab, icon: Globe, label: '예측 결과 API' },
|
||||
].map((t) => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
@ -319,7 +323,7 @@ export function AIModelManagement() {
|
||||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
<button className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
</div>
|
||||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||
</div>
|
||||
@ -340,7 +344,7 @@ export function AIModelManagement() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[8px]">{rule.model}</Badge>
|
||||
<Badge intent="purple" size="xs">{rule.model}</Badge>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
|
||||
</div>
|
||||
@ -628,7 +632,6 @@ export function AIModelManagement() {
|
||||
{/* 7대 엔진 카드 */}
|
||||
<div className="space-y-2">
|
||||
{DETECTION_ENGINES.map((eng) => {
|
||||
const sevColor = eng.severity.includes('CRITICAL') ? 'text-red-400 bg-red-500/15' : eng.severity.includes('HIGH') ? 'text-orange-400 bg-orange-500/15' : eng.severity === 'MEDIUM~CRITICAL' ? 'text-yellow-400 bg-yellow-500/15' : 'text-hint bg-muted';
|
||||
const stColor = eng.status === '운영중' ? 'bg-green-500/20 text-green-400' : eng.status === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground';
|
||||
return (
|
||||
<Card key={eng.id} className="bg-surface-raised border-border">
|
||||
@ -654,7 +657,9 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">심각도</div>
|
||||
<Badge className={`border-0 text-[9px] ${sevColor}`}>{eng.severity}</Badge>
|
||||
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
|
||||
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">쿨다운</div>
|
||||
@ -948,7 +953,7 @@ export function AIModelManagement() {
|
||||
].map((s) => (
|
||||
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge className="border-0 text-[9px] font-bold" style={{ backgroundColor: `${s.color}20`, color: s.color }}>{s.sfr}</Badge>
|
||||
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
|
||||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses';
|
||||
import {
|
||||
Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield,
|
||||
FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square,
|
||||
@ -109,10 +110,6 @@ export function MLOpsPage() {
|
||||
const [selectedTmpl, setSelectedTmpl] = useState(0);
|
||||
const [selectedLLM, setSelectedLLM] = useState(0);
|
||||
|
||||
const stColor = (s: string) => s === 'DEPLOYED' ? 'bg-green-500/20 text-green-400 border-green-500' : s === 'APPROVED' ? 'bg-blue-500/20 text-blue-400 border-blue-500' : s === 'TESTING' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500' : 'bg-muted text-muted-foreground border-slate-600';
|
||||
const gateColor = (s: string) => s === 'pass' ? 'bg-green-500/20 text-green-400' : s === 'fail' ? 'bg-red-500/20 text-red-400' : s === 'run' ? 'bg-yellow-500/20 text-yellow-400 animate-pulse' : 'bg-switch-background/50 text-hint';
|
||||
const expColor = (s: string) => s === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : s === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400';
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -161,7 +158,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">배포 모델 현황</div>
|
||||
<div className="space-y-2">{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEPLOYED</Badge>
|
||||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||||
<span className="text-[10px] text-hint">{m.ver}</span>
|
||||
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
|
||||
@ -172,7 +169,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">진행 중 실험</div>
|
||||
<div className="space-y-2">{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px] animate-pulse">실행중</Badge>
|
||||
<Badge intent="info" size="sm" className="animate-pulse">실행중</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.name}</span>
|
||||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground">{e.progress}%</span>
|
||||
@ -202,14 +199,14 @@ export function MLOpsPage() {
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EXPERIMENTS.map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-16">{e.id}</span>
|
||||
<span className="text-[11px] text-heading font-medium w-40 truncate">{e.name}</span>
|
||||
<Badge className={`border-0 text-[9px] w-14 text-center ${expColor(e.status)}`}>{e.status}</Badge>
|
||||
<Badge intent={getExperimentIntent(e.status)} size="sm" className={`w-14 ${EXPERIMENT_STATUSES[e.status as keyof typeof EXPERIMENT_STATUSES]?.pulse ? 'animate-pulse' : ''}`}>{e.status}</Badge>
|
||||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||
@ -228,7 +225,7 @@ export function MLOpsPage() {
|
||||
<Card key={m.name + m.ver} className="bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div><div className="text-[13px] font-bold text-heading">{m.name}</div><div className="text-[9px] text-hint mt-0.5">{m.ver}</div></div>
|
||||
<Badge className={`border text-[10px] font-bold ${stColor(m.status)}`}>{m.status}</Badge>
|
||||
<Badge intent={getModelStatusIntent(m.status)} size="md" className="font-bold">{MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}</Badge>
|
||||
</div>
|
||||
{/* 성능 지표 */}
|
||||
{m.accuracy > 0 && (
|
||||
@ -245,7 +242,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[9px] text-hint mb-1.5 font-bold">Quality Gates</div>
|
||||
<div className="flex gap-1">
|
||||
{m.gates.map((g, i) => (
|
||||
<Badge key={g} className={`border-0 text-[8px] ${gateColor(m.gateStatus[i])}`}>{g}</Badge>
|
||||
<Badge key={g} intent={getQualityGateIntent(m.gateStatus[i])} size="xs" className={QUALITY_GATE_STATUSES[m.gateStatus[i] as keyof typeof QUALITY_GATE_STATUSES]?.pulse ? 'animate-pulse' : ''}>{g}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -283,8 +280,8 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-heading mb-2">카나리 / A·B 테스트</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-3">위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)</div>
|
||||
<div className="h-5 bg-background rounded-lg overflow-hidden flex">
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -293,7 +290,7 @@ export function MLOpsPage() {
|
||||
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-heading text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -318,7 +315,7 @@ export function MLOpsPage() {
|
||||
"version": "v2.1.0"
|
||||
}`} />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -386,7 +383,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -422,7 +419,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
</CardContent></Card>
|
||||
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||||
@ -436,7 +433,7 @@ export function MLOpsPage() {
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.dropout}</td>
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.hidden}</td>
|
||||
<td className="py-2 px-2 text-heading font-bold">{t.f1.toFixed(3)}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge className="bg-green-500/20 text-green-400 border-0 text-[8px]">BEST</Badge>}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge intent="success" size="xs">BEST</Badge>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
@ -503,14 +500,14 @@ export function MLOpsPage() {
|
||||
<p className="text-[10px] text-muted-foreground">2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부</p>
|
||||
<p className="text-[10px] text-muted-foreground">3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인</p>
|
||||
<div className="mt-2 pt-2 border-t border-border flex gap-1">
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">배타적경제수역법 §5</Badge>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">한중어업협정 §6</Badge>
|
||||
<Badge intent="success" size="xs">배타적경제수역법 §5</Badge>
|
||||
<Badge intent="success" size="xs">한중어업협정 §6</Badge>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
||||
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading rounded-xl"><Send className="w-4 h-4" /></button>
|
||||
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
|
||||
@ -195,7 +195,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@ -22,45 +22,13 @@ import {
|
||||
type PredictionStatsDaily,
|
||||
type PredictionStatsHourly,
|
||||
} from '@/services/kpi';
|
||||
import { toDateParam } from '@shared/utils/dateFormat';
|
||||
import { toDateParam, formatDate, formatTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
||||
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getPatrolStatusClasses, getPatrolStatusLabel } from '@shared/constants/patrolStatuses';
|
||||
import { getKpiUi } from '@shared/constants/kpiUiMap';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
// ─── 작전 경보 등급 ─────────────────────
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
const ALERT_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
|
||||
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400', border: 'border-red-500/30', dot: 'bg-red-500' },
|
||||
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/30', dot: 'bg-orange-500' },
|
||||
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', border: 'border-yellow-500/30', dot: 'bg-yellow-500' },
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' },
|
||||
};
|
||||
|
||||
// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ─────────
|
||||
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
// kpiKey 기반 매핑 (백엔드 API 응답)
|
||||
realtime_detection: { icon: Radar, color: '#3b82f6' },
|
||||
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
|
||||
dark_vessel: { icon: Eye, color: '#f97316' },
|
||||
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
|
||||
tracking: { icon: Crosshair, color: '#06b6d4' },
|
||||
enforcement: { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
|
||||
|
||||
// 위반 유형/어구 → 차트 색상 매핑
|
||||
const VESSEL_TYPE_COLORS: Record<string, string> = {
|
||||
'EEZ 침범': '#ef4444',
|
||||
'다크베셀': '#f97316',
|
||||
'불법환적': '#a855f7',
|
||||
'MMSI변조': '#eab308',
|
||||
'고속도주': '#06b6d4',
|
||||
'어구 불법': '#6b7280',
|
||||
};
|
||||
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||
|
||||
// TODO: /api/weather 연동 예정
|
||||
const WEATHER_DATA = {
|
||||
@ -83,9 +51,10 @@ function PulsingDot({ color }: { color: string }) {
|
||||
}
|
||||
|
||||
function RiskBar({ value, size = 'default' }: { value: number; size?: 'default' | 'sm' }) {
|
||||
const pct = value * 100;
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 80 ? 'bg-orange-500' : pct > 70 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
const textColor = pct > 90 ? 'text-red-400' : pct > 80 ? 'text-orange-400' : pct > 70 ? 'text-yellow-400' : 'text-blue-400';
|
||||
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
|
||||
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
|
||||
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
|
||||
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
|
||||
const barW = size === 'sm' ? 'w-16' : 'w-24';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -122,22 +91,27 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
|
||||
|
||||
interface TimelineEvent { time: string; level: AlertLevel; title: string; detail: string; vessel: string; area: string }
|
||||
function TimelineItem({ event }: { event: TimelineEvent }) {
|
||||
const c = ALERT_COLORS[event.level];
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const c = ALERT_LEVELS[event.level].classes;
|
||||
return (
|
||||
<div className={`flex gap-3 p-2.5 rounded-lg ${c.bg} border ${c.border} hover:brightness-110 transition-all cursor-pointer group`}>
|
||||
<div className="flex flex-col items-center gap-1 pt-0.5 shrink-0">
|
||||
<div className="flex flex-col items-center gap-0.5 pt-0.5 shrink-0 min-w-[68px]">
|
||||
<PulsingDot color={c.dot} />
|
||||
<span className="text-[9px] text-hint tabular-nums">{event.time}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatDate(event.time)}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatTime(event.time)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-xs font-bold ${c.text}`}>{event.title}</span>
|
||||
<Badge className={`${c.bg} ${c.text} text-[8px] px-1 py-0 border-0`}>{event.level}</Badge>
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="xs">
|
||||
{getAlertLevelLabel(event.level, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed truncate">{event.detail}</p>
|
||||
<p className="text-[0.75rem] text-label leading-relaxed truncate">{event.detail}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
|
||||
@ -146,14 +120,13 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
|
||||
}
|
||||
|
||||
function PatrolStatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'추적 중': 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
'검문 중': 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
'초계 중': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
'귀항 중': 'bg-muted text-muted-foreground border-slate-500/30',
|
||||
'대기': 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
};
|
||||
return <Badge className={`${styles[status] || 'bg-muted text-muted-foreground'} text-[9px] border px-1.5 py-0`}>{status}</Badge>;
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
return (
|
||||
<Badge className={`${getPatrolStatusClasses(status)} text-[9px] border px-1.5 py-0`}>
|
||||
{getPatrolStatusLabel(status, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FuelGauge({ percent }: { percent: number }) {
|
||||
@ -276,6 +249,8 @@ const MemoSeaAreaMap = memo(SeaAreaMap);
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [defconLevel] = useState(2);
|
||||
|
||||
const kpiStore = useKpiStore();
|
||||
@ -309,7 +284,7 @@ export function Dashboard() {
|
||||
}, []);
|
||||
|
||||
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
|
||||
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
|
||||
const ui = getKpiUi(m.id) ?? getKpiUi(m.label);
|
||||
return {
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
@ -321,7 +296,7 @@ export function Dashboard() {
|
||||
}), [kpiStore.metrics]);
|
||||
|
||||
const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({
|
||||
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||
time: e.time,
|
||||
level: e.level,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
@ -370,12 +345,13 @@ export function Dashboard() {
|
||||
};
|
||||
}), [hourlyStats]);
|
||||
|
||||
// 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory
|
||||
// 위반 유형 분포: daily byCategory(위반 enum) 우선, 없으면 byGearType
|
||||
// 라벨/색상은 공통 카탈로그(violationTypes)에서 일괄 lookup
|
||||
const VESSEL_TYPE_DATA = useMemo(() => {
|
||||
if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[];
|
||||
const totals: Record<string, number> = {};
|
||||
dailyStats.forEach((d) => {
|
||||
const src = d.byGearType ?? d.byCategory ?? null;
|
||||
const src = d.byCategory ?? d.byGearType ?? null;
|
||||
if (src) {
|
||||
Object.entries(src).forEach(([k, v]) => {
|
||||
totals[k] = (totals[k] ?? 0) + (Number(v) || 0);
|
||||
@ -384,12 +360,12 @@ export function Dashboard() {
|
||||
});
|
||||
return Object.entries(totals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([name, value], i) => ({
|
||||
name,
|
||||
.map(([code, value]) => ({
|
||||
name: getViolationLabel(code, tc, lang),
|
||||
value,
|
||||
color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length],
|
||||
color: getViolationColor(code),
|
||||
}));
|
||||
}, [dailyStats]);
|
||||
}, [dailyStats, tc, lang]);
|
||||
|
||||
// 해역별 위험도: daily byZone → 표 데이터
|
||||
const AREA_RISK_DATA = useMemo(() => {
|
||||
@ -511,10 +487,10 @@ export function Dashboard() {
|
||||
실시간 상황 타임라인
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="critical" size="xs" className="px-1.5 py-0">
|
||||
긴급 {TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length}
|
||||
</Badge>
|
||||
<Badge className="bg-orange-500/15 text-orange-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="high" size="xs" className="px-1.5 py-0">
|
||||
경고 {TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -538,7 +514,7 @@ export function Dashboard() {
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-cyan-500" />
|
||||
함정 배치 현황
|
||||
<Badge className="bg-cyan-500/15 text-cyan-400 text-[8px] border-0 ml-auto px-1.5 py-0">
|
||||
<Badge intent="cyan" size="xs" className="ml-auto px-1.5 py-0">
|
||||
{PATROL_SHIPS.length}척 운용 중
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
@ -654,12 +630,12 @@ export function Dashboard() {
|
||||
<Crosshair className="w-3.5 h-3.5 text-red-500" />
|
||||
고위험 선박 추적 현황 (AI 우선순위)
|
||||
</CardTitle>
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[9px] border-0">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
<Badge intent="critical" size="sm">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-2">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<div className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<span>#</span>
|
||||
<span>MMSI</span>
|
||||
<span>선종</span>
|
||||
@ -672,20 +648,20 @@ export function Dashboard() {
|
||||
{TOP_RISK_VESSELS.map((vessel, index) => (
|
||||
<div
|
||||
key={vessel.id}
|
||||
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
>
|
||||
<span className="text-hint text-xs font-bold">#{index + 1}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<PulsingDot color={vessel.risk > 70 ? 'bg-red-500' : vessel.risk > 50 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0">전재</Badge>}
|
||||
{vessel.isDark && <Badge intent="high" size="xs" className="px-1 py-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge intent="warning" size="xs" className="px-1 py-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge intent="purple" size="xs" className="px-1 py-0">전재</Badge>}
|
||||
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
|
||||
</div>
|
||||
<RiskBar value={vessel.risk} />
|
||||
|
||||
@ -7,6 +7,10 @@ import {
|
||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GearIdentification } from './GearIdentification';
|
||||
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
||||
import { PieChart as EcPieChart } from '@lib/charts';
|
||||
@ -130,12 +134,8 @@ function CircleGauge({ value, label }: { value: number; label: string }) {
|
||||
}
|
||||
|
||||
function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) {
|
||||
const colors: Record<VesselStatus, { ring: string; bg: string; text: string }> = {
|
||||
'의심': { ring: '#f97316', bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
||||
'양호': { ring: '#10b981', bg: 'bg-green-500/10', text: 'text-green-400' },
|
||||
'경고': { ring: '#ef4444', bg: 'bg-red-500/10', text: 'text-red-400' },
|
||||
};
|
||||
const c = colors[status];
|
||||
const meta = getVesselRingMeta(status);
|
||||
const c = { ring: meta.hex, text: `text-${meta.intent === 'critical' ? 'red' : meta.intent === 'high' ? 'orange' : 'green'}-400` };
|
||||
const circumference = 2 * Math.PI * 18;
|
||||
const offset = circumference - (riskPct / 100) * circumference;
|
||||
|
||||
@ -196,6 +196,8 @@ function TransferView() {
|
||||
// ─── 메인 페이지 ──────────────────────
|
||||
|
||||
export function ChinaFishing() {
|
||||
const { t: tcCommon } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard');
|
||||
const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항');
|
||||
const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계');
|
||||
@ -294,7 +296,7 @@ export function ChinaFishing() {
|
||||
onClick={() => setMode(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
|
||||
mode === tab.key
|
||||
? 'bg-blue-600 text-heading'
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
@ -501,7 +503,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
|
||||
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>{v.country}</span>
|
||||
@ -567,10 +569,14 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 text-[8px]">
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => (
|
||||
<div key={lv} className="flex items-center gap-1">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[lv].classes.dot}`} />
|
||||
<span className="text-hint">
|
||||
{getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,9 @@ import {
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
|
||||
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||
|
||||
@ -62,30 +65,27 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
||||
};
|
||||
}
|
||||
|
||||
const PATTERN_COLORS: Record<string, string> = {
|
||||
'AIS 완전차단': '#ef4444',
|
||||
'MMSI 변조 의심': '#f97316',
|
||||
'장기소실': '#eab308',
|
||||
'신호 간헐송출': '#a855f7',
|
||||
};
|
||||
|
||||
const cols: DataColumn<Suspect>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s === '추적중' ? 'bg-red-500/20 text-red-400' : s === '감시중' ? 'bg-yellow-500/20 text-yellow-400' : s === '확인중' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge className={`border-0 text-[8px] ${l === '불법' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'}`}>{l}</Badge>; } },
|
||||
];
|
||||
|
||||
export function DarkVesselDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
|
||||
], [tc, lang]);
|
||||
|
||||
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -128,7 +128,7 @@ export function DarkVesselDetection() {
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
radius: 10000,
|
||||
color: PATTERN_COLORS[d.pattern] || '#ef4444',
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
})),
|
||||
0.08,
|
||||
),
|
||||
@ -137,7 +137,7 @@ export function DarkVesselDetection() {
|
||||
DATA.map(d => ({
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
color: PATTERN_COLORS[d.pattern] || '#ef4444',
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
radius: d.risk > 80 ? 1200 : 800,
|
||||
label: `${d.id} ${d.name}`,
|
||||
} as MarkerData)),
|
||||
@ -193,12 +193,16 @@ export function DarkVesselDetection() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">탐지 패턴</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(PATTERN_COLORS).map(([p, c]) => (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c }} />
|
||||
<span className="text-[8px] text-muted-foreground">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
|
||||
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||
|
||||
@ -8,14 +8,18 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
import { formatDate } from '@shared/utils/dateFormat';
|
||||
import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses';
|
||||
import { getAlertLevelHex } from '@shared/constants/alertLevels';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||
|
||||
type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; };
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
'고위험': '#ef4444',
|
||||
'중위험': '#eab308',
|
||||
// 한글 위험도 → AlertLevel hex 매핑
|
||||
const RISK_HEX: Record<string, string> = {
|
||||
'고위험': getAlertLevelHex('CRITICAL'),
|
||||
'중위험': getAlertLevelHex('MEDIUM'),
|
||||
'안전': '#22c55e',
|
||||
};
|
||||
|
||||
@ -50,22 +54,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
||||
};
|
||||
}
|
||||
|
||||
const cols: DataColumn<Gear>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
|
||||
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
|
||||
render: v => { const p = v as string; const c = p === '유효' ? 'bg-green-500/20 text-green-400' : p === '무허가' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
|
||||
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s.includes('불법') ? 'bg-red-500/20 text-red-400' : s === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
];
|
||||
|
||||
export function GearDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Gear>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
|
||||
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
|
||||
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -105,7 +112,7 @@ export function GearDetection() {
|
||||
lat: g.lat,
|
||||
lng: g.lng,
|
||||
radius: 6000,
|
||||
color: RISK_COLORS[g.risk] || '#64748b',
|
||||
color: RISK_HEX[g.risk] || "#64748b",
|
||||
})),
|
||||
0.1,
|
||||
),
|
||||
@ -114,7 +121,7 @@ export function GearDetection() {
|
||||
DATA.map(g => ({
|
||||
lat: g.lat,
|
||||
lng: g.lng,
|
||||
color: RISK_COLORS[g.risk] || '#64748b',
|
||||
color: RISK_HEX[g.risk] || "#64748b",
|
||||
radius: g.risk === '고위험' ? 1200 : 800,
|
||||
label: `${g.id} ${g.type}`,
|
||||
} as MarkerData)),
|
||||
|
||||
@ -781,7 +781,7 @@ export function GearIdentification() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={runIdentification}
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
어구 국적 판별 실행
|
||||
|
||||
@ -3,6 +3,10 @@ import { Loader2, RefreshCw, MapPin } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
|
||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* iran 백엔드의 실시간 어구/선단 그룹을 표시.
|
||||
@ -10,19 +14,9 @@ import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||
* - 자체 DB의 ParentResolution이 합성되어 있음
|
||||
*/
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
FLEET: 'bg-blue-500/20 text-blue-400',
|
||||
GEAR_IN_ZONE: 'bg-orange-500/20 text-orange-400',
|
||||
GEAR_OUT_ZONE: 'bg-purple-500/20 text-purple-400',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
|
||||
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
|
||||
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
export function RealGearGroups() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const [items, setItems] = useState<GearGroupItem[]>([]);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -61,7 +55,7 @@ export function RealGearGroups() {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹 (iran 백엔드)
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨
|
||||
@ -115,7 +109,7 @@ export function RealGearGroups() {
|
||||
{filtered.slice(0, 100).map((g) => (
|
||||
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge className={`${TYPE_COLORS[g.groupType]} border-0 text-[9px]`}>{g.groupType}</Badge>
|
||||
<Badge intent={getGearGroupTypeIntent(g.groupType)} size="sm">{getGearGroupTypeLabel(g.groupType, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
|
||||
@ -126,8 +120,8 @@ export function RealGearGroups() {
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{g.resolution ? (
|
||||
<Badge className={`${STATUS_COLORS[g.resolution.status] || ''} border-0 text-[9px]`}>
|
||||
{g.resolution.status}
|
||||
<Badge intent={getParentResolutionIntent(g.resolution.status)} size="sm">
|
||||
{getParentResolutionLabel(g.resolution.status, tc, lang)}
|
||||
</Badge>
|
||||
) : <span className="text-hint text-[10px]">-</span>}
|
||||
</td>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
@ -20,12 +21,7 @@ interface Props {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-500/20 text-red-400',
|
||||
HIGH: 'bg-orange-500/20 text-orange-400',
|
||||
MEDIUM: 'bg-yellow-500/20 text-yellow-400',
|
||||
LOW: 'bg-blue-500/20 text-blue-400',
|
||||
};
|
||||
// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
TERRITORIAL_SEA: '영해',
|
||||
@ -82,7 +78,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
{icon} {title}
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
||||
@ -147,7 +143,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge className={`${RISK_COLORS[v.algorithms.riskScore.level] || ''} border-0 text-[9px]`}>
|
||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||
{v.algorithms.riskScore.level}
|
||||
</Badge>
|
||||
</td>
|
||||
@ -159,7 +155,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.darkVessel.isDark ? (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
<Badge intent="purple" size="sm">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
@ -169,7 +165,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.transship.isSuspect ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{v.algorithms.transship.durationMin}분</Badge>
|
||||
<Badge intent="critical" size="sm">{v.algorithms.transship.durationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useEnforcementStore } from '@stores/enforcementStore';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
|
||||
import { getEnforcementActionLabel } from '@shared/constants/enforcementActions';
|
||||
import { getEnforcementResultLabel, getEnforcementResultClasses } from '@shared/constants/enforcementResults';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */
|
||||
|
||||
@ -19,86 +24,96 @@ interface Record {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const cols: DataColumn<Record>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
width: '130px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
width: '100px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'action', label: '조치', width: '90px' },
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '결과',
|
||||
width: '80px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const r = v as string;
|
||||
const c =
|
||||
r.includes('처벌') || r.includes('수사')
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: r.includes('오탐')
|
||||
? 'bg-muted text-muted-foreground'
|
||||
: 'bg-yellow-500/20 text-yellow-400';
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function EnforcementHistory() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { records, loading, error, load } = useEnforcementStore();
|
||||
|
||||
const cols: DataColumn<Record>[] = useMemo(() => [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{formatDateTime(v as string)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
minWidth: '90px',
|
||||
maxWidth: '160px',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '조치',
|
||||
minWidth: '70px',
|
||||
maxWidth: '110px',
|
||||
render: (v) => (
|
||||
<span className="text-label">{getEnforcementActionLabel(v as string, tc, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' || m === 'MATCH' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '결과',
|
||||
minWidth: '80px',
|
||||
maxWidth: '120px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${getEnforcementResultClasses(code)}`}>
|
||||
{getEnforcementResultLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [tc, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@ -115,23 +130,23 @@ export function EnforcementHistory() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: '총 단속', v: DATA.length, c: 'text-heading' },
|
||||
{
|
||||
l: '처벌',
|
||||
v: DATA.filter((d) => d.result.includes('처벌')).length,
|
||||
l: '처벌·수사',
|
||||
v: DATA.filter((d) => d.result === 'PUNISHED' || d.result === 'REFERRED').length,
|
||||
c: 'text-red-400',
|
||||
},
|
||||
{
|
||||
l: 'AI 일치',
|
||||
v: DATA.filter((d) => d.aiMatch === '일치').length,
|
||||
v: DATA.filter((d) => d.aiMatch === '일치' || d.aiMatch === 'MATCH').length,
|
||||
c: 'text-green-400',
|
||||
},
|
||||
{
|
||||
l: '오탐',
|
||||
v: DATA.filter((d) => d.result.includes('오탐')).length,
|
||||
v: DATA.filter((d) => d.result === 'FALSE_POSITIVE').length,
|
||||
c: 'text-yellow-400',
|
||||
},
|
||||
].map((k) => (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
@ -8,6 +8,11 @@ import {
|
||||
Filter, Upload, X, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getEventStatusClasses, getEventStatusLabel } from '@shared/constants/eventStatuses';
|
||||
import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/*
|
||||
* 이벤트 목록 — SFR-02 공통컴포넌트 적용
|
||||
@ -15,7 +20,7 @@ import { useEventStore } from '@stores/eventStore';
|
||||
* 실제 백엔드 API 연동
|
||||
*/
|
||||
|
||||
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
type AlertLevel = AlertLevelType;
|
||||
|
||||
interface EventRow {
|
||||
id: string;
|
||||
@ -33,64 +38,10 @@ interface EventRow {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<AlertLevel, { bg: string; text: string }> = {
|
||||
CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400' },
|
||||
HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400' },
|
||||
MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400' },
|
||||
LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' },
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
NEW: 'bg-red-500/20 text-red-400',
|
||||
ACK: 'bg-orange-500/20 text-orange-400',
|
||||
IN_PROGRESS: 'bg-blue-500/20 text-blue-400',
|
||||
RESOLVED: 'bg-green-500/20 text-green-400',
|
||||
FALSE_POSITIVE: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
function statusColor(s: string): string {
|
||||
if (STATUS_COLORS[s]) return STATUS_COLORS[s];
|
||||
if (s === '완료' || s === '확인 완료' || s === '경고 완료') return 'bg-green-500/20 text-green-400';
|
||||
if (s.includes('추적') || s.includes('나포')) return 'bg-red-500/20 text-red-400';
|
||||
if (s.includes('감시') || s.includes('확인')) return 'bg-yellow-500/20 text-yellow-400';
|
||||
return 'bg-blue-500/20 text-blue-400';
|
||||
}
|
||||
|
||||
const columns: DataColumn<EventRow>[] = [
|
||||
{
|
||||
key: 'level', label: '등급', width: '70px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
const s = LEVEL_STYLES[lv];
|
||||
return <Badge className={`border-0 text-[9px] ${s?.bg ?? ''} ${s?.text ?? ''}`}>{lv}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', width: '90px', sortable: true,
|
||||
render: (val) => <span className="text-heading font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'area', label: '해역', width: '90px', sortable: true },
|
||||
{ key: 'speed', label: '속력', width: '60px', align: 'right' },
|
||||
{
|
||||
key: 'status', label: '처리상태', width: '80px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
return <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', width: '70px' },
|
||||
];
|
||||
|
||||
export function EventList() {
|
||||
const { t } = useTranslation('enforcement');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const {
|
||||
events: storeEvents,
|
||||
stats,
|
||||
@ -100,6 +51,53 @@ export function EventList() {
|
||||
loadStats,
|
||||
} = useEventStore();
|
||||
|
||||
const columns: DataColumn<EventRow>[] = useMemo(() => [
|
||||
{
|
||||
key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
return (
|
||||
<Badge intent={getAlertLevelIntent(lv)} size="sm">
|
||||
{getAlertLevelLabel(lv, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(val as string)}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true,
|
||||
render: (val) => {
|
||||
const code = val as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
|
||||
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
|
||||
{
|
||||
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
|
||||
render: (val) => {
|
||||
const s = val as string;
|
||||
return (
|
||||
<Badge className={`border-0 text-[9px] ${getEventStatusClasses(s)}`}>
|
||||
{getEventStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
|
||||
], [tc, lang]);
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
|
||||
@ -51,7 +51,7 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
width: '80px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
|
||||
<Badge intent="info" size="sm">{v as string}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@ -6,6 +6,7 @@ import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigat
|
||||
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { formatTime } from '@shared/utils/dateFormat';
|
||||
import { ALERT_LEVELS, type AlertLevel } from '@shared/constants/alertLevels';
|
||||
|
||||
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
||||
|
||||
@ -98,7 +99,7 @@ export function MobileService() {
|
||||
<div className="space-y-1">
|
||||
{ALERTS.slice(0, 2).map((a, i) => (
|
||||
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[a.level as AlertLevel]?.classes.dot ?? 'bg-slate-500'}`} />
|
||||
<span className="text-[8px] text-label truncate">{a.title}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react';
|
||||
import { getDeviceStatusIntent, getDeviceStatusLabel } from '@shared/constants/deviceStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-16: 함정용 단말에서 이용가능한 Agent 개발 */
|
||||
|
||||
@ -15,19 +18,31 @@ const DATA: Agent[] = [
|
||||
{ id: 'AGT-105', ship: '서특단 1정', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:19:55', tasks: 2 },
|
||||
{ id: 'AGT-106', ship: '1503함', version: '-', status: '미배포', sync: '-', lastSync: '-', tasks: 0 },
|
||||
];
|
||||
const cols: DataColumn<Agent>[] = [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '70px' },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; const c = s === '온라인' ? 'bg-green-500/20 text-green-400' : s === '오프라인' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
];
|
||||
|
||||
export function ShipAgent() {
|
||||
const { t } = useTranslation('fieldOps');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Agent>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '70px' },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => {
|
||||
const s = v as string;
|
||||
return (
|
||||
<Badge intent={getDeviceStatusIntent(s)} size="sm">
|
||||
{getDeviceStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
|
||||
@ -8,28 +8,21 @@ import { AreaChart, PieChart } from '@lib/charts';
|
||||
import { useKpiStore } from '@stores/kpiStore';
|
||||
import { useEventStore } from '@stores/eventStore';
|
||||
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
|
||||
import { getKpiUi } from '@shared/constants/kpiUiMap';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
||||
import { type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { SystemStatusPanel } from './SystemStatusPanel';
|
||||
|
||||
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
||||
|
||||
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
|
||||
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Target, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
// 위반 유형 → 차트 색상 매핑
|
||||
const PIE_COLOR_MAP: Record<string, string> = {
|
||||
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
||||
'불법환적': '#a855f7', '어구 불법': '#6b7280',
|
||||
};
|
||||
const LV: Record<string, string> = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' };
|
||||
|
||||
// KPI_UI_MAP은 shared/constants/kpiUiMap 공통 모듈 사용
|
||||
export function MonitoringDashboard() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const kpiStore = useKpiStore();
|
||||
const eventStore = useEventStore();
|
||||
|
||||
@ -67,20 +60,20 @@ export function MonitoringDashboard() {
|
||||
const KPI = kpiStore.metrics.map((m) => ({
|
||||
label: m.label,
|
||||
value: m.value,
|
||||
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
||||
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
||||
icon: getKpiUi(m.label).icon,
|
||||
color: getKpiUi(m.label).color,
|
||||
}));
|
||||
|
||||
// PIE: store violationTypes → 차트 데이터 변환
|
||||
// PIE: store violationTypes → 공통 카탈로그 기반 라벨/색상
|
||||
const PIE = kpiStore.violationTypes.map((v) => ({
|
||||
name: v.type,
|
||||
name: getViolationLabel(v.type, tc, lang),
|
||||
value: v.pct,
|
||||
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
|
||||
color: getViolationColor(v.type),
|
||||
}));
|
||||
|
||||
// 이벤트: store events → 첫 6개, time 포맷 변환
|
||||
// 이벤트: store events → 첫 6개, time은 KST로 포맷
|
||||
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
|
||||
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
||||
time: formatDateTime(e.time),
|
||||
level: e.level,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
@ -122,8 +115,10 @@ export function MonitoringDashboard() {
|
||||
<div className="space-y-2">
|
||||
{EVENTS.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-10">{e.time}</span>
|
||||
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</Badge>
|
||||
<span className="text-[10px] text-hint font-mono whitespace-nowrap shrink-0">{e.time}</span>
|
||||
<Badge intent={getAlertLevelIntent(e.level)} size="sm" className="min-w-[52px]">
|
||||
{getAlertLevelLabel(e.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
|
||||
<span className="text-[10px] text-hint">{e.detail}</span>
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,9 @@ import {
|
||||
type LabelSession as LabelSessionType,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getLabelSessionIntent, getLabelSessionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 모선 추론 학습 세션 페이지.
|
||||
@ -18,13 +21,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
|
||||
*/
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-500/20 text-green-400',
|
||||
CANCELLED: 'bg-gray-500/20 text-gray-400',
|
||||
COMPLETED: 'bg-blue-500/20 text-blue-400',
|
||||
};
|
||||
|
||||
export function LabelSession() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
|
||||
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
|
||||
@ -162,7 +161,7 @@ export function LabelSession() {
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
|
||||
|
||||
@ -9,6 +9,9 @@ import {
|
||||
type ParentResolution,
|
||||
} from '@/services/parentInferenceApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* 모선 확정/거부/리셋 페이지.
|
||||
@ -17,19 +20,9 @@ import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
* - 모든 액션은 백엔드에서 audit_log + review_log에 기록
|
||||
*/
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
|
||||
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
|
||||
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNRESOLVED: '미해결',
|
||||
MANUAL_CONFIRMED: '확정됨',
|
||||
REVIEW_REQUIRED: '검토필요',
|
||||
};
|
||||
|
||||
export function ParentReview() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const { hasPermission } = useAuth();
|
||||
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
|
||||
const [items, setItems] = useState<ParentResolution[]>([]);
|
||||
@ -228,8 +221,8 @@ export function ParentReview() {
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
|
||||
{STATUS_LABELS[it.status] || it.status}
|
||||
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
|
||||
{getParentResolutionLabel(it.status, tc, lang)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
|
||||
@ -109,9 +109,9 @@ export function FleetOptimization() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />시뮬레이션</button>
|
||||
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />시뮬레이션</button>
|
||||
<button onClick={() => setApproved(true)} disabled={!simRunning}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-heading text-[10px] font-bold rounded-lg"><CheckCircle className="w-3 h-3" />최종 승인</button>
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-on-vivid text-[10px] font-bold rounded-lg"><CheckCircle className="w-3 h-3" />최종 승인</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ export function PatrolRoute() {
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />경로 생성</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />경로 생성</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Share2 className="w-3 h-3" />공유</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +39,7 @@ const cols: DataColumn<Plan>[] = [
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
];
|
||||
|
||||
export function EnforcementPlan() {
|
||||
@ -124,7 +124,7 @@ export function EnforcementPlan() {
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Shield className="w-5 h-5 text-orange-400" />{t('enforcementPlan.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('enforcementPlan.desc')}</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
</div>
|
||||
|
||||
{/* 로딩/에러 상태 */}
|
||||
@ -153,7 +153,7 @@ export function EnforcementPlan() {
|
||||
<div className="flex gap-4 text-[10px]">
|
||||
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{k}</Badge>
|
||||
<Badge intent="critical" size="sm">{k}</Badge>
|
||||
<span className="text-muted-foreground">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -18,7 +18,7 @@ const cols: DataColumn<Service>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '서비스명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'target', label: '제공 대상', width: '80px', sortable: true },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ key: 'format', label: '포맷', width: '60px', align: 'center' },
|
||||
{ key: 'cycle', label: '갱신주기', width: '70px' },
|
||||
{ key: 'privacy', label: '정보등급', width: '70px', align: 'center',
|
||||
|
||||
@ -37,7 +37,7 @@ export function ReportManagement() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
@ -66,7 +66,7 @@ export function ReportManagement() {
|
||||
>
|
||||
<Upload className="w-3 h-3" />증거 업로드
|
||||
</button>
|
||||
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-heading px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-on-vivid px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<Plus className="w-4 h-4" />새 보고서
|
||||
</button>
|
||||
</div>
|
||||
@ -116,7 +116,7 @@ export function ReportManagement() {
|
||||
</div>
|
||||
<div className="text-[11px] text-hint mt-1">증거 {r.evidence}건</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="bg-blue-600 text-heading text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors">한글</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,7 +131,7 @@ export function ReportManagement() {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-label">보고서 미리보기</div>
|
||||
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-heading px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
} from '@/services/kpi';
|
||||
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
||||
import { toDateParam } from '@shared/utils/dateFormat';
|
||||
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-13: 통계·지표·성과 분석 */
|
||||
|
||||
@ -60,7 +62,7 @@ const kpiCols: DataColumn<KpiRow>[] = [
|
||||
width: '60px',
|
||||
align: 'center',
|
||||
render: (v) => (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
|
||||
<Badge intent="success" size="sm">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
@ -69,6 +71,8 @@ const kpiCols: DataColumn<KpiRow>[] = [
|
||||
|
||||
export function Statistics() {
|
||||
const { t } = useTranslation('statistics');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||
@ -205,20 +209,25 @@ export function Statistics() {
|
||||
위반 유형별 분포
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{BY_TYPE.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
|
||||
>
|
||||
<div className="text-lg font-bold text-heading">
|
||||
{item.count}
|
||||
{BY_TYPE.map((item) => {
|
||||
const color = getViolationColor(item.type);
|
||||
const label = getViolationLabel(item.type, tc, lang);
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg border-l-4"
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="text-lg font-bold tabular-nums" style={{ color }}>
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.type}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -14,13 +14,7 @@ import {
|
||||
type PredictionEvent,
|
||||
} from '@/services/event';
|
||||
|
||||
// ─── 위험도 레벨 → 마커 색상 ─────────────────
|
||||
const RISK_MARKER_COLOR: Record<string, string> = {
|
||||
CRITICAL: '#ef4444',
|
||||
HIGH: '#f97316',
|
||||
MEDIUM: '#3b82f6',
|
||||
LOW: '#6b7280',
|
||||
};
|
||||
import { getAlertLevelHex } from '@shared/constants/alertLevels';
|
||||
|
||||
interface MapEvent {
|
||||
id: string;
|
||||
@ -171,7 +165,7 @@ export function LiveMapView() {
|
||||
'ais-vessels',
|
||||
vesselMarkers.map((v): MarkerData => {
|
||||
const level = v.item.algorithms.riskScore.level;
|
||||
const color = RISK_MARKER_COLOR[level] ?? '#6b7280';
|
||||
const color = getAlertLevelHex(level);
|
||||
return {
|
||||
lat: v.lat,
|
||||
lng: v.lng,
|
||||
@ -367,7 +361,7 @@ export function LiveMapView() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-heading font-medium">AI 판단 근거</span>
|
||||
<Badge className="bg-red-500/20 text-red-400 text-[10px]">신뢰도: High</Badge>
|
||||
<Badge intent="critical" size="md">신뢰도: High</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-2 border-red-500 pl-3">
|
||||
|
||||
@ -4,6 +4,7 @@ import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { Map, Shield, Crosshair, AlertTriangle, Eye, Anchor, Ship, Filter, Layers, Target, Clock, MapPin, Bell, Navigation, Info } from 'lucide-react';
|
||||
import { getTrainingZoneIntent, getTrainingZoneHex, getTrainingZoneMeta } from '@shared/constants/trainingZoneTypes';
|
||||
|
||||
/*
|
||||
* 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영
|
||||
@ -140,20 +141,12 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
|
||||
render: v => <Badge className={`border-0 text-[9px] ${v === '발령중' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
|
||||
];
|
||||
|
||||
// ─── 범례 색상 ──────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<string, { bg: string; text: string; label: string; mapColor: string }> = {
|
||||
'해군': { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: '해군 훈련 구역', mapColor: '#eab308' },
|
||||
'공군': { bg: 'bg-pink-500/20', text: 'text-pink-400', label: '공군 훈련 구역', mapColor: '#ec4899' },
|
||||
'육군': { bg: 'bg-green-500/20', text: 'text-green-400', label: '육군 훈련 구역', mapColor: '#22c55e' },
|
||||
'국과연': { bg: 'bg-blue-500/20', text: 'text-blue-400', label: '국방과학연구소', mapColor: '#3b82f6' },
|
||||
'해경': { bg: 'bg-purple-500/20', text: 'text-purple-400', label: '해양경찰청 훈련구역', mapColor: '#a855f7' },
|
||||
};
|
||||
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
|
||||
|
||||
const columns: DataColumn<TrainingZone>[] = [
|
||||
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
|
||||
render: v => { const t = TYPE_COLORS[v as string]; return <Badge className={`border-0 text-[9px] ${t?.bg} ${t?.text}`}>{v as string}</Badge>; } },
|
||||
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ key: 'sea', label: '해역', width: '60px', sortable: true },
|
||||
{ key: 'lat', label: '위도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'lng', label: '경도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
@ -214,7 +207,7 @@ export function MapControl() {
|
||||
const lat = parseDMS(z.lat);
|
||||
const lng = parseDMS(z.lng);
|
||||
if (lat === null || lng === null) return;
|
||||
const color = TYPE_COLORS[z.type]?.mapColor || '#6b7280';
|
||||
const color = getTrainingZoneHex(z.type);
|
||||
const radiusM = parseRadius(z.radius);
|
||||
const isActive = z.status === '활성';
|
||||
parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z });
|
||||
@ -285,12 +278,16 @@ export function MapControl() {
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint font-bold">범례:</span>
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: c.mapColor, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: meta.hex, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 탭 + 해역 필터 */}
|
||||
@ -315,7 +312,7 @@ export function MapControl() {
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
{['', '서해', '남해', '동해', '제주'].map(s => (
|
||||
<button key={s} onClick={() => setSeaFilter(s)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
{s || '전체'}
|
||||
</button>
|
||||
))}
|
||||
@ -346,7 +343,7 @@ export function MapControl() {
|
||||
<span className="text-[10px] text-hint">구분:</span>
|
||||
{NTM_CATEGORIES.map(c => (
|
||||
<button key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -358,7 +355,7 @@ export function MapControl() {
|
||||
<div className="space-y-2">
|
||||
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
|
||||
<div key={n.no} className="flex items-start gap-3 px-3 py-2.5 bg-red-500/5 border border-red-500/10 rounded-lg">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px] shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<Badge intent="critical" size="sm" className="shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-heading font-medium">{n.title}</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{n.detail}</div>
|
||||
@ -397,12 +394,16 @@ export function MapControl() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">훈련구역 범례</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: c.mapColor, borderColor: c.mapColor, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: meta.hex, borderColor: meta.hex, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/50" /><span className="text-[8px] text-hint">EEZ</span></div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
|
||||
|
||||
export function TransferDetection() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading">환적·접촉 탐지</h2>
|
||||
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||
|
||||
@ -14,6 +14,9 @@ import {
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getEvents, type PredictionEvent } from '@/services/event';
|
||||
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ─── 허가 정보 타입 ──────────────────────
|
||||
interface VesselPermitData {
|
||||
@ -47,14 +50,6 @@ async function fetchVesselPermit(mmsi: string): Promise<VesselPermitData | null>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 위험도 레벨 → 색상 매핑 ──────────────
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' },
|
||||
HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' },
|
||||
MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' },
|
||||
LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' },
|
||||
};
|
||||
|
||||
const RIGHT_TOOLS = [
|
||||
{ icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' },
|
||||
{ icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' },
|
||||
@ -152,10 +147,14 @@ export function VesselDetail() {
|
||||
|
||||
useMapLayers(mapRef, buildLayers, []);
|
||||
|
||||
// i18n + 카탈로그
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
// 위험도 점수 바
|
||||
const riskScore = vessel?.algorithms.riskScore.score ?? 0;
|
||||
const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW';
|
||||
const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW;
|
||||
const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel;
|
||||
const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
||||
@ -280,12 +279,12 @@ export function VesselDetail() {
|
||||
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-hint">위험도</span>
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
{riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
{getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className={`text-xl font-bold ${riskConfig.color}`}>
|
||||
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
|
||||
{Math.round(riskScore * 100)}
|
||||
</span>
|
||||
<span className="text-[10px] text-hint">/100</span>
|
||||
@ -341,15 +340,14 @@ export function VesselDetail() {
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((evt) => {
|
||||
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
|
||||
return (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
|
||||
{evt.level}
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
|
||||
<Badge intent="muted" size="xs" className="px-1.5 py-0">
|
||||
{evt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -378,8 +376,8 @@ export function VesselDetail() {
|
||||
<Ship className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
||||
{vessel && (
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
위험도: {riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
위험도: {getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
"realtimeEvent": "Realtime Events",
|
||||
"eventList": "Event List",
|
||||
"mobileService": "Mobile",
|
||||
"shipAgent": "Ship Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ Violation",
|
||||
"darkVessel": "Dark Vessel",
|
||||
"mmsiTampering": "MMSI Tampering",
|
||||
"illegalTransship": "Illegal Transship",
|
||||
"illegalGear": "Illegal Gear",
|
||||
"riskBehavior": "Risk Behavior"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "New",
|
||||
"ACK": "Acknowledged",
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"RESOLVED": "Resolved",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "Capture",
|
||||
"INSPECT": "Inspect",
|
||||
"WARN": "Warn",
|
||||
"DISPERSE": "Disperse",
|
||||
"TRACK": "Track",
|
||||
"EVIDENCE": "Evidence"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "Punished",
|
||||
"REFERRED": "Referred",
|
||||
"WARNED": "Warned",
|
||||
"RELEASED": "Released",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "Available",
|
||||
"ON_PATROL": "On Patrol",
|
||||
"IN_PURSUIT": "In Pursuit",
|
||||
"INSPECTING": "Inspecting",
|
||||
"RETURNING": "Returning",
|
||||
"STANDBY": "Standby",
|
||||
"MAINTENANCE": "Maintenance"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "Critical",
|
||||
"HIGH_CRITICAL": "High~Critical",
|
||||
"HIGH": "High",
|
||||
"MEDIUM_CRITICAL": "Medium~Critical",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "Online",
|
||||
"OFFLINE": "Offline",
|
||||
"SYNCING": "Syncing",
|
||||
"NOT_DEPLOYED": "Not Deployed"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "Unresolved",
|
||||
"REVIEW_REQUIRED": "Review Required",
|
||||
"MANUAL_CONFIRMED": "Manually Confirmed"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "Active",
|
||||
"COMPLETED": "Completed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "Deployed",
|
||||
"APPROVED": "Approved",
|
||||
"TESTING": "Testing",
|
||||
"DRAFT": "Draft"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "Fleet",
|
||||
"GEAR_IN_ZONE": "In-Zone Gear",
|
||||
"GEAR_OUT_ZONE": "Out-Zone Gear"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS Full Block",
|
||||
"MMSI_SPOOFING": "MMSI Spoofing",
|
||||
"LONG_LOSS": "Long Loss",
|
||||
"INTERMITTENT": "Intermittent",
|
||||
"SPEED_ANOMALY": "Speed Anomaly"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "Active",
|
||||
"PENDING": "Pending",
|
||||
"LOCKED": "Locked",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "Success",
|
||||
"FAILED": "Failed",
|
||||
"LOCKED": "Locked"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "Valid",
|
||||
"PENDING": "Pending",
|
||||
"EXPIRED": "Expired",
|
||||
"UNLICENSED": "Unlicensed"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "Normal",
|
||||
"CHECKING": "Checking",
|
||||
"SUSPECT_ILLEGAL": "Suspect Illegal"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "Tracking",
|
||||
"WATCHING": "Watching",
|
||||
"CHECKING": "Checking",
|
||||
"NORMAL": "Normal"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "Safe",
|
||||
"SUSPECT": "Suspect",
|
||||
"WARNING": "Warning"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "OK",
|
||||
"WARNING": "Warning",
|
||||
"ERROR": "Error"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "Navy Zone",
|
||||
"AIRFORCE": "Airforce Zone",
|
||||
"ARMY": "Army Zone",
|
||||
"ADD": "ADD",
|
||||
"KCG": "KCG"
|
||||
},
|
||||
"action": {
|
||||
"search": "Search",
|
||||
"save": "Save",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
"realtimeEvent": "실시간 이벤트",
|
||||
"eventList": "이벤트 목록",
|
||||
"mobileService": "모바일 서비스",
|
||||
"shipAgent": "함정 Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "보통",
|
||||
"low": "낮음"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ 침범",
|
||||
"darkVessel": "다크베셀",
|
||||
"mmsiTampering": "MMSI 변조",
|
||||
"illegalTransship": "불법 환적",
|
||||
"illegalGear": "불법 어구",
|
||||
"riskBehavior": "위험 행동"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "신규",
|
||||
"ACK": "확인",
|
||||
"IN_PROGRESS": "처리중",
|
||||
"RESOLVED": "완료",
|
||||
"FALSE_POSITIVE": "오탐"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "나포",
|
||||
"INSPECT": "검문",
|
||||
"WARN": "경고",
|
||||
"DISPERSE": "퇴거",
|
||||
"TRACK": "추적",
|
||||
"EVIDENCE": "증거수집"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "처벌",
|
||||
"REFERRED": "수사의뢰",
|
||||
"WARNED": "경고",
|
||||
"RELEASED": "훈방",
|
||||
"FALSE_POSITIVE": "오탐(정상)"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "가용",
|
||||
"ON_PATROL": "초계중",
|
||||
"IN_PURSUIT": "추적중",
|
||||
"INSPECTING": "검문중",
|
||||
"RETURNING": "귀항중",
|
||||
"STANDBY": "대기",
|
||||
"MAINTENANCE": "정비중"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "심각",
|
||||
"HIGH_CRITICAL": "높음~심각",
|
||||
"HIGH": "높음",
|
||||
"MEDIUM_CRITICAL": "보통~심각",
|
||||
"MEDIUM": "보통",
|
||||
"LOW": "낮음",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "온라인",
|
||||
"OFFLINE": "오프라인",
|
||||
"SYNCING": "동기화중",
|
||||
"NOT_DEPLOYED": "미배포"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "미해결",
|
||||
"REVIEW_REQUIRED": "검토 필요",
|
||||
"MANUAL_CONFIRMED": "수동 확정"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "진행중",
|
||||
"COMPLETED": "완료",
|
||||
"CANCELLED": "취소"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "배포됨",
|
||||
"APPROVED": "승인",
|
||||
"TESTING": "테스트",
|
||||
"DRAFT": "초안"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "선단",
|
||||
"GEAR_IN_ZONE": "구역 내 어구",
|
||||
"GEAR_OUT_ZONE": "구역 외 어구"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS 완전차단",
|
||||
"MMSI_SPOOFING": "MMSI 변조 의심",
|
||||
"LONG_LOSS": "장기 소실",
|
||||
"INTERMITTENT": "신호 간헐송출",
|
||||
"SPEED_ANOMALY": "속도 이상"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "활성",
|
||||
"PENDING": "승인 대기",
|
||||
"LOCKED": "잠금",
|
||||
"INACTIVE": "비활성"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "성공",
|
||||
"FAILED": "실패",
|
||||
"LOCKED": "계정 잠금"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "유효",
|
||||
"PENDING": "확인 중",
|
||||
"EXPIRED": "만료",
|
||||
"UNLICENSED": "무허가"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "정상",
|
||||
"CHECKING": "확인 중",
|
||||
"SUSPECT_ILLEGAL": "불법 의심"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "추적중",
|
||||
"WATCHING": "감시중",
|
||||
"CHECKING": "확인중",
|
||||
"NORMAL": "정상"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "양호",
|
||||
"SUSPECT": "의심",
|
||||
"WARNING": "경고"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "정상",
|
||||
"WARNING": "경고",
|
||||
"ERROR": "오류"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "해군 훈련 구역",
|
||||
"AIRFORCE": "공군 훈련 구역",
|
||||
"ARMY": "육군 훈련 구역",
|
||||
"ADD": "국방과학연구소",
|
||||
"KCG": "해양경찰청"
|
||||
},
|
||||
"action": {
|
||||
"search": "검색",
|
||||
"save": "저장",
|
||||
|
||||
@ -17,29 +17,42 @@ export const cardVariants = cva('rounded-xl border border-border', {
|
||||
defaultVariants: { variant: 'default' },
|
||||
});
|
||||
|
||||
/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 */
|
||||
/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합
|
||||
*
|
||||
* 가독성 정책:
|
||||
* - 배경: 색상별 진한 솔리드(-400) — 명확한 분류 식별
|
||||
* - 텍스트: 시맨틱 토큰 text-on-bright (theme.css = #0f172a) — 테마 무관 일관 가독성
|
||||
* - 보더: 같은 색상 계열 -600 (배경 강조)
|
||||
* - 가운데 정렬
|
||||
* - 폰트 크기: rem 기반 (root font-size 대비 비율) — 화면 비율에 따라 자동 조정
|
||||
*
|
||||
* className override는 cn(tailwind-merge) 덕분에 같은 그룹(text-color/font-size/bg) 충돌 시
|
||||
* 마지막 명시값이 적용 — !important 없이 의도된 override만 허용.
|
||||
*/
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors text-center text-on-bright',
|
||||
{
|
||||
variants: {
|
||||
intent: {
|
||||
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
high: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
success: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
muted: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
cyan: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
critical: 'bg-red-400 border-red-600',
|
||||
high: 'bg-orange-400 border-orange-600',
|
||||
warning: 'bg-yellow-400 border-yellow-600',
|
||||
info: 'bg-blue-400 border-blue-600',
|
||||
success: 'bg-green-400 border-green-600',
|
||||
muted: 'bg-slate-400 border-slate-600',
|
||||
purple: 'bg-purple-400 border-purple-600',
|
||||
cyan: 'bg-cyan-400 border-cyan-600',
|
||||
},
|
||||
// rem 기반 — root font-size 대비 비율, 화면 비율 변경 시 자동 조정
|
||||
// xs ≈ 11px, sm ≈ 12px, md ≈ 13px, lg ≈ 14px (root 14px 기준)
|
||||
size: {
|
||||
xs: 'text-[8px]',
|
||||
sm: 'text-[9px]',
|
||||
md: 'text-[10px]',
|
||||
lg: 'text-[11px]',
|
||||
xs: 'text-[0.6875rem] leading-tight',
|
||||
sm: 'text-[0.75rem] leading-tight',
|
||||
md: 'text-[0.8125rem] leading-tight',
|
||||
lg: 'text-[0.875rem] leading-tight',
|
||||
},
|
||||
},
|
||||
defaultVariants: { intent: 'info', size: 'md' },
|
||||
defaultVariants: { intent: 'info', size: 'sm' },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
35
frontend/src/lib/utils/cn.ts
Normal file
35
frontend/src/lib/utils/cn.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* className 병합 유틸 — tailwind-merge로 충돌 자동 해결.
|
||||
*
|
||||
* cva()의 base class와 컴포넌트 className prop을 합칠 때 사용.
|
||||
* 같은 그룹(text color, padding, bg 등) 클래스가 여러 개면 마지막 것만 적용.
|
||||
*
|
||||
* 사용 예:
|
||||
* cn(badgeVariants({ intent }), className)
|
||||
* → className에서 들어오는 text-red-400이 base의 text-on-bright을 override (마지막 우선)
|
||||
*
|
||||
* 시맨틱 토큰 (text-on-vivid, text-on-bright, text-heading 등)을 tailwind-merge가
|
||||
* text-color 그룹으로 인식하도록 extendTailwindMerge로 확장.
|
||||
*/
|
||||
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
// 프로젝트 시맨틱 텍스트 색상 (theme.css 정의) — text-* 그룹 충돌 해결
|
||||
'text-color': [
|
||||
'text-heading',
|
||||
'text-label',
|
||||
'text-hint',
|
||||
'text-on-vivid',
|
||||
'text-on-bright',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@ -68,6 +68,8 @@ export interface RoleWithPermissions {
|
||||
roleCd: string;
|
||||
roleNm: string;
|
||||
roleDc: string;
|
||||
/** UI 표기 색상 (#RRGGBB). NULL이면 프론트 기본 팔레트에서 결정 */
|
||||
colorHex: string | null;
|
||||
dfltYn: string;
|
||||
builtinYn: string;
|
||||
permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[];
|
||||
@ -95,8 +97,13 @@ export function fetchPermTree() {
|
||||
return apiGet<PermTreeNode[]>('/perm-tree');
|
||||
}
|
||||
|
||||
export function fetchRoles() {
|
||||
return apiGet<RoleWithPermissions[]>('/roles');
|
||||
import { updateRoleColorCache } from '@shared/constants/userRoles';
|
||||
|
||||
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
||||
const roles = await apiGet<RoleWithPermissions[]>('/roles');
|
||||
// 역할 색상 캐시 즉시 갱신 → 모든 사용처 자동 반영
|
||||
updateRoleColorCache(roles);
|
||||
return roles;
|
||||
}
|
||||
|
||||
// ─── 역할 CRUD ───────────────────────────────
|
||||
@ -104,12 +111,14 @@ export interface RoleCreatePayload {
|
||||
roleCd: string;
|
||||
roleNm: string;
|
||||
roleDc?: string;
|
||||
colorHex?: string;
|
||||
dfltYn?: string;
|
||||
}
|
||||
|
||||
export interface RoleUpdatePayload {
|
||||
roleNm?: string;
|
||||
roleDc?: string;
|
||||
colorHex?: string;
|
||||
dfltYn?: string;
|
||||
}
|
||||
|
||||
|
||||
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||
|
||||
interface ColorPickerProps {
|
||||
/** 현재 선택된 hex 색상 (#RRGGBB) */
|
||||
value: string | null | undefined;
|
||||
/** 색상 변경 콜백 */
|
||||
onChange: (hex: string) => void;
|
||||
/** 사용자 지정 팔레트. 미지정 시 ROLE_DEFAULT_PALETTE 사용 */
|
||||
palette?: string[];
|
||||
/** 직접 입력 허용 여부 (default: true) */
|
||||
allowCustom?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 정의된 팔레트에서 색상을 선택하는 컴포넌트.
|
||||
* 사용자 정의 hex 입력도 지원.
|
||||
*
|
||||
* 사용처: 역할 생성/수정 다이얼로그
|
||||
*/
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
palette = ROLE_DEFAULT_PALETTE,
|
||||
allowCustom = true,
|
||||
className = '',
|
||||
label,
|
||||
}: ColorPickerProps) {
|
||||
const [customHex, setCustomHex] = useState<string>(value ?? '');
|
||||
|
||||
const handleCustomChange = (hex: string) => {
|
||||
setCustomHex(hex);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
onChange(hex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{label && <div className="text-[10px] text-hint">{label}</div>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{palette.map((color) => {
|
||||
const selected = value?.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setCustomHex(color);
|
||||
}}
|
||||
className={`w-6 h-6 rounded-md border-2 transition-all flex items-center justify-center ${
|
||||
selected ? 'border-white scale-110' : 'border-transparent hover:border-white/40'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 text-slate-900" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? '#808080'}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCustomHex(e.target.value);
|
||||
}}
|
||||
className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer"
|
||||
title="색상 직접 선택"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customHex}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
placeholder="#RRGGBB"
|
||||
maxLength={7}
|
||||
className="flex-1 px-2 py-1 bg-surface-overlay border border-border rounded text-[11px] text-label font-mono focus:outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
export interface DataColumn<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 고정 너비 (설정 시 가변 비활성) */
|
||||
/** 선호 최소 너비. 컨텐츠가 더 길면 자동 확장. */
|
||||
width?: string;
|
||||
/** 최소 너비 (가변 모드에서 적용) */
|
||||
/** 명시 최소 너비 (width와 사실상 동일하나 의미 강조용) */
|
||||
minWidth?: string;
|
||||
/** 최대 너비 (가변 모드에서 적용) */
|
||||
/** 최대 너비 (초과 시 ellipsis 트런케이트) */
|
||||
maxWidth?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
sortable?: boolean;
|
||||
@ -144,12 +144,14 @@ export function DataTable<T extends Record<string, unknown>>({
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => {
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
|
||||
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
|
||||
// width = 선호 최소 너비. 컨텐츠가 더 길면 자동 확장.
|
||||
if (col.width) cellStyle.minWidth = col.width;
|
||||
if (col.minWidth) cellStyle.minWidth = col.minWidth;
|
||||
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap ${
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${col.sortable !== false ? 'cursor-pointer hover:text-label select-none' : ''}`}
|
||||
style={cellStyle}
|
||||
@ -188,19 +190,23 @@ export function DataTable<T extends Record<string, unknown>>({
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
|
||||
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
|
||||
if (col.width) cellStyle.minWidth = col.width;
|
||||
if (col.minWidth) cellStyle.minWidth = col.minWidth;
|
||||
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
|
||||
const rawValue = row[col.key];
|
||||
const titleText = rawValue != null ? String(rawValue) : '';
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-4 py-2 whitespace-nowrap ${
|
||||
className={`px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={cellStyle}
|
||||
title={titleText}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, page * pageSize + i)
|
||||
: <span className="text-label">{row[col.key] != null ? String(row[col.key]) : ''}</span>
|
||||
? col.render(rawValue, row, page * pageSize + i)
|
||||
: <span className="text-label">{titleText}</span>
|
||||
}
|
||||
</td>
|
||||
);
|
||||
|
||||
@ -61,7 +61,7 @@ export function ExcelExport({
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${
|
||||
exported
|
||||
? 'bg-green-600/20 text-green-400 border border-green-500/30'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-on-vivid hover:border-border'
|
||||
} ${className}`}
|
||||
>
|
||||
{exported ? <Check className="w-3 h-3" /> : <FileSpreadsheet className="w-3 h-3" />}
|
||||
|
||||
@ -145,7 +145,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end">
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
|
||||
@ -56,7 +56,7 @@ export function Pagination({ page, totalPages, totalItems, pageSize, onPageChang
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`min-w-[24px] h-6 px-1 rounded text-[10px] font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-heading'
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -33,8 +33,8 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN
|
||||
disabled={disabled || state !== 'idle'}
|
||||
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${
|
||||
state === 'done'
|
||||
? 'bg-green-600 text-heading'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-heading'
|
||||
? 'bg-green-600 text-on-vivid'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-on-vivid'
|
||||
} ${className}`}
|
||||
>
|
||||
{state === 'saving' ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type HTMLAttributes } from 'react';
|
||||
import { badgeVariants, type BadgeIntent, type BadgeSize } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 의미 기반 색상 (기존 variant 대체) */
|
||||
@ -17,11 +18,13 @@ const LEGACY_MAP: Record<string, BadgeIntent> = {
|
||||
outline: 'muted',
|
||||
};
|
||||
|
||||
export function Badge({ className = '', intent, size, variant, ...props }: BadgeProps) {
|
||||
export function Badge({ className, intent, size, variant, ...props }: BadgeProps) {
|
||||
const resolvedIntent = intent ?? (variant ? LEGACY_MAP[variant] : undefined);
|
||||
// cn() = clsx + tailwind-merge: 같은 그룹(text-color, bg, padding 등) 충돌 시
|
||||
// 마지막 className이 우선 → base 클래스의 일관 정책을 className에서 명시적으로만 override 가능
|
||||
return (
|
||||
<div
|
||||
className={`${badgeVariants({ intent: resolvedIntent, size })} ${className}`}
|
||||
className={cn(badgeVariants({ intent: resolvedIntent, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
125
frontend/src/shared/constants/alertLevels.ts
Normal file
125
frontend/src/shared/constants/alertLevels.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 위험도/알림 등급 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 EVENT_LEVEL (V008 시드).
|
||||
* 향후 `GET /api/code-master?groupCode=EVENT_LEVEL`로 fetch 예정.
|
||||
*
|
||||
* **공통 Badge 컴포넌트와 연동**: 가능한 한 `<Badge intent={getAlertLevelIntent(level)}>` 사용.
|
||||
* 카드 컨테이너 등 분리된 클래스가 필요한 경우만 `ALERT_LEVELS.classes` 사용.
|
||||
*
|
||||
* 사용처: Dashboard, MonitoringDashboard, EventList, LiveMapView, VesselDetail,
|
||||
* MobileService, ChinaFishing 등 모든 위험도 배지 / 알림 / 마커
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
export interface AlertLevelMeta {
|
||||
code: AlertLevel;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
/** 공통 Badge 컴포넌트 intent (badgeVariants 와 동기화) */
|
||||
intent: BadgeIntent;
|
||||
/** Tailwind 클래스 묶음 — 카드/컨테이너 전용 (배지가 아님)
|
||||
* 배지에는 `<Badge intent={meta.intent}>` 사용 권장.
|
||||
* 여기서 bg/border는 약한 알파 — 카드 배경에 텍스트가 묻히지 않도록.
|
||||
*/
|
||||
classes: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
dot: string;
|
||||
/** 진한 배경 (액션 버튼 등) */
|
||||
bgSolid: string;
|
||||
};
|
||||
/** hex 색상 (지도 마커, 차트, 인라인 style 용) */
|
||||
hex: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ALERT_LEVELS: Record<AlertLevel, AlertLevelMeta> = {
|
||||
CRITICAL: {
|
||||
code: 'CRITICAL',
|
||||
i18nKey: 'alert.critical',
|
||||
fallback: { ko: '심각', en: 'Critical' },
|
||||
intent: 'critical',
|
||||
classes: {
|
||||
bg: 'bg-red-500/10',
|
||||
text: 'text-red-300',
|
||||
border: 'border-red-500/30',
|
||||
dot: 'bg-red-500',
|
||||
bgSolid: 'bg-red-500',
|
||||
},
|
||||
hex: '#ef4444',
|
||||
order: 1,
|
||||
},
|
||||
HIGH: {
|
||||
code: 'HIGH',
|
||||
i18nKey: 'alert.high',
|
||||
fallback: { ko: '높음', en: 'High' },
|
||||
intent: 'high',
|
||||
classes: {
|
||||
bg: 'bg-orange-500/10',
|
||||
text: 'text-orange-300',
|
||||
border: 'border-orange-500/30',
|
||||
dot: 'bg-orange-500',
|
||||
bgSolid: 'bg-orange-500',
|
||||
},
|
||||
hex: '#f97316',
|
||||
order: 2,
|
||||
},
|
||||
MEDIUM: {
|
||||
code: 'MEDIUM',
|
||||
i18nKey: 'alert.medium',
|
||||
fallback: { ko: '보통', en: 'Medium' },
|
||||
intent: 'warning',
|
||||
classes: {
|
||||
bg: 'bg-yellow-500/10',
|
||||
text: 'text-yellow-300',
|
||||
border: 'border-yellow-500/30',
|
||||
dot: 'bg-yellow-500',
|
||||
bgSolid: 'bg-yellow-500',
|
||||
},
|
||||
hex: '#eab308',
|
||||
order: 3,
|
||||
},
|
||||
LOW: {
|
||||
code: 'LOW',
|
||||
i18nKey: 'alert.low',
|
||||
fallback: { ko: '낮음', en: 'Low' },
|
||||
intent: 'info',
|
||||
classes: {
|
||||
bg: 'bg-blue-500/10',
|
||||
text: 'text-blue-300',
|
||||
border: 'border-blue-500/30',
|
||||
dot: 'bg-blue-500',
|
||||
bgSolid: 'bg-blue-500',
|
||||
},
|
||||
hex: '#3b82f6',
|
||||
order: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export function getAlertLevelMeta(level: string): AlertLevelMeta | undefined {
|
||||
return ALERT_LEVELS[level as AlertLevel];
|
||||
}
|
||||
|
||||
export function getAlertLevelLabel(
|
||||
level: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getAlertLevelMeta(level);
|
||||
if (!meta) return level;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getAlertLevelHex(level: string): string {
|
||||
return getAlertLevelMeta(level)?.hex ?? '#6b7280';
|
||||
}
|
||||
|
||||
/** 공통 Badge intent 매핑 */
|
||||
export function getAlertLevelIntent(level: string): BadgeIntent {
|
||||
return getAlertLevelMeta(level)?.intent ?? 'muted';
|
||||
}
|
||||
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 데이터 연결/신호 상태 카탈로그
|
||||
*
|
||||
* 사용처: DataHub signal heatmap
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type ConnectionStatus = 'OK' | 'WARNING' | 'ERROR';
|
||||
|
||||
export const CONNECTION_STATUSES: Record<ConnectionStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
|
||||
OK: {
|
||||
i18nKey: 'connectionStatus.OK',
|
||||
fallback: { ko: '정상', en: 'OK' },
|
||||
intent: 'success',
|
||||
hex: '#22c55e',
|
||||
},
|
||||
WARNING: {
|
||||
i18nKey: 'connectionStatus.WARNING',
|
||||
fallback: { ko: '경고', en: 'Warning' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
ERROR: {
|
||||
i18nKey: 'connectionStatus.ERROR',
|
||||
fallback: { ko: '오류', en: 'Error' },
|
||||
intent: 'critical',
|
||||
hex: '#ef4444',
|
||||
},
|
||||
};
|
||||
|
||||
/** 소문자 호환 (DataHub 'ok' | 'warn' | 'error') */
|
||||
const LEGACY: Record<string, ConnectionStatus> = {
|
||||
ok: 'OK',
|
||||
warn: 'WARNING',
|
||||
warning: 'WARNING',
|
||||
error: 'ERROR',
|
||||
};
|
||||
|
||||
export function getConnectionStatusMeta(s: string) {
|
||||
if (CONNECTION_STATUSES[s as ConnectionStatus]) return CONNECTION_STATUSES[s as ConnectionStatus];
|
||||
const code = LEGACY[s.toLowerCase()];
|
||||
return code ? CONNECTION_STATUSES[code] : CONNECTION_STATUSES.OK;
|
||||
}
|
||||
|
||||
export function getConnectionStatusHex(s: string): string {
|
||||
return getConnectionStatusMeta(s).hex;
|
||||
}
|
||||
|
||||
export function getConnectionStatusIntent(s: string): BadgeIntent {
|
||||
return getConnectionStatusMeta(s).intent;
|
||||
}
|
||||
91
frontend/src/shared/constants/darkVesselPatterns.ts
Normal file
91
frontend/src/shared/constants/darkVesselPatterns.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 다크베셀 탐지 패턴 카탈로그
|
||||
*
|
||||
* SSOT: backend dark_pattern enum (V008 시드 DARK_PATTERN 그룹)
|
||||
* 사용처: DarkVesselDetection
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type DarkVesselPattern =
|
||||
| 'AIS_FULL_BLOCK' // AIS 완전차단
|
||||
| 'MMSI_SPOOFING' // MMSI 변조 의심
|
||||
| 'LONG_LOSS' // 장기소실
|
||||
| 'INTERMITTENT' // 신호 간헐송출
|
||||
| 'SPEED_ANOMALY'; // 속도 이상
|
||||
|
||||
interface DarkVesselPatternMeta {
|
||||
code: DarkVesselPattern;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const DARK_VESSEL_PATTERNS: Record<DarkVesselPattern, DarkVesselPatternMeta> = {
|
||||
AIS_FULL_BLOCK: {
|
||||
code: 'AIS_FULL_BLOCK',
|
||||
i18nKey: 'darkPattern.AIS_FULL_BLOCK',
|
||||
fallback: { ko: 'AIS 완전차단', en: 'AIS Full Block' },
|
||||
intent: 'critical',
|
||||
hex: '#ef4444',
|
||||
},
|
||||
MMSI_SPOOFING: {
|
||||
code: 'MMSI_SPOOFING',
|
||||
i18nKey: 'darkPattern.MMSI_SPOOFING',
|
||||
fallback: { ko: 'MMSI 변조 의심', en: 'MMSI Spoofing' },
|
||||
intent: 'high',
|
||||
hex: '#f97316',
|
||||
},
|
||||
LONG_LOSS: {
|
||||
code: 'LONG_LOSS',
|
||||
i18nKey: 'darkPattern.LONG_LOSS',
|
||||
fallback: { ko: '장기 소실', en: 'Long Loss' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
INTERMITTENT: {
|
||||
code: 'INTERMITTENT',
|
||||
i18nKey: 'darkPattern.INTERMITTENT',
|
||||
fallback: { ko: '신호 간헐송출', en: 'Intermittent' },
|
||||
intent: 'purple',
|
||||
hex: '#a855f7',
|
||||
},
|
||||
SPEED_ANOMALY: {
|
||||
code: 'SPEED_ANOMALY',
|
||||
i18nKey: 'darkPattern.SPEED_ANOMALY',
|
||||
fallback: { ko: '속도 이상', en: 'Speed Anomaly' },
|
||||
intent: 'cyan',
|
||||
hex: '#06b6d4',
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨 호환 매핑 (mock 데이터에 한글이 들어있어서) */
|
||||
const LEGACY_KO: Record<string, DarkVesselPattern> = {
|
||||
'AIS 완전차단': 'AIS_FULL_BLOCK',
|
||||
'MMSI 변조 의심': 'MMSI_SPOOFING',
|
||||
'장기소실': 'LONG_LOSS',
|
||||
'장기 소실': 'LONG_LOSS',
|
||||
'신호 간헐송출': 'INTERMITTENT',
|
||||
'속도 이상': 'SPEED_ANOMALY',
|
||||
};
|
||||
|
||||
export function getDarkVesselPatternMeta(p: string): DarkVesselPatternMeta | undefined {
|
||||
if (DARK_VESSEL_PATTERNS[p as DarkVesselPattern]) return DARK_VESSEL_PATTERNS[p as DarkVesselPattern];
|
||||
const code = LEGACY_KO[p];
|
||||
return code ? DARK_VESSEL_PATTERNS[code] : undefined;
|
||||
}
|
||||
|
||||
export function getDarkVesselPatternIntent(p: string): BadgeIntent {
|
||||
return getDarkVesselPatternMeta(p)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getDarkVesselPatternLabel(
|
||||
p: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return p;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
74
frontend/src/shared/constants/deviceStatuses.ts
Normal file
74
frontend/src/shared/constants/deviceStatuses.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 디바이스/Agent 상태 카탈로그
|
||||
*
|
||||
* 함정 Agent, 외부 시스템 연결 등 디바이스의 운영 상태 표기.
|
||||
*
|
||||
* 사용처: ShipAgent, DataHub agent 상태, ExternalService 등
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type DeviceStatus = 'ONLINE' | 'OFFLINE' | 'NOT_DEPLOYED' | 'SYNCING';
|
||||
|
||||
export interface DeviceStatusMeta {
|
||||
code: DeviceStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const DEVICE_STATUSES: Record<DeviceStatus, DeviceStatusMeta> = {
|
||||
ONLINE: {
|
||||
code: 'ONLINE',
|
||||
i18nKey: 'deviceStatus.ONLINE',
|
||||
fallback: { ko: '온라인', en: 'Online' },
|
||||
intent: 'success',
|
||||
},
|
||||
SYNCING: {
|
||||
code: 'SYNCING',
|
||||
i18nKey: 'deviceStatus.SYNCING',
|
||||
fallback: { ko: '동기화중', en: 'Syncing' },
|
||||
intent: 'info',
|
||||
},
|
||||
OFFLINE: {
|
||||
code: 'OFFLINE',
|
||||
i18nKey: 'deviceStatus.OFFLINE',
|
||||
fallback: { ko: '오프라인', en: 'Offline' },
|
||||
intent: 'critical',
|
||||
},
|
||||
NOT_DEPLOYED: {
|
||||
code: 'NOT_DEPLOYED',
|
||||
i18nKey: 'deviceStatus.NOT_DEPLOYED',
|
||||
fallback: { ko: '미배포', en: 'Not Deployed' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨도 키로 받음 (mock 호환) */
|
||||
const LEGACY_KO: Record<string, DeviceStatus> = {
|
||||
'온라인': 'ONLINE',
|
||||
'오프라인': 'OFFLINE',
|
||||
'미배포': 'NOT_DEPLOYED',
|
||||
'동기화중': 'SYNCING',
|
||||
'동기화 중': 'SYNCING',
|
||||
};
|
||||
|
||||
export function getDeviceStatusMeta(status: string): DeviceStatusMeta | undefined {
|
||||
if (DEVICE_STATUSES[status as DeviceStatus]) return DEVICE_STATUSES[status as DeviceStatus];
|
||||
const code = LEGACY_KO[status];
|
||||
return code ? DEVICE_STATUSES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getDeviceStatusIntent(status: string): BadgeIntent {
|
||||
return getDeviceStatusMeta(status)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getDeviceStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getDeviceStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
83
frontend/src/shared/constants/enforcementActions.ts
Normal file
83
frontend/src/shared/constants/enforcementActions.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 단속 조치 코드 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 ENFORCEMENT_ACTION (V008 시드).
|
||||
* 백엔드 EnforcementRecord.action enum.
|
||||
*
|
||||
* 사용처: EnforcementHistory(조치 컬럼), 단속 등록 폼
|
||||
*/
|
||||
|
||||
export type EnforcementAction =
|
||||
| 'CAPTURE'
|
||||
| 'INSPECT'
|
||||
| 'WARN'
|
||||
| 'DISPERSE'
|
||||
| 'TRACK'
|
||||
| 'EVIDENCE';
|
||||
|
||||
export interface EnforcementActionMeta {
|
||||
code: EnforcementAction;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
hex: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ENFORCEMENT_ACTIONS: Record<EnforcementAction, EnforcementActionMeta> = {
|
||||
CAPTURE: {
|
||||
code: 'CAPTURE',
|
||||
i18nKey: 'enforcementAction.CAPTURE',
|
||||
fallback: { ko: '나포', en: 'Capture' },
|
||||
hex: '#ef4444',
|
||||
order: 1,
|
||||
},
|
||||
INSPECT: {
|
||||
code: 'INSPECT',
|
||||
i18nKey: 'enforcementAction.INSPECT',
|
||||
fallback: { ko: '검문', en: 'Inspect' },
|
||||
hex: '#f59e0b',
|
||||
order: 2,
|
||||
},
|
||||
WARN: {
|
||||
code: 'WARN',
|
||||
i18nKey: 'enforcementAction.WARN',
|
||||
fallback: { ko: '경고', en: 'Warn' },
|
||||
hex: '#3b82f6',
|
||||
order: 3,
|
||||
},
|
||||
DISPERSE: {
|
||||
code: 'DISPERSE',
|
||||
i18nKey: 'enforcementAction.DISPERSE',
|
||||
fallback: { ko: '퇴거', en: 'Disperse' },
|
||||
hex: '#8b5cf6',
|
||||
order: 4,
|
||||
},
|
||||
TRACK: {
|
||||
code: 'TRACK',
|
||||
i18nKey: 'enforcementAction.TRACK',
|
||||
fallback: { ko: '추적', en: 'Track' },
|
||||
hex: '#06b6d4',
|
||||
order: 5,
|
||||
},
|
||||
EVIDENCE: {
|
||||
code: 'EVIDENCE',
|
||||
i18nKey: 'enforcementAction.EVIDENCE',
|
||||
fallback: { ko: '증거수집', en: 'Evidence' },
|
||||
hex: '#64748b',
|
||||
order: 6,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnforcementActionMeta(action: string): EnforcementActionMeta | undefined {
|
||||
return ENFORCEMENT_ACTIONS[action as EnforcementAction];
|
||||
}
|
||||
|
||||
export function getEnforcementActionLabel(
|
||||
action: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEnforcementActionMeta(action);
|
||||
if (!meta) return action;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
79
frontend/src/shared/constants/enforcementResults.ts
Normal file
79
frontend/src/shared/constants/enforcementResults.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 단속 결과 코드 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 ENFORCEMENT_RESULT (V008 시드).
|
||||
* 백엔드 EnforcementRecord.result enum.
|
||||
*
|
||||
* 사용처: EnforcementHistory(결과 컬럼), 단속 통계
|
||||
*/
|
||||
|
||||
export type EnforcementResult =
|
||||
| 'PUNISHED'
|
||||
| 'WARNED'
|
||||
| 'RELEASED'
|
||||
| 'REFERRED'
|
||||
| 'FALSE_POSITIVE';
|
||||
|
||||
export interface EnforcementResultMeta {
|
||||
code: EnforcementResult;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ENFORCEMENT_RESULTS: Record<EnforcementResult, EnforcementResultMeta> = {
|
||||
PUNISHED: {
|
||||
code: 'PUNISHED',
|
||||
i18nKey: 'enforcementResult.PUNISHED',
|
||||
fallback: { ko: '처벌', en: 'Punished' },
|
||||
classes: 'bg-red-500/20 text-red-400',
|
||||
order: 1,
|
||||
},
|
||||
REFERRED: {
|
||||
code: 'REFERRED',
|
||||
i18nKey: 'enforcementResult.REFERRED',
|
||||
fallback: { ko: '수사의뢰', en: 'Referred' },
|
||||
classes: 'bg-purple-500/20 text-purple-400',
|
||||
order: 2,
|
||||
},
|
||||
WARNED: {
|
||||
code: 'WARNED',
|
||||
i18nKey: 'enforcementResult.WARNED',
|
||||
fallback: { ko: '경고', en: 'Warned' },
|
||||
classes: 'bg-yellow-500/20 text-yellow-400',
|
||||
order: 3,
|
||||
},
|
||||
RELEASED: {
|
||||
code: 'RELEASED',
|
||||
i18nKey: 'enforcementResult.RELEASED',
|
||||
fallback: { ko: '훈방', en: 'Released' },
|
||||
classes: 'bg-green-500/20 text-green-400',
|
||||
order: 4,
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'enforcementResult.FALSE_POSITIVE',
|
||||
fallback: { ko: '오탐(정상)', en: 'False Positive' },
|
||||
classes: 'bg-muted text-muted-foreground',
|
||||
order: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnforcementResultMeta(result: string): EnforcementResultMeta | undefined {
|
||||
return ENFORCEMENT_RESULTS[result as EnforcementResult];
|
||||
}
|
||||
|
||||
export function getEnforcementResultLabel(
|
||||
result: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEnforcementResultMeta(result);
|
||||
if (!meta) return result;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getEnforcementResultClasses(result: string): string {
|
||||
return getEnforcementResultMeta(result)?.classes ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
90
frontend/src/shared/constants/engineSeverities.ts
Normal file
90
frontend/src/shared/constants/engineSeverities.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI 엔진 심각도 카탈로그
|
||||
*
|
||||
* 일반 위험도(AlertLevel)와 다른 별도 분류 체계.
|
||||
* 단일 값(CRITICAL, HIGH 등) 외에도 **범위 표기**를 지원: 'HIGH~CRITICAL', 'MEDIUM~CRITICAL'.
|
||||
*
|
||||
* 사용처: AIModelManagement (탐지 엔진별 심각도 표기)
|
||||
*
|
||||
* 향후 확장 가능: prediction_engine 메타데이터, MLOps 모델별 임계치 정보
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type EngineSeverity =
|
||||
| 'CRITICAL'
|
||||
| 'HIGH~CRITICAL'
|
||||
| 'HIGH'
|
||||
| 'MEDIUM~CRITICAL'
|
||||
| 'MEDIUM'
|
||||
| 'LOW'
|
||||
| '-';
|
||||
|
||||
interface EngineSeverityMeta {
|
||||
code: EngineSeverity;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const ENGINE_SEVERITIES: Record<EngineSeverity, EngineSeverityMeta> = {
|
||||
'CRITICAL': {
|
||||
code: 'CRITICAL',
|
||||
i18nKey: 'engineSeverity.CRITICAL',
|
||||
fallback: { ko: '심각', en: 'Critical' },
|
||||
intent: 'critical',
|
||||
},
|
||||
'HIGH~CRITICAL': {
|
||||
code: 'HIGH~CRITICAL',
|
||||
i18nKey: 'engineSeverity.HIGH_CRITICAL',
|
||||
fallback: { ko: '높음~심각', en: 'High~Critical' },
|
||||
intent: 'critical',
|
||||
},
|
||||
'HIGH': {
|
||||
code: 'HIGH',
|
||||
i18nKey: 'engineSeverity.HIGH',
|
||||
fallback: { ko: '높음', en: 'High' },
|
||||
intent: 'high',
|
||||
},
|
||||
'MEDIUM~CRITICAL': {
|
||||
code: 'MEDIUM~CRITICAL',
|
||||
i18nKey: 'engineSeverity.MEDIUM_CRITICAL',
|
||||
fallback: { ko: '보통~심각', en: 'Medium~Critical' },
|
||||
intent: 'high',
|
||||
},
|
||||
'MEDIUM': {
|
||||
code: 'MEDIUM',
|
||||
i18nKey: 'engineSeverity.MEDIUM',
|
||||
fallback: { ko: '보통', en: 'Medium' },
|
||||
intent: 'warning',
|
||||
},
|
||||
'LOW': {
|
||||
code: 'LOW',
|
||||
i18nKey: 'engineSeverity.LOW',
|
||||
fallback: { ko: '낮음', en: 'Low' },
|
||||
intent: 'info',
|
||||
},
|
||||
'-': {
|
||||
code: '-',
|
||||
i18nKey: 'engineSeverity.NONE',
|
||||
fallback: { ko: '-', en: '-' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEngineSeverityMeta(sev: string): EngineSeverityMeta {
|
||||
return ENGINE_SEVERITIES[sev as EngineSeverity] ?? ENGINE_SEVERITIES['-'];
|
||||
}
|
||||
|
||||
export function getEngineSeverityIntent(sev: string): BadgeIntent {
|
||||
return getEngineSeverityMeta(sev).intent;
|
||||
}
|
||||
|
||||
export function getEngineSeverityLabel(
|
||||
sev: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEngineSeverityMeta(sev);
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
79
frontend/src/shared/constants/eventStatuses.ts
Normal file
79
frontend/src/shared/constants/eventStatuses.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 이벤트 처리 상태 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 EVENT_STATUS (V008 시드).
|
||||
* 향후 `GET /api/code-master?groupCode=EVENT_STATUS`로 fetch 예정.
|
||||
*
|
||||
* 사용처: EventList(처리상태 컬럼), 알림 처리, 단속 등록 액션
|
||||
*/
|
||||
|
||||
export type EventStatus =
|
||||
| 'NEW'
|
||||
| 'ACK'
|
||||
| 'IN_PROGRESS'
|
||||
| 'RESOLVED'
|
||||
| 'FALSE_POSITIVE';
|
||||
|
||||
export interface EventStatusMeta {
|
||||
code: EventStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string; // bg + text 묶음
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const EVENT_STATUSES: Record<EventStatus, EventStatusMeta> = {
|
||||
NEW: {
|
||||
code: 'NEW',
|
||||
i18nKey: 'eventStatus.NEW',
|
||||
fallback: { ko: '신규', en: 'New' },
|
||||
classes: 'bg-red-500/20 text-red-400',
|
||||
order: 1,
|
||||
},
|
||||
ACK: {
|
||||
code: 'ACK',
|
||||
i18nKey: 'eventStatus.ACK',
|
||||
fallback: { ko: '확인', en: 'Acknowledged' },
|
||||
classes: 'bg-orange-500/20 text-orange-400',
|
||||
order: 2,
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
code: 'IN_PROGRESS',
|
||||
i18nKey: 'eventStatus.IN_PROGRESS',
|
||||
fallback: { ko: '처리중', en: 'In Progress' },
|
||||
classes: 'bg-blue-500/20 text-blue-400',
|
||||
order: 3,
|
||||
},
|
||||
RESOLVED: {
|
||||
code: 'RESOLVED',
|
||||
i18nKey: 'eventStatus.RESOLVED',
|
||||
fallback: { ko: '완료', en: 'Resolved' },
|
||||
classes: 'bg-green-500/20 text-green-400',
|
||||
order: 4,
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'eventStatus.FALSE_POSITIVE',
|
||||
fallback: { ko: '오탐', en: 'False Positive' },
|
||||
classes: 'bg-muted text-muted-foreground',
|
||||
order: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEventStatusMeta(status: string): EventStatusMeta | undefined {
|
||||
return EVENT_STATUSES[status as EventStatus];
|
||||
}
|
||||
|
||||
export function getEventStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEventStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getEventStatusClasses(status: string): string {
|
||||
return getEventStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
51
frontend/src/shared/constants/gearGroupTypes.ts
Normal file
51
frontend/src/shared/constants/gearGroupTypes.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 어구 그룹 타입 카탈로그
|
||||
*
|
||||
* SSOT: backend group_polygon_snapshots.group_type
|
||||
* 사용처: RealGearGroups
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type GearGroupType = 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
|
||||
|
||||
interface GearGroupTypeMeta {
|
||||
code: GearGroupType;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const GEAR_GROUP_TYPES: Record<GearGroupType, GearGroupTypeMeta> = {
|
||||
FLEET: {
|
||||
code: 'FLEET',
|
||||
i18nKey: 'gearGroupType.FLEET',
|
||||
fallback: { ko: '선단', en: 'Fleet' },
|
||||
intent: 'info',
|
||||
},
|
||||
GEAR_IN_ZONE: {
|
||||
code: 'GEAR_IN_ZONE',
|
||||
i18nKey: 'gearGroupType.GEAR_IN_ZONE',
|
||||
fallback: { ko: '구역 내 어구', en: 'In-Zone Gear' },
|
||||
intent: 'high',
|
||||
},
|
||||
GEAR_OUT_ZONE: {
|
||||
code: 'GEAR_OUT_ZONE',
|
||||
i18nKey: 'gearGroupType.GEAR_OUT_ZONE',
|
||||
fallback: { ko: '구역 외 어구', en: 'Out-Zone Gear' },
|
||||
intent: 'purple',
|
||||
},
|
||||
};
|
||||
|
||||
export function getGearGroupTypeIntent(t: string): BadgeIntent {
|
||||
return GEAR_GROUP_TYPES[t as GearGroupType]?.intent ?? 'muted';
|
||||
}
|
||||
export function getGearGroupTypeLabel(
|
||||
type: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = GEAR_GROUP_TYPES[type as GearGroupType];
|
||||
if (!meta) return type;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
23
frontend/src/shared/constants/httpStatusCodes.ts
Normal file
23
frontend/src/shared/constants/httpStatusCodes.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* HTTP 상태 코드 카탈로그 (범위 기반)
|
||||
*
|
||||
* 사용처: AccessLogs, AuditLogs API 응답 상태 표시
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
/**
|
||||
* HTTP status code → BadgeIntent
|
||||
* - 5xx: critical (서버 에러)
|
||||
* - 4xx: high (클라이언트 에러)
|
||||
* - 3xx: warning (리다이렉트)
|
||||
* - 2xx: success
|
||||
* - 그 외: muted
|
||||
*/
|
||||
export function getHttpStatusIntent(code: number): BadgeIntent {
|
||||
if (code >= 500) return 'critical';
|
||||
if (code >= 400) return 'high';
|
||||
if (code >= 300) return 'warning';
|
||||
if (code >= 200) return 'success';
|
||||
return 'muted';
|
||||
}
|
||||
31
frontend/src/shared/constants/index.ts
Normal file
31
frontend/src/shared/constants/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 분류/코드 카탈로그 통합 export
|
||||
*
|
||||
* 모든 분류 enum (위반 유형, 위험도, 이벤트 상태, 단속 조치/결과, 함정 상태 등)을
|
||||
* 이 파일을 통해 import하여 일관성 유지.
|
||||
*
|
||||
* 향후 백엔드 `GET /api/code-master?groupCode={...}` API가 준비되면
|
||||
* codeMasterStore에서 fetch한 값으로 정적 카탈로그를 override 할 수 있다.
|
||||
*/
|
||||
|
||||
export * from './violationTypes';
|
||||
export * from './alertLevels';
|
||||
export * from './eventStatuses';
|
||||
export * from './enforcementActions';
|
||||
export * from './enforcementResults';
|
||||
export * from './patrolStatuses';
|
||||
export * from './engineSeverities';
|
||||
export * from './userRoles';
|
||||
export * from './deviceStatuses';
|
||||
export * from './parentResolutionStatuses';
|
||||
export * from './modelDeploymentStatuses';
|
||||
export * from './gearGroupTypes';
|
||||
export * from './darkVesselPatterns';
|
||||
export * from './httpStatusCodes';
|
||||
export * from './userAccountStatuses';
|
||||
export * from './loginResultStatuses';
|
||||
export * from './permissionStatuses';
|
||||
export * from './vesselAnalysisStatuses';
|
||||
export * from './connectionStatuses';
|
||||
export * from './trainingZoneTypes';
|
||||
export * from './kpiUiMap';
|
||||
38
frontend/src/shared/constants/kpiUiMap.ts
Normal file
38
frontend/src/shared/constants/kpiUiMap.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* KPI 카드 UI 매핑 (아이콘 + 색상)
|
||||
*
|
||||
* Dashboard와 MonitoringDashboard에서 중복 정의되던 KPI_UI_MAP 통합.
|
||||
* 한글 라벨 / 영문 kpiKey 모두 지원.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Radar, AlertTriangle, Eye, Anchor, Crosshair, Shield, Target } from 'lucide-react';
|
||||
|
||||
export interface KpiUi {
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const KPI_UI_MAP: Record<string, KpiUi> = {
|
||||
// 한글 라벨 (DB prediction_kpi.kpi_label)
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
// 영문 kpi_key (백엔드 코드)
|
||||
realtime_detection: { icon: Radar, color: '#3b82f6' },
|
||||
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
|
||||
dark_vessel: { icon: Eye, color: '#f97316' },
|
||||
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
|
||||
illegal_transship: { icon: Anchor, color: '#a855f7' },
|
||||
tracking: { icon: Target, color: '#06b6d4' },
|
||||
tracking_active: { icon: Target, color: '#06b6d4' },
|
||||
enforcement: { icon: Shield, color: '#10b981' },
|
||||
captured_inspected: { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
|
||||
export function getKpiUi(key: string): KpiUi {
|
||||
return KPI_UI_MAP[key] ?? { icon: Radar, color: '#3b82f6' };
|
||||
}
|
||||
51
frontend/src/shared/constants/loginResultStatuses.ts
Normal file
51
frontend/src/shared/constants/loginResultStatuses.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 로그인 결과 카탈로그
|
||||
*
|
||||
* SSOT: backend kcg.auth_login_history.login_result
|
||||
* 사용처: LoginHistoryView
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type LoginResult = 'SUCCESS' | 'FAILED' | 'LOCKED';
|
||||
|
||||
interface LoginResultMeta {
|
||||
code: LoginResult;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const LOGIN_RESULTS: Record<LoginResult, LoginResultMeta> = {
|
||||
SUCCESS: {
|
||||
code: 'SUCCESS',
|
||||
i18nKey: 'loginResult.SUCCESS',
|
||||
fallback: { ko: '성공', en: 'Success' },
|
||||
intent: 'success',
|
||||
},
|
||||
FAILED: {
|
||||
code: 'FAILED',
|
||||
i18nKey: 'loginResult.FAILED',
|
||||
fallback: { ko: '실패', en: 'Failed' },
|
||||
intent: 'high',
|
||||
},
|
||||
LOCKED: {
|
||||
code: 'LOCKED',
|
||||
i18nKey: 'loginResult.LOCKED',
|
||||
fallback: { ko: '계정 잠금', en: 'Locked' },
|
||||
intent: 'critical',
|
||||
},
|
||||
};
|
||||
|
||||
export function getLoginResultIntent(s: string): BadgeIntent {
|
||||
return LOGIN_RESULTS[s as LoginResult]?.intent ?? 'muted';
|
||||
}
|
||||
export function getLoginResultLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = LOGIN_RESULTS[s as LoginResult];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
73
frontend/src/shared/constants/modelDeploymentStatuses.ts
Normal file
73
frontend/src/shared/constants/modelDeploymentStatuses.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI 모델 배포 / 품질 게이트 / 실험 상태 카탈로그
|
||||
*
|
||||
* 사용처: MLOpsPage
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
// ─── 모델 배포 상태 ──────────────
|
||||
export type ModelStatus = 'DEPLOYED' | 'APPROVED' | 'TESTING' | 'DRAFT';
|
||||
|
||||
export const MODEL_STATUSES: Record<ModelStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
DEPLOYED: {
|
||||
i18nKey: 'modelStatus.DEPLOYED',
|
||||
fallback: { ko: '배포됨', en: 'Deployed' },
|
||||
intent: 'success',
|
||||
},
|
||||
APPROVED: {
|
||||
i18nKey: 'modelStatus.APPROVED',
|
||||
fallback: { ko: '승인', en: 'Approved' },
|
||||
intent: 'info',
|
||||
},
|
||||
TESTING: {
|
||||
i18nKey: 'modelStatus.TESTING',
|
||||
fallback: { ko: '테스트', en: 'Testing' },
|
||||
intent: 'warning',
|
||||
},
|
||||
DRAFT: {
|
||||
i18nKey: 'modelStatus.DRAFT',
|
||||
fallback: { ko: '초안', en: 'Draft' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getModelStatusIntent(s: string): BadgeIntent {
|
||||
return MODEL_STATUSES[s as ModelStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getModelStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = MODEL_STATUSES[s as ModelStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 품질 게이트 상태 ──────────────
|
||||
export type QualityGateStatus = 'pass' | 'fail' | 'run' | 'pending';
|
||||
|
||||
export const QUALITY_GATE_STATUSES: Record<QualityGateStatus, { intent: BadgeIntent; pulse?: boolean; fallback: { ko: string; en: string } }> = {
|
||||
pass: { intent: 'success', fallback: { ko: '통과', en: 'Pass' } },
|
||||
fail: { intent: 'critical', fallback: { ko: '실패', en: 'Fail' } },
|
||||
run: { intent: 'warning', pulse: true, fallback: { ko: '실행중', en: 'Running' } },
|
||||
pending: { intent: 'muted', fallback: { ko: '대기', en: 'Pending' } },
|
||||
};
|
||||
|
||||
export function getQualityGateIntent(s: string): BadgeIntent {
|
||||
return QUALITY_GATE_STATUSES[s as QualityGateStatus]?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
// ─── 실험 상태 ──────────────
|
||||
export type ExperimentStatus = 'running' | 'done' | 'failed';
|
||||
|
||||
export const EXPERIMENT_STATUSES: Record<ExperimentStatus, { intent: BadgeIntent; pulse?: boolean; fallback: { ko: string; en: string } }> = {
|
||||
running: { intent: 'info', pulse: true, fallback: { ko: '실행중', en: 'Running' } },
|
||||
done: { intent: 'success', fallback: { ko: '완료', en: 'Done' } },
|
||||
failed: { intent: 'critical', fallback: { ko: '실패', en: 'Failed' } },
|
||||
};
|
||||
|
||||
export function getExperimentIntent(s: string): BadgeIntent {
|
||||
return EXPERIMENT_STATUSES[s as ExperimentStatus]?.intent ?? 'muted';
|
||||
}
|
||||
96
frontend/src/shared/constants/parentResolutionStatuses.ts
Normal file
96
frontend/src/shared/constants/parentResolutionStatuses.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 모선 추론(Parent Resolution) 상태 + 라벨 세션 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend gear_group_parent_resolution.status
|
||||
* 사용처: ParentReview, LabelSession, RealGearGroups
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
// ─── 부모 해석(Resolution) 상태 ──────────────
|
||||
export type ParentResolutionStatus = 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED';
|
||||
|
||||
interface ParentResolutionMeta {
|
||||
code: ParentResolutionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const PARENT_RESOLUTION_STATUSES: Record<ParentResolutionStatus, ParentResolutionMeta> = {
|
||||
UNRESOLVED: {
|
||||
code: 'UNRESOLVED',
|
||||
i18nKey: 'parentResolution.UNRESOLVED',
|
||||
fallback: { ko: '미해결', en: 'Unresolved' },
|
||||
intent: 'warning',
|
||||
},
|
||||
REVIEW_REQUIRED: {
|
||||
code: 'REVIEW_REQUIRED',
|
||||
i18nKey: 'parentResolution.REVIEW_REQUIRED',
|
||||
fallback: { ko: '검토 필요', en: 'Review Required' },
|
||||
intent: 'critical',
|
||||
},
|
||||
MANUAL_CONFIRMED: {
|
||||
code: 'MANUAL_CONFIRMED',
|
||||
i18nKey: 'parentResolution.MANUAL_CONFIRMED',
|
||||
fallback: { ko: '수동 확정', en: 'Manually Confirmed' },
|
||||
intent: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export function getParentResolutionIntent(s: string): BadgeIntent {
|
||||
return PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getParentResolutionLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 라벨 세션 상태 ──────────────
|
||||
export type LabelSessionStatus = 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
|
||||
|
||||
interface LabelSessionMeta {
|
||||
code: LabelSessionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const LABEL_SESSION_STATUSES: Record<LabelSessionStatus, LabelSessionMeta> = {
|
||||
ACTIVE: {
|
||||
code: 'ACTIVE',
|
||||
i18nKey: 'labelSession.ACTIVE',
|
||||
fallback: { ko: '진행중', en: 'Active' },
|
||||
intent: 'success',
|
||||
},
|
||||
COMPLETED: {
|
||||
code: 'COMPLETED',
|
||||
i18nKey: 'labelSession.COMPLETED',
|
||||
fallback: { ko: '완료', en: 'Completed' },
|
||||
intent: 'info',
|
||||
},
|
||||
CANCELLED: {
|
||||
code: 'CANCELLED',
|
||||
i18nKey: 'labelSession.CANCELLED',
|
||||
fallback: { ko: '취소', en: 'Cancelled' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getLabelSessionIntent(s: string): BadgeIntent {
|
||||
return LABEL_SESSION_STATUSES[s as LabelSessionStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getLabelSessionLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = LABEL_SESSION_STATUSES[s as LabelSessionStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
117
frontend/src/shared/constants/patrolStatuses.ts
Normal file
117
frontend/src/shared/constants/patrolStatuses.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 함정 상태 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 PATROL_STATUS (V008 시드).
|
||||
* 향후 patrol_ship_master.status 컬럼 enum.
|
||||
*
|
||||
* 사용처: Dashboard PatrolStatusBadge, ShipAgent
|
||||
*/
|
||||
|
||||
export type PatrolStatus =
|
||||
| 'AVAILABLE'
|
||||
| 'ON_PATROL'
|
||||
| 'IN_PURSUIT'
|
||||
| 'INSPECTING'
|
||||
| 'RETURNING'
|
||||
| 'STANDBY'
|
||||
| 'MAINTENANCE';
|
||||
|
||||
export interface PatrolStatusMeta {
|
||||
code: PatrolStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const PATROL_STATUSES: Record<PatrolStatus, PatrolStatusMeta> = {
|
||||
IN_PURSUIT: {
|
||||
code: 'IN_PURSUIT',
|
||||
i18nKey: 'patrolStatus.IN_PURSUIT',
|
||||
fallback: { ko: '추적중', en: 'In Pursuit' },
|
||||
classes: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
order: 1,
|
||||
},
|
||||
INSPECTING: {
|
||||
code: 'INSPECTING',
|
||||
i18nKey: 'patrolStatus.INSPECTING',
|
||||
fallback: { ko: '검문중', en: 'Inspecting' },
|
||||
classes: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
order: 2,
|
||||
},
|
||||
ON_PATROL: {
|
||||
code: 'ON_PATROL',
|
||||
i18nKey: 'patrolStatus.ON_PATROL',
|
||||
fallback: { ko: '초계중', en: 'On Patrol' },
|
||||
classes: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
order: 3,
|
||||
},
|
||||
RETURNING: {
|
||||
code: 'RETURNING',
|
||||
i18nKey: 'patrolStatus.RETURNING',
|
||||
fallback: { ko: '귀항중', en: 'Returning' },
|
||||
classes: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
order: 4,
|
||||
},
|
||||
AVAILABLE: {
|
||||
code: 'AVAILABLE',
|
||||
i18nKey: 'patrolStatus.AVAILABLE',
|
||||
fallback: { ko: '가용', en: 'Available' },
|
||||
classes: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
order: 5,
|
||||
},
|
||||
STANDBY: {
|
||||
code: 'STANDBY',
|
||||
i18nKey: 'patrolStatus.STANDBY',
|
||||
fallback: { ko: '대기', en: 'Standby' },
|
||||
classes: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
order: 6,
|
||||
},
|
||||
MAINTENANCE: {
|
||||
code: 'MAINTENANCE',
|
||||
i18nKey: 'patrolStatus.MAINTENANCE',
|
||||
fallback: { ko: '정비중', en: 'Maintenance' },
|
||||
classes: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
order: 7,
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨도 키로 받아주는 호환성 매핑 (mock 데이터에서 한글 사용 중) */
|
||||
const LEGACY_KO_LABELS: Record<string, PatrolStatus> = {
|
||||
'추적 중': 'IN_PURSUIT',
|
||||
'추적중': 'IN_PURSUIT',
|
||||
'검문 중': 'INSPECTING',
|
||||
'검문중': 'INSPECTING',
|
||||
'초계 중': 'ON_PATROL',
|
||||
'초계중': 'ON_PATROL',
|
||||
'귀항 중': 'RETURNING',
|
||||
'귀항중': 'RETURNING',
|
||||
'가용': 'AVAILABLE',
|
||||
'대기': 'STANDBY',
|
||||
'정비 중': 'MAINTENANCE',
|
||||
'정비중': 'MAINTENANCE',
|
||||
};
|
||||
|
||||
export function getPatrolStatusMeta(status: string): PatrolStatusMeta | undefined {
|
||||
// 영문 enum 우선
|
||||
if (PATROL_STATUSES[status as PatrolStatus]) {
|
||||
return PATROL_STATUSES[status as PatrolStatus];
|
||||
}
|
||||
// 한글 라벨 폴백 (mock 호환)
|
||||
const code = LEGACY_KO_LABELS[status];
|
||||
return code ? PATROL_STATUSES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getPatrolStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getPatrolStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getPatrolStatusClasses(status: string): string {
|
||||
return getPatrolStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
93
frontend/src/shared/constants/permissionStatuses.ts
Normal file
93
frontend/src/shared/constants/permissionStatuses.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 어업 허가/판정 상태 카탈로그
|
||||
*
|
||||
* 사용처: GearDetection
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type PermitStatus = 'VALID' | 'EXPIRED' | 'UNLICENSED' | 'PENDING';
|
||||
|
||||
interface PermitStatusMeta {
|
||||
code: PermitStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const PERMIT_STATUSES: Record<PermitStatus, PermitStatusMeta> = {
|
||||
VALID: {
|
||||
code: 'VALID',
|
||||
i18nKey: 'permitStatus.VALID',
|
||||
fallback: { ko: '유효', en: 'Valid' },
|
||||
intent: 'success',
|
||||
},
|
||||
PENDING: {
|
||||
code: 'PENDING',
|
||||
i18nKey: 'permitStatus.PENDING',
|
||||
fallback: { ko: '확인 중', en: 'Pending' },
|
||||
intent: 'warning',
|
||||
},
|
||||
EXPIRED: {
|
||||
code: 'EXPIRED',
|
||||
i18nKey: 'permitStatus.EXPIRED',
|
||||
fallback: { ko: '만료', en: 'Expired' },
|
||||
intent: 'high',
|
||||
},
|
||||
UNLICENSED: {
|
||||
code: 'UNLICENSED',
|
||||
i18nKey: 'permitStatus.UNLICENSED',
|
||||
fallback: { ko: '무허가', en: 'Unlicensed' },
|
||||
intent: 'critical',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, PermitStatus> = {
|
||||
'유효': 'VALID',
|
||||
'무허가': 'UNLICENSED',
|
||||
'만료': 'EXPIRED',
|
||||
'확인 중': 'PENDING',
|
||||
'확인중': 'PENDING',
|
||||
};
|
||||
|
||||
export function getPermitStatusIntent(s: string): BadgeIntent {
|
||||
if (PERMIT_STATUSES[s as PermitStatus]) return PERMIT_STATUSES[s as PermitStatus].intent;
|
||||
const code = LEGACY_KO[s];
|
||||
return code ? PERMIT_STATUSES[code].intent : 'warning';
|
||||
}
|
||||
export function getPermitStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
let meta = PERMIT_STATUSES[s as PermitStatus];
|
||||
if (!meta) {
|
||||
const code = LEGACY_KO[s];
|
||||
if (code) meta = PERMIT_STATUSES[code];
|
||||
}
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 어구 판정 상태 ──────────────
|
||||
export type GearJudgment = 'NORMAL' | 'SUSPECT_ILLEGAL' | 'CHECKING';
|
||||
|
||||
export const GEAR_JUDGMENTS: Record<GearJudgment, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
NORMAL: { i18nKey: 'gearJudgment.NORMAL', fallback: { ko: '정상', en: 'Normal' }, intent: 'success' },
|
||||
CHECKING: { i18nKey: 'gearJudgment.CHECKING', fallback: { ko: '확인 중', en: 'Checking' }, intent: 'warning' },
|
||||
SUSPECT_ILLEGAL: { i18nKey: 'gearJudgment.SUSPECT_ILLEGAL', fallback: { ko: '불법 의심', en: 'Suspect Illegal' }, intent: 'critical' },
|
||||
};
|
||||
|
||||
const GEAR_LEGACY_KO: Record<string, GearJudgment> = {
|
||||
'정상': 'NORMAL',
|
||||
'확인 중': 'CHECKING',
|
||||
'확인중': 'CHECKING',
|
||||
'불법 의심': 'SUSPECT_ILLEGAL',
|
||||
'불법의심': 'SUSPECT_ILLEGAL',
|
||||
};
|
||||
|
||||
export function getGearJudgmentIntent(s: string): BadgeIntent {
|
||||
if (GEAR_JUDGMENTS[s as GearJudgment]) return GEAR_JUDGMENTS[s as GearJudgment].intent;
|
||||
const code = GEAR_LEGACY_KO[s] ?? (s.includes('불법') ? 'SUSPECT_ILLEGAL' : undefined);
|
||||
return code ? GEAR_JUDGMENTS[code].intent : 'warning';
|
||||
}
|
||||
85
frontend/src/shared/constants/trainingZoneTypes.ts
Normal file
85
frontend/src/shared/constants/trainingZoneTypes.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 군사 훈련구역 타입 카탈로그
|
||||
*
|
||||
* 사용처: MapControl 훈련구역 마커/배지
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type TrainingZoneType = 'NAVY' | 'AIRFORCE' | 'ARMY' | 'ADD' | 'KCG';
|
||||
|
||||
interface TrainingZoneTypeMeta {
|
||||
code: TrainingZoneType;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const TRAINING_ZONE_TYPES: Record<TrainingZoneType, TrainingZoneTypeMeta> = {
|
||||
NAVY: {
|
||||
code: 'NAVY',
|
||||
i18nKey: 'trainingZone.NAVY',
|
||||
fallback: { ko: '해군 훈련 구역', en: 'Navy Training Zone' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
AIRFORCE: {
|
||||
code: 'AIRFORCE',
|
||||
i18nKey: 'trainingZone.AIRFORCE',
|
||||
fallback: { ko: '공군 훈련 구역', en: 'Airforce Training Zone' },
|
||||
intent: 'critical',
|
||||
hex: '#ec4899',
|
||||
},
|
||||
ARMY: {
|
||||
code: 'ARMY',
|
||||
i18nKey: 'trainingZone.ARMY',
|
||||
fallback: { ko: '육군 훈련 구역', en: 'Army Training Zone' },
|
||||
intent: 'success',
|
||||
hex: '#22c55e',
|
||||
},
|
||||
ADD: {
|
||||
code: 'ADD',
|
||||
i18nKey: 'trainingZone.ADD',
|
||||
fallback: { ko: '국방과학연구소', en: 'ADD' },
|
||||
intent: 'info',
|
||||
hex: '#3b82f6',
|
||||
},
|
||||
KCG: {
|
||||
code: 'KCG',
|
||||
i18nKey: 'trainingZone.KCG',
|
||||
fallback: { ko: '해양경찰청', en: 'KCG' },
|
||||
intent: 'purple',
|
||||
hex: '#a855f7',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, TrainingZoneType> = {
|
||||
'해군': 'NAVY',
|
||||
'공군': 'AIRFORCE',
|
||||
'육군': 'ARMY',
|
||||
'국과연': 'ADD',
|
||||
'해경': 'KCG',
|
||||
};
|
||||
|
||||
export function getTrainingZoneMeta(t: string): TrainingZoneTypeMeta | undefined {
|
||||
if (TRAINING_ZONE_TYPES[t as TrainingZoneType]) return TRAINING_ZONE_TYPES[t as TrainingZoneType];
|
||||
const code = LEGACY_KO[t];
|
||||
return code ? TRAINING_ZONE_TYPES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getTrainingZoneIntent(t: string): BadgeIntent {
|
||||
return getTrainingZoneMeta(t)?.intent ?? 'muted';
|
||||
}
|
||||
export function getTrainingZoneHex(t: string): string {
|
||||
return getTrainingZoneMeta(t)?.hex ?? '#6b7280';
|
||||
}
|
||||
export function getTrainingZoneLabel(
|
||||
type: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return type;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
57
frontend/src/shared/constants/userAccountStatuses.ts
Normal file
57
frontend/src/shared/constants/userAccountStatuses.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 사용자 계정 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend kcg.auth_user.user_stts_cd
|
||||
* 사용처: AccessControl, 사용자 관리 화면
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type UserAccountStatus = 'ACTIVE' | 'LOCKED' | 'INACTIVE' | 'PENDING';
|
||||
|
||||
interface UserAccountStatusMeta {
|
||||
code: UserAccountStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const USER_ACCOUNT_STATUSES: Record<UserAccountStatus, UserAccountStatusMeta> = {
|
||||
ACTIVE: {
|
||||
code: 'ACTIVE',
|
||||
i18nKey: 'userAccountStatus.ACTIVE',
|
||||
fallback: { ko: '활성', en: 'Active' },
|
||||
intent: 'success',
|
||||
},
|
||||
PENDING: {
|
||||
code: 'PENDING',
|
||||
i18nKey: 'userAccountStatus.PENDING',
|
||||
fallback: { ko: '승인 대기', en: 'Pending' },
|
||||
intent: 'warning',
|
||||
},
|
||||
LOCKED: {
|
||||
code: 'LOCKED',
|
||||
i18nKey: 'userAccountStatus.LOCKED',
|
||||
fallback: { ko: '잠금', en: 'Locked' },
|
||||
intent: 'critical',
|
||||
},
|
||||
INACTIVE: {
|
||||
code: 'INACTIVE',
|
||||
i18nKey: 'userAccountStatus.INACTIVE',
|
||||
fallback: { ko: '비활성', en: 'Inactive' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getUserAccountStatusIntent(s: string): BadgeIntent {
|
||||
return USER_ACCOUNT_STATUSES[s as UserAccountStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getUserAccountStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = USER_ACCOUNT_STATUSES[s as UserAccountStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
84
frontend/src/shared/constants/userRoles.ts
Normal file
84
frontend/src/shared/constants/userRoles.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 사용자 역할 색상 카탈로그
|
||||
*
|
||||
* SSOT: backend `auth_role.color_hex` (V017 추가).
|
||||
*
|
||||
* 동작 방식:
|
||||
* - 백엔드에서 fetch한 RoleWithPermissions[]의 colorHex가 1차 source
|
||||
* - DB에 colorHex가 NULL이거나 미등록 역할은 ROLE_FALLBACK_PALETTE에서
|
||||
* role code 해시 기반으로 안정적 색상 할당
|
||||
*
|
||||
* 사용처: MainLayout, UserRoleAssignDialog, PermissionsPanel, AccessControl
|
||||
*/
|
||||
|
||||
/** 기본 색상 팔레트 — DB color_hex가 없을 때 폴백 */
|
||||
export const ROLE_DEFAULT_PALETTE: string[] = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#64748b', // slate
|
||||
'#84cc16', // lime
|
||||
'#14b8a6', // teal
|
||||
'#f59e0b', // amber
|
||||
];
|
||||
|
||||
/** 빌트인 5개 역할의 기본 색상 (백엔드 V017 시드와 동일) */
|
||||
const BUILTIN_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: '#ef4444',
|
||||
OPERATOR: '#3b82f6',
|
||||
ANALYST: '#a855f7',
|
||||
FIELD: '#22c55e',
|
||||
VIEWER: '#eab308',
|
||||
};
|
||||
|
||||
/** 백엔드 fetch 결과를 캐시 — 역할 코드 → colorHex */
|
||||
let roleColorCache: Record<string, string | null> = {};
|
||||
|
||||
/** RoleWithPermissions[] fetch 결과로 캐시 갱신 (RoleStore 등에서 호출) */
|
||||
export function updateRoleColorCache(roles: { roleCd: string; colorHex: string | null }[]): void {
|
||||
roleColorCache = {};
|
||||
roles.forEach((r) => {
|
||||
roleColorCache[r.roleCd] = r.colorHex;
|
||||
});
|
||||
}
|
||||
|
||||
/** 코드 해시 기반 안정적 폴백 색상 */
|
||||
function hashFallbackColor(roleCd: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < roleCd.length; i++) hash = (hash * 31 + roleCd.charCodeAt(i)) | 0;
|
||||
return ROLE_DEFAULT_PALETTE[Math.abs(hash) % ROLE_DEFAULT_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 코드 → hex 색상.
|
||||
* 우선순위: 캐시(DB) → 빌트인 → 해시 폴백
|
||||
*/
|
||||
export function getRoleColorHex(roleCd: string): string {
|
||||
const cached = roleColorCache[roleCd];
|
||||
if (cached) return cached;
|
||||
if (BUILTIN_ROLE_COLORS[roleCd]) return BUILTIN_ROLE_COLORS[roleCd];
|
||||
return hashFallbackColor(roleCd);
|
||||
}
|
||||
|
||||
/**
|
||||
* hex → Tailwind 유사 클래스 묶음.
|
||||
* 인라인 style이 가능한 환경에서는 getRoleColorHex 직접 사용 권장.
|
||||
*
|
||||
* 클래스 기반이 필요한 곳을 위한 호환 함수.
|
||||
*/
|
||||
export function getRoleBadgeStyle(roleCd: string): React.CSSProperties {
|
||||
const hex = getRoleColorHex(roleCd);
|
||||
return {
|
||||
backgroundColor: hex,
|
||||
borderColor: hex,
|
||||
color: '#0f172a', // slate-900 (badgeVariants 정책과 동일)
|
||||
};
|
||||
}
|
||||
|
||||
/** import('react') 회피용 type-only import */
|
||||
import type React from 'react';
|
||||
84
frontend/src/shared/constants/vesselAnalysisStatuses.ts
Normal file
84
frontend/src/shared/constants/vesselAnalysisStatuses.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 선박 분석/감시 상태 카탈로그
|
||||
*
|
||||
* 사용처: ChinaFishing StatusRing, DarkVesselDetection 상태
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type VesselSurveillanceStatus = 'TRACKING' | 'WATCHING' | 'CHECKING' | 'NORMAL';
|
||||
|
||||
export const VESSEL_SURVEILLANCE_STATUSES: Record<VesselSurveillanceStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
TRACKING: {
|
||||
i18nKey: 'vesselSurveillance.TRACKING',
|
||||
fallback: { ko: '추적중', en: 'Tracking' },
|
||||
intent: 'critical',
|
||||
},
|
||||
WATCHING: {
|
||||
i18nKey: 'vesselSurveillance.WATCHING',
|
||||
fallback: { ko: '감시중', en: 'Watching' },
|
||||
intent: 'warning',
|
||||
},
|
||||
CHECKING: {
|
||||
i18nKey: 'vesselSurveillance.CHECKING',
|
||||
fallback: { ko: '확인중', en: 'Checking' },
|
||||
intent: 'info',
|
||||
},
|
||||
NORMAL: {
|
||||
i18nKey: 'vesselSurveillance.NORMAL',
|
||||
fallback: { ko: '정상', en: 'Normal' },
|
||||
intent: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, VesselSurveillanceStatus> = {
|
||||
'추적중': 'TRACKING',
|
||||
'추적 중': 'TRACKING',
|
||||
'감시중': 'WATCHING',
|
||||
'감시 중': 'WATCHING',
|
||||
'확인중': 'CHECKING',
|
||||
'확인 중': 'CHECKING',
|
||||
'정상': 'NORMAL',
|
||||
};
|
||||
|
||||
export function getVesselSurveillanceIntent(s: string): BadgeIntent {
|
||||
if (VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus]) {
|
||||
return VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus].intent;
|
||||
}
|
||||
const code = LEGACY_KO[s];
|
||||
return code ? VESSEL_SURVEILLANCE_STATUSES[code].intent : 'muted';
|
||||
}
|
||||
export function getVesselSurveillanceLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
let meta = VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus];
|
||||
if (!meta) {
|
||||
const code = LEGACY_KO[s];
|
||||
if (code) meta = VESSEL_SURVEILLANCE_STATUSES[code];
|
||||
}
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── ChinaFishing StatusRing 용 (한글 라벨 매핑) ──────────────
|
||||
export type VesselRiskRing = 'SAFE' | 'SUSPECT' | 'WARNING';
|
||||
|
||||
export const VESSEL_RISK_RINGS: Record<VesselRiskRing, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
|
||||
SAFE: { i18nKey: 'vesselRing.SAFE', fallback: { ko: '양호', en: 'Safe' }, intent: 'success', hex: '#10b981' },
|
||||
SUSPECT: { i18nKey: 'vesselRing.SUSPECT', fallback: { ko: '의심', en: 'Suspect' }, intent: 'high', hex: '#f97316' },
|
||||
WARNING: { i18nKey: 'vesselRing.WARNING', fallback: { ko: '경고', en: 'Warning' }, intent: 'critical', hex: '#ef4444' },
|
||||
};
|
||||
|
||||
const RING_LEGACY_KO: Record<string, VesselRiskRing> = {
|
||||
'양호': 'SAFE',
|
||||
'의심': 'SUSPECT',
|
||||
'경고': 'WARNING',
|
||||
};
|
||||
|
||||
export function getVesselRingMeta(s: string) {
|
||||
if (VESSEL_RISK_RINGS[s as VesselRiskRing]) return VESSEL_RISK_RINGS[s as VesselRiskRing];
|
||||
const code = RING_LEGACY_KO[s];
|
||||
return code ? VESSEL_RISK_RINGS[code] : VESSEL_RISK_RINGS.SAFE;
|
||||
}
|
||||
128
frontend/src/shared/constants/violationTypes.ts
Normal file
128
frontend/src/shared/constants/violationTypes.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 위반(탐지) 유형 공통 카탈로그
|
||||
*
|
||||
* 백엔드 violation_classifier.py의 enum 코드를 SSOT로 사용한다.
|
||||
* 색상, 라벨(i18n), 정렬 순서를 한 곳에서 관리.
|
||||
*
|
||||
* 신규 유형 추가/색상 변경 시 이 파일만 수정하면 모든 화면에 반영된다.
|
||||
*
|
||||
* 사용처:
|
||||
* - Dashboard / MonitoringDashboard 위반 유형 분포 차트
|
||||
* - Statistics 위반 유형별 분포
|
||||
* - EventList / EnforcementHistory 위반 유형 표시
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type ViolationCode =
|
||||
| 'EEZ_VIOLATION'
|
||||
| 'DARK_VESSEL'
|
||||
| 'MMSI_TAMPERING'
|
||||
| 'ILLEGAL_TRANSSHIP'
|
||||
| 'ILLEGAL_GEAR'
|
||||
| 'RISK_BEHAVIOR';
|
||||
|
||||
export interface ViolationTypeMeta {
|
||||
code: ViolationCode;
|
||||
/** i18n 키 (common.json) */
|
||||
i18nKey: string;
|
||||
/** 차트/UI 색상 (hex) — 차트나 인라인 style용 */
|
||||
color: string;
|
||||
/** 공통 Badge 컴포넌트 intent */
|
||||
intent: BadgeIntent;
|
||||
/** i18n 미적용 환경의 폴백 라벨 */
|
||||
fallback: { ko: string; en: string };
|
||||
/** 정렬 순서 (낮을수록 상단) */
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const VIOLATION_TYPES: Record<ViolationCode, ViolationTypeMeta> = {
|
||||
EEZ_VIOLATION: {
|
||||
code: 'EEZ_VIOLATION',
|
||||
i18nKey: 'violation.eezViolation',
|
||||
color: '#ef4444',
|
||||
intent: 'critical',
|
||||
fallback: { ko: 'EEZ 침범', en: 'EEZ Violation' },
|
||||
order: 1,
|
||||
},
|
||||
DARK_VESSEL: {
|
||||
code: 'DARK_VESSEL',
|
||||
i18nKey: 'violation.darkVessel',
|
||||
color: '#f97316',
|
||||
intent: 'high',
|
||||
fallback: { ko: '다크베셀', en: 'Dark Vessel' },
|
||||
order: 2,
|
||||
},
|
||||
MMSI_TAMPERING: {
|
||||
code: 'MMSI_TAMPERING',
|
||||
i18nKey: 'violation.mmsiTampering',
|
||||
color: '#eab308',
|
||||
intent: 'warning',
|
||||
fallback: { ko: 'MMSI 변조', en: 'MMSI Tampering' },
|
||||
order: 3,
|
||||
},
|
||||
ILLEGAL_TRANSSHIP: {
|
||||
code: 'ILLEGAL_TRANSSHIP',
|
||||
i18nKey: 'violation.illegalTransship',
|
||||
color: '#a855f7',
|
||||
intent: 'purple',
|
||||
fallback: { ko: '불법 환적', en: 'Illegal Transship' },
|
||||
order: 4,
|
||||
},
|
||||
ILLEGAL_GEAR: {
|
||||
code: 'ILLEGAL_GEAR',
|
||||
i18nKey: 'violation.illegalGear',
|
||||
color: '#06b6d4',
|
||||
intent: 'cyan',
|
||||
fallback: { ko: '불법 어구', en: 'Illegal Gear' },
|
||||
order: 5,
|
||||
},
|
||||
RISK_BEHAVIOR: {
|
||||
code: 'RISK_BEHAVIOR',
|
||||
i18nKey: 'violation.riskBehavior',
|
||||
color: '#6b7280',
|
||||
intent: 'muted',
|
||||
fallback: { ko: '위험 행동', en: 'Risk Behavior' },
|
||||
order: 6,
|
||||
},
|
||||
};
|
||||
|
||||
export function getViolationIntent(code: string): BadgeIntent {
|
||||
return VIOLATION_TYPES[code as ViolationCode]?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
/** 등록되지 않은 코드를 위한 폴백 색상 팔레트 (안정적 매핑) */
|
||||
const FALLBACK_PALETTE = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||
|
||||
/** 코드 → 색상 (등록 안 된 코드는 코드 해시 기반 안정적 색상 반환) */
|
||||
export function getViolationColor(code: string): string {
|
||||
const meta = VIOLATION_TYPES[code as ViolationCode];
|
||||
if (meta) return meta.color;
|
||||
// 등록되지 않은 코드: 문자열 해시 기반으로 안정적 색상 할당
|
||||
let hash = 0;
|
||||
for (let i = 0; i < code.length; i++) hash = (hash * 31 + code.charCodeAt(i)) | 0;
|
||||
return FALLBACK_PALETTE[Math.abs(hash) % FALLBACK_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 → 표시 라벨 (i18n)
|
||||
* @param code 백엔드 enum 코드
|
||||
* @param t react-i18next의 t 함수 (common namespace)
|
||||
* @param lang 'ko' | 'en' (현재 언어, fallback 용도)
|
||||
*/
|
||||
export function getViolationLabel(
|
||||
code: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = VIOLATION_TYPES[code as ViolationCode];
|
||||
if (!meta) return code;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
/** 카탈로그 정렬 순으로 코드 목록 반환 */
|
||||
export function listViolationCodes(): ViolationCode[] {
|
||||
return Object.values(VIOLATION_TYPES)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((m) => m.code);
|
||||
}
|
||||
@ -7,12 +7,12 @@
|
||||
|
||||
const KST: Intl.DateTimeFormatOptions = { timeZone: 'Asia/Seoul' };
|
||||
|
||||
/** 2026-04-07 14:30:00 형식 (KST) */
|
||||
/** 2026-04-07 14:30:00 형식 (KST). sv-SE 로케일은 ISO 유사 출력을 보장. */
|
||||
export const formatDateTime = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleString('ko-KR', {
|
||||
return d.toLocaleString('sv-SE', {
|
||||
...KST,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
@ -25,7 +25,7 @@ export const formatDate = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleDateString('ko-KR', {
|
||||
return d.toLocaleDateString('sv-SE', {
|
||||
...KST,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
});
|
||||
@ -36,7 +36,7 @@ export const formatTime = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleTimeString('ko-KR', {
|
||||
return d.toLocaleTimeString('sv-SE', {
|
||||
...KST,
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
--text-heading: #ffffff;
|
||||
--text-label: #cbd5e1;
|
||||
--text-hint: #64748b;
|
||||
/* 컬러풀 배경 위 텍스트 — 테마 무관 고정 */
|
||||
--text-on-vivid: #ffffff; /* -600/-700 진한 배경 위 (액션 버튼 등) */
|
||||
--text-on-bright: #0f172a; /* -300/-400 밝은 배경 위 (배지 등) */
|
||||
--scrollbar-thumb: #334155;
|
||||
--scrollbar-hover: #475569;
|
||||
}
|
||||
@ -91,6 +94,9 @@
|
||||
--text-heading: #0f172a;
|
||||
--text-label: #334155;
|
||||
--text-hint: #94a3b8;
|
||||
/* 컬러풀 배경 위 텍스트 — 라이트 모드도 동일 (가독성 일관) */
|
||||
--text-on-vivid: #ffffff;
|
||||
--text-on-bright: #0f172a;
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
--scrollbar-hover: #94a3b8;
|
||||
}
|
||||
@ -139,6 +145,20 @@
|
||||
--color-text-heading: var(--text-heading);
|
||||
--color-text-label: var(--text-label);
|
||||
--color-text-hint: var(--text-hint);
|
||||
--color-text-on-vivid: var(--text-on-vivid);
|
||||
--color-text-on-bright: var(--text-on-bright);
|
||||
}
|
||||
|
||||
/* 시맨틱 텍스트/배경 유틸리티 — Tailwind v4 자동 생성 의존하지 않고 명시 정의
|
||||
* (--color-text-* 같은 복합 이름은 v4가 utility 자동 매핑하지 않으므로 직접 선언) */
|
||||
@layer utilities {
|
||||
.text-heading { color: var(--text-heading); }
|
||||
.text-label { color: var(--text-label); }
|
||||
.text-hint { color: var(--text-hint); }
|
||||
.text-on-vivid { color: var(--text-on-vivid); }
|
||||
.text-on-bright { color: var(--text-on-bright); }
|
||||
.bg-surface-raised { background-color: var(--surface-raised); }
|
||||
.bg-surface-overlay { background-color: var(--surface-overlay); }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user