refactor(realestate): 상수·데이터·순수헬퍼를 realEstateUtils로 추출 + 테스트
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:
@@ -1,155 +1,18 @@
|
|||||||
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 { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
ResponsiveContainer, Cell,
|
ResponsiveContainer, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||||
|
import {
|
||||||
|
SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS,
|
||||||
|
formatDate, formatPrice, getDDays, createMarkerIcon,
|
||||||
|
} from './realEstateUtils';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './RealEstate.css';
|
import './RealEstate.css';
|
||||||
|
|
||||||
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
|
||||||
const SAMPLE_COMPLEXES = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: '래미안 원베일리',
|
|
||||||
address: '서울 서초구 반포동',
|
|
||||||
lat: 37.5065,
|
|
||||||
lng: 126.9942,
|
|
||||||
units: 2990,
|
|
||||||
types: ['59㎡', '84㎡', '114㎡'],
|
|
||||||
avgPricePerPyeong: 9500,
|
|
||||||
subscriptionStart: '2024-01-08',
|
|
||||||
subscriptionEnd: '2024-01-10',
|
|
||||||
resultDate: '2024-01-15',
|
|
||||||
status: '완료',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: '올림픽파크 포레온',
|
|
||||||
address: '서울 강동구 둔촌동',
|
|
||||||
lat: 37.5284,
|
|
||||||
lng: 127.1340,
|
|
||||||
units: 12032,
|
|
||||||
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 3800,
|
|
||||||
subscriptionStart: '2022-12-05',
|
|
||||||
subscriptionEnd: '2022-12-07',
|
|
||||||
resultDate: '2022-12-12',
|
|
||||||
status: '완료',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['대단지', '역세권', '재건축'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '역대 최대 규모 재건축 단지.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: '힐스테이트 동탄',
|
|
||||||
address: '경기 화성시 동탄2신도시',
|
|
||||||
lat: 37.2001,
|
|
||||||
lng: 127.0724,
|
|
||||||
units: 1534,
|
|
||||||
types: ['59㎡', '74㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 1850,
|
|
||||||
subscriptionStart: '2026-04-10',
|
|
||||||
subscriptionEnd: '2026-04-12',
|
|
||||||
resultDate: '2026-04-17',
|
|
||||||
status: '청약예정',
|
|
||||||
priority: 'normal',
|
|
||||||
tags: ['동탄2', '신도시', 'SRT'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: '롯데캐슬 마곡',
|
|
||||||
address: '서울 강서구 마곡동',
|
|
||||||
lat: 37.5626,
|
|
||||||
lng: 126.8295,
|
|
||||||
units: 868,
|
|
||||||
types: ['59㎡', '84㎡'],
|
|
||||||
avgPricePerPyeong: 4200,
|
|
||||||
subscriptionStart: '2026-03-20',
|
|
||||||
subscriptionEnd: '2026-03-22',
|
|
||||||
resultDate: '2026-03-27',
|
|
||||||
status: '청약중',
|
|
||||||
priority: 'high',
|
|
||||||
tags: ['마곡', '9호선', '공항철도'],
|
|
||||||
naverUrl: '',
|
|
||||||
floorPlanUrl: '',
|
|
||||||
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
|
||||||
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
|
||||||
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
|
||||||
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
|
||||||
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
|
||||||
name: '', address: '', lat: '', lng: '',
|
|
||||||
units: '', types: '', avgPricePerPyeong: '',
|
|
||||||
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
|
||||||
status: '청약예정', priority: 'normal',
|
|
||||||
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TABS = ['목록', '일정', '분석'];
|
|
||||||
|
|
||||||
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
|
||||||
const formatDate = (d) => {
|
|
||||||
if (!d) return '-';
|
|
||||||
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (v) => {
|
|
||||||
if (!v) return '-';
|
|
||||||
return `${v.toLocaleString()}만원`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDDays = (dateStr) => {
|
|
||||||
if (!dateStr) return null;
|
|
||||||
const target = new Date(dateStr);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
|
||||||
if (diff === 0) return 'D-Day';
|
|
||||||
if (diff > 0) return `D-${diff}`;
|
|
||||||
return `D+${Math.abs(diff)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarkerIcon = (status, isSelected = false) => {
|
|
||||||
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
|
||||||
const size = isSelected ? 18 : 12;
|
|
||||||
return L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="
|
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
|
||||||
background:${cfg.color};
|
|
||||||
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
|
||||||
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
|
||||||
transition:all 0.2s;
|
|
||||||
"></div>`,
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size / 2, size / 2],
|
|
||||||
popupAnchor: [0, -(size / 2 + 4)],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
// ── 지도 중심 이동 (react-leaflet 내부 훅) ────────────────────────────────────
|
||||||
const MapFlyTo = ({ position, zoom }) => {
|
const MapFlyTo = ({ position, zoom }) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|||||||
141
src/pages/realestate/realEstateUtils.js
Normal file
141
src/pages/realestate/realEstateUtils.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
export const SAMPLE_COMPLEXES = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '래미안 원베일리',
|
||||||
|
address: '서울 서초구 반포동',
|
||||||
|
lat: 37.5065,
|
||||||
|
lng: 126.9942,
|
||||||
|
units: 2990,
|
||||||
|
types: ['59㎡', '84㎡', '114㎡'],
|
||||||
|
avgPricePerPyeong: 9500,
|
||||||
|
subscriptionStart: '2024-01-08',
|
||||||
|
subscriptionEnd: '2024-01-10',
|
||||||
|
resultDate: '2024-01-15',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['강남권', '한강뷰', '역세권', '브랜드'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '반포동 재건축 단지. 경쟁률 수백대 1 예상.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '올림픽파크 포레온',
|
||||||
|
address: '서울 강동구 둔촌동',
|
||||||
|
lat: 37.5284,
|
||||||
|
lng: 127.1340,
|
||||||
|
units: 12032,
|
||||||
|
types: ['39㎡', '49㎡', '59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 3800,
|
||||||
|
subscriptionStart: '2022-12-05',
|
||||||
|
subscriptionEnd: '2022-12-07',
|
||||||
|
resultDate: '2022-12-12',
|
||||||
|
status: '완료',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['대단지', '역세권', '재건축'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '역대 최대 규모 재건축 단지.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '힐스테이트 동탄',
|
||||||
|
address: '경기 화성시 동탄2신도시',
|
||||||
|
lat: 37.2001,
|
||||||
|
lng: 127.0724,
|
||||||
|
units: 1534,
|
||||||
|
types: ['59㎡', '74㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 1850,
|
||||||
|
subscriptionStart: '2026-04-10',
|
||||||
|
subscriptionEnd: '2026-04-12',
|
||||||
|
resultDate: '2026-04-17',
|
||||||
|
status: '청약예정',
|
||||||
|
priority: 'normal',
|
||||||
|
tags: ['동탄2', '신도시', 'SRT'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '동탄 핵심 입지. 교통 개선 기대.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '롯데캐슬 마곡',
|
||||||
|
address: '서울 강서구 마곡동',
|
||||||
|
lat: 37.5626,
|
||||||
|
lng: 126.8295,
|
||||||
|
units: 868,
|
||||||
|
types: ['59㎡', '84㎡'],
|
||||||
|
avgPricePerPyeong: 4200,
|
||||||
|
subscriptionStart: '2026-03-20',
|
||||||
|
subscriptionEnd: '2026-03-22',
|
||||||
|
resultDate: '2026-03-27',
|
||||||
|
status: '청약중',
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['마곡', '9호선', '공항철도'],
|
||||||
|
naverUrl: '',
|
||||||
|
floorPlanUrl: '',
|
||||||
|
memo: '마곡 업무지구 직주근접. 강서 핵심 입지.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
'청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
'청약중': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
|
||||||
|
'결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
'완료': { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
|
||||||
|
|
||||||
|
export const EMPTY_FORM = {
|
||||||
|
name: '', address: '', lat: '', lng: '',
|
||||||
|
units: '', types: '', avgPricePerPyeong: '',
|
||||||
|
subscriptionStart: '', subscriptionEnd: '', resultDate: '',
|
||||||
|
status: '청약예정', priority: 'normal',
|
||||||
|
tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TABS = ['목록', '일정', '분석'];
|
||||||
|
|
||||||
|
// ── 유틸 함수 ──────────────────────────────────────────────────────────────────
|
||||||
|
export const formatDate = (d) => {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPrice = (v) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
return `${v.toLocaleString()}만원`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDDays = (dateStr) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diff === 0) return 'D-Day';
|
||||||
|
if (diff > 0) return `D-${diff}`;
|
||||||
|
return `D+${Math.abs(diff)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMarkerIcon = (status, isSelected = false) => {
|
||||||
|
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
|
||||||
|
const size = isSelected ? 18 : 12;
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${cfg.color};
|
||||||
|
box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
|
||||||
|
border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
|
||||||
|
transition:all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
});
|
||||||
|
};
|
||||||
31
src/pages/realestate/realEstateUtils.test.js
Normal file
31
src/pages/realestate/realEstateUtils.test.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';
|
||||||
|
|
||||||
|
describe('formatPrice', () => {
|
||||||
|
it('만원 표기 / falsy → -', () => {
|
||||||
|
expect(formatPrice(4500)).toBe('4,500만원');
|
||||||
|
expect(formatPrice(0)).toBe('-');
|
||||||
|
expect(formatPrice(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDDays (오늘 기준)', () => {
|
||||||
|
const iso = (off) => {
|
||||||
|
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
|
||||||
|
};
|
||||||
|
it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
|
||||||
|
expect(getDDays(iso(0))).toBe('D-Day');
|
||||||
|
expect(getDDays(iso(4))).toBe('D-4');
|
||||||
|
expect(getDDays(iso(-2))).toBe('D+2');
|
||||||
|
expect(getDDays(null)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('유효 날짜 한국어 포맷 / falsy → -', () => {
|
||||||
|
expect(formatDate('2026-04-10')).toContain('2026');
|
||||||
|
expect(formatDate(null)).toBe('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user