From bf834dae7fa2d1751a460002125dfd80c642462e Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 23:06:16 +0900 Subject: [PATCH] =?UTF-8?q?refactor(realestate):=20=EC=B9=B4=EB=93=9C/?= =?UTF-8?q?=EC=A7=80=EB=8F=84=ED=8C=A8=EB=84=90/=EC=9D=BC=EC=A0=95/?= =?UTF-8?q?=EB=B6=84=EC=84=9D/=EB=AA=A8=EB=8B=AC=20=ED=91=9C=ED=98=84=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/realestate/RealEstate.jsx | 582 +----------------- .../realestate/components/ComplexCard.jsx | 39 ++ .../realestate/components/ComplexModal.jsx | 157 +++++ .../realestate/components/PriceAnalysis.jsx | 134 ++++ .../realestate/components/RightPanel.jsx | 202 ++++++ .../realestate/components/ScheduleView.jsx | 59 ++ 6 files changed, 597 insertions(+), 576 deletions(-) create mode 100644 src/pages/realestate/components/ComplexCard.jsx create mode 100644 src/pages/realestate/components/ComplexModal.jsx create mode 100644 src/pages/realestate/components/PriceAnalysis.jsx create mode 100644 src/pages/realestate/components/RightPanel.jsx create mode 100644 src/pages/realestate/components/ScheduleView.jsx diff --git a/src/pages/realestate/RealEstate.jsx b/src/pages/realestate/RealEstate.jsx index 090a623..5754e66 100644 --- a/src/pages/realestate/RealEstate.jsx +++ b/src/pages/realestate/RealEstate.jsx @@ -1,587 +1,17 @@ import React, { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; -import { - BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, - ResponsiveContainer, Cell, -} from 'recharts'; import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; import { - SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS, - formatDate, formatPrice, getDDays, createMarkerIcon, + SAMPLE_COMPLEXES, STATUS_CONFIG, TABS, } from './realEstateUtils'; +import ComplexCard from './components/ComplexCard'; +import RightPanel from './components/RightPanel'; +import ScheduleView from './components/ScheduleView'; +import PriceAnalysis from './components/PriceAnalysis'; +import ComplexModal from './components/ComplexModal'; import 'leaflet/dist/leaflet.css'; import './RealEstate.css'; -// ── 지도 중심 이동 (react-leaflet 내부 훅) ──────────────────────────────────── -const MapFlyTo = ({ position, zoom }) => { - const map = useMap(); - useEffect(() => { - if (position) { - map.flyTo(position, zoom ?? 14, { duration: 1.0 }); - } - }, [position, zoom, map]); - return null; -}; - -// ── 단지 카드 ────────────────────────────────────────────────────────────────── -const ComplexCard = ({ complex, isSelected, onClick }) => { - const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료']; - const dday = getDDays(complex.subscriptionStart); - const isUpcoming = complex.status === '청약예정' || complex.status === '청약중'; - - return ( -
-
- - {complex.status} - - {complex.priority === 'high' && } -
-

{complex.name}

-

{complex.address}

-
- {complex.units.toLocaleString()}세대 - · - {formatPrice(complex.avgPricePerPyeong)}/평 -
-
- {complex.types.map((t) => ( - {t} - ))} -
- {isUpcoming && dday && ( -
- 청약 {dday} -
- )} -
- ); -}; - -// ── 인라인 지도 + 단지 상세 패널 ────────────────────────────────────────────── -const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => { - const cfg = selectedComplex - ? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료'] - : null; - - const mapCenter = useMemo(() => { - if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng]; - if (complexes.length === 0) return [37.5665, 126.9780]; - return [ - complexes.reduce((s, c) => s + c.lat, 0) / complexes.length, - complexes.reduce((s, c) => s + c.lng, 0) / complexes.length, - ]; - }, []); // 초기 중심값만 계산 (flyTo로 이후 이동) - - return ( -
- {/* ── 지도 ── */} -
-
- - - - {complexes.map((c) => ( - onSelectComplex(c) }} - > - -
- {c.name} - {c.address} - {c.status} · {c.units.toLocaleString()}세대 - {formatPrice(c.avgPricePerPyeong)}/평 -
-
-
- ))} -
- {selectedComplex && ( -
- {selectedComplex.name} -
- )} -
-
- - {/* ── 상세 패널 ── */} - {selectedComplex ? ( -
-
-
- - {selectedComplex.status} - -

{selectedComplex.name}

-

{selectedComplex.address}

-
-
- - -
-
- -
-
-
-

세대수

-

{selectedComplex.units.toLocaleString()}

-
-
-

평당가

-

- {formatPrice(selectedComplex.avgPricePerPyeong)} -

-
-
-

우선순위

-

- {PRIORITY_LABELS[selectedComplex.priority]} -

-
-
-
- -
-

평형대

-
- {selectedComplex.types.map((t) => ( - {t} - ))} -
-
- -
-

청약 일정

-
-
-
-
-

청약 시작

-

{formatDate(selectedComplex.subscriptionStart)}

-
-
-
-
-
-

청약 마감

-

{formatDate(selectedComplex.subscriptionEnd)}

-
-
-
-
-
-

당첨 발표

-

{formatDate(selectedComplex.resultDate)}

-
-
-
-
- - {selectedComplex.tags.length > 0 && ( -
-

특징

-
- {selectedComplex.tags.map((tag) => ( - {tag} - ))} -
-
- )} - - {selectedComplex.memo && ( -
-

메모

-

{selectedComplex.memo}

-
- )} - -
- {selectedComplex.naverUrl ? ( - - 네이버 부동산 → - - ) : ( - - 네이버 검색 → - - )} - {selectedComplex.floorPlanUrl && ( - - 평면도 보기 - - )} -
-
- ) : ( -
-
🏢
-

카드 또는 지도 마커를 클릭하면
단지 상세 정보가 표시됩니다

-
- )} -
- ); -}; - -// ── 청약 일정 타임라인 ───────────────────────────────────────────────────────── -const ScheduleView = ({ complexes }) => { - const events = complexes - .filter((c) => c.subscriptionStart) - .flatMap((c) => [ - { date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' }, - { date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' }, - { date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' }, - ]) - .filter((e) => e.date) - .sort((a, b) => new Date(a.date) - new Date(b.date)); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const upcoming = events.filter((e) => new Date(e.date) >= today); - const past = events.filter((e) => new Date(e.date) < today).reverse(); - - const EventItem = ({ event }) => { - const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료']; - const dday = getDDays(event.date); - return ( -
-
- {dday} - {formatDate(event.date)} -
-
-
-

{event.complex.name}

-

{event.label}

-
-
- ); - }; - - return ( -
- {upcoming.length > 0 && ( -
-

예정 일정

- {upcoming.map((e, i) => )} -
- )} - {past.length > 0 && ( -
-

지난 일정

- {past.map((e, i) => )} -
- )} - {events.length === 0 &&

등록된 청약 일정이 없습니다.

} -
- ); -}; - -// ── 가격 분석 ────────────────────────────────────────────────────────────────── -const PriceAnalysis = ({ complexes }) => { - const chartData = [...complexes] - .filter((c) => c.avgPricePerPyeong > 0) - .sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong) - .map((c) => ({ - name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name, - price: c.avgPricePerPyeong, - status: c.status, - fullName: c.name, - })); - - const CustomTooltip = ({ active, payload }) => { - if (!active || !payload?.length) return null; - return ( -
-

{payload[0].payload.fullName}

-

{payload[0].value.toLocaleString()}만원/평

-
- ); - }; - - const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0); - const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0; - const max = prices.length ? Math.max(...prices) : 0; - const min = prices.length ? Math.min(...prices) : 0; - - return ( -
-
-
-

평균 평당가

-

{formatPrice(avg)}

-
-
-

최고 평당가

-

{formatPrice(max)}

-
-
-

최저 평당가

-

{formatPrice(min)}

-
-
- -
-
-
-

가격 비교

-

단지별 평당가

-

관심 단지의 평당 분양가를 비교합니다.

-
-
-
- - - - - `${(v / 1000).toFixed(1)}k`} - width={44} - /> - } cursor={{ fill: 'rgba(255,255,255,0.03)' }} /> - - {chartData.map((entry, index) => { - const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료']; - return ; - })} - - - -
-
- -
-
-
-

비교표

-

단지 상세 비교

-
-
-
- - - - - - - - - - - - - {complexes.map((c) => { - const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료']; - return ( - - - - - - - - - ); - })} - -
단지명상태세대수평형대평당가청약 시작
{c.name} - - {c.status} - - {c.units.toLocaleString()}{c.types.join(', ')} - {formatPrice(c.avgPricePerPyeong)} - {formatDate(c.subscriptionStart)}
-
-
-
- ); -}; - -// ── 단지 추가/편집 모달 ──────────────────────────────────────────────────────── -const ComplexModal = ({ complex, onClose, onSave }) => { - const [form, setForm] = useState( - complex - ? { - ...complex, - types: complex.types.join(', '), - tags: complex.tags.join(', '), - lat: String(complex.lat), - lng: String(complex.lng), - units: String(complex.units), - avgPricePerPyeong: String(complex.avgPricePerPyeong), - } - : { ...EMPTY_FORM } - ); - - const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value })); - - const handleSubmit = (e) => { - e.preventDefault(); - onSave({ - ...form, - lat: parseFloat(form.lat) || 37.5665, - lng: parseFloat(form.lng) || 126.9780, - units: parseInt(form.units) || 0, - avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0, - types: form.types.split(',').map((t) => t.trim()).filter(Boolean), - tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), - }); - }; - - return ( -
-
e.stopPropagation()}> -
-

{complex ? '단지 편집' : '새 단지 추가'}

- -
-
-
-

기본 정보

-
- - -
- -
- - -
-
- -
-

단지 정보

-
- - -
-
- - -
- -
- -
-

청약 일정

-
- - - -
-
- -
-

링크 & 메모

- - -