diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fed012b..84490ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a9efafd..e2a4a4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index a23a840..c3f032b 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -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", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 3cfafec..c910f2e 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -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": "저장", diff --git a/frontend/src/lib/theme/variants.ts b/frontend/src/lib/theme/variants.ts index a9d08a8..e8756e6 100644 --- a/frontend/src/lib/theme/variants.ts +++ b/frontend/src/lib/theme/variants.ts @@ -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' }, }, ); diff --git a/frontend/src/lib/utils/cn.ts b/frontend/src/lib/utils/cn.ts new file mode 100644 index 0000000..d39b5ea --- /dev/null +++ b/frontend/src/lib/utils/cn.ts @@ -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)); +} diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 95571a9..2951b30 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -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('/perm-tree'); } -export function fetchRoles() { - return apiGet('/roles'); +import { updateRoleColorCache } from '@shared/constants/userRoles'; + +export async function fetchRoles(): Promise { + const roles = await apiGet('/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; } diff --git a/frontend/src/shared/components/common/ColorPicker.tsx b/frontend/src/shared/components/common/ColorPicker.tsx new file mode 100644 index 0000000..153107c --- /dev/null +++ b/frontend/src/shared/components/common/ColorPicker.tsx @@ -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(value ?? ''); + + const handleCustomChange = (hex: string) => { + setCustomHex(hex); + if (/^#[0-9a-fA-F]{6}$/.test(hex)) { + onChange(hex); + } + }; + + return ( +
+ {label &&
{label}
} +
+ {palette.map((color) => { + const selected = value?.toLowerCase() === color.toLowerCase(); + return ( + + ); + })} +
+ {allowCustom && ( +
+ { + onChange(e.target.value); + setCustomHex(e.target.value); + }} + className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer" + title="색상 직접 선택" + /> + 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" + /> +
+ )} +
+ ); +} diff --git a/frontend/src/shared/components/common/DataTable.tsx b/frontend/src/shared/components/common/DataTable.tsx index 5653778..ad26afa 100644 --- a/frontend/src/shared/components/common/DataTable.tsx +++ b/frontend/src/shared/components/common/DataTable.tsx @@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext'; export interface DataColumn { 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>({ {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 ( >({ > {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 ( {col.render - ? col.render(row[col.key], row, page * pageSize + i) - : {row[col.key] != null ? String(row[col.key]) : ''} + ? col.render(rawValue, row, page * pageSize + i) + : {titleText} } ); diff --git a/frontend/src/shared/components/common/ExcelExport.tsx b/frontend/src/shared/components/common/ExcelExport.tsx index efc5ba9..cfd2fb9 100644 --- a/frontend/src/shared/components/common/ExcelExport.tsx +++ b/frontend/src/shared/components/common/ExcelExport.tsx @@ -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 ? : } diff --git a/frontend/src/shared/components/common/NotificationBanner.tsx b/frontend/src/shared/components/common/NotificationBanner.tsx index daf434f..1e29f8f 100644 --- a/frontend/src/shared/components/common/NotificationBanner.tsx +++ b/frontend/src/shared/components/common/NotificationBanner.tsx @@ -145,7 +145,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
diff --git a/frontend/src/shared/components/common/Pagination.tsx b/frontend/src/shared/components/common/Pagination.tsx index 3d02b5b..3a23981 100644 --- a/frontend/src/shared/components/common/Pagination.tsx +++ b/frontend/src/shared/components/common/Pagination.tsx @@ -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' }`} > diff --git a/frontend/src/shared/components/common/SaveButton.tsx b/frontend/src/shared/components/common/SaveButton.tsx index 3b35518..98be3d1 100644 --- a/frontend/src/shared/components/common/SaveButton.tsx +++ b/frontend/src/shared/components/common/SaveButton.tsx @@ -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' ? diff --git a/frontend/src/shared/components/ui/badge.tsx b/frontend/src/shared/components/ui/badge.tsx index edfbef9..73cc3c4 100644 --- a/frontend/src/shared/components/ui/badge.tsx +++ b/frontend/src/shared/components/ui/badge.tsx @@ -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 { /** 의미 기반 색상 (기존 variant 대체) */ @@ -17,11 +18,13 @@ const LEGACY_MAP: Record = { 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 (
); diff --git a/frontend/src/shared/constants/alertLevels.ts b/frontend/src/shared/constants/alertLevels.ts new file mode 100644 index 0000000..c80a8e4 --- /dev/null +++ b/frontend/src/shared/constants/alertLevels.ts @@ -0,0 +1,125 @@ +/** + * 위험도/알림 등급 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 EVENT_LEVEL (V008 시드). + * 향후 `GET /api/code-master?groupCode=EVENT_LEVEL`로 fetch 예정. + * + * **공통 Badge 컴포넌트와 연동**: 가능한 한 `` 사용. + * 카드 컨테이너 등 분리된 클래스가 필요한 경우만 `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 클래스 묶음 — 카드/컨테이너 전용 (배지가 아님) + * 배지에는 `` 사용 권장. + * 여기서 bg/border는 약한 알파 — 카드 배경에 텍스트가 묻히지 않도록. + */ + classes: { + bg: string; + text: string; + border: string; + dot: string; + /** 진한 배경 (액션 버튼 등) */ + bgSolid: string; + }; + /** hex 색상 (지도 마커, 차트, 인라인 style 용) */ + hex: string; + order: number; +} + +export const ALERT_LEVELS: Record = { + 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'; +} diff --git a/frontend/src/shared/constants/connectionStatuses.ts b/frontend/src/shared/constants/connectionStatuses.ts new file mode 100644 index 0000000..df04b3b --- /dev/null +++ b/frontend/src/shared/constants/connectionStatuses.ts @@ -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 = { + 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 = { + 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; +} diff --git a/frontend/src/shared/constants/darkVesselPatterns.ts b/frontend/src/shared/constants/darkVesselPatterns.ts new file mode 100644 index 0000000..6a57b0e --- /dev/null +++ b/frontend/src/shared/constants/darkVesselPatterns.ts @@ -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 = { + 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 = { + '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] }); +} diff --git a/frontend/src/shared/constants/deviceStatuses.ts b/frontend/src/shared/constants/deviceStatuses.ts new file mode 100644 index 0000000..52fe89b --- /dev/null +++ b/frontend/src/shared/constants/deviceStatuses.ts @@ -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 = { + 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 = { + '온라인': '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] }); +} diff --git a/frontend/src/shared/constants/enforcementActions.ts b/frontend/src/shared/constants/enforcementActions.ts new file mode 100644 index 0000000..a95b107 --- /dev/null +++ b/frontend/src/shared/constants/enforcementActions.ts @@ -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 = { + 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] }); +} diff --git a/frontend/src/shared/constants/enforcementResults.ts b/frontend/src/shared/constants/enforcementResults.ts new file mode 100644 index 0000000..2ebe058 --- /dev/null +++ b/frontend/src/shared/constants/enforcementResults.ts @@ -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 = { + 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'; +} diff --git a/frontend/src/shared/constants/engineSeverities.ts b/frontend/src/shared/constants/engineSeverities.ts new file mode 100644 index 0000000..b8587f2 --- /dev/null +++ b/frontend/src/shared/constants/engineSeverities.ts @@ -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 = { + '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] }); +} diff --git a/frontend/src/shared/constants/eventStatuses.ts b/frontend/src/shared/constants/eventStatuses.ts new file mode 100644 index 0000000..0c83c4a --- /dev/null +++ b/frontend/src/shared/constants/eventStatuses.ts @@ -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 = { + 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'; +} diff --git a/frontend/src/shared/constants/gearGroupTypes.ts b/frontend/src/shared/constants/gearGroupTypes.ts new file mode 100644 index 0000000..10f828d --- /dev/null +++ b/frontend/src/shared/constants/gearGroupTypes.ts @@ -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 = { + 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] }); +} diff --git a/frontend/src/shared/constants/httpStatusCodes.ts b/frontend/src/shared/constants/httpStatusCodes.ts new file mode 100644 index 0000000..3640ae0 --- /dev/null +++ b/frontend/src/shared/constants/httpStatusCodes.ts @@ -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'; +} diff --git a/frontend/src/shared/constants/index.ts b/frontend/src/shared/constants/index.ts new file mode 100644 index 0000000..6d395b6 --- /dev/null +++ b/frontend/src/shared/constants/index.ts @@ -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'; diff --git a/frontend/src/shared/constants/kpiUiMap.ts b/frontend/src/shared/constants/kpiUiMap.ts new file mode 100644 index 0000000..c478576 --- /dev/null +++ b/frontend/src/shared/constants/kpiUiMap.ts @@ -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 = { + // 한글 라벨 (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' }; +} diff --git a/frontend/src/shared/constants/loginResultStatuses.ts b/frontend/src/shared/constants/loginResultStatuses.ts new file mode 100644 index 0000000..86bbbea --- /dev/null +++ b/frontend/src/shared/constants/loginResultStatuses.ts @@ -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 = { + 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] }); +} diff --git a/frontend/src/shared/constants/modelDeploymentStatuses.ts b/frontend/src/shared/constants/modelDeploymentStatuses.ts new file mode 100644 index 0000000..626caac --- /dev/null +++ b/frontend/src/shared/constants/modelDeploymentStatuses.ts @@ -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 = { + 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 = { + 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 = { + 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'; +} diff --git a/frontend/src/shared/constants/parentResolutionStatuses.ts b/frontend/src/shared/constants/parentResolutionStatuses.ts new file mode 100644 index 0000000..487e296 --- /dev/null +++ b/frontend/src/shared/constants/parentResolutionStatuses.ts @@ -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 = { + 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 = { + 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] }); +} diff --git a/frontend/src/shared/constants/patrolStatuses.ts b/frontend/src/shared/constants/patrolStatuses.ts new file mode 100644 index 0000000..a8a314c --- /dev/null +++ b/frontend/src/shared/constants/patrolStatuses.ts @@ -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 = { + 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 = { + '추적 중': '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'; +} diff --git a/frontend/src/shared/constants/permissionStatuses.ts b/frontend/src/shared/constants/permissionStatuses.ts new file mode 100644 index 0000000..471626e --- /dev/null +++ b/frontend/src/shared/constants/permissionStatuses.ts @@ -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 = { + 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 = { + '유효': '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 = { + 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 = { + '정상': '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'; +} diff --git a/frontend/src/shared/constants/trainingZoneTypes.ts b/frontend/src/shared/constants/trainingZoneTypes.ts new file mode 100644 index 0000000..8fe39f2 --- /dev/null +++ b/frontend/src/shared/constants/trainingZoneTypes.ts @@ -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 = { + 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 = { + '해군': '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] }); +} diff --git a/frontend/src/shared/constants/userAccountStatuses.ts b/frontend/src/shared/constants/userAccountStatuses.ts new file mode 100644 index 0000000..59dfe54 --- /dev/null +++ b/frontend/src/shared/constants/userAccountStatuses.ts @@ -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 = { + 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] }); +} diff --git a/frontend/src/shared/constants/userRoles.ts b/frontend/src/shared/constants/userRoles.ts new file mode 100644 index 0000000..91695af --- /dev/null +++ b/frontend/src/shared/constants/userRoles.ts @@ -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 = { + ADMIN: '#ef4444', + OPERATOR: '#3b82f6', + ANALYST: '#a855f7', + FIELD: '#22c55e', + VIEWER: '#eab308', +}; + +/** 백엔드 fetch 결과를 캐시 — 역할 코드 → colorHex */ +let roleColorCache: Record = {}; + +/** 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'; diff --git a/frontend/src/shared/constants/vesselAnalysisStatuses.ts b/frontend/src/shared/constants/vesselAnalysisStatuses.ts new file mode 100644 index 0000000..50d4527 --- /dev/null +++ b/frontend/src/shared/constants/vesselAnalysisStatuses.ts @@ -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 = { + 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 = { + '추적중': '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 = { + 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 = { + '양호': '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; +} diff --git a/frontend/src/shared/constants/violationTypes.ts b/frontend/src/shared/constants/violationTypes.ts new file mode 100644 index 0000000..541bdd4 --- /dev/null +++ b/frontend/src/shared/constants/violationTypes.ts @@ -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 = { + 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); +} diff --git a/frontend/src/shared/utils/dateFormat.ts b/frontend/src/shared/utils/dateFormat.ts index 44a83df..55f6a03 100644 --- a/frontend/src/shared/utils/dateFormat.ts +++ b/frontend/src/shared/utils/dateFormat.ts @@ -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, diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 615f6b0..1407978 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -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 {