diff --git a/src/pages/realestate/RealEstate.jsx b/src/pages/realestate/RealEstate.jsx index 44e1dcc..090a623 100644 --- a/src/pages/realestate/RealEstate.jsx +++ b/src/pages/realestate/RealEstate.jsx @@ -1,155 +1,18 @@ import React, { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; -import L from '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, +} from './realEstateUtils'; import 'leaflet/dist/leaflet.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: `
`, - iconSize: [size, size], - iconAnchor: [size / 2, size / 2], - popupAnchor: [0, -(size / 2 + 4)], - }); -}; - // ── 지도 중심 이동 (react-leaflet 내부 훅) ──────────────────────────────────── const MapFlyTo = ({ position, zoom }) => { const map = useMap(); diff --git a/src/pages/realestate/realEstateUtils.js b/src/pages/realestate/realEstateUtils.js new file mode 100644 index 0000000..cfcc0ec --- /dev/null +++ b/src/pages/realestate/realEstateUtils.js @@ -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: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -(size / 2 + 4)], + }); +}; diff --git a/src/pages/realestate/realEstateUtils.test.js b/src/pages/realestate/realEstateUtils.test.js new file mode 100644 index 0000000..593c286 --- /dev/null +++ b/src/pages/realestate/realEstateUtils.test.js @@ -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('-'); + }); +});