Compare commits

...
2 Commits
4 changed files with 440 additions and 96 deletions
+13
View File
@@ -71,6 +71,19 @@ app.get('/api/dongs', async (req, res) => {
} }
}); });
app.get('/api/reverse-geocode', async (req, res) => {
const numLat = Number(req.query.lat);
const numLng = Number(req.query.lng);
if (!Number.isFinite(numLat) || !Number.isFinite(numLng)) {
return res.status(400).json({ error: 'lat, lng 파라미터가 필요합니다.' });
}
try {
res.json(await reverseGeocode(numLat, numLng));
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.get('/api/poles', async (req, res) => { app.get('/api/poles', async (req, res) => {
const { gu = '', dong = '', start = '', end = '' } = req.query; const { gu = '', dong = '', start = '', end = '' } = req.query;
try { try {
+11 -23
View File
@@ -1,4 +1,5 @@
const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data'; const VWORLD_ENDPOINT = 'https://api.vworld.kr/req/data';
const VWORLD_ADDRESS_ENDPOINT = 'https://api.vworld.kr/req/address';
function credentials() { function credentials() {
return { return {
@@ -80,31 +81,18 @@ export async function reverseGeocode(lat, lng) {
domain, domain,
}); });
const res = await fetch(`${VWORLD_ENDPOINT}?${params}`); const res = await fetch(`${VWORLD_ADDRESS_ENDPOINT}?${params}`);
if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`); if (!res.ok) throw new Error(`VWorld 지오코딩 실패 (HTTP ${res.status})`);
const json = await res.json(); const json = await res.json();
const result = json.response?.result; const response = json.response;
if (response?.status !== 'OK') return { gu: '', dong: '' };
if (!result?.text) return { gu: '', dong: '' }; const first = Array.isArray(response.result) ? response.result[0] : response.result;
const structure = first?.structure;
if (!structure) return { gu: '', dong: '' };
const text = result.text; return {
const structure = result.structure; gu: structure.level2 || '',
dong: structure.level3 || structure.level4L || '',
let gu = ''; };
let dong = '';
if (structure) {
gu = structure.level1 || '';
dong = structure.level2 || '';
} else {
const parts = text.split(' ');
if (parts.length >= 2) {
gu = parts[0];
dong = parts[1];
} else if (parts.length === 1) {
gu = parts[0];
}
}
return { gu, dong };
} }
+130 -11
View File
@@ -179,6 +179,17 @@
.pole-list__title { .pole-list__title {
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.pole-list__header-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
} }
.pole-list__close { .pole-list__close {
@@ -190,12 +201,6 @@
color: #6b7280; color: #6b7280;
} }
.pole-list__header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.pole-list__upload { .pole-list__upload {
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
@@ -212,6 +217,44 @@
background-color: #f3f4f6; background-color: #f3f4f6;
} }
.pole-list__refresh {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: #374151;
white-space: nowrap;
}
.pole-list__refresh:hover:not(:disabled) {
background-color: #f3f4f6;
}
.pole-list__refresh:disabled {
opacity: 0.6;
cursor: default;
}
.pole-list__refresh-icon {
display: inline-block;
}
@keyframes pole-refresh-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.pole-list__refresh--loading .pole-list__refresh-icon {
animation: pole-refresh-spin 0.8s linear infinite;
}
.pole-list__scroll { .pole-list__scroll {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
@@ -325,6 +368,15 @@
border-color: transparent; border-color: transparent;
} }
.pole-list__filter-count {
margin-left: auto;
align-self: center;
font-size: 12px;
font-weight: 700;
color: #3b82f6;
white-space: nowrap;
}
.pole-list__area-filter { .pole-list__area-filter {
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
@@ -472,6 +524,7 @@
} }
.level-modal { .level-modal {
position: relative;
background: white; background: white;
border-radius: 16px; border-radius: 16px;
padding: 20px; padding: 20px;
@@ -483,6 +536,38 @@
gap: 16px; gap: 16px;
} }
.level-modal__close {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.45);
color: white;
font-size: 13px;
line-height: 28px;
text-align: center;
cursor: pointer;
padding: 0;
z-index: 1;
}
.level-modal__close:hover {
background-color: rgba(0, 0, 0, 0.7);
}
.level-modal--busy .level-modal__label {
color: #3b82f6;
}
.level-modal--busy .level-modal__buttons,
.level-modal--busy .level-modal__close {
opacity: 0.5;
pointer-events: none;
}
.level-modal__img { .level-modal__img {
width: 100%; width: 100%;
max-height: 240px; max-height: 240px;
@@ -491,6 +576,22 @@
background: #f3f4f6; background: #f3f4f6;
} }
.level-modal__meta {
width: 100%;
background: #f8fafc;
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
color: #475569;
line-height: 1.6;
}
.level-modal__meta-row {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-modal__label { .level-modal__label {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -498,21 +599,21 @@
} }
.level-modal__buttons { .level-modal__buttons {
display: flex; display: grid;
gap: 12px; grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%; width: 100%;
} }
.level-modal__btn { .level-modal__btn {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 14px 0; padding: 12px 0;
border-radius: 10px; border-radius: 10px;
border: 2px solid transparent; border: 2px solid transparent;
font-size: 18px; font-size: 17px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
color: white; color: white;
@@ -535,6 +636,24 @@
background-color: #a855f7; background-color: #a855f7;
} }
.level-modal__btn--Ignore {
background-color: #64748b;
}
.level-modal__key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background-color: rgba(255, 255, 255, 0.25);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.level-modal__desc { .level-modal__desc {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
+286 -62
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Map from 'ol/Map'; import OLMap from 'ol/Map';
import View from 'ol/View'; import View from 'ol/View';
import Overlay from 'ol/Overlay'; import Overlay from 'ol/Overlay';
import TileLayer from 'ol/layer/Tile'; import TileLayer from 'ol/layer/Tile';
@@ -17,6 +17,9 @@ import './App.css';
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY; const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY;
const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' }; const LEVEL_COLORS = { A: '#22c55e', B: '#f97316', Pole: '#a855f7' };
const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' }; const LEVEL_LABELS = { A: 'A', B: 'B', Pole: 'P' };
const LEVEL_IGNORE = 'IGNORE';
const LEVEL_CANCEL = 'CANCEL';
const LEVEL_KEYS = { 1: 'A', 2: 'B', 3: 'Pole', 4: LEVEL_IGNORE };
function createTileSource(layer) { function createTileSource(layer) {
return new XYZ({ return new XYZ({
@@ -46,6 +49,10 @@ function App() {
const folderInputRef = useRef(null); const folderInputRef = useRef(null);
const levelResolveRef = useRef(null); const levelResolveRef = useRef(null);
const [levelModal, setLevelModal] = useState(null); const [levelModal, setLevelModal] = useState(null);
const [modalAddress, setModalAddress] = useState(undefined);
const [modalBusy, setModalBusy] = useState(false);
const previewUrlRef = useRef(null);
const reverseGeocodeCacheRef = useRef(new Map());
const [levelFilter, setLevelFilter] = useState('all'); const [levelFilter, setLevelFilter] = useState('all');
const [guFilter, setGuFilter] = useState('용산구'); const [guFilter, setGuFilter] = useState('용산구');
const [dongFilter, setDongFilter] = useState('all'); const [dongFilter, setDongFilter] = useState('all');
@@ -56,6 +63,7 @@ function App() {
const popupRef = useRef(null); const popupRef = useRef(null);
const popupOverlayRef = useRef(null); const popupOverlayRef = useRef(null);
const [popupPole, setPopupPole] = useState(null); const [popupPole, setPopupPole] = useState(null);
const [refreshing, setRefreshing] = useState(false);
const guList = useMemo(() => { const guList = useMemo(() => {
const set = new Set(allDongs.map((d) => d.gu_name)); const set = new Set(allDongs.map((d) => d.gu_name));
@@ -179,6 +187,23 @@ function App() {
} }
}, []); }, []);
const refreshPoles = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
const res = await fetch('/api/poles');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const next = await res.json();
polesRef.current = next;
setPoles(next);
} catch (err) {
console.error('전신주 목록 갱신 실패:', err);
alert('목록 갱신에 실패했습니다.');
} finally {
setRefreshing(false);
}
}, [refreshing]);
const flyTo = useCallback((lng, lat) => { const flyTo = useCallback((lng, lat) => {
if (!mapRef.current) return; if (!mapRef.current) return;
mapRef.current.getView().animate({ mapRef.current.getView().animate({
@@ -198,25 +223,105 @@ function App() {
}); });
}, []); }, []);
const requestLevel = useCallback((file, gpsData) => { const lookupAddress = useCallback(async (lat, lng) => {
const previewUrl = URL.createObjectURL(file); if (!(reverseGeocodeCacheRef.current instanceof Map)) {
return new Promise((resolve) => { reverseGeocodeCacheRef.current = new Map();
levelResolveRef.current = (level) => { }
URL.revokeObjectURL(previewUrl); const key = `${Number(lat).toFixed(5)},${Number(lng).toFixed(5)}`;
resolve(level); if (reverseGeocodeCacheRef.current.has(key)) {
}; return reverseGeocodeCacheRef.current.get(key);
setLevelModal({ file, previewUrl, gpsData }); }
}); try {
}, []); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const handleLevelSelect = useCallback((level) => { const res = await fetch(`/api/reverse-geocode?lat=${lat}&lng=${lng}`, {
setLevelModal(null); signal: controller.signal,
if (levelResolveRef.current) { });
levelResolveRef.current(level); clearTimeout(timer);
levelResolveRef.current = null; if (!res.ok) throw new Error(`HTTP ${res.status}`);
const geo = await res.json();
const label = [geo.gu, geo.dong].filter(Boolean).join(' ') || null;
reverseGeocodeCacheRef.current.set(key, label);
return label;
} catch (err) {
console.error('역지오코딩 실패:', err);
return null;
} }
}, []); }, []);
const claimPreviewUrl = useCallback((file, preloadedUrl) => {
const url = preloadedUrl || URL.createObjectURL(file);
if (previewUrlRef.current && previewUrlRef.current !== url) {
URL.revokeObjectURL(previewUrlRef.current);
}
previewUrlRef.current = url;
return url;
}, []);
const closeLevelModal = useCallback(() => {
levelResolveRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setLevelModal(null);
setModalAddress(undefined);
setModalBusy(false);
}, []);
const requestLevel = useCallback((info) => {
const previewUrl = claimPreviewUrl(info.file, info.previewUrl);
setModalBusy(false);
setLevelModal({
file: info.file,
previewUrl,
lat: info.lat,
lng: info.lng,
timestamp: info.timestamp,
});
return new Promise((resolve) => {
levelResolveRef.current = resolve;
});
}, [claimPreviewUrl]);
const handleLevelSelect = useCallback(
(level) => {
if (modalBusy || !levelResolveRef.current) return;
levelResolveRef.current(level);
levelResolveRef.current = null;
setModalBusy(true);
},
[modalBusy],
);
useEffect(() => {
if (!levelModal) return;
const onKeyDown = (e) => {
const level = LEVEL_KEYS[e.key];
if (level) {
e.preventDefault();
handleLevelSelect(level);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [levelModal, handleLevelSelect]);
useEffect(() => {
if (!levelModal) {
setModalAddress(undefined);
return;
}
let cancelled = false;
setModalAddress(undefined);
lookupAddress(levelModal.lat, levelModal.lng).then((address) => {
if (!cancelled) setModalAddress(address);
});
return () => {
cancelled = true;
};
}, [levelModal, lookupAddress]);
const refreshGps = useCallback(() => { const refreshGps = useCallback(() => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
@@ -254,7 +359,7 @@ function App() {
const markerLayer = new VectorLayer({ source: markerSource }); const markerLayer = new VectorLayer({ source: markerSource });
markerLayerRef.current = markerLayer; markerLayerRef.current = markerLayer;
const map = new Map({ const map = new OLMap({
target: mapContainerRef.current, target: mapContainerRef.current,
layers: [ layers: [
new TileLayer({ source: createTileSource('Base') }), new TileLayer({ source: createTileSource('Base') }),
@@ -378,6 +483,10 @@ function App() {
markerLayerRef.current = null; markerLayerRef.current = null;
currentLocationRef.current = null; currentLocationRef.current = null;
fallbackUsedRef.current = false; fallbackUsedRef.current = false;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
}; };
}, []); }, []);
@@ -427,8 +536,14 @@ function App() {
setGpsInfo({ source, lat, lng }); setGpsInfo({ source, lat, lng });
const level = await requestLevel(file, { lat, lng }); const level = await requestLevel({
if (!level) { file,
lat,
lng,
timestamp: exifTimestamp || new Date().toISOString(),
});
closeLevelModal();
if (!level || level === LEVEL_IGNORE || level === LEVEL_CANCEL) {
setProcessing(false); setProcessing(false);
e.target.value = ''; e.target.value = '';
return; return;
@@ -488,57 +603,102 @@ function App() {
return; return;
} }
const preparePhoto = async (file) => {
let lat;
let lng;
let exifTimestamp = null;
try {
const buffer = await file.arrayBuffer();
const exifData = await exifr.parse(buffer);
if (exifData && exifData.latitude && exifData.longitude) {
lat = exifData.latitude;
lng = exifData.longitude;
}
if (exifData && exifData.DateTimeOriginal) {
exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString();
} else if (exifData && exifData.DateTime) {
exifTimestamp = new Date(exifData.DateTime).toISOString();
}
} catch (err) {
console.error('EXIF 읽기 실패:', file.name, err);
}
const previewUrl = URL.createObjectURL(file);
try {
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => reject(new Error('preview decode failed'));
img.src = previewUrl;
});
} catch {
// 디코딩 미지원 형식(예: HEIC)은 모달에서 브라우저가 다시 시도한다.
}
return {
file,
lat,
lng,
exifTimestamp,
timestamp: exifTimestamp || new Date().toISOString(),
previewUrl,
};
};
setProcessing(true); setProcessing(true);
setUploadProgress({ current: 0, total: imageFiles.length }); setUploadProgress({ current: 0, total: imageFiles.length });
let registered = 0; let registered = 0;
let ignoredCount = 0;
let skipped = 0; let skipped = 0;
let duplicated = 0; let duplicated = 0;
let cancelledRemaining = 0;
let pendingPrepPromise = preparePhoto(imageFiles[0]);
for (let i = 0; i < imageFiles.length; i++) { for (let i = 0; i < imageFiles.length; i++) {
const file = imageFiles[i];
setUploadProgress({ current: i + 1, total: imageFiles.length }); setUploadProgress({ current: i + 1, total: imageFiles.length });
let prep = null;
try { try {
let lat; prep = await pendingPrepPromise;
let lng; } catch (err) {
let exifTimestamp = null; console.error('사진 준비 실패:', imageFiles[i].name, err);
}
pendingPrepPromise =
i + 1 < imageFiles.length ? preparePhoto(imageFiles[i + 1]) : null;
try { if (!prep) {
const buffer = await file.arrayBuffer(); skipped++;
const exifData = await exifr.parse(buffer); continue;
if (exifData && exifData.latitude && exifData.longitude) { }
lat = exifData.latitude; if (prep.lat !== undefined) {
lng = exifData.longitude; lastPositionRef.current = { lat: prep.lat, lng: prep.lng };
lastPositionRef.current = { lat, lng }; } else {
} const last = lastPositionRef.current;
if (exifData && exifData.DateTimeOriginal) { if (!last) {
exifTimestamp = new Date(exifData.DateTimeOriginal).toISOString(); URL.revokeObjectURL(prep.previewUrl);
} else if (exifData && exifData.DateTime) {
exifTimestamp = new Date(exifData.DateTime).toISOString();
}
} catch (err) {
console.error('EXIF 읽기 실패:', file.name, err);
}
if (lat === undefined) {
const last = lastPositionRef.current;
if (last) {
lat = last.lat;
lng = last.lng;
} else {
skipped++;
continue;
}
}
const level = await requestLevel(file, { lat, lng });
if (!level) {
skipped++; skipped++;
continue; continue;
} }
prep.lat = last.lat;
prep.lng = last.lng;
}
const compressed = await imageCompression(file, { const level = await requestLevel(prep);
if (!level) {
skipped++;
continue;
}
if (level === LEVEL_IGNORE) {
ignoredCount++;
continue;
}
if (level === LEVEL_CANCEL) {
cancelledRemaining = imageFiles.length - i;
break;
}
try {
const compressed = await imageCompression(prep.file, {
maxSizeMB: 0.5, maxSizeMB: 0.5,
maxWidthOrHeight: 800, maxWidthOrHeight: 800,
useWebWorker: true, useWebWorker: true,
@@ -549,9 +709,9 @@ function App() {
formData.append('level', level); formData.append('level', level);
formData.append('photo', compressed, `${id}.jpg`); formData.append('photo', compressed, `${id}.jpg`);
formData.append('id', id); formData.append('id', id);
formData.append('lat', lat); formData.append('lat', prep.lat);
formData.append('lng', lng); formData.append('lng', prep.lng);
formData.append('timestamp', exifTimestamp || new Date().toISOString()); formData.append('timestamp', prep.timestamp);
const res = await fetch('/api/poles', { method: 'POST', body: formData }); const res = await fetch('/api/poles', { method: 'POST', body: formData });
if (!res.ok) { if (!res.ok) {
@@ -570,16 +730,30 @@ function App() {
}); });
registered++; registered++;
} catch (err) { } catch (err) {
console.error('전신주 등록 실패:', file.name, err); console.error('전신주 등록 실패:', prep.file.name, err);
skipped++; skipped++;
} }
} }
if (pendingPrepPromise) {
try {
const leftover = await pendingPrepPromise;
if (leftover?.previewUrl && leftover.previewUrl !== previewUrlRef.current) {
URL.revokeObjectURL(leftover.previewUrl);
}
} catch {
// 준비 자체가 실패한 경우 정리할 자원이 없다.
}
}
closeLevelModal();
setUploadProgress(null); setUploadProgress(null);
setProcessing(false); setProcessing(false);
const parts = [`등록: ${registered}장`]; const parts = [`등록: ${registered}장`];
if (ignoredCount > 0) parts.push(`무시: ${ignoredCount}장`);
if (duplicated > 0) parts.push(`중복: ${duplicated}장`); if (duplicated > 0) parts.push(`중복: ${duplicated}장`);
if (skipped > 0) parts.push(`건너뜀: ${skipped}장`); if (skipped > 0) parts.push(`건너뜀: ${skipped}장`);
if (cancelledRemaining > 0) parts.push(`취소: ${cancelledRemaining}장`);
alert(parts.join(' / ')); alert(parts.join(' / '));
e.target.value = ''; e.target.value = '';
}; };
@@ -665,34 +839,75 @@ function App() {
{levelModal && ( {levelModal && (
<div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}> <div className="level-modal-backdrop" onClick={() => handleLevelSelect(null)}>
<div className="level-modal" onClick={(e) => e.stopPropagation()}> <div
className={`level-modal${modalBusy ? ' level-modal--busy' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className="level-modal__close"
aria-label="등록 작업 취소"
title="작업 취소"
disabled={modalBusy}
onClick={() => handleLevelSelect(LEVEL_CANCEL)}
>
✕
</button>
<img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" /> <img src={levelModal.previewUrl} alt="전신주 사진" className="level-modal__img" />
<div className="level-modal__label">전신주 등급을 선택하세요</div> <div className="level-modal__meta">
<div className="level-modal__meta-row">
📍 {modalAddress === undefined ? '주소 조회 중...' : modalAddress || '주소를 찾을 수 없음'}
</div>
{levelModal.timestamp && (
<div className="level-modal__meta-row">
🕒 {new Date(levelModal.timestamp).toLocaleString('ko-KR')}
</div>
)}
</div>
<div className="level-modal__label">
{modalBusy ? '처리 중...' : '전신주 등급을 선택하세요'}
</div>
<div className="level-modal__buttons"> <div className="level-modal__buttons">
<button <button
type="button" type="button"
className="level-modal__btn level-modal__btn--A" className="level-modal__btn level-modal__btn--A"
disabled={modalBusy}
onClick={() => handleLevelSelect('A')} onClick={() => handleLevelSelect('A')}
> >
<span className="level-modal__key">1</span>
A A
<span className="level-modal__desc">양호</span> <span className="level-modal__desc">양호</span>
</button> </button>
<button <button
type="button" type="button"
className="level-modal__btn level-modal__btn--B" className="level-modal__btn level-modal__btn--B"
disabled={modalBusy}
onClick={() => handleLevelSelect('B')} onClick={() => handleLevelSelect('B')}
> >
<span className="level-modal__key">2</span>
B B
<span className="level-modal__desc">불량</span> <span className="level-modal__desc">불량</span>
</button> </button>
<button <button
type="button" type="button"
className="level-modal__btn level-modal__btn--Pole" className="level-modal__btn level-modal__btn--Pole"
disabled={modalBusy}
onClick={() => handleLevelSelect('Pole')} onClick={() => handleLevelSelect('Pole')}
> >
<span className="level-modal__key">3</span>
Pole Pole
<span className="level-modal__desc">기준</span> <span className="level-modal__desc">기준</span>
</button> </button>
<button
type="button"
className="level-modal__btn level-modal__btn--Ignore"
disabled={modalBusy}
onClick={() => handleLevelSelect(LEVEL_IGNORE)}
>
<span className="level-modal__key">4</span>
무시
<span className="level-modal__desc">등록 안 함</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -704,7 +919,7 @@ function App() {
onClick={() => setListOpen((o) => !o)} onClick={() => setListOpen((o) => !o)}
className="list-toggle" className="list-toggle"
> >
📋 {filteredPoles.length}기 📋 {poles.length}기
</button> </button>
)} )}
@@ -743,6 +958,14 @@ function App() {
전신주 목록 ({filteredPoles.length}기) 전신주 목록 ({filteredPoles.length}기)
</span> </span>
<div className="pole-list__header-actions"> <div className="pole-list__header-actions">
<button
type="button"
className={`pole-list__refresh${refreshing ? ' pole-list__refresh--loading' : ''}`}
onClick={refreshPoles}
disabled={refreshing}
>
<span className="pole-list__refresh-icon">🔄</span> 리프레시
</button>
<button <button
type="button" type="button"
className="pole-list__upload" className="pole-list__upload"
@@ -811,6 +1034,7 @@ function App() {
{f.label} {f.label}
</button> </button>
))} ))}
<span className="pole-list__filter-count">{filteredPoles.length}기</span>
</div> </div>
<div className="pole-list__area-filter"> <div className="pole-list__area-filter">