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}
-
- )}
-
-
-
- ) : (
-
-
🏢
-
카드 또는 지도 마커를 클릭하면
단지 상세 정보가 표시됩니다
-
- )}
-
- );
-};
-
-// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
-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 ? '단지 편집' : '새 단지 추가'}
-
-
-
-
-
- );
-};
-
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
const RealEstate = () => {
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
diff --git a/src/pages/realestate/components/ComplexCard.jsx b/src/pages/realestate/components/ComplexCard.jsx
new file mode 100644
index 0000000..ff9745e
--- /dev/null
+++ b/src/pages/realestate/components/ComplexCard.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
+
+// ── 단지 카드 ──────────────────────────────────────────────────────────────────
+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}
+
+ )}
+
+ );
+};
+
+export default ComplexCard;
diff --git a/src/pages/realestate/components/ComplexModal.jsx b/src/pages/realestate/components/ComplexModal.jsx
new file mode 100644
index 0000000..39f6d9a
--- /dev/null
+++ b/src/pages/realestate/components/ComplexModal.jsx
@@ -0,0 +1,157 @@
+import React, { useState } from 'react';
+import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
+
+// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
+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 ? '단지 편집' : '새 단지 추가'}
+
+
+
+
+
+ );
+};
+
+export default ComplexModal;
diff --git a/src/pages/realestate/components/PriceAnalysis.jsx b/src/pages/realestate/components/PriceAnalysis.jsx
new file mode 100644
index 0000000..39e901c
--- /dev/null
+++ b/src/pages/realestate/components/PriceAnalysis.jsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
+import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
+
+// ── 가격 분석 ──────────────────────────────────────────────────────────────────
+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)} |
+
+ );
+ })}
+
+
+
+
+
+ );
+};
+
+export default PriceAnalysis;
diff --git a/src/pages/realestate/components/RightPanel.jsx b/src/pages/realestate/components/RightPanel.jsx
new file mode 100644
index 0000000..86b2e58
--- /dev/null
+++ b/src/pages/realestate/components/RightPanel.jsx
@@ -0,0 +1,202 @@
+import React, { useEffect, useMemo } from 'react';
+import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
+import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';
+
+// ── 지도 중심 이동 (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 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}
+
+ )}
+
+
+
+ ) : (
+
+
🏢
+
카드 또는 지도 마커를 클릭하면
단지 상세 정보가 표시됩니다
+
+ )}
+
+ );
+};
+
+export default RightPanel;
diff --git a/src/pages/realestate/components/ScheduleView.jsx b/src/pages/realestate/components/ScheduleView.jsx
new file mode 100644
index 0000000..0200f1b
--- /dev/null
+++ b/src/pages/realestate/components/ScheduleView.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
+
+// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
+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 &&
등록된 청약 일정이 없습니다.
}
+
+ );
+};
+
+export default ScheduleView;