refactor(frontend): enforcement/field-ops/patrol PageContainer/PageHeader 적용

- EnforcementHistory/EventList/EnforcementPlan: primary Button 액션
- EventList: Select 공통 컴포넌트로 등급 필터 치환
- AIAlert/ShipAgent/MobileService: PageContainer + PageHeader(demo)
- PatrolRoute/FleetOptimization: primary Button 액션 2개씩

Phase B-3 완료. 총 10개 파일.
This commit is contained in:
htlee 2026-04-08 11:57:01 +09:00
부모 a1c521792d
커밋 2976796652
8개의 변경된 파일155개의 추가작업 그리고 133개의 파일을 삭제

파일 보기

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useEnforcementStore } from '@stores/enforcementStore';
@ -121,14 +122,13 @@ export function EnforcementHistory() {
const DATA: Record[] = records as Record[];
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-400" />
{t('history.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={FileText}
iconColor="text-blue-400"
title={t('history.title')}
description={t('history.desc')}
/>
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
<div className="flex gap-2">
@ -188,6 +188,6 @@ export function EnforcementHistory() {
exportFilename="단속이력"
/>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,9 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { FileUpload } from '@shared/components/common/FileUpload';
import {
@ -135,45 +138,41 @@ export function EventList() {
const kpiTotal = (stats['TOTAL'] as number | undefined) ?? EVENTS.length;
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Radar className="w-5 h-5 text-blue-400" />
{t('eventList.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('eventList.desc')}
</p>
</div>
<div className="flex items-center gap-2">
{/* 등급 필터 */}
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
title="등급 필터"
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-label focus:outline-none focus:border-blue-500/50"
<PageContainer>
<PageHeader
icon={Radar}
iconColor="text-blue-400"
title={t('eventList.title')}
description={t('eventList.desc')}
actions={
<>
<div className="flex items-center gap-1">
<Filter className="w-3.5 h-3.5 text-hint" />
<Select
size="sm"
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
title="등급 필터"
className="w-32"
>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</Select>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowUpload(!showUpload)}
icon={<Upload className="w-3 h-3" />}
>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</select>
</div>
<button
type="button"
onClick={() => setShowUpload(!showUpload)}
className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors"
>
<Upload className="w-3 h-3" />
</button>
</div>
</div>
</Button>
</>
}
/>
{/* KPI 요약 */}
<div className="grid grid-cols-5 gap-3">
@ -248,6 +247,6 @@ export function EventList() {
exportFilename="이벤트목록"
/>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Send, Loader2, AlertTriangle } from 'lucide-react';
import { getAlerts, type PredictionAlert } from '@/services/event';
@ -140,34 +141,37 @@ export function AIAlert() {
if (loading) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
<PageContainer>
<div className="flex items-center justify-center gap-2 text-muted-foreground py-8">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
</PageContainer>
);
}
if (error) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-red-400">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
</button>
</div>
<PageContainer>
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
</button>
</div>
</PageContainer>
);
}
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Send className="w-5 h-5 text-yellow-400" />
{t('aiAlert.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('aiAlert.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-400"
title={t('aiAlert.title')}
description={t('aiAlert.desc')}
/>
<div className="flex gap-2">
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
@ -191,6 +195,6 @@ export function AIAlert() {
searchKeys={['channel', 'recipient']}
exportFilename="AI알림이력"
/>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import { useEventStore } from '@stores/eventStore';
@ -52,11 +53,13 @@ export function MobileService() {
);
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Smartphone}
iconColor="text-blue-400"
title={t('mobileService.title')}
description={t('mobileService.desc')}
/>
<div className="grid grid-cols-3 gap-3">
{/* 모바일 프리뷰 */}
<Card>
@ -143,6 +146,6 @@ export function MobileService() {
</CardContent></Card>
</div>
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react';
import { Monitor } from 'lucide-react';
import { getDeviceStatusIntent, getDeviceStatusLabel } from '@shared/constants/deviceStatuses';
import { useSettingsStore } from '@stores/settingsStore';
@ -44,16 +44,14 @@ export function ShipAgent() {
], [tc, lang]);
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Monitor}
iconColor="text-cyan-400"
title={t('shipAgent.title')}
description={t('shipAgent.desc')}
demo
/>
<div className="flex gap-2">
{[{ l: '전체 Agent', v: DATA.length, c: 'text-heading' }, { l: '온라인', v: DATA.filter(d => d.status === '온라인').length, c: 'text-green-400' }, { l: '오프라인', v: DATA.filter(d => d.status === '오프라인').length, c: 'text-red-400' }, { l: '미배포', v: DATA.filter(d => d.status === '미배포').length, c: 'text-muted-foreground' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
@ -62,6 +60,6 @@ export function ShipAgent() {
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="함정명, Agent ID 검색..." searchKeys={['ship', 'id']} exportFilename="함정Agent" />
</div>
</PageContainer>
);
}

파일 보기

@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react';
import { usePatrolStore } from '@stores/patrolStore';
@ -96,24 +98,31 @@ export function FleetOptimization() {
useMapLayers(mapRef, buildLayers, [ships, COVERAGE, FLEET_ROUTES]);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
</div>
<div className="flex gap-1.5">
<button onClick={() => setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /></button>
<button onClick={() => setApproved(true)} disabled={!simRunning}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-on-vivid text-[10px] font-bold rounded-lg"><CheckCircle className="w-3 h-3" /> </button>
</div>
</div>
<PageContainer>
<PageHeader
icon={Users}
iconColor="text-purple-400"
title={t('fleetOptimization.title')}
description={t('fleetOptimization.desc')}
demo
actions={
<>
<Button variant="primary" size="sm" onClick={() => setSimRunning(true)} icon={<Play className="w-3 h-3" />}>
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setApproved(true)}
disabled={!simRunning}
icon={<CheckCircle className="w-3 h-3" />}
className="bg-green-600 hover:bg-green-500 border-green-700"
>
</Button>
</>
}
/>
{/* KPI */}
<div className="flex gap-2">
@ -218,6 +227,6 @@ export function FleetOptimization() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -4,6 +4,8 @@ import type maplibregl from 'maplibre-gl';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Navigation, Ship, MapPin, Clock, Wind, Anchor, Play, BarChart3, Target, Settings, CheckCircle, Share2 } from 'lucide-react';
import { usePatrolStore } from '@stores/patrolStore';
@ -93,23 +95,24 @@ export function PatrolRoute() {
if (!currentShip || !route) return null;
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
</div>
<div className="flex gap-1.5">
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Share2 className="w-3 h-3" /></button>
</div>
</div>
<PageContainer>
<PageHeader
icon={Navigation}
iconColor="text-cyan-400"
title={t('patrolRoute.title')}
description={t('patrolRoute.desc')}
demo
actions={
<>
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>
</Button>
<Button variant="secondary" size="sm" icon={<Share2 className="w-3 h-3" />}>
</Button>
</>
}
/>
<div className="grid grid-cols-4 gap-3">
{/* 함정 선택 */}
@ -215,6 +218,6 @@ export function PatrolRoute() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
@ -118,14 +120,18 @@ export function EnforcementPlan() {
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Shield className="w-5 h-5 text-orange-400" />{t('enforcementPlan.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('enforcementPlan.desc')}</p>
</div>
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" /> </button>
</div>
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-orange-400"
title={t('enforcementPlan.title')}
description={t('enforcementPlan.desc')}
actions={
<Button variant="primary" size="md" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
}
/>
{/* 로딩/에러 상태 */}
{loading && (
@ -185,6 +191,6 @@ export function EnforcementPlan() {
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}