refactor(realestate): 카드/지도패널/일정/분석/모달 표현 컴포넌트 추출

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
2026-07-09 23:06:16 +09:00
parent e578c41485
commit bf834dae7f
6 changed files with 597 additions and 576 deletions

View File

@@ -1,587 +1,17 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; 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 { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import { import {
SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS, SAMPLE_COMPLEXES, STATUS_CONFIG, TABS,
formatDate, formatPrice, getDDays, createMarkerIcon,
} from './realEstateUtils'; } 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 'leaflet/dist/leaflet.css';
import './RealEstate.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 (
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
<div className="re-card__top">
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
{complex.status}
</span>
{complex.priority === 'high' && <span className="re-priority-star"></span>}
</div>
<h3 className="re-card__name">{complex.name}</h3>
<p className="re-card__address">{complex.address}</p>
<div className="re-card__stats">
<span>{complex.units.toLocaleString()}세대</span>
<span className="re-card__dot">·</span>
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/</span>
</div>
<div className="re-chip-group">
{complex.types.map((t) => (
<span key={t} className="re-chip">{t}</span>
))}
</div>
{isUpcoming && dday && (
<div className="re-card__dday" style={{ color: cfg.color }}>
청약 {dday}
</div>
)}
</div>
);
};
// ── 인라인 지도 + 단지 상세 패널 ──────────────────────────────────────────────
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 (
<div className="re-right-panel">
{/* ── 지도 ── */}
<div className="re-panel re-panel--map">
<div className="re-mini-map-wrap">
<MapContainer
key="inline-map"
center={mapCenter}
zoom={10}
className="re-map"
scrollWheelZoom
zoomControl={false}
>
<MapFlyTo
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
zoom={14}
/>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com">CartoDB</a>'
/>
{complexes.map((c) => (
<Marker
key={c.id}
position={[c.lat, c.lng]}
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
eventHandlers={{ click: () => onSelectComplex(c) }}
>
<Popup>
<div className="re-popup">
<strong>{c.name}</strong>
<span>{c.address}</span>
<span>{c.status} · {c.units.toLocaleString()}세대</span>
<span>{formatPrice(c.avgPricePerPyeong)}/</span>
</div>
</Popup>
</Marker>
))}
</MapContainer>
{selectedComplex && (
<div className="re-map-label">
<span style={{ color: cfg.color }}></span> {selectedComplex.name}
</div>
)}
</div>
</div>
{/* ── 상세 패널 ── */}
{selectedComplex ? (
<div className="re-detail" key={selectedComplex.id}>
<div className="re-detail__header">
<div>
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
{selectedComplex.status}
</span>
<h2 className="re-detail__name">{selectedComplex.name}</h2>
<p className="re-detail__address">{selectedComplex.address}</p>
</div>
<div className="re-detail__header-actions">
<button className="button ghost small" onClick={onEdit}>편집</button>
<button className="button danger small" onClick={onDelete}>삭제</button>
</div>
</div>
<div className="re-detail__section">
<div className="re-detail__stats-grid">
<div className="re-stat">
<p className="re-stat__label">세대수</p>
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
</div>
<div className="re-stat">
<p className="re-stat__label">평당가</p>
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
{formatPrice(selectedComplex.avgPricePerPyeong)}
</p>
</div>
<div className="re-stat">
<p className="re-stat__label">우선순위</p>
<p className="re-stat__value" style={{ fontSize: 13 }}>
{PRIORITY_LABELS[selectedComplex.priority]}
</p>
</div>
</div>
</div>
<div className="re-detail__section">
<p className="re-detail__section-title">평형대</p>
<div className="re-chip-group">
{selectedComplex.types.map((t) => (
<span key={t} className="re-chip re-chip--lg">{t}</span>
))}
</div>
</div>
<div className="re-detail__section">
<p className="re-detail__section-title">청약 일정</p>
<div className="re-timeline">
<div className="re-timeline__item">
<div className="re-timeline__dot re-timeline__dot--start" />
<div>
<p className="re-timeline__label">청약 시작</p>
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
</div>
</div>
<div className="re-timeline__item">
<div className="re-timeline__dot" />
<div>
<p className="re-timeline__label">청약 마감</p>
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
</div>
</div>
<div className="re-timeline__item">
<div className="re-timeline__dot re-timeline__dot--result" />
<div>
<p className="re-timeline__label">당첨 발표</p>
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
</div>
</div>
</div>
</div>
{selectedComplex.tags.length > 0 && (
<div className="re-detail__section">
<p className="re-detail__section-title">특징</p>
<div className="re-chip-group">
{selectedComplex.tags.map((tag) => (
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
))}
</div>
</div>
)}
{selectedComplex.memo && (
<div className="re-detail__section">
<p className="re-detail__section-title">메모</p>
<p className="re-detail__memo">{selectedComplex.memo}</p>
</div>
)}
<div className="re-detail__actions">
{selectedComplex.naverUrl ? (
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
네이버 부동산
</a>
) : (
<a
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
target="_blank"
rel="noreferrer"
className="button ghost small"
>
네이버 검색
</a>
)}
{selectedComplex.floorPlanUrl && (
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
평면도 보기
</a>
)}
</div>
</div>
) : (
<div className="re-detail re-detail--empty">
<div className="re-detail__empty-icon">🏢</div>
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
</div>
)}
</div>
);
};
// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
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 (
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
<div className="re-schedule-item__date">
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
</div>
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
<div className="re-schedule-item__content">
<p className="re-schedule-item__complex">{event.complex.name}</p>
<p className="re-schedule-item__label">{event.label}</p>
</div>
</div>
);
};
return (
<div className="re-schedule">
{upcoming.length > 0 && (
<div className="re-schedule-section">
<h4 className="re-schedule-section__title">예정 일정</h4>
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
</div>
)}
{past.length > 0 && (
<div className="re-schedule-section">
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
{past.map((e, i) => <EventItem key={i} event={e} />)}
</div>
)}
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
</div>
);
};
// ── 가격 분석 ──────────────────────────────────────────────────────────────────
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 (
<div className="re-chart-tooltip">
<p>{payload[0].payload.fullName}</p>
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/</p>
</div>
);
};
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 (
<div className="re-analysis">
<div className="re-analysis__stats">
<div className="re-stat-card">
<p className="re-stat-card__label">평균 평당가</p>
<p className="re-stat-card__value">{formatPrice(avg)}</p>
</div>
<div className="re-stat-card">
<p className="re-stat-card__label">최고 평당가</p>
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
</div>
<div className="re-stat-card">
<p className="re-stat-card__label">최저 평당가</p>
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
</div>
</div>
<div className="re-panel">
<div className="re-panel__head">
<div>
<p className="re-panel__eyebrow">가격 비교</p>
<h3>단지별 평당가</h3>
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
</div>
</div>
<div className="re-chart-wrapper">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="name"
stroke="var(--text-dim)"
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
angle={-20}
textAnchor="end"
height={60}
/>
<YAxis
stroke="var(--text-dim)"
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
width={44}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="re-panel">
<div className="re-panel__head">
<div>
<p className="re-panel__eyebrow">비교표</p>
<h3>단지 상세 비교</h3>
</div>
</div>
<div className="re-table-wrapper">
<table className="re-table">
<thead>
<tr>
<th>단지명</th>
<th>상태</th>
<th>세대수</th>
<th>평형대</th>
<th>평당가</th>
<th>청약 시작</th>
</tr>
</thead>
<tbody>
{complexes.map((c) => {
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
return (
<tr key={c.id}>
<td className="re-table__name">{c.name}</td>
<td>
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
{c.status}
</span>
</td>
<td>{c.units.toLocaleString()}</td>
<td>{c.types.join(', ')}</td>
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
{formatPrice(c.avgPricePerPyeong)}
</td>
<td>{formatDate(c.subscriptionStart)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
// ── 단지 추가/편집 모달 ────────────────────────────────────────────────────────
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 (
<div className="re-modal-overlay" onClick={onClose}>
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
<div className="re-modal__header">
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
<button className="re-modal__close" onClick={onClose}></button>
</div>
<form className="re-modal__form" onSubmit={handleSubmit}>
<div className="re-form-section">
<p className="re-form-section__title">기본 정보</p>
<div className="re-form-row">
<label className="re-form-label">
단지명 *
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
</label>
<label className="re-form-label">
상태
<select className="re-form-input" value={form.status} onChange={set('status')}>
{Object.keys(STATUS_CONFIG).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
</div>
<label className="re-form-label">
주소
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
</label>
<div className="re-form-row">
<label className="re-form-label">
위도 (lat)
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
</label>
<label className="re-form-label">
경도 (lng)
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
</label>
</div>
</div>
<div className="re-form-section">
<p className="re-form-section__title">단지 정보</p>
<div className="re-form-row">
<label className="re-form-label">
세대수
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
</label>
<label className="re-form-label">
평당가 (만원)
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
</label>
</div>
<div className="re-form-row">
<label className="re-form-label">
평형대 (쉼표 구분)
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
</label>
<label className="re-form-label">
우선순위
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
<option value="high"> 최우선</option>
<option value="normal">보통</option>
<option value="low">낮음</option>
</select>
</label>
</div>
<label className="re-form-label">
특징 태그 (쉼표 구분)
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
</label>
</div>
<div className="re-form-section">
<p className="re-form-section__title">청약 일정</p>
<div className="re-form-row re-form-row--three">
<label className="re-form-label">
청약 시작
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
</label>
<label className="re-form-label">
청약 마감
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
</label>
<label className="re-form-label">
당첨 발표
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
</label>
</div>
</div>
<div className="re-form-section">
<p className="re-form-section__title">링크 & 메모</p>
<label className="re-form-label">
네이버 부동산 URL
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
</label>
<label className="re-form-label">
평면도 URL
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
</label>
<label className="re-form-label">
메모
<textarea
className="re-form-input re-form-textarea"
value={form.memo}
onChange={set('memo')}
placeholder="관심 포인트, 분석 내용 등"
rows={3}
/>
</label>
</div>
<div className="re-modal__footer">
<button type="button" className="button ghost" onClick={onClose}>취소</button>
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
</div>
</form>
</div>
</div>
);
};
// ── 메인 컴포넌트 ────────────────────────────────────────────────────────────── // ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
const RealEstate = () => { const RealEstate = () => {
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES); const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);

View File

@@ -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 (
<div className={`re-card ${isSelected ? 'is-selected' : ''}`} onClick={onClick}>
<div className="re-card__top">
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
{complex.status}
</span>
{complex.priority === 'high' && <span className="re-priority-star"></span>}
</div>
<h3 className="re-card__name">{complex.name}</h3>
<p className="re-card__address">{complex.address}</p>
<div className="re-card__stats">
<span>{complex.units.toLocaleString()}세대</span>
<span className="re-card__dot">·</span>
<span style={{ color: '#f59e0b' }}>{formatPrice(complex.avgPricePerPyeong)}/</span>
</div>
<div className="re-chip-group">
{complex.types.map((t) => (
<span key={t} className="re-chip">{t}</span>
))}
</div>
{isUpcoming && dday && (
<div className="re-card__dday" style={{ color: cfg.color }}>
청약 {dday}
</div>
)}
</div>
);
};
export default ComplexCard;

View File

@@ -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 (
<div className="re-modal-overlay" onClick={onClose}>
<div className="re-modal" onClick={(e) => e.stopPropagation()}>
<div className="re-modal__header">
<h3>{complex ? '단지 편집' : '새 단지 추가'}</h3>
<button className="re-modal__close" onClick={onClose}></button>
</div>
<form className="re-modal__form" onSubmit={handleSubmit}>
<div className="re-form-section">
<p className="re-form-section__title">기본 정보</p>
<div className="re-form-row">
<label className="re-form-label">
단지명 *
<input className="re-form-input" value={form.name} onChange={set('name')} placeholder="단지명 입력" required />
</label>
<label className="re-form-label">
상태
<select className="re-form-input" value={form.status} onChange={set('status')}>
{Object.keys(STATUS_CONFIG).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
</div>
<label className="re-form-label">
주소
<input className="re-form-input" value={form.address} onChange={set('address')} placeholder="서울 서초구 반포동" />
</label>
<div className="re-form-row">
<label className="re-form-label">
위도 (lat)
<input className="re-form-input" value={form.lat} onChange={set('lat')} placeholder="37.5665" type="number" step="0.0001" />
</label>
<label className="re-form-label">
경도 (lng)
<input className="re-form-input" value={form.lng} onChange={set('lng')} placeholder="126.9780" type="number" step="0.0001" />
</label>
</div>
</div>
<div className="re-form-section">
<p className="re-form-section__title">단지 정보</p>
<div className="re-form-row">
<label className="re-form-label">
세대수
<input className="re-form-input" value={form.units} onChange={set('units')} placeholder="2990" type="number" />
</label>
<label className="re-form-label">
평당가 (만원)
<input className="re-form-input" value={form.avgPricePerPyeong} onChange={set('avgPricePerPyeong')} placeholder="4500" type="number" />
</label>
</div>
<div className="re-form-row">
<label className="re-form-label">
평형대 (쉼표 구분)
<input className="re-form-input" value={form.types} onChange={set('types')} placeholder="59㎡, 84㎡, 114㎡" />
</label>
<label className="re-form-label">
우선순위
<select className="re-form-input" value={form.priority} onChange={set('priority')}>
<option value="high"> 최우선</option>
<option value="normal">보통</option>
<option value="low">낮음</option>
</select>
</label>
</div>
<label className="re-form-label">
특징 태그 (쉼표 구분)
<input className="re-form-input" value={form.tags} onChange={set('tags')} placeholder="강남권, 역세권, 브랜드" />
</label>
</div>
<div className="re-form-section">
<p className="re-form-section__title">청약 일정</p>
<div className="re-form-row re-form-row--three">
<label className="re-form-label">
청약 시작
<input className="re-form-input" type="date" value={form.subscriptionStart} onChange={set('subscriptionStart')} />
</label>
<label className="re-form-label">
청약 마감
<input className="re-form-input" type="date" value={form.subscriptionEnd} onChange={set('subscriptionEnd')} />
</label>
<label className="re-form-label">
당첨 발표
<input className="re-form-input" type="date" value={form.resultDate} onChange={set('resultDate')} />
</label>
</div>
</div>
<div className="re-form-section">
<p className="re-form-section__title">링크 & 메모</p>
<label className="re-form-label">
네이버 부동산 URL
<input className="re-form-input" value={form.naverUrl} onChange={set('naverUrl')} placeholder="https://new.land.naver.com/complexes/..." />
</label>
<label className="re-form-label">
평면도 URL
<input className="re-form-input" value={form.floorPlanUrl} onChange={set('floorPlanUrl')} placeholder="https://..." />
</label>
<label className="re-form-label">
메모
<textarea
className="re-form-input re-form-textarea"
value={form.memo}
onChange={set('memo')}
placeholder="관심 포인트, 분석 내용 등"
rows={3}
/>
</label>
</div>
<div className="re-modal__footer">
<button type="button" className="button ghost" onClick={onClose}>취소</button>
<button type="submit" className="button primary">{complex ? '저장' : '추가'}</button>
</div>
</form>
</div>
</div>
);
};
export default ComplexModal;

View File

@@ -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 (
<div className="re-chart-tooltip">
<p>{payload[0].payload.fullName}</p>
<p className="re-chart-tooltip__value">{payload[0].value.toLocaleString()}만원/</p>
</div>
);
};
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 (
<div className="re-analysis">
<div className="re-analysis__stats">
<div className="re-stat-card">
<p className="re-stat-card__label">평균 평당가</p>
<p className="re-stat-card__value">{formatPrice(avg)}</p>
</div>
<div className="re-stat-card">
<p className="re-stat-card__label">최고 평당가</p>
<p className="re-stat-card__value" style={{ color: '#f59e0b' }}>{formatPrice(max)}</p>
</div>
<div className="re-stat-card">
<p className="re-stat-card__label">최저 평당가</p>
<p className="re-stat-card__value" style={{ color: '#34d399' }}>{formatPrice(min)}</p>
</div>
</div>
<div className="re-panel">
<div className="re-panel__head">
<div>
<p className="re-panel__eyebrow">가격 비교</p>
<h3>단지별 평당가</h3>
<p className="re-panel__sub">관심 단지의 평당 분양가를 비교합니다.</p>
</div>
</div>
<div className="re-chart-wrapper">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 50 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="name"
stroke="var(--text-dim)"
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
angle={-20}
textAnchor="end"
height={60}
/>
<YAxis
stroke="var(--text-dim)"
tick={{ fill: 'var(--text-dim)', fontSize: 11 }}
tickFormatter={(v) => `${(v / 1000).toFixed(1)}k`}
width={44}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.03)' }} />
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
{chartData.map((entry, index) => {
const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료'];
return <Cell key={index} fill={cfg.color} fillOpacity={0.75} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="re-panel">
<div className="re-panel__head">
<div>
<p className="re-panel__eyebrow">비교표</p>
<h3>단지 상세 비교</h3>
</div>
</div>
<div className="re-table-wrapper">
<table className="re-table">
<thead>
<tr>
<th>단지명</th>
<th>상태</th>
<th>세대수</th>
<th>평형대</th>
<th>평당가</th>
<th>청약 시작</th>
</tr>
</thead>
<tbody>
{complexes.map((c) => {
const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료'];
return (
<tr key={c.id}>
<td className="re-table__name">{c.name}</td>
<td>
<span className="re-badge" style={{ color: cfg.color, background: cfg.bg }}>
{c.status}
</span>
</td>
<td>{c.units.toLocaleString()}</td>
<td>{c.types.join(', ')}</td>
<td style={{ color: '#f59e0b', fontWeight: 600 }}>
{formatPrice(c.avgPricePerPyeong)}
</td>
<td>{formatDate(c.subscriptionStart)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default PriceAnalysis;

View File

@@ -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 (
<div className="re-right-panel">
{/* ── 지도 ── */}
<div className="re-panel re-panel--map">
<div className="re-mini-map-wrap">
<MapContainer
key="inline-map"
center={mapCenter}
zoom={10}
className="re-map"
scrollWheelZoom
zoomControl={false}
>
<MapFlyTo
position={selectedComplex ? [selectedComplex.lat, selectedComplex.lng] : null}
zoom={14}
/>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com">CartoDB</a>'
/>
{complexes.map((c) => (
<Marker
key={c.id}
position={[c.lat, c.lng]}
icon={createMarkerIcon(c.status, selectedComplex?.id === c.id)}
eventHandlers={{ click: () => onSelectComplex(c) }}
>
<Popup>
<div className="re-popup">
<strong>{c.name}</strong>
<span>{c.address}</span>
<span>{c.status} · {c.units.toLocaleString()}세대</span>
<span>{formatPrice(c.avgPricePerPyeong)}/</span>
</div>
</Popup>
</Marker>
))}
</MapContainer>
{selectedComplex && (
<div className="re-map-label">
<span style={{ color: cfg.color }}></span> {selectedComplex.name}
</div>
)}
</div>
</div>
{/* ── 상세 패널 ── */}
{selectedComplex ? (
<div className="re-detail" key={selectedComplex.id}>
<div className="re-detail__header">
<div>
<span className="re-badge re-badge--lg" style={{ color: cfg.color, background: cfg.bg }}>
{selectedComplex.status}
</span>
<h2 className="re-detail__name">{selectedComplex.name}</h2>
<p className="re-detail__address">{selectedComplex.address}</p>
</div>
<div className="re-detail__header-actions">
<button className="button ghost small" onClick={onEdit}>편집</button>
<button className="button danger small" onClick={onDelete}>삭제</button>
</div>
</div>
<div className="re-detail__section">
<div className="re-detail__stats-grid">
<div className="re-stat">
<p className="re-stat__label">세대수</p>
<p className="re-stat__value">{selectedComplex.units.toLocaleString()}</p>
</div>
<div className="re-stat">
<p className="re-stat__label">평당가</p>
<p className="re-stat__value" style={{ color: '#f59e0b' }}>
{formatPrice(selectedComplex.avgPricePerPyeong)}
</p>
</div>
<div className="re-stat">
<p className="re-stat__label">우선순위</p>
<p className="re-stat__value" style={{ fontSize: 13 }}>
{PRIORITY_LABELS[selectedComplex.priority]}
</p>
</div>
</div>
</div>
<div className="re-detail__section">
<p className="re-detail__section-title">평형대</p>
<div className="re-chip-group">
{selectedComplex.types.map((t) => (
<span key={t} className="re-chip re-chip--lg">{t}</span>
))}
</div>
</div>
<div className="re-detail__section">
<p className="re-detail__section-title">청약 일정</p>
<div className="re-timeline">
<div className="re-timeline__item">
<div className="re-timeline__dot re-timeline__dot--start" />
<div>
<p className="re-timeline__label">청약 시작</p>
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionStart)}</p>
</div>
</div>
<div className="re-timeline__item">
<div className="re-timeline__dot" />
<div>
<p className="re-timeline__label">청약 마감</p>
<p className="re-timeline__date">{formatDate(selectedComplex.subscriptionEnd)}</p>
</div>
</div>
<div className="re-timeline__item">
<div className="re-timeline__dot re-timeline__dot--result" />
<div>
<p className="re-timeline__label">당첨 발표</p>
<p className="re-timeline__date">{formatDate(selectedComplex.resultDate)}</p>
</div>
</div>
</div>
</div>
{selectedComplex.tags.length > 0 && (
<div className="re-detail__section">
<p className="re-detail__section-title">특징</p>
<div className="re-chip-group">
{selectedComplex.tags.map((tag) => (
<span key={tag} className="re-chip re-chip--tag">{tag}</span>
))}
</div>
</div>
)}
{selectedComplex.memo && (
<div className="re-detail__section">
<p className="re-detail__section-title">메모</p>
<p className="re-detail__memo">{selectedComplex.memo}</p>
</div>
)}
<div className="re-detail__actions">
{selectedComplex.naverUrl ? (
<a href={selectedComplex.naverUrl} target="_blank" rel="noreferrer" className="button primary small">
네이버 부동산
</a>
) : (
<a
href={`https://new.land.naver.com/search?query=${encodeURIComponent(selectedComplex.name)}`}
target="_blank"
rel="noreferrer"
className="button ghost small"
>
네이버 검색
</a>
)}
{selectedComplex.floorPlanUrl && (
<a href={selectedComplex.floorPlanUrl} target="_blank" rel="noreferrer" className="button ghost small">
평면도 보기
</a>
)}
</div>
</div>
) : (
<div className="re-detail re-detail--empty">
<div className="re-detail__empty-icon">🏢</div>
<p>카드 또는 지도 마커를 클릭하면<br />단지 상세 정보가 표시됩니다</p>
</div>
)}
</div>
);
};
export default RightPanel;

View File

@@ -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 (
<div className={`re-schedule-item re-schedule-item--${event.type}`}>
<div className="re-schedule-item__date">
<span className="re-schedule-item__dday" style={{ color: cfg.color }}>{dday}</span>
<span className="re-schedule-item__datestr">{formatDate(event.date)}</span>
</div>
<div className="re-schedule-item__dot" style={{ background: cfg.color, boxShadow: `0 0 6px ${cfg.color}` }} />
<div className="re-schedule-item__content">
<p className="re-schedule-item__complex">{event.complex.name}</p>
<p className="re-schedule-item__label">{event.label}</p>
</div>
</div>
);
};
return (
<div className="re-schedule">
{upcoming.length > 0 && (
<div className="re-schedule-section">
<h4 className="re-schedule-section__title">예정 일정</h4>
{upcoming.map((e, i) => <EventItem key={i} event={e} />)}
</div>
)}
{past.length > 0 && (
<div className="re-schedule-section">
<h4 className="re-schedule-section__title re-schedule-section__title--past">지난 일정</h4>
{past.map((e, i) => <EventItem key={i} event={e} />)}
</div>
)}
{events.length === 0 && <p className="re-empty">등록된 청약 일정이 없습니다.</p>}
</div>
);
};
export default ScheduleView;