From 1af16dde470a4097cbfab98f63aa4908419291d6 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 16 Mar 2026 02:10:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=80=EB=8F=99=EC=82=B0=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Icons.jsx | 24 + src/index.css | 15 +- src/pages/realestate/RealEstate.css | 1026 +++++++++++++++++++ src/pages/realestate/RealEstate.jsx | 909 +++++++++++++++++ src/pages/subscription/Subscription.css | 1167 ++++++++++++++++++++++ src/pages/subscription/Subscription.jsx | 1214 +++++++++++++++++++++++ src/routes.jsx | 20 + 7 files changed, 4369 insertions(+), 6 deletions(-) create mode 100644 src/pages/realestate/RealEstate.css create mode 100644 src/pages/realestate/RealEstate.jsx create mode 100644 src/pages/subscription/Subscription.css create mode 100644 src/pages/subscription/Subscription.jsx diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 7d3c40e..4a872b7 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -71,3 +71,27 @@ export const IconTodo = () => ); + +export const IconSubscription = () => + svg( + <> + + + + + + ); + +export const IconBuilding = () => + svg( + <> + + + + + + + + + + ); diff --git a/src/index.css b/src/index.css index cedc139..a324804 100644 --- a/src/index.css +++ b/src/index.css @@ -82,12 +82,15 @@ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* ── Page Accent Colors ──────────────────────────────────────────── */ - --accent-home: #00d4ff; - --accent-blog: #c084fc; - --accent-lotto: #34d399; - --accent-stock: #38bdf8; - --accent-travel: #fb923c; - --accent-lab: #fbbf24; + --accent-home: #00d4ff; + --accent-blog: #c084fc; + --accent-lotto: #34d399; + --accent-stock: #38bdf8; + --accent-realestate: #f43f5e; + --accent-subscription: #f43f5e; + --accent-todo: #f472b6; + --accent-travel: #fb923c; + --accent-lab: #fbbf24; /* ── Convenience alias ───────────────────────────────────────────── */ --accent: var(--neon-cyan); diff --git a/src/pages/realestate/RealEstate.css b/src/pages/realestate/RealEstate.css new file mode 100644 index 0000000..7baa493 --- /dev/null +++ b/src/pages/realestate/RealEstate.css @@ -0,0 +1,1026 @@ +/* ═══════════════════════════════════════════════════════════════════════ + RealEstate.css — 부동산 청약 관리 페이지 + ═══════════════════════════════════════════════════════════════════════ */ + +.re { + display: grid; + gap: 28px; + width: 100%; +} + +/* ── 헤더 ─────────────────────────────────────────────────────────────── */ + +.re-header { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 24px; + align-items: center; +} + +.re-kicker { + text-transform: uppercase; + letter-spacing: 0.3em; + font-size: 12px; + color: var(--accent-realestate, #f59e0b); + margin: 0 0 10px; +} + +.re-header h1 { + margin: 0 0 12px; + font-family: var(--font-display); + font-size: clamp(30px, 4vw, 40px); +} + +.re-sub { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.re-header-actions { + display: flex; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; +} + +/* ── 스탯 바 ──────────────────────────────────────────────────────────── */ + +.re-stats-bar { + display: flex; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + overflow: hidden; + align-self: center; +} + +.re-stat-item { + padding: 18px 22px; + text-align: center; + border-right: 1px solid var(--line); + flex: 1; +} + +.re-stat-item:last-child { + border-right: none; +} + +.re-stat-item__value { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + color: var(--text-bright); + margin: 0; + letter-spacing: -0.03em; + line-height: 1; +} + +.re-stat-item__label { + font-size: 10px; + color: var(--text-muted); + margin: 6px 0 0; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ── 탭 바 ────────────────────────────────────────────────────────────── */ + +.re-tabs-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.re-tabs { + display: flex; + gap: 2px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 4px; +} + +.re-tab { + padding: 8px 20px; + border: none; + background: transparent; + color: var(--text-dim); + border-radius: var(--radius-xs); + cursor: pointer; + font-family: var(--font-body); + font-size: 13px; + font-weight: 500; + transition: all 0.2s var(--ease-out); + white-space: nowrap; +} + +.re-tab:hover { + color: var(--text-bright); + background: var(--surface-raised); +} + +.re-tab.is-active { + color: var(--text-bright); + background: var(--bg-tertiary); + box-shadow: 0 0 0 1px var(--line); +} + +/* ── 필터 ─────────────────────────────────────────────────────────────── */ + +.re-filter { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.re-filter-btn { + padding: 5px 14px; + border: 1px solid var(--line); + background: transparent; + color: var(--text-dim); + border-radius: 100px; + cursor: pointer; + font-size: 12px; + font-family: var(--font-body); + transition: all 0.15s; +} + +.re-filter-btn:hover { + border-color: var(--line-bright); + color: var(--text); +} + +.re-filter-btn.is-active { + background: rgba(245, 158, 11, 0.12); + border-color: rgba(245, 158, 11, 0.4); + color: #f59e0b; +} + +/* ── 목록 레이아웃 ────────────────────────────────────────────────────── */ + +.re-list-layout { + display: grid; + grid-template-columns: 1fr 380px; + gap: 20px; + align-items: start; +} + +.re-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; +} + +/* ── 우측 패널 (지도 + 상세) ──────────────────────────────────────────── */ + +.re-right-panel { + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 24px; + max-height: calc(100vh - 120px); + overflow-y: auto; +} + +.re-panel--map { + flex-shrink: 0; +} + +.re-mini-map-wrap { + height: 240px; + position: relative; +} + +.re-map-label { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 800; + background: rgba(7, 11, 25, 0.85); + backdrop-filter: blur(6px); + border: 1px solid var(--line-bright); + border-radius: var(--radius-sm); + padding: 4px 10px; + font-size: 11px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 5px; + pointer-events: none; +} + +/* ── 단지 카드 ────────────────────────────────────────────────────────── */ + +.re-card { + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 18px; + background: var(--surface); + cursor: pointer; + transition: all 0.2s var(--ease-out); + display: grid; + gap: 9px; + position: relative; + overflow: hidden; + animation: fadeIn 0.3s var(--ease-out) both; +} + +.re-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, #f59e0b, #f97316); + opacity: 0; + transition: opacity 0.2s; +} + +.re-card:hover { + border-color: var(--line-bright); + background: var(--surface-raised); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.re-card:hover::before, +.re-card.is-selected::before { + opacity: 1; +} + +.re-card.is-selected { + border-color: rgba(245, 158, 11, 0.4); + background: var(--surface-raised); + box-shadow: 0 0 20px rgba(245, 158, 11, 0.12), 0 0 60px rgba(245, 158, 11, 0.04); +} + +.re-card__top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.re-card__name { + font-family: var(--font-display); + font-size: 15px; + font-weight: 600; + margin: 0; + color: var(--text-bright); + line-height: 1.3; +} + +.re-card__address { + font-size: 12px; + color: var(--text-muted); + margin: 0; +} + +.re-card__stats { + font-size: 13px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; +} + +.re-card__dot { + color: var(--text-muted); +} + +.re-card__dday { + font-family: var(--font-display); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; +} + +.re-priority-star { + color: #f59e0b; + font-size: 14px; + filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5)); +} + +/* ── 뱃지 & 칩 ────────────────────────────────────────────────────────── */ + +.re-badge { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 100px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.re-badge--lg { + padding: 4px 12px; + font-size: 11px; +} + +.re-chip { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: var(--radius-xs); + font-size: 11px; + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.re-chip--lg { + padding: 5px 12px; + font-size: 12px; +} + +.re-chip--tag { + background: var(--neon-purple-muted); + color: var(--neon-purple); + border-color: rgba(139, 92, 246, 0.2); +} + +.re-chip-group { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* ── 상세 패널 ────────────────────────────────────────────────────────── */ + +.re-detail { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + overflow: hidden; + animation: fadeIn 0.2s var(--ease-out); +} + +.re-detail--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + gap: 12px; + color: var(--text-muted); + font-size: 13px; + min-height: 320px; +} + +.re-detail__empty-icon { + font-size: 44px; + opacity: 0.3; +} + +.re-detail__header { + padding: 20px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + border-bottom: 1px solid var(--line); + background: var(--surface-raised); +} + +.re-detail__name { + font-family: var(--font-display); + font-size: 18px; + font-weight: 700; + margin: 8px 0 4px; + color: var(--text-bright); +} + +.re-detail__address { + font-size: 12px; + color: var(--text-muted); + margin: 0; +} + +.re-detail__header-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.re-detail__section { + padding: 16px 20px; + border-bottom: 1px solid var(--line-subtle); +} + +.re-detail__section:last-of-type { + border-bottom: none; +} + +.re-detail__section-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); + margin: 0 0 10px; +} + +.re-detail__stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.re-stat { + text-align: center; +} + +.re-stat__label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 4px; +} + +.re-stat__value { + font-family: var(--font-display); + font-size: 15px; + font-weight: 700; + color: var(--text-bright); + margin: 0; +} + +.re-detail__memo { + font-size: 13px; + color: var(--text-dim); + margin: 0; + line-height: 1.7; +} + +.re-detail__actions { + padding: 16px 20px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* ── 타임라인 ─────────────────────────────────────────────────────────── */ + +.re-timeline { + display: grid; + gap: 0; +} + +.re-timeline__item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 0; + position: relative; +} + +.re-timeline__item:not(:last-child)::after { + content: ''; + position: absolute; + left: 7px; + top: 22px; + bottom: -8px; + width: 1px; + background: var(--line); +} + +.re-timeline__dot { + width: 15px; + height: 15px; + border-radius: 50%; + border: 2px solid var(--line-bright); + background: var(--bg); + flex-shrink: 0; + margin-top: 2px; +} + +.re-timeline__dot--start { + background: var(--neon-cyan); + border-color: var(--neon-cyan); + box-shadow: 0 0 8px rgba(0, 212, 255, 0.5); +} + +.re-timeline__dot--result { + background: #f59e0b; + border-color: #f59e0b; + box-shadow: 0 0 8px rgba(245, 158, 11, 0.5); +} + +.re-timeline__label { + font-size: 12px; + color: var(--text-dim); + margin: 0 0 2px; +} + +.re-timeline__date { + font-size: 13px; + font-weight: 600; + color: var(--text-bright); + margin: 0; +} + +/* ── 패널 ─────────────────────────────────────────────────────────────── */ + +.re-panel { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + overflow: hidden; +} + +.re-panel__head { + padding: 20px 24px; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.re-panel__eyebrow { + text-transform: uppercase; + letter-spacing: 0.22em; + font-size: 10px; + color: var(--accent-realestate, #f59e0b); + margin: 0 0 6px; +} + +.re-panel__head h3 { + margin: 0 0 4px; + font-family: var(--font-display); + font-size: 18px; +} + +.re-panel__sub { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + +/* ── 지도 ─────────────────────────────────────────────────────────────── */ + +.re-map-container { + height: 520px; +} + +.re-map { + width: 100%; + height: 100%; +} + +/* Leaflet popup 커스텀 */ +.leaflet-popup-content-wrapper { + background: #0d1427 !important; + border: 1px solid rgba(0, 212, 255, 0.25) !important; + border-radius: 10px !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important; + color: #ccd6f6 !important; +} + +.leaflet-popup-tip { + background: #0d1427 !important; +} + +.re-popup { + display: flex; + flex-direction: column; + gap: 3px; + font-family: 'Inter', 'Noto Sans KR', sans-serif; + min-width: 160px; +} + +.re-popup strong { + font-size: 13px; + color: #e8f0fe; + font-weight: 600; +} + +.re-popup span { + font-size: 11px; + color: #8892b0; +} + +/* ── 일정 뷰 ──────────────────────────────────────────────────────────── */ + +.re-schedule { + padding: 24px; + display: grid; + gap: 28px; +} + +.re-schedule-section { + display: grid; + gap: 0; +} + +.re-schedule-section__title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); + margin: 0 0 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.re-schedule-section__title--past { + opacity: 0.5; +} + +.re-schedule-item { + display: grid; + grid-template-columns: 100px 12px 1fr; + gap: 0 16px; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--line-subtle); +} + +.re-schedule-item:last-child { + border-bottom: none; +} + +.re-schedule-item__date { + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; +} + +.re-schedule-item__dday { + font-family: var(--font-display); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.re-schedule-item__datestr { + font-size: 10px; + color: var(--text-muted); +} + +.re-schedule-item__dot { + width: 10px; + height: 10px; + border-radius: 50%; + justify-self: center; + flex-shrink: 0; +} + +.re-schedule-item__content { + display: grid; + gap: 2px; +} + +.re-schedule-item__complex { + font-size: 14px; + font-weight: 600; + color: var(--text-bright); + margin: 0; +} + +.re-schedule-item__label { + font-size: 12px; + color: var(--text-dim); + margin: 0; +} + +/* ── 분석 뷰 ──────────────────────────────────────────────────────────── */ + +.re-analysis { + display: grid; + gap: 20px; +} + +.re-analysis__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.re-stat-card { + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 20px; + background: var(--surface); + text-align: center; +} + +.re-stat-card__label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin: 0 0 8px; +} + +.re-stat-card__value { + font-family: var(--font-display); + font-size: 20px; + font-weight: 700; + color: var(--text-bright); + margin: 0; + letter-spacing: -0.03em; +} + +.re-chart-wrapper { + padding: 8px 8px 20px; +} + +.re-chart-tooltip { + background: var(--bg-secondary); + border: 1px solid var(--line-bright); + border-radius: var(--radius-sm); + padding: 10px 14px; + font-family: var(--font-body); + font-size: 13px; + color: var(--text); + box-shadow: var(--shadow-md); +} + +.re-chart-tooltip__value { + color: #f59e0b; + font-weight: 700; + font-family: var(--font-display); + font-size: 15px; + margin-top: 3px; +} + +/* ── 비교 테이블 ──────────────────────────────────────────────────────── */ + +.re-table-wrapper { + padding: 0 24px 24px; + overflow-x: auto; +} + +.re-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.re-table th { + text-align: left; + padding: 10px 14px; + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + border-bottom: 1px solid var(--line); + font-weight: 500; + white-space: nowrap; +} + +.re-table td { + padding: 13px 14px; + color: var(--text); + border-bottom: 1px solid var(--line-subtle); + white-space: nowrap; +} + +.re-table tr:last-child td { + border-bottom: none; +} + +.re-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.re-table__name { + font-weight: 600; + color: var(--text-bright) !important; + white-space: normal !important; +} + +/* ── 모달 ─────────────────────────────────────────────────────────────── */ + +.re-modal-overlay { + position: fixed; + inset: 0; + background: rgba(7, 11, 25, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.re-modal { + background: var(--bg-secondary); + border: 1px solid var(--line-bright); + border-radius: var(--radius-xl); + width: 100%; + max-width: 620px; + max-height: 88vh; + overflow-y: auto; + box-shadow: var(--shadow-lg), 0 0 60px rgba(245, 158, 11, 0.06); + animation: fadeIn 0.2s var(--ease-out); +} + +.re-modal__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + background: var(--bg-secondary); + z-index: 1; +} + +.re-modal__header h3 { + font-family: var(--font-display); + font-size: 18px; + margin: 0; +} + +.re-modal__close { + background: none; + border: none; + color: var(--text-dim); + font-size: 16px; + cursor: pointer; + padding: 6px 10px; + border-radius: var(--radius-xs); + transition: color 0.15s, background 0.15s; + line-height: 1; +} + +.re-modal__close:hover { + color: var(--text-bright); + background: rgba(255, 255, 255, 0.06); +} + +.re-modal__form { + padding: 0; +} + +.re-form-section { + padding: 20px 24px; + border-bottom: 1px solid var(--line-subtle); + display: grid; + gap: 14px; +} + +.re-form-section:last-of-type { + border-bottom: none; +} + +.re-form-section__title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); + margin: 0; +} + +.re-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.re-form-row--three { + grid-template-columns: 1fr 1fr 1fr; +} + +.re-form-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 11px; + color: var(--text-dim); + font-weight: 500; + letter-spacing: 0.02em; +} + +.re-form-input { + background: var(--bg-tertiary); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 9px 12px; + color: var(--text-bright); + font-family: var(--font-body); + font-size: 13px; + transition: border-color 0.15s; + width: 100%; +} + +.re-form-input:focus { + outline: none; + border-color: rgba(245, 158, 11, 0.5); + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.08); +} + +.re-form-input option { + background: var(--bg-secondary); +} + +.re-form-textarea { + resize: vertical; + min-height: 72px; +} + +.re-modal__footer { + padding: 16px 24px; + display: flex; + justify-content: flex-end; + gap: 12px; + border-top: 1px solid var(--line); + background: var(--bg-secondary); + position: sticky; + bottom: 0; +} + +/* ── 빈 상태 ──────────────────────────────────────────────────────────── */ + +.re-empty { + color: var(--text-muted); + font-size: 13px; + text-align: center; + padding: 48px 24px; +} + +/* ── 반응형 ───────────────────────────────────────────────────────────── */ + +@media (max-width: 1100px) { + .re-list-layout { + grid-template-columns: 1fr 340px; + } +} + +@media (max-width: 900px) { + .re-list-layout { + grid-template-columns: 1fr; + } + + .re-right-panel { + position: static; + max-height: none; + } +} + +@media (max-width: 768px) { + .re-header { + grid-template-columns: 1fr; + } + + .re-stats-bar { + display: grid; + grid-template-columns: repeat(2, 1fr); + } + + .re-stat-item { + border-right: none; + border-bottom: 1px solid var(--line); + } + + .re-stat-item:nth-child(1), + .re-stat-item:nth-child(2) { + border-right: 1px solid var(--line); + } + + .re-stat-item:nth-child(3), + .re-stat-item:nth-child(4) { + border-bottom: none; + } + + .re-analysis__stats { + grid-template-columns: 1fr; + } + + .re-form-row { + grid-template-columns: 1fr; + } + + .re-form-row--three { + grid-template-columns: 1fr; + } + + .re-modal { + max-height: 95vh; + } + + .re-mini-map-wrap { + height: 200px; + } + + .re-tabs-bar { + flex-direction: column; + align-items: flex-start; + } + + .re-schedule-item { + grid-template-columns: 80px 10px 1fr; + gap: 0 12px; + } +} diff --git a/src/pages/realestate/RealEstate.jsx b/src/pages/realestate/RealEstate.jsx new file mode 100644 index 0000000..44e1dcc --- /dev/null +++ b/src/pages/realestate/RealEstate.jsx @@ -0,0 +1,909 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; +import L from 'leaflet'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Cell, +} from 'recharts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; +import '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 ? '단지 편집' : '새 단지 추가'}

+ +
+
+
+

기본 정보

+
+ + +
+ +
+ + +
+
+ +
+

단지 정보

+
+ + +
+
+ + +
+ +
+ +
+

청약 일정

+
+ + + +
+
+ +
+

링크 & 메모

+ + +