Compare commits
3 커밋
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| bcfc0b3a9f | |||
| 6b3dc8276e | |||
| 8906ed0680 |
@ -87,5 +87,7 @@
|
|||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"frontend-design@claude-plugins-official": true
|
"frontend-design@claude-plugins-official": true
|
||||||
}
|
},
|
||||||
|
"deny": [],
|
||||||
|
"allow": []
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-04-17",
|
"applied_date": "2026-04-20",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
# commit-msg hook
|
# commit-msg hook
|
||||||
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
# Conventional Commits 형식 검증 (한/영 혼용 지원)
|
||||||
#==============================================================================
|
#==============================================================================
|
||||||
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
|
|
||||||
|
|
||||||
COMMIT_MSG_FILE="$1"
|
COMMIT_MSG_FILE="$1"
|
||||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||||
|
|||||||
@ -14,13 +14,23 @@ router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (re
|
|||||||
try {
|
try {
|
||||||
const { status, substance, search, acdntSn } = req.query
|
const { status, substance, search, acdntSn } = req.query
|
||||||
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
|
||||||
const items = await listAnalyses({
|
const page = parseInt(req.query.page as string, 10) || 1
|
||||||
|
const limit = parseInt(req.query.limit as string, 10) || 10
|
||||||
|
|
||||||
|
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 페이지네이션 파라미터' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await listAnalyses({
|
||||||
status: status as string | undefined,
|
status: status as string | undefined,
|
||||||
substance: substance as string | undefined,
|
substance: substance as string | undefined,
|
||||||
search: search as string | undefined,
|
search: search as string | undefined,
|
||||||
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
})
|
})
|
||||||
res.json(items)
|
res.json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[hns] 분석 목록 오류:', err)
|
console.error('[hns] 분석 목록 오류:', err)
|
||||||
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })
|
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })
|
||||||
|
|||||||
@ -120,6 +120,8 @@ interface ListAnalysesInput {
|
|||||||
substance?: string
|
substance?: string
|
||||||
search?: string
|
search?: string
|
||||||
acdntSn?: number
|
acdntSn?: number
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||||
@ -147,7 +149,12 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<{
|
||||||
|
items: HnsAnalysisItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}> {
|
||||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||||
const params: (string | number)[] = []
|
const params: (string | number)[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
@ -170,18 +177,36 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
|
|||||||
params.push(input.acdntSn)
|
params.push(input.acdntSn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ')
|
||||||
|
|
||||||
|
const countResult = await wingPool.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM HNS_ANALYSIS WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const total = parseInt(countResult.rows[0].cnt as string, 10)
|
||||||
|
|
||||||
|
const page = input.page ?? 1
|
||||||
|
const limit = input.limit ?? 10
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
const { rows } = await wingPool.query(
|
const { rows } = await wingPool.query(
|
||||||
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||||
RSLT_DATA, REG_DTM
|
RSLT_DATA, REG_DTM
|
||||||
FROM HNS_ANALYSIS
|
FROM HNS_ANALYSIS
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${whereClause}
|
||||||
ORDER BY ACDNT_DTM DESC NULLS LAST`,
|
ORDER BY ACDNT_DTM DESC NULLS LAST
|
||||||
params
|
LIMIT $${idx++} OFFSET $${idx}`,
|
||||||
|
[...params, limit, offset]
|
||||||
)
|
)
|
||||||
|
|
||||||
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
|
return {
|
||||||
|
items: rows.map((r: Record<string, unknown>) => rowToAnalysis(r)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
## [2026-04-17]
|
## [2026-04-17]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
- HNS: 분석 목록 서버사이드 페이지네이션 추가 및 대기확산 히트맵 렌더링 개선
|
||||||
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
@ -233,12 +233,10 @@ function MapCenterTracker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
update();
|
update();
|
||||||
map.on('move', update);
|
map.on('moveend', update);
|
||||||
map.on('zoom', update);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off('move', update);
|
map.off('moveend', update);
|
||||||
map.off('zoom', update);
|
|
||||||
};
|
};
|
||||||
}, [map, onCenterChange]);
|
}, [map, onCenterChange]);
|
||||||
|
|
||||||
@ -507,6 +505,87 @@ export function MapView({
|
|||||||
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
|
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
|
||||||
const wmsOpacity = layerOpacity / 100;
|
const wmsOpacity = layerOpacity / 100;
|
||||||
|
|
||||||
|
// HNS 대기확산 히트맵 이미지 (Canvas 렌더링 + PNG 변환은 고비용이므로 별도 메모이제이션)
|
||||||
|
const heatmapImage = useMemo(() => {
|
||||||
|
if (!dispersionHeatmap || dispersionHeatmap.length === 0) return null;
|
||||||
|
|
||||||
|
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
||||||
|
const minConc = Math.min(
|
||||||
|
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
|
||||||
|
);
|
||||||
|
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
// 경위도 바운드 계산
|
||||||
|
let minLon = Infinity,
|
||||||
|
maxLon = -Infinity,
|
||||||
|
minLat = Infinity,
|
||||||
|
maxLat = -Infinity;
|
||||||
|
for (const p of dispersionHeatmap) {
|
||||||
|
if (p.lon < minLon) minLon = p.lon;
|
||||||
|
if (p.lon > maxLon) maxLon = p.lon;
|
||||||
|
if (p.lat < minLat) minLat = p.lat;
|
||||||
|
if (p.lat > maxLat) maxLat = p.lat;
|
||||||
|
}
|
||||||
|
const padLon = (maxLon - minLon) * 0.02;
|
||||||
|
const padLat = (maxLat - minLat) * 0.02;
|
||||||
|
minLon -= padLon;
|
||||||
|
maxLon += padLon;
|
||||||
|
minLat -= padLat;
|
||||||
|
maxLat += padLat;
|
||||||
|
|
||||||
|
// 캔버스에 농도 이미지 렌더링
|
||||||
|
const W = 1200,
|
||||||
|
H = 960;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// 로그 스케일: 농도 범위를 고르게 분포
|
||||||
|
const logMin = Math.log(minConc);
|
||||||
|
const logMax = Math.log(maxConc);
|
||||||
|
const logRange = logMax - logMin || 1;
|
||||||
|
|
||||||
|
const stops: [number, number, number, number][] = [
|
||||||
|
[34, 197, 94, 220], // green (저농도)
|
||||||
|
[234, 179, 8, 235], // yellow
|
||||||
|
[249, 115, 22, 245], // orange
|
||||||
|
[239, 68, 68, 250], // red (고농도)
|
||||||
|
[185, 28, 28, 255], // dark red (초고농도)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of filtered) {
|
||||||
|
// 로그 스케일 정규화 (0~1)
|
||||||
|
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
|
||||||
|
const t = ratio * (stops.length - 1);
|
||||||
|
const lo = Math.floor(t);
|
||||||
|
const hi = Math.min(lo + 1, stops.length - 1);
|
||||||
|
const f = t - lo;
|
||||||
|
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
|
||||||
|
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
|
||||||
|
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
|
||||||
|
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
|
||||||
|
|
||||||
|
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
|
||||||
|
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
|
||||||
|
|
||||||
|
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 12, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
||||||
|
const imageUrl = canvas.toDataURL('image/png');
|
||||||
|
return {
|
||||||
|
imageUrl,
|
||||||
|
bounds: [minLon, minLat, maxLon, maxLat] as [number, number, number, number],
|
||||||
|
};
|
||||||
|
}, [dispersionHeatmap]);
|
||||||
|
|
||||||
// deck.gl 레이어 구축
|
// deck.gl 레이어 구축
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers = useMemo((): any[] => {
|
const deckLayers = useMemo((): any[] => {
|
||||||
@ -806,97 +885,18 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
// --- HNS 대기확산 히트맵 (BitmapLayer, 캐싱된 이미지 사용) ---
|
||||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
if (heatmapImage) {
|
||||||
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
|
||||||
const minConc = Math.min(
|
|
||||||
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
|
|
||||||
);
|
|
||||||
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
|
|
||||||
console.log(
|
|
||||||
'[MapView] HNS 히트맵:',
|
|
||||||
dispersionHeatmap.length,
|
|
||||||
'→ filtered:',
|
|
||||||
filtered.length,
|
|
||||||
'maxConc:',
|
|
||||||
maxConc.toFixed(2),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
// 경위도 바운드 계산
|
|
||||||
let minLon = Infinity,
|
|
||||||
maxLon = -Infinity,
|
|
||||||
minLat = Infinity,
|
|
||||||
maxLat = -Infinity;
|
|
||||||
for (const p of dispersionHeatmap) {
|
|
||||||
if (p.lon < minLon) minLon = p.lon;
|
|
||||||
if (p.lon > maxLon) maxLon = p.lon;
|
|
||||||
if (p.lat < minLat) minLat = p.lat;
|
|
||||||
if (p.lat > maxLat) maxLat = p.lat;
|
|
||||||
}
|
|
||||||
const padLon = (maxLon - minLon) * 0.02;
|
|
||||||
const padLat = (maxLat - minLat) * 0.02;
|
|
||||||
minLon -= padLon;
|
|
||||||
maxLon += padLon;
|
|
||||||
minLat -= padLat;
|
|
||||||
maxLat += padLat;
|
|
||||||
|
|
||||||
// 캔버스에 농도 이미지 렌더링
|
|
||||||
const W = 1200,
|
|
||||||
H = 960;
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = W;
|
|
||||||
canvas.height = H;
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
|
|
||||||
// 로그 스케일: 농도 범위를 고르게 분포
|
|
||||||
const logMin = Math.log(minConc);
|
|
||||||
const logMax = Math.log(maxConc);
|
|
||||||
const logRange = logMax - logMin || 1;
|
|
||||||
|
|
||||||
const stops: [number, number, number, number][] = [
|
|
||||||
[34, 197, 94, 220], // green (저농도)
|
|
||||||
[234, 179, 8, 235], // yellow
|
|
||||||
[249, 115, 22, 245], // orange
|
|
||||||
[239, 68, 68, 250], // red (고농도)
|
|
||||||
[185, 28, 28, 255], // dark red (초고농도)
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const p of filtered) {
|
|
||||||
// 로그 스케일 정규화 (0~1)
|
|
||||||
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
|
|
||||||
const t = ratio * (stops.length - 1);
|
|
||||||
const lo = Math.floor(t);
|
|
||||||
const hi = Math.min(lo + 1, stops.length - 1);
|
|
||||||
const f = t - lo;
|
|
||||||
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
|
|
||||||
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
|
|
||||||
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
|
|
||||||
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
|
|
||||||
|
|
||||||
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
|
|
||||||
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
|
|
||||||
|
|
||||||
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(px, py, 12, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
|
|
||||||
const imageUrl = canvas.toDataURL('image/png');
|
|
||||||
result.push(
|
result.push(
|
||||||
new BitmapLayer({
|
new BitmapLayer({
|
||||||
id: 'hns-dispersion-bitmap',
|
id: 'hns-dispersion-bitmap',
|
||||||
image: imageUrl,
|
image: heatmapImage.imageUrl,
|
||||||
bounds: [minLon, minLat, maxLon, maxLat],
|
bounds: heatmapImage.bounds,
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
pickable: false,
|
pickable: false,
|
||||||
}) as unknown as DeckLayer,
|
}) as unknown as DeckLayer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
||||||
if (dispersionResult && incidentCoord) {
|
if (dispersionResult && incidentCoord) {
|
||||||
@ -1300,7 +1300,7 @@ export function MapView({
|
|||||||
isDrawingBoom,
|
isDrawingBoom,
|
||||||
drawingPoints,
|
drawingPoints,
|
||||||
dispersionResult,
|
dispersionResult,
|
||||||
dispersionHeatmap,
|
heatmapImage,
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
backtrackReplay,
|
backtrackReplay,
|
||||||
sensitiveResources,
|
sensitiveResources,
|
||||||
|
|||||||
@ -8,6 +8,8 @@ interface HNSAnalysisListTableProps {
|
|||||||
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
|
onSelectAnalysis?: (sn: number, localRsltData?: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
const RISK_LABEL: Record<string, string> = {
|
const RISK_LABEL: Record<string, string> = {
|
||||||
CRITICAL: '심각',
|
CRITICAL: '심각',
|
||||||
HIGH: '위험',
|
HIGH: '위험',
|
||||||
@ -39,12 +41,17 @@ function substanceTag(sbstNm: string | null): string {
|
|||||||
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnalysisListTableProps) {
|
||||||
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
|
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await fetchHnsAnalyses();
|
const res = await fetchHnsAnalyses({ page, limit: PAGE_SIZE });
|
||||||
setAnalyses(items);
|
setAnalyses(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
|
console.error('[hns] 분석 목록 조회 실패, localStorage fallback:', err);
|
||||||
// DB 실패 시 localStorage에서 불러오기
|
// DB 실패 시 localStorage에서 불러오기
|
||||||
@ -93,7 +100,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@ -105,7 +112,7 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-heading-3 font-bold text-fg">HNS 대기확산 분석 목록</h1>
|
<h1 className="text-heading-3 font-bold text-fg">HNS 대기확산 분석 목록</h1>
|
||||||
<p className="text-title-3 text-fg-disabled mt-1">총 {analyses.length}건</p>
|
<p className="text-title-3 text-fg-disabled mt-1">총 {total}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -299,6 +306,66 @@ export function HNSAnalysisListTable({ onTabChange, onSelectAnalysis }: HNSAnaly
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1 px-5 py-3 border-t border-stroke">
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
|
||||||
|
.reduce<number[]>((acc, p) => {
|
||||||
|
if (acc.length > 0 && p - acc[acc.length - 1] > 1) acc.push(-acc.length);
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p) =>
|
||||||
|
p < 0 ? (
|
||||||
|
<span key={`ellipsis-${p}`} className="px-1.5 text-fg-disabled text-label-1">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={`min-w-[32px] px-2.5 py-1.5 text-label-1 rounded-sm ${
|
||||||
|
p === page
|
||||||
|
? 'bg-color-accent text-white font-semibold'
|
||||||
|
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
className="px-2.5 py-1.5 text-label-1 rounded-sm bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -288,8 +288,8 @@ export function HNSScenarioView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchHnsAnalyses()
|
fetchHnsAnalyses()
|
||||||
.then((items) => {
|
.then((res) => {
|
||||||
if (!cancelled) setIncidents(items);
|
if (!cancelled) setIncidents(res.items);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
|
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -6,13 +6,22 @@ import type { HnsAnalysisItem, CreateHnsAnalysisInput } from '@interfaces/hns/Hn
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
interface HnsAnalysesResponse {
|
||||||
|
items: HnsAnalysisItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchHnsAnalyses(params?: {
|
export async function fetchHnsAnalyses(params?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
substance?: string;
|
substance?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
acdntSn?: number;
|
acdntSn?: number;
|
||||||
}): Promise<HnsAnalysisItem[]> {
|
page?: number;
|
||||||
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
|
limit?: number;
|
||||||
|
}): Promise<HnsAnalysesResponse> {
|
||||||
|
const response = await api.get<HnsAnalysesResponse>('/hns/analyses', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -156,8 +156,8 @@ export function AnalysisSelectModal({
|
|||||||
const items = await fetchPredictionAnalyses();
|
const items = await fetchPredictionAnalyses();
|
||||||
setPredItems(items);
|
setPredItems(items);
|
||||||
} else if (type === 'hns') {
|
} else if (type === 'hns') {
|
||||||
const items = await fetchHnsAnalyses();
|
const res = await fetchHnsAnalyses();
|
||||||
setHnsItems(items);
|
setHnsItems(res.items);
|
||||||
} else {
|
} else {
|
||||||
const items = await fetchRescueOps();
|
const items = await fetchRescueOps();
|
||||||
setRescueItems(items);
|
setRescueItems(items);
|
||||||
|
|||||||
@ -175,12 +175,12 @@ export function IncidentsRightPanel({
|
|||||||
})
|
})
|
||||||
.catch(() => setPredItems([]));
|
.catch(() => setPredItems([]));
|
||||||
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
||||||
.then((items) => {
|
.then((res) => {
|
||||||
setHnsItems(items);
|
setHnsItems(res.items);
|
||||||
const allIds = new Set(items.map((i) => String(i.hnsAnlysSn)));
|
const allIds = new Set(res.items.map((i) => String(i.hnsAnlysSn)));
|
||||||
setCheckedHnsIds(allIds);
|
setCheckedHnsIds(allIds);
|
||||||
onCheckedHnsChange?.(
|
onCheckedHnsChange?.(
|
||||||
items.map((h) => ({
|
res.items.map((h) => ({
|
||||||
id: String(h.hnsAnlysSn),
|
id: String(h.hnsAnlysSn),
|
||||||
hnsAnlysSn: h.hnsAnlysSn,
|
hnsAnlysSn: h.hnsAnlysSn,
|
||||||
acdntSn: h.acdntSn,
|
acdntSn: h.acdntSn,
|
||||||
|
|||||||
@ -221,7 +221,7 @@ export function IncidentsView() {
|
|||||||
const acdntSn = parseInt(selectedIncidentId, 10);
|
const acdntSn = parseInt(selectedIncidentId, 10);
|
||||||
if (Number.isNaN(acdntSn)) return;
|
if (Number.isNaN(acdntSn)) return;
|
||||||
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
|
||||||
.then((items) => setHnsAnalyses(items))
|
.then((res) => setHnsAnalyses(res.items))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [selectedIncidentId]);
|
}, [selectedIncidentId]);
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user