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:
부모
20d6743c17
커밋
5812d9dea3
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deck.gl": "^9.2.11",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^26.0.3",
|
||||
@ -20,6 +21,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2824,7 +2826,7 @@
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -4907,6 +4909,16 @@
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"@deck.gl/mapbox": "^9.2.11",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deck.gl": "^9.2.11",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^26.0.3",
|
||||
@ -24,6 +25,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
"realtimeEvent": "Realtime Events",
|
||||
"eventList": "Event List",
|
||||
"mobileService": "Mobile",
|
||||
"shipAgent": "Ship Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ Violation",
|
||||
"darkVessel": "Dark Vessel",
|
||||
"mmsiTampering": "MMSI Tampering",
|
||||
"illegalTransship": "Illegal Transship",
|
||||
"illegalGear": "Illegal Gear",
|
||||
"riskBehavior": "Risk Behavior"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "New",
|
||||
"ACK": "Acknowledged",
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"RESOLVED": "Resolved",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "Capture",
|
||||
"INSPECT": "Inspect",
|
||||
"WARN": "Warn",
|
||||
"DISPERSE": "Disperse",
|
||||
"TRACK": "Track",
|
||||
"EVIDENCE": "Evidence"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "Punished",
|
||||
"REFERRED": "Referred",
|
||||
"WARNED": "Warned",
|
||||
"RELEASED": "Released",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "Available",
|
||||
"ON_PATROL": "On Patrol",
|
||||
"IN_PURSUIT": "In Pursuit",
|
||||
"INSPECTING": "Inspecting",
|
||||
"RETURNING": "Returning",
|
||||
"STANDBY": "Standby",
|
||||
"MAINTENANCE": "Maintenance"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "Critical",
|
||||
"HIGH_CRITICAL": "High~Critical",
|
||||
"HIGH": "High",
|
||||
"MEDIUM_CRITICAL": "Medium~Critical",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "Online",
|
||||
"OFFLINE": "Offline",
|
||||
"SYNCING": "Syncing",
|
||||
"NOT_DEPLOYED": "Not Deployed"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "Unresolved",
|
||||
"REVIEW_REQUIRED": "Review Required",
|
||||
"MANUAL_CONFIRMED": "Manually Confirmed"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "Active",
|
||||
"COMPLETED": "Completed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "Deployed",
|
||||
"APPROVED": "Approved",
|
||||
"TESTING": "Testing",
|
||||
"DRAFT": "Draft"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "Fleet",
|
||||
"GEAR_IN_ZONE": "In-Zone Gear",
|
||||
"GEAR_OUT_ZONE": "Out-Zone Gear"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS Full Block",
|
||||
"MMSI_SPOOFING": "MMSI Spoofing",
|
||||
"LONG_LOSS": "Long Loss",
|
||||
"INTERMITTENT": "Intermittent",
|
||||
"SPEED_ANOMALY": "Speed Anomaly"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "Active",
|
||||
"PENDING": "Pending",
|
||||
"LOCKED": "Locked",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "Success",
|
||||
"FAILED": "Failed",
|
||||
"LOCKED": "Locked"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "Valid",
|
||||
"PENDING": "Pending",
|
||||
"EXPIRED": "Expired",
|
||||
"UNLICENSED": "Unlicensed"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "Normal",
|
||||
"CHECKING": "Checking",
|
||||
"SUSPECT_ILLEGAL": "Suspect Illegal"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "Tracking",
|
||||
"WATCHING": "Watching",
|
||||
"CHECKING": "Checking",
|
||||
"NORMAL": "Normal"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "Safe",
|
||||
"SUSPECT": "Suspect",
|
||||
"WARNING": "Warning"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "OK",
|
||||
"WARNING": "Warning",
|
||||
"ERROR": "Error"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "Navy Zone",
|
||||
"AIRFORCE": "Airforce Zone",
|
||||
"ARMY": "Army Zone",
|
||||
"ADD": "ADD",
|
||||
"KCG": "KCG"
|
||||
},
|
||||
"action": {
|
||||
"search": "Search",
|
||||
"save": "Save",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
"realtimeEvent": "실시간 이벤트",
|
||||
"eventList": "이벤트 목록",
|
||||
"mobileService": "모바일 서비스",
|
||||
"shipAgent": "함정 Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "보통",
|
||||
"low": "낮음"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ 침범",
|
||||
"darkVessel": "다크베셀",
|
||||
"mmsiTampering": "MMSI 변조",
|
||||
"illegalTransship": "불법 환적",
|
||||
"illegalGear": "불법 어구",
|
||||
"riskBehavior": "위험 행동"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "신규",
|
||||
"ACK": "확인",
|
||||
"IN_PROGRESS": "처리중",
|
||||
"RESOLVED": "완료",
|
||||
"FALSE_POSITIVE": "오탐"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "나포",
|
||||
"INSPECT": "검문",
|
||||
"WARN": "경고",
|
||||
"DISPERSE": "퇴거",
|
||||
"TRACK": "추적",
|
||||
"EVIDENCE": "증거수집"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "처벌",
|
||||
"REFERRED": "수사의뢰",
|
||||
"WARNED": "경고",
|
||||
"RELEASED": "훈방",
|
||||
"FALSE_POSITIVE": "오탐(정상)"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "가용",
|
||||
"ON_PATROL": "초계중",
|
||||
"IN_PURSUIT": "추적중",
|
||||
"INSPECTING": "검문중",
|
||||
"RETURNING": "귀항중",
|
||||
"STANDBY": "대기",
|
||||
"MAINTENANCE": "정비중"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "심각",
|
||||
"HIGH_CRITICAL": "높음~심각",
|
||||
"HIGH": "높음",
|
||||
"MEDIUM_CRITICAL": "보통~심각",
|
||||
"MEDIUM": "보통",
|
||||
"LOW": "낮음",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "온라인",
|
||||
"OFFLINE": "오프라인",
|
||||
"SYNCING": "동기화중",
|
||||
"NOT_DEPLOYED": "미배포"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "미해결",
|
||||
"REVIEW_REQUIRED": "검토 필요",
|
||||
"MANUAL_CONFIRMED": "수동 확정"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "진행중",
|
||||
"COMPLETED": "완료",
|
||||
"CANCELLED": "취소"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "배포됨",
|
||||
"APPROVED": "승인",
|
||||
"TESTING": "테스트",
|
||||
"DRAFT": "초안"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "선단",
|
||||
"GEAR_IN_ZONE": "구역 내 어구",
|
||||
"GEAR_OUT_ZONE": "구역 외 어구"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS 완전차단",
|
||||
"MMSI_SPOOFING": "MMSI 변조 의심",
|
||||
"LONG_LOSS": "장기 소실",
|
||||
"INTERMITTENT": "신호 간헐송출",
|
||||
"SPEED_ANOMALY": "속도 이상"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "활성",
|
||||
"PENDING": "승인 대기",
|
||||
"LOCKED": "잠금",
|
||||
"INACTIVE": "비활성"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "성공",
|
||||
"FAILED": "실패",
|
||||
"LOCKED": "계정 잠금"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "유효",
|
||||
"PENDING": "확인 중",
|
||||
"EXPIRED": "만료",
|
||||
"UNLICENSED": "무허가"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "정상",
|
||||
"CHECKING": "확인 중",
|
||||
"SUSPECT_ILLEGAL": "불법 의심"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "추적중",
|
||||
"WATCHING": "감시중",
|
||||
"CHECKING": "확인중",
|
||||
"NORMAL": "정상"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "양호",
|
||||
"SUSPECT": "의심",
|
||||
"WARNING": "경고"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "정상",
|
||||
"WARNING": "경고",
|
||||
"ERROR": "오류"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "해군 훈련 구역",
|
||||
"AIRFORCE": "공군 훈련 구역",
|
||||
"ARMY": "육군 훈련 구역",
|
||||
"ADD": "국방과학연구소",
|
||||
"KCG": "해양경찰청"
|
||||
},
|
||||
"action": {
|
||||
"search": "검색",
|
||||
"save": "저장",
|
||||
|
||||
@ -17,29 +17,42 @@ export const cardVariants = cva('rounded-xl border border-border', {
|
||||
defaultVariants: { variant: 'default' },
|
||||
});
|
||||
|
||||
/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 */
|
||||
/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합
|
||||
*
|
||||
* 가독성 정책:
|
||||
* - 배경: 색상별 진한 솔리드(-400) — 명확한 분류 식별
|
||||
* - 텍스트: 시맨틱 토큰 text-on-bright (theme.css = #0f172a) — 테마 무관 일관 가독성
|
||||
* - 보더: 같은 색상 계열 -600 (배경 강조)
|
||||
* - 가운데 정렬
|
||||
* - 폰트 크기: rem 기반 (root font-size 대비 비율) — 화면 비율에 따라 자동 조정
|
||||
*
|
||||
* className override는 cn(tailwind-merge) 덕분에 같은 그룹(text-color/font-size/bg) 충돌 시
|
||||
* 마지막 명시값이 적용 — !important 없이 의도된 override만 허용.
|
||||
*/
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors',
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors text-center text-on-bright',
|
||||
{
|
||||
variants: {
|
||||
intent: {
|
||||
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
high: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
success: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
muted: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
cyan: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
critical: 'bg-red-400 border-red-600',
|
||||
high: 'bg-orange-400 border-orange-600',
|
||||
warning: 'bg-yellow-400 border-yellow-600',
|
||||
info: 'bg-blue-400 border-blue-600',
|
||||
success: 'bg-green-400 border-green-600',
|
||||
muted: 'bg-slate-400 border-slate-600',
|
||||
purple: 'bg-purple-400 border-purple-600',
|
||||
cyan: 'bg-cyan-400 border-cyan-600',
|
||||
},
|
||||
// rem 기반 — root font-size 대비 비율, 화면 비율 변경 시 자동 조정
|
||||
// xs ≈ 11px, sm ≈ 12px, md ≈ 13px, lg ≈ 14px (root 14px 기준)
|
||||
size: {
|
||||
xs: 'text-[8px]',
|
||||
sm: 'text-[9px]',
|
||||
md: 'text-[10px]',
|
||||
lg: 'text-[11px]',
|
||||
xs: 'text-[0.6875rem] leading-tight',
|
||||
sm: 'text-[0.75rem] leading-tight',
|
||||
md: 'text-[0.8125rem] leading-tight',
|
||||
lg: 'text-[0.875rem] leading-tight',
|
||||
},
|
||||
},
|
||||
defaultVariants: { intent: 'info', size: 'md' },
|
||||
defaultVariants: { intent: 'info', size: 'sm' },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
35
frontend/src/lib/utils/cn.ts
Normal file
35
frontend/src/lib/utils/cn.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* className 병합 유틸 — tailwind-merge로 충돌 자동 해결.
|
||||
*
|
||||
* cva()의 base class와 컴포넌트 className prop을 합칠 때 사용.
|
||||
* 같은 그룹(text color, padding, bg 등) 클래스가 여러 개면 마지막 것만 적용.
|
||||
*
|
||||
* 사용 예:
|
||||
* cn(badgeVariants({ intent }), className)
|
||||
* → className에서 들어오는 text-red-400이 base의 text-on-bright을 override (마지막 우선)
|
||||
*
|
||||
* 시맨틱 토큰 (text-on-vivid, text-on-bright, text-heading 등)을 tailwind-merge가
|
||||
* text-color 그룹으로 인식하도록 extendTailwindMerge로 확장.
|
||||
*/
|
||||
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
// 프로젝트 시맨틱 텍스트 색상 (theme.css 정의) — text-* 그룹 충돌 해결
|
||||
'text-color': [
|
||||
'text-heading',
|
||||
'text-label',
|
||||
'text-hint',
|
||||
'text-on-vivid',
|
||||
'text-on-bright',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@ -68,6 +68,8 @@ export interface RoleWithPermissions {
|
||||
roleCd: string;
|
||||
roleNm: string;
|
||||
roleDc: string;
|
||||
/** UI 표기 색상 (#RRGGBB). NULL이면 프론트 기본 팔레트에서 결정 */
|
||||
colorHex: string | null;
|
||||
dfltYn: string;
|
||||
builtinYn: string;
|
||||
permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[];
|
||||
@ -95,8 +97,13 @@ export function fetchPermTree() {
|
||||
return apiGet<PermTreeNode[]>('/perm-tree');
|
||||
}
|
||||
|
||||
export function fetchRoles() {
|
||||
return apiGet<RoleWithPermissions[]>('/roles');
|
||||
import { updateRoleColorCache } from '@shared/constants/userRoles';
|
||||
|
||||
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
||||
const roles = await apiGet<RoleWithPermissions[]>('/roles');
|
||||
// 역할 색상 캐시 즉시 갱신 → 모든 사용처 자동 반영
|
||||
updateRoleColorCache(roles);
|
||||
return roles;
|
||||
}
|
||||
|
||||
// ─── 역할 CRUD ───────────────────────────────
|
||||
@ -104,12 +111,14 @@ export interface RoleCreatePayload {
|
||||
roleCd: string;
|
||||
roleNm: string;
|
||||
roleDc?: string;
|
||||
colorHex?: string;
|
||||
dfltYn?: string;
|
||||
}
|
||||
|
||||
export interface RoleUpdatePayload {
|
||||
roleNm?: string;
|
||||
roleDc?: string;
|
||||
colorHex?: string;
|
||||
dfltYn?: string;
|
||||
}
|
||||
|
||||
|
||||
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
|
||||
|
||||
interface ColorPickerProps {
|
||||
/** 현재 선택된 hex 색상 (#RRGGBB) */
|
||||
value: string | null | undefined;
|
||||
/** 색상 변경 콜백 */
|
||||
onChange: (hex: string) => void;
|
||||
/** 사용자 지정 팔레트. 미지정 시 ROLE_DEFAULT_PALETTE 사용 */
|
||||
palette?: string[];
|
||||
/** 직접 입력 허용 여부 (default: true) */
|
||||
allowCustom?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리 정의된 팔레트에서 색상을 선택하는 컴포넌트.
|
||||
* 사용자 정의 hex 입력도 지원.
|
||||
*
|
||||
* 사용처: 역할 생성/수정 다이얼로그
|
||||
*/
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
palette = ROLE_DEFAULT_PALETTE,
|
||||
allowCustom = true,
|
||||
className = '',
|
||||
label,
|
||||
}: ColorPickerProps) {
|
||||
const [customHex, setCustomHex] = useState<string>(value ?? '');
|
||||
|
||||
const handleCustomChange = (hex: string) => {
|
||||
setCustomHex(hex);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
onChange(hex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{label && <div className="text-[10px] text-hint">{label}</div>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{palette.map((color) => {
|
||||
const selected = value?.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setCustomHex(color);
|
||||
}}
|
||||
className={`w-6 h-6 rounded-md border-2 transition-all flex items-center justify-center ${
|
||||
selected ? 'border-white scale-110' : 'border-transparent hover:border-white/40'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 text-slate-900" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? '#808080'}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCustomHex(e.target.value);
|
||||
}}
|
||||
className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer"
|
||||
title="색상 직접 선택"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customHex}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
placeholder="#RRGGBB"
|
||||
maxLength={7}
|
||||
className="flex-1 px-2 py-1 bg-surface-overlay border border-border rounded text-[11px] text-label font-mono focus:outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
export interface DataColumn<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 고정 너비 (설정 시 가변 비활성) */
|
||||
/** 선호 최소 너비. 컨텐츠가 더 길면 자동 확장. */
|
||||
width?: string;
|
||||
/** 최소 너비 (가변 모드에서 적용) */
|
||||
/** 명시 최소 너비 (width와 사실상 동일하나 의미 강조용) */
|
||||
minWidth?: string;
|
||||
/** 최대 너비 (가변 모드에서 적용) */
|
||||
/** 최대 너비 (초과 시 ellipsis 트런케이트) */
|
||||
maxWidth?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
sortable?: boolean;
|
||||
@ -144,12 +144,14 @@ export function DataTable<T extends Record<string, unknown>>({
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => {
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
|
||||
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
|
||||
// width = 선호 최소 너비. 컨텐츠가 더 길면 자동 확장.
|
||||
if (col.width) cellStyle.minWidth = col.width;
|
||||
if (col.minWidth) cellStyle.minWidth = col.minWidth;
|
||||
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap ${
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${col.sortable !== false ? 'cursor-pointer hover:text-label select-none' : ''}`}
|
||||
style={cellStyle}
|
||||
@ -188,19 +190,23 @@ export function DataTable<T extends Record<string, unknown>>({
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; }
|
||||
else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; }
|
||||
if (col.width) cellStyle.minWidth = col.width;
|
||||
if (col.minWidth) cellStyle.minWidth = col.minWidth;
|
||||
if (col.maxWidth) cellStyle.maxWidth = col.maxWidth;
|
||||
const rawValue = row[col.key];
|
||||
const titleText = rawValue != null ? String(rawValue) : '';
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-4 py-2 whitespace-nowrap ${
|
||||
className={`px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={cellStyle}
|
||||
title={titleText}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, page * pageSize + i)
|
||||
: <span className="text-label">{row[col.key] != null ? String(row[col.key]) : ''}</span>
|
||||
? col.render(rawValue, row, page * pageSize + i)
|
||||
: <span className="text-label">{titleText}</span>
|
||||
}
|
||||
</td>
|
||||
);
|
||||
|
||||
@ -61,7 +61,7 @@ export function ExcelExport({
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${
|
||||
exported
|
||||
? 'bg-green-600/20 text-green-400 border border-green-500/30'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-on-vivid hover:border-border'
|
||||
} ${className}`}
|
||||
>
|
||||
{exported ? <Check className="w-3 h-3" /> : <FileSpreadsheet className="w-3 h-3" />}
|
||||
|
||||
@ -145,7 +145,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end">
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
|
||||
@ -56,7 +56,7 @@ export function Pagination({ page, totalPages, totalItems, pageSize, onPageChang
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`min-w-[24px] h-6 px-1 rounded text-[10px] font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-heading'
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -33,8 +33,8 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN
|
||||
disabled={disabled || state !== 'idle'}
|
||||
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${
|
||||
state === 'done'
|
||||
? 'bg-green-600 text-heading'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-heading'
|
||||
? 'bg-green-600 text-on-vivid'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-on-vivid'
|
||||
} ${className}`}
|
||||
>
|
||||
{state === 'saving' ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type HTMLAttributes } from 'react';
|
||||
import { badgeVariants, type BadgeIntent, type BadgeSize } from '@lib/theme/variants';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 의미 기반 색상 (기존 variant 대체) */
|
||||
@ -17,11 +18,13 @@ const LEGACY_MAP: Record<string, BadgeIntent> = {
|
||||
outline: 'muted',
|
||||
};
|
||||
|
||||
export function Badge({ className = '', intent, size, variant, ...props }: BadgeProps) {
|
||||
export function Badge({ className, intent, size, variant, ...props }: BadgeProps) {
|
||||
const resolvedIntent = intent ?? (variant ? LEGACY_MAP[variant] : undefined);
|
||||
// cn() = clsx + tailwind-merge: 같은 그룹(text-color, bg, padding 등) 충돌 시
|
||||
// 마지막 className이 우선 → base 클래스의 일관 정책을 className에서 명시적으로만 override 가능
|
||||
return (
|
||||
<div
|
||||
className={`${badgeVariants({ intent: resolvedIntent, size })} ${className}`}
|
||||
className={cn(badgeVariants({ intent: resolvedIntent, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
125
frontend/src/shared/constants/alertLevels.ts
Normal file
125
frontend/src/shared/constants/alertLevels.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 위험도/알림 등급 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 EVENT_LEVEL (V008 시드).
|
||||
* 향후 `GET /api/code-master?groupCode=EVENT_LEVEL`로 fetch 예정.
|
||||
*
|
||||
* **공통 Badge 컴포넌트와 연동**: 가능한 한 `<Badge intent={getAlertLevelIntent(level)}>` 사용.
|
||||
* 카드 컨테이너 등 분리된 클래스가 필요한 경우만 `ALERT_LEVELS.classes` 사용.
|
||||
*
|
||||
* 사용처: Dashboard, MonitoringDashboard, EventList, LiveMapView, VesselDetail,
|
||||
* MobileService, ChinaFishing 등 모든 위험도 배지 / 알림 / 마커
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
export interface AlertLevelMeta {
|
||||
code: AlertLevel;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
/** 공통 Badge 컴포넌트 intent (badgeVariants 와 동기화) */
|
||||
intent: BadgeIntent;
|
||||
/** Tailwind 클래스 묶음 — 카드/컨테이너 전용 (배지가 아님)
|
||||
* 배지에는 `<Badge intent={meta.intent}>` 사용 권장.
|
||||
* 여기서 bg/border는 약한 알파 — 카드 배경에 텍스트가 묻히지 않도록.
|
||||
*/
|
||||
classes: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
dot: string;
|
||||
/** 진한 배경 (액션 버튼 등) */
|
||||
bgSolid: string;
|
||||
};
|
||||
/** hex 색상 (지도 마커, 차트, 인라인 style 용) */
|
||||
hex: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ALERT_LEVELS: Record<AlertLevel, AlertLevelMeta> = {
|
||||
CRITICAL: {
|
||||
code: 'CRITICAL',
|
||||
i18nKey: 'alert.critical',
|
||||
fallback: { ko: '심각', en: 'Critical' },
|
||||
intent: 'critical',
|
||||
classes: {
|
||||
bg: 'bg-red-500/10',
|
||||
text: 'text-red-300',
|
||||
border: 'border-red-500/30',
|
||||
dot: 'bg-red-500',
|
||||
bgSolid: 'bg-red-500',
|
||||
},
|
||||
hex: '#ef4444',
|
||||
order: 1,
|
||||
},
|
||||
HIGH: {
|
||||
code: 'HIGH',
|
||||
i18nKey: 'alert.high',
|
||||
fallback: { ko: '높음', en: 'High' },
|
||||
intent: 'high',
|
||||
classes: {
|
||||
bg: 'bg-orange-500/10',
|
||||
text: 'text-orange-300',
|
||||
border: 'border-orange-500/30',
|
||||
dot: 'bg-orange-500',
|
||||
bgSolid: 'bg-orange-500',
|
||||
},
|
||||
hex: '#f97316',
|
||||
order: 2,
|
||||
},
|
||||
MEDIUM: {
|
||||
code: 'MEDIUM',
|
||||
i18nKey: 'alert.medium',
|
||||
fallback: { ko: '보통', en: 'Medium' },
|
||||
intent: 'warning',
|
||||
classes: {
|
||||
bg: 'bg-yellow-500/10',
|
||||
text: 'text-yellow-300',
|
||||
border: 'border-yellow-500/30',
|
||||
dot: 'bg-yellow-500',
|
||||
bgSolid: 'bg-yellow-500',
|
||||
},
|
||||
hex: '#eab308',
|
||||
order: 3,
|
||||
},
|
||||
LOW: {
|
||||
code: 'LOW',
|
||||
i18nKey: 'alert.low',
|
||||
fallback: { ko: '낮음', en: 'Low' },
|
||||
intent: 'info',
|
||||
classes: {
|
||||
bg: 'bg-blue-500/10',
|
||||
text: 'text-blue-300',
|
||||
border: 'border-blue-500/30',
|
||||
dot: 'bg-blue-500',
|
||||
bgSolid: 'bg-blue-500',
|
||||
},
|
||||
hex: '#3b82f6',
|
||||
order: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export function getAlertLevelMeta(level: string): AlertLevelMeta | undefined {
|
||||
return ALERT_LEVELS[level as AlertLevel];
|
||||
}
|
||||
|
||||
export function getAlertLevelLabel(
|
||||
level: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getAlertLevelMeta(level);
|
||||
if (!meta) return level;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getAlertLevelHex(level: string): string {
|
||||
return getAlertLevelMeta(level)?.hex ?? '#6b7280';
|
||||
}
|
||||
|
||||
/** 공통 Badge intent 매핑 */
|
||||
export function getAlertLevelIntent(level: string): BadgeIntent {
|
||||
return getAlertLevelMeta(level)?.intent ?? 'muted';
|
||||
}
|
||||
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 데이터 연결/신호 상태 카탈로그
|
||||
*
|
||||
* 사용처: DataHub signal heatmap
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type ConnectionStatus = 'OK' | 'WARNING' | 'ERROR';
|
||||
|
||||
export const CONNECTION_STATUSES: Record<ConnectionStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
|
||||
OK: {
|
||||
i18nKey: 'connectionStatus.OK',
|
||||
fallback: { ko: '정상', en: 'OK' },
|
||||
intent: 'success',
|
||||
hex: '#22c55e',
|
||||
},
|
||||
WARNING: {
|
||||
i18nKey: 'connectionStatus.WARNING',
|
||||
fallback: { ko: '경고', en: 'Warning' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
ERROR: {
|
||||
i18nKey: 'connectionStatus.ERROR',
|
||||
fallback: { ko: '오류', en: 'Error' },
|
||||
intent: 'critical',
|
||||
hex: '#ef4444',
|
||||
},
|
||||
};
|
||||
|
||||
/** 소문자 호환 (DataHub 'ok' | 'warn' | 'error') */
|
||||
const LEGACY: Record<string, ConnectionStatus> = {
|
||||
ok: 'OK',
|
||||
warn: 'WARNING',
|
||||
warning: 'WARNING',
|
||||
error: 'ERROR',
|
||||
};
|
||||
|
||||
export function getConnectionStatusMeta(s: string) {
|
||||
if (CONNECTION_STATUSES[s as ConnectionStatus]) return CONNECTION_STATUSES[s as ConnectionStatus];
|
||||
const code = LEGACY[s.toLowerCase()];
|
||||
return code ? CONNECTION_STATUSES[code] : CONNECTION_STATUSES.OK;
|
||||
}
|
||||
|
||||
export function getConnectionStatusHex(s: string): string {
|
||||
return getConnectionStatusMeta(s).hex;
|
||||
}
|
||||
|
||||
export function getConnectionStatusIntent(s: string): BadgeIntent {
|
||||
return getConnectionStatusMeta(s).intent;
|
||||
}
|
||||
91
frontend/src/shared/constants/darkVesselPatterns.ts
Normal file
91
frontend/src/shared/constants/darkVesselPatterns.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 다크베셀 탐지 패턴 카탈로그
|
||||
*
|
||||
* SSOT: backend dark_pattern enum (V008 시드 DARK_PATTERN 그룹)
|
||||
* 사용처: DarkVesselDetection
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type DarkVesselPattern =
|
||||
| 'AIS_FULL_BLOCK' // AIS 완전차단
|
||||
| 'MMSI_SPOOFING' // MMSI 변조 의심
|
||||
| 'LONG_LOSS' // 장기소실
|
||||
| 'INTERMITTENT' // 신호 간헐송출
|
||||
| 'SPEED_ANOMALY'; // 속도 이상
|
||||
|
||||
interface DarkVesselPatternMeta {
|
||||
code: DarkVesselPattern;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const DARK_VESSEL_PATTERNS: Record<DarkVesselPattern, DarkVesselPatternMeta> = {
|
||||
AIS_FULL_BLOCK: {
|
||||
code: 'AIS_FULL_BLOCK',
|
||||
i18nKey: 'darkPattern.AIS_FULL_BLOCK',
|
||||
fallback: { ko: 'AIS 완전차단', en: 'AIS Full Block' },
|
||||
intent: 'critical',
|
||||
hex: '#ef4444',
|
||||
},
|
||||
MMSI_SPOOFING: {
|
||||
code: 'MMSI_SPOOFING',
|
||||
i18nKey: 'darkPattern.MMSI_SPOOFING',
|
||||
fallback: { ko: 'MMSI 변조 의심', en: 'MMSI Spoofing' },
|
||||
intent: 'high',
|
||||
hex: '#f97316',
|
||||
},
|
||||
LONG_LOSS: {
|
||||
code: 'LONG_LOSS',
|
||||
i18nKey: 'darkPattern.LONG_LOSS',
|
||||
fallback: { ko: '장기 소실', en: 'Long Loss' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
INTERMITTENT: {
|
||||
code: 'INTERMITTENT',
|
||||
i18nKey: 'darkPattern.INTERMITTENT',
|
||||
fallback: { ko: '신호 간헐송출', en: 'Intermittent' },
|
||||
intent: 'purple',
|
||||
hex: '#a855f7',
|
||||
},
|
||||
SPEED_ANOMALY: {
|
||||
code: 'SPEED_ANOMALY',
|
||||
i18nKey: 'darkPattern.SPEED_ANOMALY',
|
||||
fallback: { ko: '속도 이상', en: 'Speed Anomaly' },
|
||||
intent: 'cyan',
|
||||
hex: '#06b6d4',
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨 호환 매핑 (mock 데이터에 한글이 들어있어서) */
|
||||
const LEGACY_KO: Record<string, DarkVesselPattern> = {
|
||||
'AIS 완전차단': 'AIS_FULL_BLOCK',
|
||||
'MMSI 변조 의심': 'MMSI_SPOOFING',
|
||||
'장기소실': 'LONG_LOSS',
|
||||
'장기 소실': 'LONG_LOSS',
|
||||
'신호 간헐송출': 'INTERMITTENT',
|
||||
'속도 이상': 'SPEED_ANOMALY',
|
||||
};
|
||||
|
||||
export function getDarkVesselPatternMeta(p: string): DarkVesselPatternMeta | undefined {
|
||||
if (DARK_VESSEL_PATTERNS[p as DarkVesselPattern]) return DARK_VESSEL_PATTERNS[p as DarkVesselPattern];
|
||||
const code = LEGACY_KO[p];
|
||||
return code ? DARK_VESSEL_PATTERNS[code] : undefined;
|
||||
}
|
||||
|
||||
export function getDarkVesselPatternIntent(p: string): BadgeIntent {
|
||||
return getDarkVesselPatternMeta(p)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getDarkVesselPatternLabel(
|
||||
p: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return p;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
74
frontend/src/shared/constants/deviceStatuses.ts
Normal file
74
frontend/src/shared/constants/deviceStatuses.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 디바이스/Agent 상태 카탈로그
|
||||
*
|
||||
* 함정 Agent, 외부 시스템 연결 등 디바이스의 운영 상태 표기.
|
||||
*
|
||||
* 사용처: ShipAgent, DataHub agent 상태, ExternalService 등
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type DeviceStatus = 'ONLINE' | 'OFFLINE' | 'NOT_DEPLOYED' | 'SYNCING';
|
||||
|
||||
export interface DeviceStatusMeta {
|
||||
code: DeviceStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const DEVICE_STATUSES: Record<DeviceStatus, DeviceStatusMeta> = {
|
||||
ONLINE: {
|
||||
code: 'ONLINE',
|
||||
i18nKey: 'deviceStatus.ONLINE',
|
||||
fallback: { ko: '온라인', en: 'Online' },
|
||||
intent: 'success',
|
||||
},
|
||||
SYNCING: {
|
||||
code: 'SYNCING',
|
||||
i18nKey: 'deviceStatus.SYNCING',
|
||||
fallback: { ko: '동기화중', en: 'Syncing' },
|
||||
intent: 'info',
|
||||
},
|
||||
OFFLINE: {
|
||||
code: 'OFFLINE',
|
||||
i18nKey: 'deviceStatus.OFFLINE',
|
||||
fallback: { ko: '오프라인', en: 'Offline' },
|
||||
intent: 'critical',
|
||||
},
|
||||
NOT_DEPLOYED: {
|
||||
code: 'NOT_DEPLOYED',
|
||||
i18nKey: 'deviceStatus.NOT_DEPLOYED',
|
||||
fallback: { ko: '미배포', en: 'Not Deployed' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨도 키로 받음 (mock 호환) */
|
||||
const LEGACY_KO: Record<string, DeviceStatus> = {
|
||||
'온라인': 'ONLINE',
|
||||
'오프라인': 'OFFLINE',
|
||||
'미배포': 'NOT_DEPLOYED',
|
||||
'동기화중': 'SYNCING',
|
||||
'동기화 중': 'SYNCING',
|
||||
};
|
||||
|
||||
export function getDeviceStatusMeta(status: string): DeviceStatusMeta | undefined {
|
||||
if (DEVICE_STATUSES[status as DeviceStatus]) return DEVICE_STATUSES[status as DeviceStatus];
|
||||
const code = LEGACY_KO[status];
|
||||
return code ? DEVICE_STATUSES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getDeviceStatusIntent(status: string): BadgeIntent {
|
||||
return getDeviceStatusMeta(status)?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
export function getDeviceStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getDeviceStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
83
frontend/src/shared/constants/enforcementActions.ts
Normal file
83
frontend/src/shared/constants/enforcementActions.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 단속 조치 코드 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 ENFORCEMENT_ACTION (V008 시드).
|
||||
* 백엔드 EnforcementRecord.action enum.
|
||||
*
|
||||
* 사용처: EnforcementHistory(조치 컬럼), 단속 등록 폼
|
||||
*/
|
||||
|
||||
export type EnforcementAction =
|
||||
| 'CAPTURE'
|
||||
| 'INSPECT'
|
||||
| 'WARN'
|
||||
| 'DISPERSE'
|
||||
| 'TRACK'
|
||||
| 'EVIDENCE';
|
||||
|
||||
export interface EnforcementActionMeta {
|
||||
code: EnforcementAction;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
hex: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ENFORCEMENT_ACTIONS: Record<EnforcementAction, EnforcementActionMeta> = {
|
||||
CAPTURE: {
|
||||
code: 'CAPTURE',
|
||||
i18nKey: 'enforcementAction.CAPTURE',
|
||||
fallback: { ko: '나포', en: 'Capture' },
|
||||
hex: '#ef4444',
|
||||
order: 1,
|
||||
},
|
||||
INSPECT: {
|
||||
code: 'INSPECT',
|
||||
i18nKey: 'enforcementAction.INSPECT',
|
||||
fallback: { ko: '검문', en: 'Inspect' },
|
||||
hex: '#f59e0b',
|
||||
order: 2,
|
||||
},
|
||||
WARN: {
|
||||
code: 'WARN',
|
||||
i18nKey: 'enforcementAction.WARN',
|
||||
fallback: { ko: '경고', en: 'Warn' },
|
||||
hex: '#3b82f6',
|
||||
order: 3,
|
||||
},
|
||||
DISPERSE: {
|
||||
code: 'DISPERSE',
|
||||
i18nKey: 'enforcementAction.DISPERSE',
|
||||
fallback: { ko: '퇴거', en: 'Disperse' },
|
||||
hex: '#8b5cf6',
|
||||
order: 4,
|
||||
},
|
||||
TRACK: {
|
||||
code: 'TRACK',
|
||||
i18nKey: 'enforcementAction.TRACK',
|
||||
fallback: { ko: '추적', en: 'Track' },
|
||||
hex: '#06b6d4',
|
||||
order: 5,
|
||||
},
|
||||
EVIDENCE: {
|
||||
code: 'EVIDENCE',
|
||||
i18nKey: 'enforcementAction.EVIDENCE',
|
||||
fallback: { ko: '증거수집', en: 'Evidence' },
|
||||
hex: '#64748b',
|
||||
order: 6,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnforcementActionMeta(action: string): EnforcementActionMeta | undefined {
|
||||
return ENFORCEMENT_ACTIONS[action as EnforcementAction];
|
||||
}
|
||||
|
||||
export function getEnforcementActionLabel(
|
||||
action: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEnforcementActionMeta(action);
|
||||
if (!meta) return action;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
79
frontend/src/shared/constants/enforcementResults.ts
Normal file
79
frontend/src/shared/constants/enforcementResults.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 단속 결과 코드 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 ENFORCEMENT_RESULT (V008 시드).
|
||||
* 백엔드 EnforcementRecord.result enum.
|
||||
*
|
||||
* 사용처: EnforcementHistory(결과 컬럼), 단속 통계
|
||||
*/
|
||||
|
||||
export type EnforcementResult =
|
||||
| 'PUNISHED'
|
||||
| 'WARNED'
|
||||
| 'RELEASED'
|
||||
| 'REFERRED'
|
||||
| 'FALSE_POSITIVE';
|
||||
|
||||
export interface EnforcementResultMeta {
|
||||
code: EnforcementResult;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ENFORCEMENT_RESULTS: Record<EnforcementResult, EnforcementResultMeta> = {
|
||||
PUNISHED: {
|
||||
code: 'PUNISHED',
|
||||
i18nKey: 'enforcementResult.PUNISHED',
|
||||
fallback: { ko: '처벌', en: 'Punished' },
|
||||
classes: 'bg-red-500/20 text-red-400',
|
||||
order: 1,
|
||||
},
|
||||
REFERRED: {
|
||||
code: 'REFERRED',
|
||||
i18nKey: 'enforcementResult.REFERRED',
|
||||
fallback: { ko: '수사의뢰', en: 'Referred' },
|
||||
classes: 'bg-purple-500/20 text-purple-400',
|
||||
order: 2,
|
||||
},
|
||||
WARNED: {
|
||||
code: 'WARNED',
|
||||
i18nKey: 'enforcementResult.WARNED',
|
||||
fallback: { ko: '경고', en: 'Warned' },
|
||||
classes: 'bg-yellow-500/20 text-yellow-400',
|
||||
order: 3,
|
||||
},
|
||||
RELEASED: {
|
||||
code: 'RELEASED',
|
||||
i18nKey: 'enforcementResult.RELEASED',
|
||||
fallback: { ko: '훈방', en: 'Released' },
|
||||
classes: 'bg-green-500/20 text-green-400',
|
||||
order: 4,
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'enforcementResult.FALSE_POSITIVE',
|
||||
fallback: { ko: '오탐(정상)', en: 'False Positive' },
|
||||
classes: 'bg-muted text-muted-foreground',
|
||||
order: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnforcementResultMeta(result: string): EnforcementResultMeta | undefined {
|
||||
return ENFORCEMENT_RESULTS[result as EnforcementResult];
|
||||
}
|
||||
|
||||
export function getEnforcementResultLabel(
|
||||
result: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEnforcementResultMeta(result);
|
||||
if (!meta) return result;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getEnforcementResultClasses(result: string): string {
|
||||
return getEnforcementResultMeta(result)?.classes ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
90
frontend/src/shared/constants/engineSeverities.ts
Normal file
90
frontend/src/shared/constants/engineSeverities.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI 엔진 심각도 카탈로그
|
||||
*
|
||||
* 일반 위험도(AlertLevel)와 다른 별도 분류 체계.
|
||||
* 단일 값(CRITICAL, HIGH 등) 외에도 **범위 표기**를 지원: 'HIGH~CRITICAL', 'MEDIUM~CRITICAL'.
|
||||
*
|
||||
* 사용처: AIModelManagement (탐지 엔진별 심각도 표기)
|
||||
*
|
||||
* 향후 확장 가능: prediction_engine 메타데이터, MLOps 모델별 임계치 정보
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type EngineSeverity =
|
||||
| 'CRITICAL'
|
||||
| 'HIGH~CRITICAL'
|
||||
| 'HIGH'
|
||||
| 'MEDIUM~CRITICAL'
|
||||
| 'MEDIUM'
|
||||
| 'LOW'
|
||||
| '-';
|
||||
|
||||
interface EngineSeverityMeta {
|
||||
code: EngineSeverity;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const ENGINE_SEVERITIES: Record<EngineSeverity, EngineSeverityMeta> = {
|
||||
'CRITICAL': {
|
||||
code: 'CRITICAL',
|
||||
i18nKey: 'engineSeverity.CRITICAL',
|
||||
fallback: { ko: '심각', en: 'Critical' },
|
||||
intent: 'critical',
|
||||
},
|
||||
'HIGH~CRITICAL': {
|
||||
code: 'HIGH~CRITICAL',
|
||||
i18nKey: 'engineSeverity.HIGH_CRITICAL',
|
||||
fallback: { ko: '높음~심각', en: 'High~Critical' },
|
||||
intent: 'critical',
|
||||
},
|
||||
'HIGH': {
|
||||
code: 'HIGH',
|
||||
i18nKey: 'engineSeverity.HIGH',
|
||||
fallback: { ko: '높음', en: 'High' },
|
||||
intent: 'high',
|
||||
},
|
||||
'MEDIUM~CRITICAL': {
|
||||
code: 'MEDIUM~CRITICAL',
|
||||
i18nKey: 'engineSeverity.MEDIUM_CRITICAL',
|
||||
fallback: { ko: '보통~심각', en: 'Medium~Critical' },
|
||||
intent: 'high',
|
||||
},
|
||||
'MEDIUM': {
|
||||
code: 'MEDIUM',
|
||||
i18nKey: 'engineSeverity.MEDIUM',
|
||||
fallback: { ko: '보통', en: 'Medium' },
|
||||
intent: 'warning',
|
||||
},
|
||||
'LOW': {
|
||||
code: 'LOW',
|
||||
i18nKey: 'engineSeverity.LOW',
|
||||
fallback: { ko: '낮음', en: 'Low' },
|
||||
intent: 'info',
|
||||
},
|
||||
'-': {
|
||||
code: '-',
|
||||
i18nKey: 'engineSeverity.NONE',
|
||||
fallback: { ko: '-', en: '-' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEngineSeverityMeta(sev: string): EngineSeverityMeta {
|
||||
return ENGINE_SEVERITIES[sev as EngineSeverity] ?? ENGINE_SEVERITIES['-'];
|
||||
}
|
||||
|
||||
export function getEngineSeverityIntent(sev: string): BadgeIntent {
|
||||
return getEngineSeverityMeta(sev).intent;
|
||||
}
|
||||
|
||||
export function getEngineSeverityLabel(
|
||||
sev: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEngineSeverityMeta(sev);
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
79
frontend/src/shared/constants/eventStatuses.ts
Normal file
79
frontend/src/shared/constants/eventStatuses.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 이벤트 처리 상태 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 EVENT_STATUS (V008 시드).
|
||||
* 향후 `GET /api/code-master?groupCode=EVENT_STATUS`로 fetch 예정.
|
||||
*
|
||||
* 사용처: EventList(처리상태 컬럼), 알림 처리, 단속 등록 액션
|
||||
*/
|
||||
|
||||
export type EventStatus =
|
||||
| 'NEW'
|
||||
| 'ACK'
|
||||
| 'IN_PROGRESS'
|
||||
| 'RESOLVED'
|
||||
| 'FALSE_POSITIVE';
|
||||
|
||||
export interface EventStatusMeta {
|
||||
code: EventStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string; // bg + text 묶음
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const EVENT_STATUSES: Record<EventStatus, EventStatusMeta> = {
|
||||
NEW: {
|
||||
code: 'NEW',
|
||||
i18nKey: 'eventStatus.NEW',
|
||||
fallback: { ko: '신규', en: 'New' },
|
||||
classes: 'bg-red-500/20 text-red-400',
|
||||
order: 1,
|
||||
},
|
||||
ACK: {
|
||||
code: 'ACK',
|
||||
i18nKey: 'eventStatus.ACK',
|
||||
fallback: { ko: '확인', en: 'Acknowledged' },
|
||||
classes: 'bg-orange-500/20 text-orange-400',
|
||||
order: 2,
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
code: 'IN_PROGRESS',
|
||||
i18nKey: 'eventStatus.IN_PROGRESS',
|
||||
fallback: { ko: '처리중', en: 'In Progress' },
|
||||
classes: 'bg-blue-500/20 text-blue-400',
|
||||
order: 3,
|
||||
},
|
||||
RESOLVED: {
|
||||
code: 'RESOLVED',
|
||||
i18nKey: 'eventStatus.RESOLVED',
|
||||
fallback: { ko: '완료', en: 'Resolved' },
|
||||
classes: 'bg-green-500/20 text-green-400',
|
||||
order: 4,
|
||||
},
|
||||
FALSE_POSITIVE: {
|
||||
code: 'FALSE_POSITIVE',
|
||||
i18nKey: 'eventStatus.FALSE_POSITIVE',
|
||||
fallback: { ko: '오탐', en: 'False Positive' },
|
||||
classes: 'bg-muted text-muted-foreground',
|
||||
order: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEventStatusMeta(status: string): EventStatusMeta | undefined {
|
||||
return EVENT_STATUSES[status as EventStatus];
|
||||
}
|
||||
|
||||
export function getEventStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getEventStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getEventStatusClasses(status: string): string {
|
||||
return getEventStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground';
|
||||
}
|
||||
51
frontend/src/shared/constants/gearGroupTypes.ts
Normal file
51
frontend/src/shared/constants/gearGroupTypes.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 어구 그룹 타입 카탈로그
|
||||
*
|
||||
* SSOT: backend group_polygon_snapshots.group_type
|
||||
* 사용처: RealGearGroups
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type GearGroupType = 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
|
||||
|
||||
interface GearGroupTypeMeta {
|
||||
code: GearGroupType;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const GEAR_GROUP_TYPES: Record<GearGroupType, GearGroupTypeMeta> = {
|
||||
FLEET: {
|
||||
code: 'FLEET',
|
||||
i18nKey: 'gearGroupType.FLEET',
|
||||
fallback: { ko: '선단', en: 'Fleet' },
|
||||
intent: 'info',
|
||||
},
|
||||
GEAR_IN_ZONE: {
|
||||
code: 'GEAR_IN_ZONE',
|
||||
i18nKey: 'gearGroupType.GEAR_IN_ZONE',
|
||||
fallback: { ko: '구역 내 어구', en: 'In-Zone Gear' },
|
||||
intent: 'high',
|
||||
},
|
||||
GEAR_OUT_ZONE: {
|
||||
code: 'GEAR_OUT_ZONE',
|
||||
i18nKey: 'gearGroupType.GEAR_OUT_ZONE',
|
||||
fallback: { ko: '구역 외 어구', en: 'Out-Zone Gear' },
|
||||
intent: 'purple',
|
||||
},
|
||||
};
|
||||
|
||||
export function getGearGroupTypeIntent(t: string): BadgeIntent {
|
||||
return GEAR_GROUP_TYPES[t as GearGroupType]?.intent ?? 'muted';
|
||||
}
|
||||
export function getGearGroupTypeLabel(
|
||||
type: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = GEAR_GROUP_TYPES[type as GearGroupType];
|
||||
if (!meta) return type;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
23
frontend/src/shared/constants/httpStatusCodes.ts
Normal file
23
frontend/src/shared/constants/httpStatusCodes.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* HTTP 상태 코드 카탈로그 (범위 기반)
|
||||
*
|
||||
* 사용처: AccessLogs, AuditLogs API 응답 상태 표시
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
/**
|
||||
* HTTP status code → BadgeIntent
|
||||
* - 5xx: critical (서버 에러)
|
||||
* - 4xx: high (클라이언트 에러)
|
||||
* - 3xx: warning (리다이렉트)
|
||||
* - 2xx: success
|
||||
* - 그 외: muted
|
||||
*/
|
||||
export function getHttpStatusIntent(code: number): BadgeIntent {
|
||||
if (code >= 500) return 'critical';
|
||||
if (code >= 400) return 'high';
|
||||
if (code >= 300) return 'warning';
|
||||
if (code >= 200) return 'success';
|
||||
return 'muted';
|
||||
}
|
||||
31
frontend/src/shared/constants/index.ts
Normal file
31
frontend/src/shared/constants/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 분류/코드 카탈로그 통합 export
|
||||
*
|
||||
* 모든 분류 enum (위반 유형, 위험도, 이벤트 상태, 단속 조치/결과, 함정 상태 등)을
|
||||
* 이 파일을 통해 import하여 일관성 유지.
|
||||
*
|
||||
* 향후 백엔드 `GET /api/code-master?groupCode={...}` API가 준비되면
|
||||
* codeMasterStore에서 fetch한 값으로 정적 카탈로그를 override 할 수 있다.
|
||||
*/
|
||||
|
||||
export * from './violationTypes';
|
||||
export * from './alertLevels';
|
||||
export * from './eventStatuses';
|
||||
export * from './enforcementActions';
|
||||
export * from './enforcementResults';
|
||||
export * from './patrolStatuses';
|
||||
export * from './engineSeverities';
|
||||
export * from './userRoles';
|
||||
export * from './deviceStatuses';
|
||||
export * from './parentResolutionStatuses';
|
||||
export * from './modelDeploymentStatuses';
|
||||
export * from './gearGroupTypes';
|
||||
export * from './darkVesselPatterns';
|
||||
export * from './httpStatusCodes';
|
||||
export * from './userAccountStatuses';
|
||||
export * from './loginResultStatuses';
|
||||
export * from './permissionStatuses';
|
||||
export * from './vesselAnalysisStatuses';
|
||||
export * from './connectionStatuses';
|
||||
export * from './trainingZoneTypes';
|
||||
export * from './kpiUiMap';
|
||||
38
frontend/src/shared/constants/kpiUiMap.ts
Normal file
38
frontend/src/shared/constants/kpiUiMap.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* KPI 카드 UI 매핑 (아이콘 + 색상)
|
||||
*
|
||||
* Dashboard와 MonitoringDashboard에서 중복 정의되던 KPI_UI_MAP 통합.
|
||||
* 한글 라벨 / 영문 kpiKey 모두 지원.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Radar, AlertTriangle, Eye, Anchor, Crosshair, Shield, Target } from 'lucide-react';
|
||||
|
||||
export interface KpiUi {
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const KPI_UI_MAP: Record<string, KpiUi> = {
|
||||
// 한글 라벨 (DB prediction_kpi.kpi_label)
|
||||
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
||||
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
||||
'다크베셀': { icon: Eye, color: '#f97316' },
|
||||
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
||||
'추적 중': { icon: Crosshair, color: '#06b6d4' },
|
||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
||||
// 영문 kpi_key (백엔드 코드)
|
||||
realtime_detection: { icon: Radar, color: '#3b82f6' },
|
||||
eez_violation: { icon: AlertTriangle, color: '#ef4444' },
|
||||
dark_vessel: { icon: Eye, color: '#f97316' },
|
||||
illegal_transshipment: { icon: Anchor, color: '#a855f7' },
|
||||
illegal_transship: { icon: Anchor, color: '#a855f7' },
|
||||
tracking: { icon: Target, color: '#06b6d4' },
|
||||
tracking_active: { icon: Target, color: '#06b6d4' },
|
||||
enforcement: { icon: Shield, color: '#10b981' },
|
||||
captured_inspected: { icon: Shield, color: '#10b981' },
|
||||
};
|
||||
|
||||
export function getKpiUi(key: string): KpiUi {
|
||||
return KPI_UI_MAP[key] ?? { icon: Radar, color: '#3b82f6' };
|
||||
}
|
||||
51
frontend/src/shared/constants/loginResultStatuses.ts
Normal file
51
frontend/src/shared/constants/loginResultStatuses.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 로그인 결과 카탈로그
|
||||
*
|
||||
* SSOT: backend kcg.auth_login_history.login_result
|
||||
* 사용처: LoginHistoryView
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type LoginResult = 'SUCCESS' | 'FAILED' | 'LOCKED';
|
||||
|
||||
interface LoginResultMeta {
|
||||
code: LoginResult;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const LOGIN_RESULTS: Record<LoginResult, LoginResultMeta> = {
|
||||
SUCCESS: {
|
||||
code: 'SUCCESS',
|
||||
i18nKey: 'loginResult.SUCCESS',
|
||||
fallback: { ko: '성공', en: 'Success' },
|
||||
intent: 'success',
|
||||
},
|
||||
FAILED: {
|
||||
code: 'FAILED',
|
||||
i18nKey: 'loginResult.FAILED',
|
||||
fallback: { ko: '실패', en: 'Failed' },
|
||||
intent: 'high',
|
||||
},
|
||||
LOCKED: {
|
||||
code: 'LOCKED',
|
||||
i18nKey: 'loginResult.LOCKED',
|
||||
fallback: { ko: '계정 잠금', en: 'Locked' },
|
||||
intent: 'critical',
|
||||
},
|
||||
};
|
||||
|
||||
export function getLoginResultIntent(s: string): BadgeIntent {
|
||||
return LOGIN_RESULTS[s as LoginResult]?.intent ?? 'muted';
|
||||
}
|
||||
export function getLoginResultLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = LOGIN_RESULTS[s as LoginResult];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
73
frontend/src/shared/constants/modelDeploymentStatuses.ts
Normal file
73
frontend/src/shared/constants/modelDeploymentStatuses.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI 모델 배포 / 품질 게이트 / 실험 상태 카탈로그
|
||||
*
|
||||
* 사용처: MLOpsPage
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
// ─── 모델 배포 상태 ──────────────
|
||||
export type ModelStatus = 'DEPLOYED' | 'APPROVED' | 'TESTING' | 'DRAFT';
|
||||
|
||||
export const MODEL_STATUSES: Record<ModelStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
DEPLOYED: {
|
||||
i18nKey: 'modelStatus.DEPLOYED',
|
||||
fallback: { ko: '배포됨', en: 'Deployed' },
|
||||
intent: 'success',
|
||||
},
|
||||
APPROVED: {
|
||||
i18nKey: 'modelStatus.APPROVED',
|
||||
fallback: { ko: '승인', en: 'Approved' },
|
||||
intent: 'info',
|
||||
},
|
||||
TESTING: {
|
||||
i18nKey: 'modelStatus.TESTING',
|
||||
fallback: { ko: '테스트', en: 'Testing' },
|
||||
intent: 'warning',
|
||||
},
|
||||
DRAFT: {
|
||||
i18nKey: 'modelStatus.DRAFT',
|
||||
fallback: { ko: '초안', en: 'Draft' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getModelStatusIntent(s: string): BadgeIntent {
|
||||
return MODEL_STATUSES[s as ModelStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getModelStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = MODEL_STATUSES[s as ModelStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 품질 게이트 상태 ──────────────
|
||||
export type QualityGateStatus = 'pass' | 'fail' | 'run' | 'pending';
|
||||
|
||||
export const QUALITY_GATE_STATUSES: Record<QualityGateStatus, { intent: BadgeIntent; pulse?: boolean; fallback: { ko: string; en: string } }> = {
|
||||
pass: { intent: 'success', fallback: { ko: '통과', en: 'Pass' } },
|
||||
fail: { intent: 'critical', fallback: { ko: '실패', en: 'Fail' } },
|
||||
run: { intent: 'warning', pulse: true, fallback: { ko: '실행중', en: 'Running' } },
|
||||
pending: { intent: 'muted', fallback: { ko: '대기', en: 'Pending' } },
|
||||
};
|
||||
|
||||
export function getQualityGateIntent(s: string): BadgeIntent {
|
||||
return QUALITY_GATE_STATUSES[s as QualityGateStatus]?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
// ─── 실험 상태 ──────────────
|
||||
export type ExperimentStatus = 'running' | 'done' | 'failed';
|
||||
|
||||
export const EXPERIMENT_STATUSES: Record<ExperimentStatus, { intent: BadgeIntent; pulse?: boolean; fallback: { ko: string; en: string } }> = {
|
||||
running: { intent: 'info', pulse: true, fallback: { ko: '실행중', en: 'Running' } },
|
||||
done: { intent: 'success', fallback: { ko: '완료', en: 'Done' } },
|
||||
failed: { intent: 'critical', fallback: { ko: '실패', en: 'Failed' } },
|
||||
};
|
||||
|
||||
export function getExperimentIntent(s: string): BadgeIntent {
|
||||
return EXPERIMENT_STATUSES[s as ExperimentStatus]?.intent ?? 'muted';
|
||||
}
|
||||
96
frontend/src/shared/constants/parentResolutionStatuses.ts
Normal file
96
frontend/src/shared/constants/parentResolutionStatuses.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 모선 추론(Parent Resolution) 상태 + 라벨 세션 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend gear_group_parent_resolution.status
|
||||
* 사용처: ParentReview, LabelSession, RealGearGroups
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
// ─── 부모 해석(Resolution) 상태 ──────────────
|
||||
export type ParentResolutionStatus = 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED';
|
||||
|
||||
interface ParentResolutionMeta {
|
||||
code: ParentResolutionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const PARENT_RESOLUTION_STATUSES: Record<ParentResolutionStatus, ParentResolutionMeta> = {
|
||||
UNRESOLVED: {
|
||||
code: 'UNRESOLVED',
|
||||
i18nKey: 'parentResolution.UNRESOLVED',
|
||||
fallback: { ko: '미해결', en: 'Unresolved' },
|
||||
intent: 'warning',
|
||||
},
|
||||
REVIEW_REQUIRED: {
|
||||
code: 'REVIEW_REQUIRED',
|
||||
i18nKey: 'parentResolution.REVIEW_REQUIRED',
|
||||
fallback: { ko: '검토 필요', en: 'Review Required' },
|
||||
intent: 'critical',
|
||||
},
|
||||
MANUAL_CONFIRMED: {
|
||||
code: 'MANUAL_CONFIRMED',
|
||||
i18nKey: 'parentResolution.MANUAL_CONFIRMED',
|
||||
fallback: { ko: '수동 확정', en: 'Manually Confirmed' },
|
||||
intent: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export function getParentResolutionIntent(s: string): BadgeIntent {
|
||||
return PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getParentResolutionLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 라벨 세션 상태 ──────────────
|
||||
export type LabelSessionStatus = 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
|
||||
|
||||
interface LabelSessionMeta {
|
||||
code: LabelSessionStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const LABEL_SESSION_STATUSES: Record<LabelSessionStatus, LabelSessionMeta> = {
|
||||
ACTIVE: {
|
||||
code: 'ACTIVE',
|
||||
i18nKey: 'labelSession.ACTIVE',
|
||||
fallback: { ko: '진행중', en: 'Active' },
|
||||
intent: 'success',
|
||||
},
|
||||
COMPLETED: {
|
||||
code: 'COMPLETED',
|
||||
i18nKey: 'labelSession.COMPLETED',
|
||||
fallback: { ko: '완료', en: 'Completed' },
|
||||
intent: 'info',
|
||||
},
|
||||
CANCELLED: {
|
||||
code: 'CANCELLED',
|
||||
i18nKey: 'labelSession.CANCELLED',
|
||||
fallback: { ko: '취소', en: 'Cancelled' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getLabelSessionIntent(s: string): BadgeIntent {
|
||||
return LABEL_SESSION_STATUSES[s as LabelSessionStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getLabelSessionLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = LABEL_SESSION_STATUSES[s as LabelSessionStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
117
frontend/src/shared/constants/patrolStatuses.ts
Normal file
117
frontend/src/shared/constants/patrolStatuses.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 함정 상태 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 PATROL_STATUS (V008 시드).
|
||||
* 향후 patrol_ship_master.status 컬럼 enum.
|
||||
*
|
||||
* 사용처: Dashboard PatrolStatusBadge, ShipAgent
|
||||
*/
|
||||
|
||||
export type PatrolStatus =
|
||||
| 'AVAILABLE'
|
||||
| 'ON_PATROL'
|
||||
| 'IN_PURSUIT'
|
||||
| 'INSPECTING'
|
||||
| 'RETURNING'
|
||||
| 'STANDBY'
|
||||
| 'MAINTENANCE';
|
||||
|
||||
export interface PatrolStatusMeta {
|
||||
code: PatrolStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
classes: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const PATROL_STATUSES: Record<PatrolStatus, PatrolStatusMeta> = {
|
||||
IN_PURSUIT: {
|
||||
code: 'IN_PURSUIT',
|
||||
i18nKey: 'patrolStatus.IN_PURSUIT',
|
||||
fallback: { ko: '추적중', en: 'In Pursuit' },
|
||||
classes: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
order: 1,
|
||||
},
|
||||
INSPECTING: {
|
||||
code: 'INSPECTING',
|
||||
i18nKey: 'patrolStatus.INSPECTING',
|
||||
fallback: { ko: '검문중', en: 'Inspecting' },
|
||||
classes: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
order: 2,
|
||||
},
|
||||
ON_PATROL: {
|
||||
code: 'ON_PATROL',
|
||||
i18nKey: 'patrolStatus.ON_PATROL',
|
||||
fallback: { ko: '초계중', en: 'On Patrol' },
|
||||
classes: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
order: 3,
|
||||
},
|
||||
RETURNING: {
|
||||
code: 'RETURNING',
|
||||
i18nKey: 'patrolStatus.RETURNING',
|
||||
fallback: { ko: '귀항중', en: 'Returning' },
|
||||
classes: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
order: 4,
|
||||
},
|
||||
AVAILABLE: {
|
||||
code: 'AVAILABLE',
|
||||
i18nKey: 'patrolStatus.AVAILABLE',
|
||||
fallback: { ko: '가용', en: 'Available' },
|
||||
classes: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
order: 5,
|
||||
},
|
||||
STANDBY: {
|
||||
code: 'STANDBY',
|
||||
i18nKey: 'patrolStatus.STANDBY',
|
||||
fallback: { ko: '대기', en: 'Standby' },
|
||||
classes: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
order: 6,
|
||||
},
|
||||
MAINTENANCE: {
|
||||
code: 'MAINTENANCE',
|
||||
i18nKey: 'patrolStatus.MAINTENANCE',
|
||||
fallback: { ko: '정비중', en: 'Maintenance' },
|
||||
classes: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
order: 7,
|
||||
},
|
||||
};
|
||||
|
||||
/** 한글 라벨도 키로 받아주는 호환성 매핑 (mock 데이터에서 한글 사용 중) */
|
||||
const LEGACY_KO_LABELS: Record<string, PatrolStatus> = {
|
||||
'추적 중': 'IN_PURSUIT',
|
||||
'추적중': 'IN_PURSUIT',
|
||||
'검문 중': 'INSPECTING',
|
||||
'검문중': 'INSPECTING',
|
||||
'초계 중': 'ON_PATROL',
|
||||
'초계중': 'ON_PATROL',
|
||||
'귀항 중': 'RETURNING',
|
||||
'귀항중': 'RETURNING',
|
||||
'가용': 'AVAILABLE',
|
||||
'대기': 'STANDBY',
|
||||
'정비 중': 'MAINTENANCE',
|
||||
'정비중': 'MAINTENANCE',
|
||||
};
|
||||
|
||||
export function getPatrolStatusMeta(status: string): PatrolStatusMeta | undefined {
|
||||
// 영문 enum 우선
|
||||
if (PATROL_STATUSES[status as PatrolStatus]) {
|
||||
return PATROL_STATUSES[status as PatrolStatus];
|
||||
}
|
||||
// 한글 라벨 폴백 (mock 호환)
|
||||
const code = LEGACY_KO_LABELS[status];
|
||||
return code ? PATROL_STATUSES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getPatrolStatusLabel(
|
||||
status: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getPatrolStatusMeta(status);
|
||||
if (!meta) return status;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
export function getPatrolStatusClasses(status: string): string {
|
||||
return getPatrolStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground border-border';
|
||||
}
|
||||
93
frontend/src/shared/constants/permissionStatuses.ts
Normal file
93
frontend/src/shared/constants/permissionStatuses.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 어업 허가/판정 상태 카탈로그
|
||||
*
|
||||
* 사용처: GearDetection
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type PermitStatus = 'VALID' | 'EXPIRED' | 'UNLICENSED' | 'PENDING';
|
||||
|
||||
interface PermitStatusMeta {
|
||||
code: PermitStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const PERMIT_STATUSES: Record<PermitStatus, PermitStatusMeta> = {
|
||||
VALID: {
|
||||
code: 'VALID',
|
||||
i18nKey: 'permitStatus.VALID',
|
||||
fallback: { ko: '유효', en: 'Valid' },
|
||||
intent: 'success',
|
||||
},
|
||||
PENDING: {
|
||||
code: 'PENDING',
|
||||
i18nKey: 'permitStatus.PENDING',
|
||||
fallback: { ko: '확인 중', en: 'Pending' },
|
||||
intent: 'warning',
|
||||
},
|
||||
EXPIRED: {
|
||||
code: 'EXPIRED',
|
||||
i18nKey: 'permitStatus.EXPIRED',
|
||||
fallback: { ko: '만료', en: 'Expired' },
|
||||
intent: 'high',
|
||||
},
|
||||
UNLICENSED: {
|
||||
code: 'UNLICENSED',
|
||||
i18nKey: 'permitStatus.UNLICENSED',
|
||||
fallback: { ko: '무허가', en: 'Unlicensed' },
|
||||
intent: 'critical',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, PermitStatus> = {
|
||||
'유효': 'VALID',
|
||||
'무허가': 'UNLICENSED',
|
||||
'만료': 'EXPIRED',
|
||||
'확인 중': 'PENDING',
|
||||
'확인중': 'PENDING',
|
||||
};
|
||||
|
||||
export function getPermitStatusIntent(s: string): BadgeIntent {
|
||||
if (PERMIT_STATUSES[s as PermitStatus]) return PERMIT_STATUSES[s as PermitStatus].intent;
|
||||
const code = LEGACY_KO[s];
|
||||
return code ? PERMIT_STATUSES[code].intent : 'warning';
|
||||
}
|
||||
export function getPermitStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
let meta = PERMIT_STATUSES[s as PermitStatus];
|
||||
if (!meta) {
|
||||
const code = LEGACY_KO[s];
|
||||
if (code) meta = PERMIT_STATUSES[code];
|
||||
}
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── 어구 판정 상태 ──────────────
|
||||
export type GearJudgment = 'NORMAL' | 'SUSPECT_ILLEGAL' | 'CHECKING';
|
||||
|
||||
export const GEAR_JUDGMENTS: Record<GearJudgment, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
NORMAL: { i18nKey: 'gearJudgment.NORMAL', fallback: { ko: '정상', en: 'Normal' }, intent: 'success' },
|
||||
CHECKING: { i18nKey: 'gearJudgment.CHECKING', fallback: { ko: '확인 중', en: 'Checking' }, intent: 'warning' },
|
||||
SUSPECT_ILLEGAL: { i18nKey: 'gearJudgment.SUSPECT_ILLEGAL', fallback: { ko: '불법 의심', en: 'Suspect Illegal' }, intent: 'critical' },
|
||||
};
|
||||
|
||||
const GEAR_LEGACY_KO: Record<string, GearJudgment> = {
|
||||
'정상': 'NORMAL',
|
||||
'확인 중': 'CHECKING',
|
||||
'확인중': 'CHECKING',
|
||||
'불법 의심': 'SUSPECT_ILLEGAL',
|
||||
'불법의심': 'SUSPECT_ILLEGAL',
|
||||
};
|
||||
|
||||
export function getGearJudgmentIntent(s: string): BadgeIntent {
|
||||
if (GEAR_JUDGMENTS[s as GearJudgment]) return GEAR_JUDGMENTS[s as GearJudgment].intent;
|
||||
const code = GEAR_LEGACY_KO[s] ?? (s.includes('불법') ? 'SUSPECT_ILLEGAL' : undefined);
|
||||
return code ? GEAR_JUDGMENTS[code].intent : 'warning';
|
||||
}
|
||||
85
frontend/src/shared/constants/trainingZoneTypes.ts
Normal file
85
frontend/src/shared/constants/trainingZoneTypes.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 군사 훈련구역 타입 카탈로그
|
||||
*
|
||||
* 사용처: MapControl 훈련구역 마커/배지
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type TrainingZoneType = 'NAVY' | 'AIRFORCE' | 'ARMY' | 'ADD' | 'KCG';
|
||||
|
||||
interface TrainingZoneTypeMeta {
|
||||
code: TrainingZoneType;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export const TRAINING_ZONE_TYPES: Record<TrainingZoneType, TrainingZoneTypeMeta> = {
|
||||
NAVY: {
|
||||
code: 'NAVY',
|
||||
i18nKey: 'trainingZone.NAVY',
|
||||
fallback: { ko: '해군 훈련 구역', en: 'Navy Training Zone' },
|
||||
intent: 'warning',
|
||||
hex: '#eab308',
|
||||
},
|
||||
AIRFORCE: {
|
||||
code: 'AIRFORCE',
|
||||
i18nKey: 'trainingZone.AIRFORCE',
|
||||
fallback: { ko: '공군 훈련 구역', en: 'Airforce Training Zone' },
|
||||
intent: 'critical',
|
||||
hex: '#ec4899',
|
||||
},
|
||||
ARMY: {
|
||||
code: 'ARMY',
|
||||
i18nKey: 'trainingZone.ARMY',
|
||||
fallback: { ko: '육군 훈련 구역', en: 'Army Training Zone' },
|
||||
intent: 'success',
|
||||
hex: '#22c55e',
|
||||
},
|
||||
ADD: {
|
||||
code: 'ADD',
|
||||
i18nKey: 'trainingZone.ADD',
|
||||
fallback: { ko: '국방과학연구소', en: 'ADD' },
|
||||
intent: 'info',
|
||||
hex: '#3b82f6',
|
||||
},
|
||||
KCG: {
|
||||
code: 'KCG',
|
||||
i18nKey: 'trainingZone.KCG',
|
||||
fallback: { ko: '해양경찰청', en: 'KCG' },
|
||||
intent: 'purple',
|
||||
hex: '#a855f7',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, TrainingZoneType> = {
|
||||
'해군': 'NAVY',
|
||||
'공군': 'AIRFORCE',
|
||||
'육군': 'ARMY',
|
||||
'국과연': 'ADD',
|
||||
'해경': 'KCG',
|
||||
};
|
||||
|
||||
export function getTrainingZoneMeta(t: string): TrainingZoneTypeMeta | undefined {
|
||||
if (TRAINING_ZONE_TYPES[t as TrainingZoneType]) return TRAINING_ZONE_TYPES[t as TrainingZoneType];
|
||||
const code = LEGACY_KO[t];
|
||||
return code ? TRAINING_ZONE_TYPES[code] : undefined;
|
||||
}
|
||||
|
||||
export function getTrainingZoneIntent(t: string): BadgeIntent {
|
||||
return getTrainingZoneMeta(t)?.intent ?? 'muted';
|
||||
}
|
||||
export function getTrainingZoneHex(t: string): string {
|
||||
return getTrainingZoneMeta(t)?.hex ?? '#6b7280';
|
||||
}
|
||||
export function getTrainingZoneLabel(
|
||||
type: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return type;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
57
frontend/src/shared/constants/userAccountStatuses.ts
Normal file
57
frontend/src/shared/constants/userAccountStatuses.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 사용자 계정 상태 카탈로그
|
||||
*
|
||||
* SSOT: backend kcg.auth_user.user_stts_cd
|
||||
* 사용처: AccessControl, 사용자 관리 화면
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type UserAccountStatus = 'ACTIVE' | 'LOCKED' | 'INACTIVE' | 'PENDING';
|
||||
|
||||
interface UserAccountStatusMeta {
|
||||
code: UserAccountStatus;
|
||||
i18nKey: string;
|
||||
fallback: { ko: string; en: string };
|
||||
intent: BadgeIntent;
|
||||
}
|
||||
|
||||
export const USER_ACCOUNT_STATUSES: Record<UserAccountStatus, UserAccountStatusMeta> = {
|
||||
ACTIVE: {
|
||||
code: 'ACTIVE',
|
||||
i18nKey: 'userAccountStatus.ACTIVE',
|
||||
fallback: { ko: '활성', en: 'Active' },
|
||||
intent: 'success',
|
||||
},
|
||||
PENDING: {
|
||||
code: 'PENDING',
|
||||
i18nKey: 'userAccountStatus.PENDING',
|
||||
fallback: { ko: '승인 대기', en: 'Pending' },
|
||||
intent: 'warning',
|
||||
},
|
||||
LOCKED: {
|
||||
code: 'LOCKED',
|
||||
i18nKey: 'userAccountStatus.LOCKED',
|
||||
fallback: { ko: '잠금', en: 'Locked' },
|
||||
intent: 'critical',
|
||||
},
|
||||
INACTIVE: {
|
||||
code: 'INACTIVE',
|
||||
i18nKey: 'userAccountStatus.INACTIVE',
|
||||
fallback: { ko: '비활성', en: 'Inactive' },
|
||||
intent: 'muted',
|
||||
},
|
||||
};
|
||||
|
||||
export function getUserAccountStatusIntent(s: string): BadgeIntent {
|
||||
return USER_ACCOUNT_STATUSES[s as UserAccountStatus]?.intent ?? 'muted';
|
||||
}
|
||||
export function getUserAccountStatusLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = USER_ACCOUNT_STATUSES[s as UserAccountStatus];
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
84
frontend/src/shared/constants/userRoles.ts
Normal file
84
frontend/src/shared/constants/userRoles.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 사용자 역할 색상 카탈로그
|
||||
*
|
||||
* SSOT: backend `auth_role.color_hex` (V017 추가).
|
||||
*
|
||||
* 동작 방식:
|
||||
* - 백엔드에서 fetch한 RoleWithPermissions[]의 colorHex가 1차 source
|
||||
* - DB에 colorHex가 NULL이거나 미등록 역할은 ROLE_FALLBACK_PALETTE에서
|
||||
* role code 해시 기반으로 안정적 색상 할당
|
||||
*
|
||||
* 사용처: MainLayout, UserRoleAssignDialog, PermissionsPanel, AccessControl
|
||||
*/
|
||||
|
||||
/** 기본 색상 팔레트 — DB color_hex가 없을 때 폴백 */
|
||||
export const ROLE_DEFAULT_PALETTE: string[] = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#64748b', // slate
|
||||
'#84cc16', // lime
|
||||
'#14b8a6', // teal
|
||||
'#f59e0b', // amber
|
||||
];
|
||||
|
||||
/** 빌트인 5개 역할의 기본 색상 (백엔드 V017 시드와 동일) */
|
||||
const BUILTIN_ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: '#ef4444',
|
||||
OPERATOR: '#3b82f6',
|
||||
ANALYST: '#a855f7',
|
||||
FIELD: '#22c55e',
|
||||
VIEWER: '#eab308',
|
||||
};
|
||||
|
||||
/** 백엔드 fetch 결과를 캐시 — 역할 코드 → colorHex */
|
||||
let roleColorCache: Record<string, string | null> = {};
|
||||
|
||||
/** RoleWithPermissions[] fetch 결과로 캐시 갱신 (RoleStore 등에서 호출) */
|
||||
export function updateRoleColorCache(roles: { roleCd: string; colorHex: string | null }[]): void {
|
||||
roleColorCache = {};
|
||||
roles.forEach((r) => {
|
||||
roleColorCache[r.roleCd] = r.colorHex;
|
||||
});
|
||||
}
|
||||
|
||||
/** 코드 해시 기반 안정적 폴백 색상 */
|
||||
function hashFallbackColor(roleCd: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < roleCd.length; i++) hash = (hash * 31 + roleCd.charCodeAt(i)) | 0;
|
||||
return ROLE_DEFAULT_PALETTE[Math.abs(hash) % ROLE_DEFAULT_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 역할 코드 → hex 색상.
|
||||
* 우선순위: 캐시(DB) → 빌트인 → 해시 폴백
|
||||
*/
|
||||
export function getRoleColorHex(roleCd: string): string {
|
||||
const cached = roleColorCache[roleCd];
|
||||
if (cached) return cached;
|
||||
if (BUILTIN_ROLE_COLORS[roleCd]) return BUILTIN_ROLE_COLORS[roleCd];
|
||||
return hashFallbackColor(roleCd);
|
||||
}
|
||||
|
||||
/**
|
||||
* hex → Tailwind 유사 클래스 묶음.
|
||||
* 인라인 style이 가능한 환경에서는 getRoleColorHex 직접 사용 권장.
|
||||
*
|
||||
* 클래스 기반이 필요한 곳을 위한 호환 함수.
|
||||
*/
|
||||
export function getRoleBadgeStyle(roleCd: string): React.CSSProperties {
|
||||
const hex = getRoleColorHex(roleCd);
|
||||
return {
|
||||
backgroundColor: hex,
|
||||
borderColor: hex,
|
||||
color: '#0f172a', // slate-900 (badgeVariants 정책과 동일)
|
||||
};
|
||||
}
|
||||
|
||||
/** import('react') 회피용 type-only import */
|
||||
import type React from 'react';
|
||||
84
frontend/src/shared/constants/vesselAnalysisStatuses.ts
Normal file
84
frontend/src/shared/constants/vesselAnalysisStatuses.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 선박 분석/감시 상태 카탈로그
|
||||
*
|
||||
* 사용처: ChinaFishing StatusRing, DarkVesselDetection 상태
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type VesselSurveillanceStatus = 'TRACKING' | 'WATCHING' | 'CHECKING' | 'NORMAL';
|
||||
|
||||
export const VESSEL_SURVEILLANCE_STATUSES: Record<VesselSurveillanceStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent }> = {
|
||||
TRACKING: {
|
||||
i18nKey: 'vesselSurveillance.TRACKING',
|
||||
fallback: { ko: '추적중', en: 'Tracking' },
|
||||
intent: 'critical',
|
||||
},
|
||||
WATCHING: {
|
||||
i18nKey: 'vesselSurveillance.WATCHING',
|
||||
fallback: { ko: '감시중', en: 'Watching' },
|
||||
intent: 'warning',
|
||||
},
|
||||
CHECKING: {
|
||||
i18nKey: 'vesselSurveillance.CHECKING',
|
||||
fallback: { ko: '확인중', en: 'Checking' },
|
||||
intent: 'info',
|
||||
},
|
||||
NORMAL: {
|
||||
i18nKey: 'vesselSurveillance.NORMAL',
|
||||
fallback: { ko: '정상', en: 'Normal' },
|
||||
intent: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
const LEGACY_KO: Record<string, VesselSurveillanceStatus> = {
|
||||
'추적중': 'TRACKING',
|
||||
'추적 중': 'TRACKING',
|
||||
'감시중': 'WATCHING',
|
||||
'감시 중': 'WATCHING',
|
||||
'확인중': 'CHECKING',
|
||||
'확인 중': 'CHECKING',
|
||||
'정상': 'NORMAL',
|
||||
};
|
||||
|
||||
export function getVesselSurveillanceIntent(s: string): BadgeIntent {
|
||||
if (VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus]) {
|
||||
return VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus].intent;
|
||||
}
|
||||
const code = LEGACY_KO[s];
|
||||
return code ? VESSEL_SURVEILLANCE_STATUSES[code].intent : 'muted';
|
||||
}
|
||||
export function getVesselSurveillanceLabel(
|
||||
s: string,
|
||||
t: (k: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
let meta = VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus];
|
||||
if (!meta) {
|
||||
const code = LEGACY_KO[s];
|
||||
if (code) meta = VESSEL_SURVEILLANCE_STATUSES[code];
|
||||
}
|
||||
if (!meta) return s;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
// ─── ChinaFishing StatusRing 용 (한글 라벨 매핑) ──────────────
|
||||
export type VesselRiskRing = 'SAFE' | 'SUSPECT' | 'WARNING';
|
||||
|
||||
export const VESSEL_RISK_RINGS: Record<VesselRiskRing, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
|
||||
SAFE: { i18nKey: 'vesselRing.SAFE', fallback: { ko: '양호', en: 'Safe' }, intent: 'success', hex: '#10b981' },
|
||||
SUSPECT: { i18nKey: 'vesselRing.SUSPECT', fallback: { ko: '의심', en: 'Suspect' }, intent: 'high', hex: '#f97316' },
|
||||
WARNING: { i18nKey: 'vesselRing.WARNING', fallback: { ko: '경고', en: 'Warning' }, intent: 'critical', hex: '#ef4444' },
|
||||
};
|
||||
|
||||
const RING_LEGACY_KO: Record<string, VesselRiskRing> = {
|
||||
'양호': 'SAFE',
|
||||
'의심': 'SUSPECT',
|
||||
'경고': 'WARNING',
|
||||
};
|
||||
|
||||
export function getVesselRingMeta(s: string) {
|
||||
if (VESSEL_RISK_RINGS[s as VesselRiskRing]) return VESSEL_RISK_RINGS[s as VesselRiskRing];
|
||||
const code = RING_LEGACY_KO[s];
|
||||
return code ? VESSEL_RISK_RINGS[code] : VESSEL_RISK_RINGS.SAFE;
|
||||
}
|
||||
128
frontend/src/shared/constants/violationTypes.ts
Normal file
128
frontend/src/shared/constants/violationTypes.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 위반(탐지) 유형 공통 카탈로그
|
||||
*
|
||||
* 백엔드 violation_classifier.py의 enum 코드를 SSOT로 사용한다.
|
||||
* 색상, 라벨(i18n), 정렬 순서를 한 곳에서 관리.
|
||||
*
|
||||
* 신규 유형 추가/색상 변경 시 이 파일만 수정하면 모든 화면에 반영된다.
|
||||
*
|
||||
* 사용처:
|
||||
* - Dashboard / MonitoringDashboard 위반 유형 분포 차트
|
||||
* - Statistics 위반 유형별 분포
|
||||
* - EventList / EnforcementHistory 위반 유형 표시
|
||||
*/
|
||||
|
||||
import type { BadgeIntent } from '@lib/theme/variants';
|
||||
|
||||
export type ViolationCode =
|
||||
| 'EEZ_VIOLATION'
|
||||
| 'DARK_VESSEL'
|
||||
| 'MMSI_TAMPERING'
|
||||
| 'ILLEGAL_TRANSSHIP'
|
||||
| 'ILLEGAL_GEAR'
|
||||
| 'RISK_BEHAVIOR';
|
||||
|
||||
export interface ViolationTypeMeta {
|
||||
code: ViolationCode;
|
||||
/** i18n 키 (common.json) */
|
||||
i18nKey: string;
|
||||
/** 차트/UI 색상 (hex) — 차트나 인라인 style용 */
|
||||
color: string;
|
||||
/** 공통 Badge 컴포넌트 intent */
|
||||
intent: BadgeIntent;
|
||||
/** i18n 미적용 환경의 폴백 라벨 */
|
||||
fallback: { ko: string; en: string };
|
||||
/** 정렬 순서 (낮을수록 상단) */
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const VIOLATION_TYPES: Record<ViolationCode, ViolationTypeMeta> = {
|
||||
EEZ_VIOLATION: {
|
||||
code: 'EEZ_VIOLATION',
|
||||
i18nKey: 'violation.eezViolation',
|
||||
color: '#ef4444',
|
||||
intent: 'critical',
|
||||
fallback: { ko: 'EEZ 침범', en: 'EEZ Violation' },
|
||||
order: 1,
|
||||
},
|
||||
DARK_VESSEL: {
|
||||
code: 'DARK_VESSEL',
|
||||
i18nKey: 'violation.darkVessel',
|
||||
color: '#f97316',
|
||||
intent: 'high',
|
||||
fallback: { ko: '다크베셀', en: 'Dark Vessel' },
|
||||
order: 2,
|
||||
},
|
||||
MMSI_TAMPERING: {
|
||||
code: 'MMSI_TAMPERING',
|
||||
i18nKey: 'violation.mmsiTampering',
|
||||
color: '#eab308',
|
||||
intent: 'warning',
|
||||
fallback: { ko: 'MMSI 변조', en: 'MMSI Tampering' },
|
||||
order: 3,
|
||||
},
|
||||
ILLEGAL_TRANSSHIP: {
|
||||
code: 'ILLEGAL_TRANSSHIP',
|
||||
i18nKey: 'violation.illegalTransship',
|
||||
color: '#a855f7',
|
||||
intent: 'purple',
|
||||
fallback: { ko: '불법 환적', en: 'Illegal Transship' },
|
||||
order: 4,
|
||||
},
|
||||
ILLEGAL_GEAR: {
|
||||
code: 'ILLEGAL_GEAR',
|
||||
i18nKey: 'violation.illegalGear',
|
||||
color: '#06b6d4',
|
||||
intent: 'cyan',
|
||||
fallback: { ko: '불법 어구', en: 'Illegal Gear' },
|
||||
order: 5,
|
||||
},
|
||||
RISK_BEHAVIOR: {
|
||||
code: 'RISK_BEHAVIOR',
|
||||
i18nKey: 'violation.riskBehavior',
|
||||
color: '#6b7280',
|
||||
intent: 'muted',
|
||||
fallback: { ko: '위험 행동', en: 'Risk Behavior' },
|
||||
order: 6,
|
||||
},
|
||||
};
|
||||
|
||||
export function getViolationIntent(code: string): BadgeIntent {
|
||||
return VIOLATION_TYPES[code as ViolationCode]?.intent ?? 'muted';
|
||||
}
|
||||
|
||||
/** 등록되지 않은 코드를 위한 폴백 색상 팔레트 (안정적 매핑) */
|
||||
const FALLBACK_PALETTE = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||
|
||||
/** 코드 → 색상 (등록 안 된 코드는 코드 해시 기반 안정적 색상 반환) */
|
||||
export function getViolationColor(code: string): string {
|
||||
const meta = VIOLATION_TYPES[code as ViolationCode];
|
||||
if (meta) return meta.color;
|
||||
// 등록되지 않은 코드: 문자열 해시 기반으로 안정적 색상 할당
|
||||
let hash = 0;
|
||||
for (let i = 0; i < code.length; i++) hash = (hash * 31 + code.charCodeAt(i)) | 0;
|
||||
return FALLBACK_PALETTE[Math.abs(hash) % FALLBACK_PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 → 표시 라벨 (i18n)
|
||||
* @param code 백엔드 enum 코드
|
||||
* @param t react-i18next의 t 함수 (common namespace)
|
||||
* @param lang 'ko' | 'en' (현재 언어, fallback 용도)
|
||||
*/
|
||||
export function getViolationLabel(
|
||||
code: string,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
lang: 'ko' | 'en' = 'ko',
|
||||
): string {
|
||||
const meta = VIOLATION_TYPES[code as ViolationCode];
|
||||
if (!meta) return code;
|
||||
return t(meta.i18nKey, { defaultValue: meta.fallback[lang] });
|
||||
}
|
||||
|
||||
/** 카탈로그 정렬 순으로 코드 목록 반환 */
|
||||
export function listViolationCodes(): ViolationCode[] {
|
||||
return Object.values(VIOLATION_TYPES)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((m) => m.code);
|
||||
}
|
||||
@ -7,12 +7,12 @@
|
||||
|
||||
const KST: Intl.DateTimeFormatOptions = { timeZone: 'Asia/Seoul' };
|
||||
|
||||
/** 2026-04-07 14:30:00 형식 (KST) */
|
||||
/** 2026-04-07 14:30:00 형식 (KST). sv-SE 로케일은 ISO 유사 출력을 보장. */
|
||||
export const formatDateTime = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleString('ko-KR', {
|
||||
return d.toLocaleString('sv-SE', {
|
||||
...KST,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
@ -25,7 +25,7 @@ export const formatDate = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleDateString('ko-KR', {
|
||||
return d.toLocaleDateString('sv-SE', {
|
||||
...KST,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
});
|
||||
@ -36,7 +36,7 @@ export const formatTime = (value: string | Date | null | undefined): string => {
|
||||
if (!value) return '-';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
return d.toLocaleTimeString('ko-KR', {
|
||||
return d.toLocaleTimeString('sv-SE', {
|
||||
...KST,
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
--text-heading: #ffffff;
|
||||
--text-label: #cbd5e1;
|
||||
--text-hint: #64748b;
|
||||
/* 컬러풀 배경 위 텍스트 — 테마 무관 고정 */
|
||||
--text-on-vivid: #ffffff; /* -600/-700 진한 배경 위 (액션 버튼 등) */
|
||||
--text-on-bright: #0f172a; /* -300/-400 밝은 배경 위 (배지 등) */
|
||||
--scrollbar-thumb: #334155;
|
||||
--scrollbar-hover: #475569;
|
||||
}
|
||||
@ -91,6 +94,9 @@
|
||||
--text-heading: #0f172a;
|
||||
--text-label: #334155;
|
||||
--text-hint: #94a3b8;
|
||||
/* 컬러풀 배경 위 텍스트 — 라이트 모드도 동일 (가독성 일관) */
|
||||
--text-on-vivid: #ffffff;
|
||||
--text-on-bright: #0f172a;
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
--scrollbar-hover: #94a3b8;
|
||||
}
|
||||
@ -139,6 +145,20 @@
|
||||
--color-text-heading: var(--text-heading);
|
||||
--color-text-label: var(--text-label);
|
||||
--color-text-hint: var(--text-hint);
|
||||
--color-text-on-vivid: var(--text-on-vivid);
|
||||
--color-text-on-bright: var(--text-on-bright);
|
||||
}
|
||||
|
||||
/* 시맨틱 텍스트/배경 유틸리티 — Tailwind v4 자동 생성 의존하지 않고 명시 정의
|
||||
* (--color-text-* 같은 복합 이름은 v4가 utility 자동 매핑하지 않으므로 직접 선언) */
|
||||
@layer utilities {
|
||||
.text-heading { color: var(--text-heading); }
|
||||
.text-label { color: var(--text-label); }
|
||||
.text-hint { color: var(--text-hint); }
|
||||
.text-on-vivid { color: var(--text-on-vivid); }
|
||||
.text-on-bright { color: var(--text-on-bright); }
|
||||
.bg-surface-raised { background-color: var(--surface-raised); }
|
||||
.bg-surface-overlay { background-color: var(--surface-overlay); }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user