feat(frontend): UI 공통 인프라 + 19개 분류 카탈로그 구축

- cn() 유틸 신규 (clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록)
- theme.css @layer utilities로 직접 정의 (Tailwind v4 복합 이름 매핑 실패 대응):
  text-heading/label/hint/on-vivid/on-bright, bg-surface-raised/overlay
- badgeVariants (CVA) 재구축: 8 intent x 4 size, rem 기반, !important 제거
- Badge 컴포넌트가 cn(badgeVariants, className)로 override 허용
- DataTable width 의미 변경: 고정 -> 선호 최소 너비 (minWidth), truncate + title 툴팁
- dateFormat.ts sv-SE 로케일로 YYYY-MM-DD HH:mm:ss 일관된 KST 출력
- ColorPicker 신규 (팔레트 + native color + hex 입력)
- shared/constants/ 19개 카탈로그: violation/alert/event/enforcement/patrol/
  engine/userRole/device/parentResolution/modelDeployment/gearGroup/darkVessel/
  httpStatus/userAccount/loginResult/permission/vesselAnalysis/connection/trainingZone
  + kpiUiMap. 백엔드 enum/code_master 기반 SSOT
- i18n ko/en common.json에 카테고리 섹션 추가
- adminApi.fetchRoles()가 updateRoleColorCache 자동 호출
- 공통 컴포넌트 (ExcelExport/NotificationBanner/Pagination/SaveButton) 시맨틱 토큰 적용
This commit is contained in:
htlee 2026-04-08 10:53:40 +09:00
부모 20d6743c17
커밋 5812d9dea3
38개의 변경된 파일2170개의 추가작업 그리고 40개의 파일을 삭제

파일 보기

@ -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": {

파일 보기

@ -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' },
},
);

파일 보기

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

파일 보기

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

파일 보기

@ -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';
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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';
}

파일 보기

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

파일 보기

@ -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';
}

파일 보기

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

파일 보기

@ -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';
}

파일 보기

@ -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';

파일 보기

@ -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' };
}

파일 보기

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

파일 보기

@ -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';
}

파일 보기

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

파일 보기

@ -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';
}

파일 보기

@ -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';
}

파일 보기

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

파일 보기

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

파일 보기

@ -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';

파일 보기

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

파일 보기

@ -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 {