merge: RealEstate 모듈 분해 리팩토링 (909→190줄 shell + useComplexes 훅)
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:
@@ -0,0 +1,368 @@
|
|||||||
|
# RealEstate.jsx 분해 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `RealEstate.jsx`(909줄)를 기능 모듈 구조(shell + hooks/useComplexes + realEstateUtils + components/5)로 **동작 보존 분해**한다.
|
||||||
|
|
||||||
|
**Architecture:** 순수 추출. 상수·데이터·순수헬퍼→`realEstateUtils.js`, 표현 컴포넌트 5개→`components/*.jsx`(verbatim), 데이터 로직(complexes+CRUD)→`hooks/useComplexes.js`, `RealEstate.jsx`는 UI 상태+조합 shell(~150줄)로 축소. 각 Task는 코드 이동 후 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + Vite, react-leaflet/leaflet, recharts, Vitest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Behavior-preserving 순수 추출**: 옮기는 컴포넌트/헬퍼 본문(JSX·로직)은 verbatim. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
|
||||||
|
- 경로: `RealEstate.jsx`(`src/pages/realestate/`) api `'../../api'`; `components/*`·`hooks/*`는 api `'../../../api'`, utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`·hook `'./hooks/useComplexes'`·components `'./components/*'`·CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon), api import 없음.
|
||||||
|
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
|
||||||
|
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js` 동일 디렉토리.
|
||||||
|
- 각 컴포넌트/파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 커밋은 web-ui, 변경 파일만 `git add`. 커밋 trailer:
|
||||||
|
```
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||||
|
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `realEstateUtils.js` — 상수·데이터·순수헬퍼 추출 + 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/realEstateUtils.js`
|
||||||
|
- Test: `src/pages/realestate/realEstateUtils.test.js`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `SAMPLE_COMPLEXES`, `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`, `formatDate(d)`, `formatPrice(v)`, `getDDays(dateStr)`, `createMarkerIcon(status, isSelected)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** — Create `src/pages/realestate/realEstateUtils.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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('-');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js`
|
||||||
|
Expected: FAIL — `Failed to resolve import "./realEstateUtils.js"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create realEstateUtils.js** — 상단 `import L from 'leaflet';` 후, 현 `RealEstate.jsx`의 아래 항목을 **verbatim** 이동하고 각각 `export`:
|
||||||
|
- `SAMPLE_COMPLEXES` (현 RealEstate.jsx의 4개 단지 배열 전체를 그대로 복사) — `export const SAMPLE_COMPLEXES = [...]`
|
||||||
|
- `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS` (verbatim) — 각 `export const`
|
||||||
|
- `formatDate`, `formatPrice`, `getDDays` (verbatim) — 각 `export const`
|
||||||
|
- `createMarkerIcon` (verbatim, `L.divIcon` 사용) — `export const createMarkerIcon = (status, isSelected = false) => {...}`
|
||||||
|
|
||||||
|
명시적 내용(작은 것들, verbatim):
|
||||||
|
```js
|
||||||
|
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)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
`SAMPLE_COMPLEXES`는 현 RealEstate.jsx:14-91의 배열을 그대로 옮겨 `export const SAMPLE_COMPLEXES = [ ... ];`로.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire RealEstate.jsx** — 위 항목들의 기존 정의(SAMPLE_COMPLEXES/STATUS_CONFIG/PRIORITY_LABELS/EMPTY_FORM/TABS/formatDate/formatPrice/getDDays/createMarkerIcon)를 삭제하고, import 추가:
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS,
|
||||||
|
formatDate, formatPrice, getDDays, createMarkerIcon,
|
||||||
|
} from './realEstateUtils';
|
||||||
|
```
|
||||||
|
(이 시점엔 컴포넌트들이 아직 RealEstate.jsx 내부 → import한 심볼 사용. `import L from 'leaflet'`는 createMarkerIcon이 utils로 갔으니 RealEstate.jsx에서 더 이상 직접 안 쓰면 제거; 단 다른 곳(예: 없음)에서 안 쓰면 제거. `import 'leaflet/dist/leaflet.css'`는 지도 렌더 위해 shell 또는 RightPanel에 유지 필요 → 이번 단계는 RealEstate.jsx에 유지.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run -- src/pages/realestate/realEstateUtils.test.js` → PASS.
|
||||||
|
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 파일 0 에러(미사용 import 없도록).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/realEstateUtils.js src/pages/realestate/realEstateUtils.test.js src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 상수·데이터·순수헬퍼를 realEstateUtils로 추출 + 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 표현 컴포넌트 5개 추출 (ComplexCard/RightPanel/ScheduleView/PriceAnalysis/ComplexModal)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/components/ComplexCard.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/RightPanel.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/ScheduleView.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/PriceAnalysis.jsx`
|
||||||
|
- Create: `src/pages/realestate/components/ComplexModal.jsx`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (Task 1): utils 심볼.
|
||||||
|
- Produces: 각 기본 export — `ComplexCard({complex,isSelected,onClick})`, `RightPanel({complexes,selectedComplex,onSelectComplex,onEdit,onDelete})`, `ScheduleView({complexes})`, `PriceAnalysis({complexes})`, `ComplexModal({complex,onClose,onSave})`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the five components (본문 verbatim + 상단 import)**
|
||||||
|
|
||||||
|
각 새 파일 = 상단 import 블록 + 현 RealEstate.jsx의 해당 함수 본문 verbatim + `export default <Name>;`. **`MapFlyTo`는 RightPanel.jsx 안에 내부 컴포넌트로 함께 이동**(현 RealEstate.jsx의 MapFlyTo 정의를 RightPanel.jsx 상단에 그대로 두고 RightPanel이 사용). `EventItem`은 ScheduleView 내부, `CustomTooltip`은 PriceAnalysis 내부에 이미 중첩되어 있으므로 그대로.
|
||||||
|
|
||||||
|
import 블록(각 컴포넌트가 실제 사용하는 심볼만 — 아래는 현 코드 사용 기준):
|
||||||
|
|
||||||
|
`ComplexCard.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`RightPanel.jsx` (MapFlyTo 내부 포함):
|
||||||
|
```jsx
|
||||||
|
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';
|
||||||
|
```
|
||||||
|
`ScheduleView.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`PriceAnalysis.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
`ComplexModal.jsx`:
|
||||||
|
```jsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';
|
||||||
|
```
|
||||||
|
> 실제 본문이 참조하는 심볼만 포함할 것(빌드/lint가 누락·미사용 검출). RightPanel은 `leaflet/dist/leaflet.css`를 shell에서 로드하므로 별도 import 불필요.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire RealEstate.jsx** — 5개 컴포넌트 + MapFlyTo 정의 삭제, import 추가:
|
||||||
|
```js
|
||||||
|
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';
|
||||||
|
```
|
||||||
|
RealEstate.jsx 상단의 이제-미사용 import 정리: `MapContainer/TileLayer/Marker/Popup/useMap`(react-leaflet), `BarChart~Cell`(recharts)가 shell에서 더 이상 안 쓰이면 제거(shell은 지도/차트를 직접 렌더하지 않음). `import 'leaflet/dist/leaflet.css'`는 지도 스타일 위해 **shell에 유지**(RightPanel이 렌더될 때 필요). `useMemo`는 shell의 filteredComplexes/stats에서 계속 사용 → 유지.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests + build + lint**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 5파일 + RealEstate.jsx 0 에러.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/components/ComplexCard.jsx src/pages/realestate/components/RightPanel.jsx src/pages/realestate/components/ScheduleView.jsx src/pages/realestate/components/PriceAnalysis.jsx src/pages/realestate/components/ComplexModal.jsx src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 카드/지도패널/일정/분석/모달 표현 컴포넌트 추출"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `useComplexes` 훅 추출 → RealEstate를 shell 컨테이너로 + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/realestate/hooks/useComplexes.js`
|
||||||
|
- Modify: `src/pages/realestate/RealEstate.jsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `useComplexes()` 기본 export → `{ complexes, addComplex, updateComplex, deleteComplex }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create useComplexes.js** — 현 메인의 데이터 로직을 그대로 옮김(UI 관심사는 제외):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||||
|
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||||
|
|
||||||
|
export default function useComplexes() {
|
||||||
|
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/realestate/complexes')
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addComplex = async (data) => {
|
||||||
|
const newComplex = { ...data, id: Date.now() };
|
||||||
|
setComplexes((prev) => [...prev, newComplex]);
|
||||||
|
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateComplex = async (data) => {
|
||||||
|
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||||
|
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComplex = async (id) => {
|
||||||
|
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reduce RealEstate.jsx to the shell container** — 컴포넌트 본문 최상단 부분(상태 선언 ~ 핸들러 ~ memo)을 아래로 교체. **`return (...)` JSX 블록은 verbatim 유지**(이미 `complexes`/`filteredComplexes`/`stats`/`selectedComplex`/`activeTab`/`filterStatus`/`handleDelete`/`handleModalSave`/`setSelectedComplex`/`setEditingComplex`/`setShowModal`/`setFilterStatus`/`setActiveTab`를 참조 — 이들은 아래 재구성으로 동일하게 제공됨).
|
||||||
|
|
||||||
|
RealEstate.jsx 상단 import는 다음으로 정리(현 사용 기준): React 훅(`useState`, `useMemo`), `Link`, utils(SAMPLE_COMPLEXES는 이제 훅이 씀 → shell에서 제거 가능; STATUS_CONFIG/TABS는 shell 렌더에서 사용 → 유지, 그 외 유틸 중 shell이 직접 쓰는 것만), `useComplexes`, 5개 컴포넌트, `import 'leaflet/dist/leaflet.css'`, `import './RealEstate.css'`. (미사용 import 제거로 lint 0.)
|
||||||
|
|
||||||
|
컴포넌트 함수 본문(선언부) 교체:
|
||||||
|
```js
|
||||||
|
const RealEstate = () => {
|
||||||
|
const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||||
|
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState('목록');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('전체');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingComplex, setEditingComplex] = useState(null);
|
||||||
|
|
||||||
|
const handleAdd = (data) => {
|
||||||
|
addComplex(data);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = (data) => {
|
||||||
|
updateComplex(data);
|
||||||
|
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||||
|
setEditingComplex(null);
|
||||||
|
setShowModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
|
deleteComplex(id);
|
||||||
|
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalSave = (data) => {
|
||||||
|
if (editingComplex) {
|
||||||
|
handleUpdate({ ...editingComplex, ...data });
|
||||||
|
} else {
|
||||||
|
handleAdd(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredComplexes = useMemo(() => {
|
||||||
|
if (filterStatus === '전체') return complexes;
|
||||||
|
return complexes.filter((c) => c.status === filterStatus);
|
||||||
|
}, [complexes, filterStatus]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: complexes.length,
|
||||||
|
upcoming: complexes.filter((c) => c.status === '청약예정').length,
|
||||||
|
active: complexes.filter((c) => c.status === '청약중').length,
|
||||||
|
avgPrice: complexes.length
|
||||||
|
? Math.round(complexes.reduce((s, c) => s + c.avgPricePerPyeong, 0) / complexes.length)
|
||||||
|
: 0,
|
||||||
|
}), [complexes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// ── 현 RealEstate.jsx의 return JSX 전체를 verbatim 유지 ──
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
> `handleAdd`/`handleUpdate`/`handleDelete`는 비동기 await를 훅으로 옮겼으므로 shell에선 동기(비-async). 원래도 optimistic 갱신 후 UI(setShowModal/setSelected)를 처리하고 API는 fire-and-forget이었으므로 동작 동일(setComplexes→UI→API 순서 보존). `handleModalSave`/`filteredComplexes`/`stats`와 return JSX는 원본 그대로.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full suite + lint + build**
|
||||||
|
|
||||||
|
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/realestate/RealEstate.jsx` 로 shell 축소(~150줄) 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add src/pages/realestate/hooks/useComplexes.js src/pages/realestate/RealEstate.jsx
|
||||||
|
git commit -m "refactor(realestate): 데이터 로직 useComplexes 훅 추출 → RealEstate shell 축소"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual verification (dev server)**
|
||||||
|
|
||||||
|
`npm run dev` → `http://localhost:3007/realestate/property` 접속. 3탭(목록/일정/분석) 전환, 목록 카드 선택→우측 지도 flyTo+상세, 필터, 단지 추가/편집/삭제 모달, 지도 마커 클릭이 리팩토링 전과 **동일 동작**인지 확인. 청약 대시보드 링크(`/realestate`) 동작 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 결과
|
||||||
|
|
||||||
|
**Spec coverage** (설계 §1–§7):
|
||||||
|
- §3 file map(utils/hook/components 5/shell) → Task 1/2/3 ✅
|
||||||
|
- §4 useComplexes(데이터만, UI 경계는 shell) → Task 3 ✅ (confirm·selectedComplex 동기화 shell 유지)
|
||||||
|
- §5 테스트(realEstateUtils.test + build/lint/수동) → Task 1 + 각 Task 검증 + Task 3 수동 ✅
|
||||||
|
- §6 완료기준 → 각 Task 검증 ✅
|
||||||
|
|
||||||
|
**Placeholder scan:** 신규 코드(utils 작은 것들/훅/테스트/shell 선언부)는 전체 코드 명시. SAMPLE_COMPLEXES·표현 컴포넌트·return JSX는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 원본 존재, import 심볼 명시). ✅
|
||||||
|
|
||||||
|
**Type consistency:** utils export(Task1) ↔ Task2/3 import 일치. 컴포넌트 기본 export ↔ shell import 일치. `useComplexes` 반환 `{complexes,addComplex,updateComplex,deleteComplex}` ↔ shell 사용 일치. 경로(shell `../../api` 아님— shell은 api 직접 안 씀; components/hooks `../../../api`) 일치. ✅
|
||||||
|
|
||||||
|
**참고:** Task 순서 의존(utils→components→hook/shell). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# RealEstate.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 2)
|
||||||
|
|
||||||
|
- **작성일**: 2026-07-09
|
||||||
|
- **역할/저장소**: FE (`web-ui`)
|
||||||
|
- **범위**: FE 대형 컴포넌트 분해 sweep의 **2번째 서브프로젝트**. 컨벤션은 sub-project 1(Subscription)에서 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 이번은 적용만.
|
||||||
|
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 & 목표
|
||||||
|
|
||||||
|
`src/pages/realestate/RealEstate.jsx`(909줄, `/realestate/property` 관심 단지 페이지)를 기능 모듈 구조로 동작 보존 분해한다. Subscription과 달리 **메인이 데이터 상태(complexes)+CRUD API를 직접 보유**하고 하위는 순수 표현 컴포넌트(props)인 "스마트 컨테이너 + 표현" 구조 → 데이터 로직은 `hooks/useComplexes.js`로 추출(컨벤션: shell은 조합만).
|
||||||
|
|
||||||
|
비목표: 동작/스타일 변경, 신규 기능, MapFlyTo/EventItem/CustomTooltip 같은 소형 내부 컴포넌트의 과분해.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현 구조 맵 (RealEstate.jsx 909줄)
|
||||||
|
|
||||||
|
- 상수/데이터: `SAMPLE_COMPLEXES`(폴백 샘플 4개), `STATUS_CONFIG`, `PRIORITY_LABELS`, `EMPTY_FORM`, `TABS`(['목록','일정','분석'])
|
||||||
|
- 순수 헬퍼: `formatDate`, `formatPrice`, `getDDays`, `createMarkerIcon`(leaflet `L.divIcon`)
|
||||||
|
- 컴포넌트: `MapFlyTo`(react-leaflet 훅), `ComplexCard`, `RightPanel`(지도+상세, ~183줄, MapFlyTo 사용), `ScheduleView`(내부 `EventItem`), `PriceAnalysis`(차트+표, 내부 `CustomTooltip`), `ComplexModal`(폼 모달)
|
||||||
|
- 메인 `RealEstate`: 상태(complexes/selectedComplex/activeTab/filterStatus/showModal/editingComplex) + `useEffect` 로드 + `handleAdd/handleUpdate/handleDelete/handleModalSave` + `filteredComplexes`/`stats` memo + 헤더/탭/목록·일정·분석 렌더 + 모달.
|
||||||
|
|
||||||
|
API: `apiGet/apiPost/apiPut/apiDelete` → `/api/realestate/complexes`(+`/:id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 목표 file map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/realestate/
|
||||||
|
├── RealEstate.jsx # shell/컨테이너 (~150줄): UI 상태(selected/activeTab/filter/modal) + useComplexes 소비 + 조합
|
||||||
|
├── RealEstate.css # 변경 없음 (shell에서 import 유지)
|
||||||
|
├── realEstateUtils.js # SAMPLE_COMPLEXES·STATUS_CONFIG·PRIORITY_LABELS·EMPTY_FORM·TABS + formatDate·formatPrice·getDDays·createMarkerIcon
|
||||||
|
├── realEstateUtils.test.js # 순수헬퍼 유닛 (신규)
|
||||||
|
├── hooks/
|
||||||
|
│ └── useComplexes.js # complexes 상태 + 로드 + add/update/delete (낙관적 UI + API), { complexes, addComplex, updateComplex, deleteComplex } 반환
|
||||||
|
└── components/
|
||||||
|
├── ComplexCard.jsx # ({ complex, isSelected, onClick })
|
||||||
|
├── RightPanel.jsx # ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) — MapFlyTo 내부 유지
|
||||||
|
├── ScheduleView.jsx # ({ complexes }) — EventItem 내부 중첩 유지
|
||||||
|
├── PriceAnalysis.jsx # ({ complexes }) — CustomTooltip 내부 중첩 유지
|
||||||
|
└── ComplexModal.jsx # ({ complex, onClose, onSave })
|
||||||
|
```
|
||||||
|
|
||||||
|
**import 경로 규칙:** components/hooks → api `'../../../api'`; components/hooks → utils `'../realEstateUtils'`; shell → utils `'./realEstateUtils'`, hook `'./hooks/useComplexes'`, components `'./components/*'`, CSS `'./RealEstate.css'`. `realEstateUtils.js`는 `leaflet`의 `L`만 import(createMarkerIcon용), api import 없음. `RightPanel.jsx`는 leaflet/react-leaflet import 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `useComplexes` 훅 (데이터 레이어 추출)
|
||||||
|
|
||||||
|
현 메인의 데이터 로직을 그대로 옮김(동작 보존):
|
||||||
|
- 상태 `complexes`(초기값 `SAMPLE_COMPLEXES`), `useEffect`로 `GET /api/realestate/complexes` 로드(배열 있으면 교체).
|
||||||
|
- `addComplex(data)` — 낙관적 append(`id: Date.now()`) + `POST`.
|
||||||
|
- `updateComplex(data)` — 낙관적 map 교체 + `PUT /:id`.
|
||||||
|
- `deleteComplex(id)` — 낙관적 filter + `DELETE /:id`. (confirm은 shell에 유지할지 훅에 둘지 — **동작 보존 위해 현 위치(핸들러) 그대로**: `handleDelete`의 `confirm`은 shell에 남기고 훅은 순수 삭제만 담당. 즉 훅은 `deleteComplex(id)`가 낙관적 삭제+API, shell이 confirm 후 호출.)
|
||||||
|
- 반환: `{ complexes, addComplex, updateComplex, deleteComplex }`. selectedComplex 동기화(삭제/수정 시)는 shell이 담당(UI 상태이므로).
|
||||||
|
|
||||||
|
> 주의: 현 코드의 `handleUpdate`가 `selectedComplex?.id === data.id`면 `setSelectedComplex(data)`도 함. 이는 UI 상태(selectedComplex) 갱신이므로 **shell에 유지**. 훅은 complexes만 관리. shell의 `handleUpdate` 래퍼가 `updateComplex(data)` 호출 + selectedComplex 동기화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 (안전망 — 현재 RealEstate 테스트 0)
|
||||||
|
|
||||||
|
1. **`realEstateUtils.test.js`(신규, 필수)** — 순수 헬퍼:
|
||||||
|
- `formatPrice`: 4500→"4,500만원", 0/falsy→"-".
|
||||||
|
- `getDDays`: 오늘=D-Day, 미래 D-N, 과거 D+N, null→null.
|
||||||
|
- `formatDate`: 유효 날짜 한국어 포맷, falsy→"-".
|
||||||
|
2. 컴포넌트는 표현 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동). ComplexCard 스모크(선택) 정도 가능.
|
||||||
|
3. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규 파일 0 에러. 최종 개발서버 `/realestate/property` 3탭(목록/일정/분석)·지도 마커·단지 추가/편집/삭제 모달이 리팩토링 전과 동일 동작.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 완료 기준
|
||||||
|
|
||||||
|
- [ ] RealEstate.jsx가 shell(~150줄) + hooks/useComplexes + realEstateUtils(+test) + components/5로 분해, 동작/스타일 불변.
|
||||||
|
- [ ] `realEstateUtils.test.js` 통과, 전체 `npm run test:run` green.
|
||||||
|
- [ ] `npm run lint`·`npm run build` 통과.
|
||||||
|
- [ ] `/realestate/property` 수동 검증 동일 동작(지도·모달·탭·필터).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 리스크 / 오픈 이슈
|
||||||
|
|
||||||
|
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/수동으로 위험 최소화.
|
||||||
|
- **훅 추출 시 상태 경계**: complexes(데이터)는 훅, selectedComplex/activeTab/filter/modal(UI)은 shell. 현 동작(특히 update 시 selectedComplex 동기화, delete 시 confirm)을 그대로 재현.
|
||||||
|
- **leaflet/recharts import**: RightPanel(leaflet)·PriceAnalysis(recharts)가 각자 필요한 것만 import. createMarkerIcon은 utils에서 `L` import.
|
||||||
|
- **후속 follow-up**: RightPanel(~200줄)은 지도/상세 2책임 — 필요 시 후속 분할 여지 기록.
|
||||||
|
- **다음 서브프로젝트**: PortfolioTab(671, stock/) → InstaCards(1013) → MusicStudio(1936).
|
||||||
@@ -1,761 +1,42 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, 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 L from 'leaflet';
|
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
STATUS_CONFIG, TABS,
|
||||||
ResponsiveContainer, Cell,
|
} from './realEstateUtils';
|
||||||
} from 'recharts';
|
import useComplexes from './hooks/useComplexes';
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
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';
|
||||||
|
|
||||||
// ── 샘플 데이터 ────────────────────────────────────────────────────────────────
|
|
||||||
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 내부 훅) ────────────────────────────────────
|
|
||||||
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='© <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, addComplex, updateComplex, deleteComplex } = useComplexes();
|
||||||
const [selectedComplex, setSelectedComplex] = useState(null);
|
const [selectedComplex, setSelectedComplex] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('목록');
|
const [activeTab, setActiveTab] = useState('목록');
|
||||||
const [filterStatus, setFilterStatus] = useState('전체');
|
const [filterStatus, setFilterStatus] = useState('전체');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingComplex, setEditingComplex] = useState(null);
|
const [editingComplex, setEditingComplex] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleAdd = (data) => {
|
||||||
apiGet('/api/realestate/complexes')
|
addComplex(data);
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdd = async (data) => {
|
|
||||||
const newComplex = { ...data, id: Date.now() };
|
|
||||||
setComplexes((prev) => [...prev, newComplex]);
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
try { await apiPost('/api/realestate/complexes', data); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (data) => {
|
const handleUpdate = (data) => {
|
||||||
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
updateComplex(data);
|
||||||
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
if (selectedComplex?.id === data.id) setSelectedComplex(data);
|
||||||
setEditingComplex(null);
|
setEditingComplex(null);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = (id) => {
|
||||||
if (!confirm('삭제하시겠습니까?')) return;
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
deleteComplex(id);
|
||||||
if (selectedComplex?.id === id) setSelectedComplex(null);
|
if (selectedComplex?.id === id) setSelectedComplex(null);
|
||||||
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSave = (data) => {
|
const handleModalSave = (data) => {
|
||||||
|
|||||||
39
src/pages/realestate/components/ComplexCard.jsx
Normal file
39
src/pages/realestate/components/ComplexCard.jsx
Normal 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;
|
||||||
157
src/pages/realestate/components/ComplexModal.jsx
Normal file
157
src/pages/realestate/components/ComplexModal.jsx
Normal 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;
|
||||||
134
src/pages/realestate/components/PriceAnalysis.jsx
Normal file
134
src/pages/realestate/components/PriceAnalysis.jsx
Normal 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;
|
||||||
202
src/pages/realestate/components/RightPanel.jsx
Normal file
202
src/pages/realestate/components/RightPanel.jsx
Normal 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='© <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;
|
||||||
59
src/pages/realestate/components/ScheduleView.jsx
Normal file
59
src/pages/realestate/components/ScheduleView.jsx
Normal 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;
|
||||||
33
src/pages/realestate/hooks/useComplexes.js
Normal file
33
src/pages/realestate/hooks/useComplexes.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
|
||||||
|
import { SAMPLE_COMPLEXES } from '../realEstateUtils';
|
||||||
|
|
||||||
|
export default function useComplexes() {
|
||||||
|
const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet('/api/realestate/complexes')
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) setComplexes(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addComplex = async (data) => {
|
||||||
|
const newComplex = { ...data, id: Date.now() };
|
||||||
|
setComplexes((prev) => [...prev, newComplex]);
|
||||||
|
try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateComplex = async (data) => {
|
||||||
|
setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
|
||||||
|
try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteComplex = async (id) => {
|
||||||
|
setComplexes((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return { complexes, addComplex, updateComplex, deleteComplex };
|
||||||
|
}
|
||||||
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