diff --git a/docs/superpowers/plans/2026-07-09-fe-module-refactor-realestate.md b/docs/superpowers/plans/2026-07-09-fe-module-refactor-realestate.md new file mode 100644 index 0000000..97612b0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-fe-module-refactor-realestate.md @@ -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) + 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: `
`, + 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 ;`. **`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. diff --git a/docs/superpowers/specs/2026-07-09-fe-module-refactor-realestate-design.md b/docs/superpowers/specs/2026-07-09-fe-module-refactor-realestate-design.md new file mode 100644 index 0000000..a59acb7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-fe-module-refactor-realestate-design.md @@ -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). diff --git a/src/pages/realestate/RealEstate.jsx b/src/pages/realestate/RealEstate.jsx index 44e1dcc..ec35421 100644 --- a/src/pages/realestate/RealEstate.jsx +++ b/src/pages/realestate/RealEstate.jsx @@ -1,761 +1,42 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, 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'; + STATUS_CONFIG, TABS, +} from './realEstateUtils'; +import useComplexes from './hooks/useComplexes'; +import ComplexCard from './components/ComplexCard'; +import RightPanel from './components/RightPanel'; +import ScheduleView from './components/ScheduleView'; +import PriceAnalysis from './components/PriceAnalysis'; +import ComplexModal from './components/ComplexModal'; import 'leaflet/dist/leaflet.css'; import './RealEstate.css'; -// ── 샘플 데이터 ──────────────────────────────────────────────────────────────── -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(); - useEffect(() => { - if (position) { - map.flyTo(position, zoom ?? 14, { duration: 1.0 }); - } - }, [position, zoom, map]); - return null; -}; - -// ── 단지 카드 ────────────────────────────────────────────────────────────────── -const ComplexCard = ({ complex, isSelected, onClick }) => { - const cfg = STATUS_CONFIG[complex.status] || STATUS_CONFIG['완료']; - const dday = getDDays(complex.subscriptionStart); - const isUpcoming = complex.status === '청약예정' || complex.status === '청약중'; - - return ( -
-
- - {complex.status} - - {complex.priority === 'high' && } -
-

{complex.name}

-

{complex.address}

-
- {complex.units.toLocaleString()}세대 - · - {formatPrice(complex.avgPricePerPyeong)}/평 -
-
- {complex.types.map((t) => ( - {t} - ))} -
- {isUpcoming && dday && ( -
- 청약 {dday} -
- )} -
- ); -}; - -// ── 인라인 지도 + 단지 상세 패널 ────────────────────────────────────────────── -const RightPanel = ({ complexes, selectedComplex, onSelectComplex, onEdit, onDelete }) => { - const cfg = selectedComplex - ? STATUS_CONFIG[selectedComplex.status] || STATUS_CONFIG['완료'] - : null; - - const mapCenter = useMemo(() => { - if (selectedComplex) return [selectedComplex.lat, selectedComplex.lng]; - if (complexes.length === 0) return [37.5665, 126.9780]; - return [ - complexes.reduce((s, c) => s + c.lat, 0) / complexes.length, - complexes.reduce((s, c) => s + c.lng, 0) / complexes.length, - ]; - }, []); // 초기 중심값만 계산 (flyTo로 이후 이동) - - return ( -
- {/* ── 지도 ── */} -
-
- - - - {complexes.map((c) => ( - onSelectComplex(c) }} - > - -
- {c.name} - {c.address} - {c.status} · {c.units.toLocaleString()}세대 - {formatPrice(c.avgPricePerPyeong)}/평 -
-
-
- ))} -
- {selectedComplex && ( -
- {selectedComplex.name} -
- )} -
-
- - {/* ── 상세 패널 ── */} - {selectedComplex ? ( -
-
-
- - {selectedComplex.status} - -

{selectedComplex.name}

-

{selectedComplex.address}

-
-
- - -
-
- -
-
-
-

세대수

-

{selectedComplex.units.toLocaleString()}

-
-
-

평당가

-

- {formatPrice(selectedComplex.avgPricePerPyeong)} -

-
-
-

우선순위

-

- {PRIORITY_LABELS[selectedComplex.priority]} -

-
-
-
- -
-

평형대

-
- {selectedComplex.types.map((t) => ( - {t} - ))} -
-
- -
-

청약 일정

-
-
-
-
-

청약 시작

-

{formatDate(selectedComplex.subscriptionStart)}

-
-
-
-
-
-

청약 마감

-

{formatDate(selectedComplex.subscriptionEnd)}

-
-
-
-
-
-

당첨 발표

-

{formatDate(selectedComplex.resultDate)}

-
-
-
-
- - {selectedComplex.tags.length > 0 && ( -
-

특징

-
- {selectedComplex.tags.map((tag) => ( - {tag} - ))} -
-
- )} - - {selectedComplex.memo && ( -
-

메모

-

{selectedComplex.memo}

-
- )} - -
- {selectedComplex.naverUrl ? ( - - 네이버 부동산 → - - ) : ( - - 네이버 검색 → - - )} - {selectedComplex.floorPlanUrl && ( - - 평면도 보기 - - )} -
-
- ) : ( -
-
🏢
-

카드 또는 지도 마커를 클릭하면
단지 상세 정보가 표시됩니다

-
- )} -
- ); -}; - -// ── 청약 일정 타임라인 ───────────────────────────────────────────────────────── -const ScheduleView = ({ complexes }) => { - const events = complexes - .filter((c) => c.subscriptionStart) - .flatMap((c) => [ - { date: c.subscriptionStart, label: '청약 시작', complex: c, type: 'start' }, - { date: c.subscriptionEnd, label: '청약 마감', complex: c, type: 'end' }, - { date: c.resultDate, label: '당첨 발표', complex: c, type: 'result' }, - ]) - .filter((e) => e.date) - .sort((a, b) => new Date(a.date) - new Date(b.date)); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const upcoming = events.filter((e) => new Date(e.date) >= today); - const past = events.filter((e) => new Date(e.date) < today).reverse(); - - const EventItem = ({ event }) => { - const cfg = STATUS_CONFIG[event.complex.status] || STATUS_CONFIG['완료']; - const dday = getDDays(event.date); - return ( -
-
- {dday} - {formatDate(event.date)} -
-
-
-

{event.complex.name}

-

{event.label}

-
-
- ); - }; - - return ( -
- {upcoming.length > 0 && ( -
-

예정 일정

- {upcoming.map((e, i) => )} -
- )} - {past.length > 0 && ( -
-

지난 일정

- {past.map((e, i) => )} -
- )} - {events.length === 0 &&

등록된 청약 일정이 없습니다.

} -
- ); -}; - -// ── 가격 분석 ────────────────────────────────────────────────────────────────── -const PriceAnalysis = ({ complexes }) => { - const chartData = [...complexes] - .filter((c) => c.avgPricePerPyeong > 0) - .sort((a, b) => b.avgPricePerPyeong - a.avgPricePerPyeong) - .map((c) => ({ - name: c.name.length > 9 ? c.name.slice(0, 9) + '…' : c.name, - price: c.avgPricePerPyeong, - status: c.status, - fullName: c.name, - })); - - const CustomTooltip = ({ active, payload }) => { - if (!active || !payload?.length) return null; - return ( -
-

{payload[0].payload.fullName}

-

{payload[0].value.toLocaleString()}만원/평

-
- ); - }; - - const prices = complexes.map((c) => c.avgPricePerPyeong).filter((v) => v > 0); - const avg = prices.length ? Math.round(prices.reduce((s, v) => s + v, 0) / prices.length) : 0; - const max = prices.length ? Math.max(...prices) : 0; - const min = prices.length ? Math.min(...prices) : 0; - - return ( -
-
-
-

평균 평당가

-

{formatPrice(avg)}

-
-
-

최고 평당가

-

{formatPrice(max)}

-
-
-

최저 평당가

-

{formatPrice(min)}

-
-
- -
-
-
-

가격 비교

-

단지별 평당가

-

관심 단지의 평당 분양가를 비교합니다.

-
-
-
- - - - - `${(v / 1000).toFixed(1)}k`} - width={44} - /> - } cursor={{ fill: 'rgba(255,255,255,0.03)' }} /> - - {chartData.map((entry, index) => { - const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG['완료']; - return ; - })} - - - -
-
- -
-
-
-

비교표

-

단지 상세 비교

-
-
-
- - - - - - - - - - - - - {complexes.map((c) => { - const cfg = STATUS_CONFIG[c.status] || STATUS_CONFIG['완료']; - return ( - - - - - - - - - ); - })} - -
단지명상태세대수평형대평당가청약 시작
{c.name} - - {c.status} - - {c.units.toLocaleString()}{c.types.join(', ')} - {formatPrice(c.avgPricePerPyeong)} - {formatDate(c.subscriptionStart)}
-
-
-
- ); -}; - -// ── 단지 추가/편집 모달 ──────────────────────────────────────────────────────── -const ComplexModal = ({ complex, onClose, onSave }) => { - const [form, setForm] = useState( - complex - ? { - ...complex, - types: complex.types.join(', '), - tags: complex.tags.join(', '), - lat: String(complex.lat), - lng: String(complex.lng), - units: String(complex.units), - avgPricePerPyeong: String(complex.avgPricePerPyeong), - } - : { ...EMPTY_FORM } - ); - - const set = (field) => (e) => setForm((prev) => ({ ...prev, [field]: e.target.value })); - - const handleSubmit = (e) => { - e.preventDefault(); - onSave({ - ...form, - lat: parseFloat(form.lat) || 37.5665, - lng: parseFloat(form.lng) || 126.9780, - units: parseInt(form.units) || 0, - avgPricePerPyeong: parseInt(form.avgPricePerPyeong) || 0, - types: form.types.split(',').map((t) => t.trim()).filter(Boolean), - tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), - }); - }; - - return ( -
-
e.stopPropagation()}> -
-

{complex ? '단지 편집' : '새 단지 추가'}

- -
-
-
-

기본 정보

-
- - -
- -
- - -
-
- -
-

단지 정보

-
- - -
-
- - -
- -
- -
-

청약 일정

-
- - - -
-
- -
-

링크 & 메모

- - -