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}
+
+ )}
+
+
+
+ ) : (
+
+
🏢
+
카드 또는 지도 마커를 클릭하면
단지 상세 정보가 표시됩니다
+
+ )}
+
+ );
+};
+
+// ── 청약 일정 타임라인 ─────────────────────────────────────────────────────────
+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 ? '단지 편집' : '새 단지 추가'}
+
+
+
+
+
+ );
+};
+
+// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
+const RealEstate = () => {
+ const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);
+ const [selectedComplex, setSelectedComplex] = useState(null);
+ const [activeTab, setActiveTab] = useState('목록');
+ const [filterStatus, setFilterStatus] = useState('전체');
+ const [showModal, setShowModal] = useState(false);
+ const [editingComplex, setEditingComplex] = useState(null);
+
+ useEffect(() => {
+ apiGet('/api/realestate/complexes')
+ .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);
+ try { await apiPost('/api/realestate/complexes', data); } catch {}
+ };
+
+ const handleUpdate = async (data) => {
+ setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
+ if (selectedComplex?.id === data.id) setSelectedComplex(data);
+ setEditingComplex(null);
+ setShowModal(false);
+ try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch {}
+ };
+
+ const handleDelete = async (id) => {
+ if (!confirm('삭제하시겠습니까?')) return;
+ setComplexes((prev) => prev.filter((c) => c.id !== id));
+ if (selectedComplex?.id === id) setSelectedComplex(null);
+ try { await apiDelete(`/api/realestate/complexes/${id}`); } catch {}
+ };
+
+ 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 (
+
+ {/* 헤더 */}
+
+
+ {/* 탭 바 */}
+
+
+ {TABS.map((tab) => (
+
+ ))}
+
+ {activeTab === '목록' && (
+
+ {['전체', ...Object.keys(STATUS_CONFIG)].map((s) => (
+
+ ))}
+
+ )}
+
+
+ {/* 목록 탭 — 카드 + 지도/상세 */}
+ {activeTab === '목록' && (
+
+
+ {filteredComplexes.length === 0 ? (
+
등록된 단지가 없습니다.
+ ) : (
+ filteredComplexes.map((c) => (
+
setSelectedComplex(c)}
+ />
+ ))
+ )}
+
+
{ setEditingComplex(selectedComplex); setShowModal(true); }}
+ onDelete={() => handleDelete(selectedComplex.id)}
+ />
+
+ )}
+
+ {activeTab === '일정' && (
+
+
+
+
캘린더
+
청약 일정
+
청약 시작·마감·당첨 발표일을 타임라인으로 확인합니다.
+
+
+
+
+ )}
+
+ {activeTab === '분석' && (
+
+ )}
+
+ {showModal && (
+
{ setShowModal(false); setEditingComplex(null); }}
+ onSave={handleModalSave}
+ />
+ )}
+
+ );
+};
+
+export default RealEstate;
diff --git a/src/pages/subscription/Subscription.css b/src/pages/subscription/Subscription.css
new file mode 100644
index 0000000..ac9cce7
--- /dev/null
+++ b/src/pages/subscription/Subscription.css
@@ -0,0 +1,1167 @@
+/* ═══════════════════════════════════════════════════════════════════════
+ Subscription.css — 청약 관리 페이지
+ ═══════════════════════════════════════════════════════════════════════ */
+
+.sub {
+ display: grid;
+ gap: 24px;
+ width: 100%;
+}
+
+/* ── 헤더 ─────────────────────────────────────────────────────────────── */
+
+.sub-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
+ gap: 24px;
+ align-items: center;
+}
+
+.sub-kicker {
+ text-transform: uppercase;
+ letter-spacing: 0.3em;
+ font-size: 12px;
+ color: var(--accent-subscription, #f43f5e);
+ margin: 0 0 10px;
+}
+
+.sub-header h1 {
+ margin: 0 0 10px;
+ font-family: var(--font-display);
+ font-size: clamp(28px, 4vw, 38px);
+}
+
+.sub-desc {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.sub-header-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 16px;
+ flex-wrap: wrap;
+}
+
+/* ── 스탯 바 ──────────────────────────────────────────────────────────── */
+
+.sub-stats-bar {
+ display: flex;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ overflow: hidden;
+ align-self: center;
+}
+
+.sub-stat-item {
+ padding: 16px 20px;
+ text-align: center;
+ border-right: 1px solid var(--line);
+ flex: 1;
+}
+
+.sub-stat-item:last-child { border-right: none; }
+
+.sub-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;
+}
+
+.sub-stat-item__label {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin: 6px 0 0;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+/* ── 내 조건 카드 ─────────────────────────────────────────────────────── */
+
+.sub-profile-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 24px;
+ align-items: center;
+ border: 1px solid rgba(244, 63, 94, 0.25);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(135deg, rgba(244,63,94,0.05) 0%, rgba(139,92,246,0.05) 100%);
+ padding: 20px 24px;
+ position: relative;
+ overflow: hidden;
+}
+
+.sub-profile-card::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, #f43f5e, #8b5cf6);
+}
+
+.sub-profile-card__title-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.sub-profile-card__label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.sub-profile-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.sub-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 100px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.sub-tag.is-pass {
+ background: rgba(52, 211, 153, 0.12);
+ color: #34d399;
+ border: 1px solid rgba(52, 211, 153, 0.2);
+}
+
+.sub-tag.is-fail {
+ background: rgba(248, 113, 113, 0.1);
+ color: #f87171;
+ border: 1px solid rgba(248, 113, 113, 0.2);
+}
+
+.sub-tag.is-neutral {
+ background: var(--neon-cyan-muted);
+ color: var(--neon-cyan);
+ border: 1px solid rgba(0, 212, 255, 0.15);
+}
+
+.sub-tag.is-special {
+ background: rgba(244, 63, 94, 0.1);
+ color: #f43f5e;
+ border: 1px solid rgba(244, 63, 94, 0.2);
+}
+
+.sub-profile-card__right {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ flex-shrink: 0;
+}
+
+.sub-profile-score {
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.sub-profile-score__number {
+ font-family: var(--font-display);
+ font-size: 40px;
+ font-weight: 700;
+ color: #f43f5e;
+ line-height: 1;
+ filter: drop-shadow(0 0 12px rgba(244, 63, 94, 0.4));
+}
+
+.sub-profile-score__label {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.sub-profile-score__breakdown {
+ display: grid;
+ gap: 8px;
+ min-width: 200px;
+}
+
+.sub-score-row {
+ display: grid;
+ grid-template-columns: 60px 1fr 40px;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.sub-score-bar {
+ height: 5px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 100px;
+ overflow: hidden;
+}
+
+.sub-score-bar__fill {
+ height: 100%;
+ border-radius: 100px;
+ transition: width 0.5s var(--ease-out);
+}
+
+.sub-score-val {
+ text-align: right;
+ color: var(--text-dim);
+ font-size: 10px;
+}
+
+/* ── 탭 바 ────────────────────────────────────────────────────────────── */
+
+.sub-tabs-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.sub-tabs {
+ display: flex;
+ gap: 2px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 4px;
+}
+
+.sub-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;
+}
+
+.sub-tab:hover { color: var(--text-bright); background: var(--surface-raised); }
+
+.sub-tab.is-active {
+ color: var(--text-bright);
+ background: var(--bg-tertiary);
+ box-shadow: 0 0 0 1px var(--line);
+}
+
+/* ── 필터 ─────────────────────────────────────────────────────────────── */
+
+.sub-filter {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.sub-filter-btn {
+ padding: 4px 12px;
+ 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;
+}
+
+.sub-filter-btn:hover { border-color: var(--line-bright); color: var(--text); }
+
+.sub-filter-btn.is-active {
+ background: rgba(244, 63, 94, 0.1);
+ border-color: rgba(244, 63, 94, 0.35);
+ color: #f43f5e;
+}
+
+/* ── 목록 레이아웃 ────────────────────────────────────────────────────── */
+
+.sub-list-layout {
+ display: grid;
+ grid-template-columns: 1fr 400px;
+ gap: 20px;
+ align-items: start;
+}
+
+.sub-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+ gap: 14px;
+}
+
+/* ── 청약 카드 ────────────────────────────────────────────────────────── */
+
+.sub-card {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ background: var(--surface);
+ cursor: pointer;
+ display: grid;
+ gap: 8px;
+ position: relative;
+ overflow: hidden;
+ transition: all 0.2s var(--ease-out);
+ animation: fadeIn 0.25s var(--ease-out) both;
+}
+
+.sub-card::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, #f43f5e, #8b5cf6);
+ opacity: 0;
+ transition: opacity 0.2s;
+}
+
+.sub-card:hover { border-color: var(--line-bright); background: var(--surface-raised); transform: translateY(-2px); box-shadow: var(--shadow-md); }
+.sub-card:hover::before, .sub-card.is-selected::before { opacity: 1; }
+.sub-card.is-selected { border-color: rgba(244, 63, 94, 0.4); background: var(--surface-raised); box-shadow: 0 0 20px rgba(244,63,94,0.1); }
+
+.sub-card__top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.sub-card__badges {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.sub-card__name {
+ font-family: var(--font-display);
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text-bright);
+ line-height: 1.3;
+}
+
+.sub-card__address {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.sub-card__info {
+ font-size: 12px;
+ color: var(--text-dim);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.sub-card__dot { color: var(--text-muted); }
+
+.sub-card__bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.sub-card__dday {
+ font-family: var(--font-display);
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-dim);
+}
+
+/* ── 뱃지류 ───────────────────────────────────────────────────────────── */
+
+.sub-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 100px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+}
+
+.sub-badge--lg { padding: 3px 10px; font-size: 11px; }
+
+.sub-type-badge {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+}
+
+.sub-type-badge--lg { font-size: 12px; }
+
+.sub-special-badge {
+ font-size: 10px;
+ background: rgba(244, 63, 94, 0.1);
+ color: #f43f5e;
+ border-radius: 100px;
+ padding: 1px 6px;
+}
+
+.sub-eligibility {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 100px;
+}
+
+.sub-eligibility.is-ok { color: #34d399; background: rgba(52,211,153,0.1); }
+.sub-eligibility.is-warn { color: #f59e0b; background: rgba(245,158,11,0.1); }
+.sub-eligibility--badge { font-size: 10px; }
+
+/* ── 상세 패널 ────────────────────────────────────────────────────────── */
+
+.sub-detail-panel {
+ position: sticky;
+ top: 24px;
+}
+
+.sub-detail {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ overflow: hidden;
+ animation: fadeIn 0.2s var(--ease-out);
+}
+
+.sub-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: 300px;
+ text-align: center;
+}
+
+.sub-detail__empty-icon { font-size: 44px; opacity: 0.3; }
+
+.sub-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);
+}
+
+.sub-detail__badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.sub-detail__name {
+ font-family: var(--font-display);
+ font-size: 17px;
+ font-weight: 700;
+ margin: 0 0 4px;
+ color: var(--text-bright);
+}
+
+.sub-detail__address {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.sub-detail__actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.sub-detail__price {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.sub-detail__price-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-muted);
+}
+
+.sub-detail__price-value {
+ font-family: var(--font-display);
+ font-size: 18px;
+ font-weight: 700;
+ color: #f59e0b;
+}
+
+.sub-detail__price-loan {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+/* ── 상세 섹션 탭 ─────────────────────────────────────────────────────── */
+
+.sub-detail__sections { }
+
+.sub-section-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--line);
+ padding: 0 12px;
+}
+
+.sub-section-tab {
+ padding: 10px 14px;
+ border: none;
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all 0.15s;
+ font-family: var(--font-body);
+}
+
+.sub-section-tab:hover { color: var(--text); }
+
+.sub-section-tab.is-active {
+ color: #f43f5e;
+ border-bottom-color: #f43f5e;
+}
+
+.sub-detail__section-content { padding: 16px 20px; }
+
+.sub-detail__memo {
+ padding: 14px 20px;
+ border-top: 1px solid var(--line-subtle);
+}
+
+.sub-detail__memo-title {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--text-muted);
+ margin: 0 0 6px;
+}
+
+.sub-detail__memo p {
+ font-size: 13px;
+ color: var(--text-dim);
+ margin: 0;
+ line-height: 1.7;
+}
+
+/* ── 요건 비교표 ──────────────────────────────────────────────────────── */
+
+.sub-compare {
+ display: grid;
+ gap: 0;
+}
+
+.sub-compare__row {
+ display: grid;
+ grid-template-columns: 90px 1fr 1fr 20px;
+ gap: 8px;
+ align-items: center;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--line-subtle);
+ font-size: 12px;
+}
+
+.sub-compare__row:last-of-type { border-bottom: none; }
+
+.sub-compare__label {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.sub-compare__required {
+ color: var(--text-dim);
+}
+
+.sub-compare__mine {
+ font-weight: 600;
+}
+
+.sub-compare__row.is-pass .sub-compare__mine { color: #34d399; }
+.sub-compare__row.is-fail .sub-compare__mine { color: #f87171; }
+
+.sub-compare__icon {
+ font-size: 13px;
+ font-weight: 700;
+ text-align: right;
+}
+
+.sub-compare__row.is-pass .sub-compare__icon { color: #34d399; }
+.sub-compare__row.is-fail .sub-compare__icon { color: #f87171; }
+
+.sub-compare__notes {
+ margin: 10px 0 0;
+ font-size: 12px;
+ color: var(--text-dim);
+ background: rgba(255,255,255,0.02);
+ border-radius: var(--radius-xs);
+ padding: 8px 10px;
+ border-left: 2px solid rgba(244,63,94,0.4);
+}
+
+.sub-empty-sm {
+ color: var(--text-muted);
+ font-size: 12px;
+ padding: 12px 0;
+}
+
+/* ── 일정 미니 ────────────────────────────────────────────────────────── */
+
+.sub-schedule-mini {
+ display: grid;
+ gap: 0;
+}
+
+.sub-schedule-mini__item {
+ display: grid;
+ grid-template-columns: 10px 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 9px 0;
+ border-bottom: 1px solid var(--line-subtle);
+ font-size: 12px;
+}
+
+.sub-schedule-mini__item:last-child { border-bottom: none; }
+
+.sub-schedule-mini__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sub-schedule-mini__content {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.sub-schedule-mini__label { color: var(--text-dim); font-size: 12px; }
+.sub-schedule-mini__date { color: var(--text-bright); font-size: 12px; font-weight: 500; }
+.sub-schedule-mini__dday { font-family: var(--font-display); font-size: 11px; font-weight: 700; white-space: nowrap; }
+
+/* ── 자금 섹션 ────────────────────────────────────────────────────────── */
+
+.sub-finance {
+ display: grid;
+ gap: 14px;
+}
+
+.sub-finance__total {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.sub-finance__total-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-muted);
+}
+
+.sub-finance__total-value {
+ font-family: var(--font-display);
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--text-bright);
+}
+
+.sub-finance__breakdown { }
+
+.sub-finance__bar {
+ display: flex;
+ height: 8px;
+ border-radius: 100px;
+ overflow: hidden;
+ margin-bottom: 10px;
+ gap: 1px;
+}
+
+.sub-finance__bar > div {
+ border-radius: 0;
+ transition: width 0.4s var(--ease-out);
+}
+
+.sub-finance__bar > div:first-child { border-radius: 100px 0 0 100px; }
+.sub-finance__bar > div:last-child { border-radius: 0 100px 100px 0; }
+
+.sub-finance__legend {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.sub-finance__legend span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.sub-finance__legend em {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+ flex-shrink: 0;
+ font-style: normal;
+}
+
+.sub-finance__fund {
+ border-top: 1px solid var(--line-subtle);
+ padding-top: 12px;
+ display: grid;
+ gap: 6px;
+}
+
+.sub-finance__fund-row {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-dim);
+ align-items: center;
+}
+
+.sub-finance__fund-row--warn {
+ color: #f87171;
+}
+
+.sub-finance__fund-type {
+ font-size: 10px;
+ background: rgba(255,255,255,0.05);
+ border-radius: 4px;
+ padding: 1px 6px;
+ color: var(--text-muted);
+}
+
+/* ── 일정 탭 ──────────────────────────────────────────────────────────── */
+
+.sub-sched-panel {
+ padding: 24px;
+ display: grid;
+ gap: 28px;
+}
+
+.sub-sched-group {
+ display: grid;
+ gap: 0;
+}
+
+.sub-sched-group__title {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--text-muted);
+ margin: 0 0 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--line);
+}
+
+.sub-sched-group__title--past { opacity: 0.5; }
+
+.sub-sched-row {
+ display: grid;
+ grid-template-columns: 110px 10px 1fr;
+ gap: 0 16px;
+ align-items: center;
+ padding: 13px 0;
+ border-bottom: 1px solid var(--line-subtle);
+}
+
+.sub-sched-row:last-child { border-bottom: none; }
+
+.sub-sched-row__dates {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 3px;
+}
+
+.sub-sched-row__dday {
+ font-family: var(--font-display);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.sub-sched-row__date {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.sub-sched-row__dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ justify-self: center;
+ flex-shrink: 0;
+}
+
+.sub-sched-row__content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.sub-sched-row__complex {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-bright);
+}
+
+.sub-sched-row__label {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+/* ── 자금 탭 ──────────────────────────────────────────────────────────── */
+
+.sub-finance-tab {
+ display: grid;
+ gap: 20px;
+}
+
+.sub-finance-summary {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+}
+
+.sub-fin-stat {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ padding: 18px 20px;
+ background: var(--surface);
+ text-align: center;
+}
+
+.sub-fin-stat__label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-muted);
+ margin: 0 0 8px;
+}
+
+.sub-fin-stat__value {
+ font-family: var(--font-display);
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-bright);
+ margin: 0;
+ letter-spacing: -0.02em;
+}
+
+/* ── 패널 공통 ────────────────────────────────────────────────────────── */
+
+.sub-panel {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.sub-panel__head {
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.sub-panel__eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.22em;
+ font-size: 10px;
+ color: var(--accent-subscription, #f43f5e);
+ margin: 0 0 6px;
+}
+
+.sub-panel__head h3 {
+ margin: 0 0 3px;
+ font-family: var(--font-display);
+ font-size: 18px;
+}
+
+.sub-panel__sub {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.sub-panel__body {
+ padding: 20px 24px;
+}
+
+/* ── 모달 공통 ────────────────────────────────────────────────────────── */
+
+.sub-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;
+}
+
+.sub-modal {
+ background: var(--bg-secondary);
+ border: 1px solid rgba(244, 63, 94, 0.3);
+ border-radius: var(--radius-xl);
+ width: 100%;
+ max-width: 500px;
+ max-height: 88vh;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg), 0 0 60px rgba(244,63,94,0.06);
+ animation: fadeIn 0.2s var(--ease-out);
+}
+
+.sub-modal--wide { max-width: 640px; }
+
+.sub-modal__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 18px 24px;
+ border-bottom: 1px solid var(--line);
+ position: sticky;
+ top: 0;
+ background: var(--bg-secondary);
+ z-index: 1;
+}
+
+.sub-modal__header h3 { font-family: var(--font-display); font-size: 17px; margin: 0; }
+
+.sub-modal__score {
+ font-family: var(--font-display);
+ font-size: 15px;
+ font-weight: 700;
+ color: #f43f5e;
+}
+
+.sub-modal__close {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 15px;
+ cursor: pointer;
+ padding: 6px 8px;
+ border-radius: var(--radius-xs);
+ transition: color 0.15s, background 0.15s;
+ line-height: 1;
+}
+
+.sub-modal__close:hover { color: var(--text-bright); background: rgba(255,255,255,0.06); }
+
+.sub-modal__tabs {
+ display: flex;
+ border-bottom: 1px solid var(--line);
+ padding: 0 16px;
+ background: var(--bg-secondary);
+ position: sticky;
+ top: 57px;
+ z-index: 1;
+}
+
+.sub-modal__tab {
+ padding: 10px 14px;
+ border: none;
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: all 0.15s;
+ font-family: var(--font-body);
+}
+
+.sub-modal__tab:hover { color: var(--text); }
+.sub-modal__tab.is-active { color: #f43f5e; border-bottom-color: #f43f5e; }
+
+.sub-modal__form { padding: 0; }
+
+.sub-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;
+}
+
+/* ── 폼 요소 ──────────────────────────────────────────────────────────── */
+
+.sub-form-section {
+ padding: 18px 24px;
+ display: grid;
+ gap: 12px;
+}
+
+.sub-form-section__title {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.sub-form-section__hint {
+ font-size: 12px;
+ color: var(--text-dim);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.sub-form-section__sub {
+ font-size: 11px;
+ color: var(--text-dim);
+ margin: 4px 0 0;
+ font-weight: 600;
+}
+
+.sub-form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.sub-form-row--three { grid-template-columns: 1fr 1fr 1fr; }
+
+.sub-form-label {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 11px;
+ color: var(--text-dim);
+ font-weight: 500;
+}
+
+.sub-form-input {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 8px 11px;
+ color: var(--text-bright);
+ font-family: var(--font-body);
+ font-size: 13px;
+ transition: border-color 0.15s;
+ width: 100%;
+}
+
+.sub-form-input:focus {
+ outline: none;
+ border-color: rgba(244, 63, 94, 0.5);
+ box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.08);
+}
+
+.sub-form-input option { background: var(--bg-secondary); }
+
+.sub-form-textarea { resize: vertical; min-height: 64px; }
+
+.sub-form-checks {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.sub-form-check-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.sub-form-check {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.sub-form-check input[type="checkbox"] {
+ width: 14px;
+ height: 14px;
+ accent-color: #f43f5e;
+ cursor: pointer;
+}
+
+/* ── 공통 ─────────────────────────────────────────────────────────────── */
+
+.sub-empty {
+ color: var(--text-muted);
+ font-size: 13px;
+ text-align: center;
+ padding: 48px 24px;
+}
+
+/* ── 반응형 ───────────────────────────────────────────────────────────── */
+
+@media (max-width: 1100px) {
+ .sub-list-layout { grid-template-columns: 1fr 360px; }
+}
+
+@media (max-width: 900px) {
+ .sub-list-layout { grid-template-columns: 1fr; }
+ .sub-detail-panel { position: static; }
+ .sub-profile-card { grid-template-columns: 1fr; }
+ .sub-profile-card__right { flex-direction: column; align-items: flex-start; }
+ .sub-profile-score__breakdown { min-width: 0; width: 100%; }
+}
+
+@media (max-width: 768px) {
+ .sub-header { grid-template-columns: 1fr; }
+ .sub-stats-bar { display: grid; grid-template-columns: repeat(2, 1fr); }
+ .sub-stat-item { border-right: none; border-bottom: 1px solid var(--line); }
+ .sub-stat-item:nth-child(1), .sub-stat-item:nth-child(2) { border-right: 1px solid var(--line); }
+ .sub-stat-item:nth-child(3), .sub-stat-item:nth-child(4) { border-bottom: none; }
+ .sub-finance-summary { grid-template-columns: 1fr; }
+ .sub-form-row { grid-template-columns: 1fr; }
+ .sub-form-row--three { grid-template-columns: 1fr; }
+ .sub-form-check-grid { grid-template-columns: repeat(2, 1fr); }
+ .sub-tabs-bar { flex-direction: column; align-items: flex-start; }
+ .sub-sched-row { grid-template-columns: 90px 10px 1fr; gap: 0 10px; }
+ .sub-compare__row { grid-template-columns: 70px 1fr 1fr 16px; }
+}
diff --git a/src/pages/subscription/Subscription.jsx b/src/pages/subscription/Subscription.jsx
new file mode 100644
index 0000000..4eed0d5
--- /dev/null
+++ b/src/pages/subscription/Subscription.jsx
@@ -0,0 +1,1214 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
+import './Subscription.css';
+
+// ── 상수 ───────────────────────────────────────────────────────────────────────
+const STATUS_CONFIG = {
+ '검토중': { color: '#94a3b8', bg: 'rgba(148,163,184,0.1)' },
+ '신청예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
+ '신청완료': { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)' },
+ '당첨': { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
+ '탈락': { color: '#f87171', bg: 'rgba(248,113,113,0.1)' },
+ '포기': { color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
+};
+
+const TYPE_CONFIG = {
+ '줍줍': { color: '#fbbf24' },
+ '특공': { color: '#f43f5e' },
+ '일반': { color: '#60a5fa' },
+};
+
+const SPECIAL_TYPES = ['신혼부부', '생애최초', '다자녀', '노부모부양', '기관추천'];
+const LOAN_TYPES = ['중도금대출', '잔금대출', '혼합', '자기자금'];
+const SUPPLY_TYPES = ['가점', '추첨', '혼합'];
+const RESIDENCE_AREAS = ['서울', '경기', '인천', '수도권', '부산', '대구', '대전', '광주', '전국'];
+const SPECIAL_QUALS = ['신혼부부', '생애최초', '다자녀', '노부모부양', '기관추천'];
+const TABS = ['청약 목록', '일정', '자금'];
+
+const DEFAULT_PROFILE = {
+ isHouseholdHead: true,
+ isHomeless: true,
+ homelessPeriod: 60,
+ savingsMonths: 84,
+ savingsCount: 84,
+ dependents: 2,
+ residencyArea: '서울',
+ isMarried: true,
+ marriageMonths: 36,
+ monthlyIncome: 600,
+ specialQuals: ['신혼부부'],
+};
+
+const SAMPLE_ITEMS = [
+ {
+ id: 1,
+ complexName: '힐스테이트 동탄',
+ address: '경기 화성시 동탄2신도시',
+ pyeong: '84㎡',
+ totalPrice: 55000,
+ type: '일반',
+ specialType: '',
+ supplyType: '가점',
+ status: '신청예정',
+ priority: 'high',
+ req_householdHead: true,
+ req_noHomePeriod: 24,
+ req_savingsCount: 24,
+ req_residencyArea: '수도권',
+ req_incomeLimit: 160,
+ req_dependentMin: 0,
+ req_notes: '수도권 2년 이상 거주 우선',
+ applicationStart: '2026-04-10',
+ applicationEnd: '2026-04-12',
+ winnerAnnouncement: '2026-04-17',
+ contractStart: '2026-05-01',
+ contractEnd: '2026-05-03',
+ interim1Date: '2026-10-01', interim1Ratio: 10,
+ interim2Date: '2027-04-01', interim2Ratio: 10,
+ interim3Date: '2027-10-01', interim3Ratio: 10,
+ balanceDate: '2028-06-01',
+ depositRatio: 10,
+ interimRatio: 60,
+ balanceRatio: 30,
+ loanAmount: 30000,
+ loanType: '중도금대출',
+ ownFunds: 25000,
+ memo: '동탄 핵심 입지, 가점으로 충분히 도전 가능.',
+ },
+ {
+ id: 2,
+ complexName: '롯데캐슬 마곡',
+ address: '서울 강서구 마곡동',
+ pyeong: '59㎡',
+ totalPrice: 72000,
+ type: '특공',
+ specialType: '신혼부부',
+ supplyType: '추첨',
+ status: '신청예정',
+ priority: 'high',
+ req_householdHead: false,
+ req_noHomePeriod: 0,
+ req_savingsCount: 6,
+ req_residencyArea: '서울',
+ req_incomeLimit: 130,
+ req_dependentMin: 0,
+ req_notes: '혼인 7년 이내, 무주택 세대',
+ applicationStart: '2026-03-20',
+ applicationEnd: '2026-03-22',
+ winnerAnnouncement: '2026-03-27',
+ contractStart: '2026-04-10',
+ contractEnd: '2026-04-12',
+ interim1Date: '2026-09-15', interim1Ratio: 10,
+ interim2Date: '2027-03-15', interim2Ratio: 10,
+ interim3Date: '', interim3Ratio: 10,
+ balanceDate: '2027-12-01',
+ depositRatio: 10,
+ interimRatio: 60,
+ balanceRatio: 30,
+ loanAmount: 40000,
+ loanType: '혼합',
+ ownFunds: 32000,
+ memo: '신혼부부 특공. 소득 요건 130% 확인 필요.',
+ },
+];
+
+const EMPTY_ITEM = {
+ complexName: '', address: '', pyeong: '', totalPrice: '',
+ type: '일반', specialType: '', supplyType: '가점', status: '검토중', priority: 'normal',
+ req_householdHead: true, req_noHomePeriod: 24, req_savingsCount: 24,
+ req_residencyArea: '수도권', req_incomeLimit: 160, req_dependentMin: 0, req_notes: '',
+ applicationStart: '', applicationEnd: '', winnerAnnouncement: '',
+ contractStart: '', contractEnd: '',
+ interim1Date: '', interim1Ratio: 10,
+ interim2Date: '', interim2Ratio: 10,
+ interim3Date: '', interim3Ratio: 10,
+ balanceDate: '',
+ depositRatio: 10, interimRatio: 60, balanceRatio: 30,
+ loanAmount: '', loanType: '중도금대출', ownFunds: '', memo: '',
+};
+
+// ── 가점 계산 ─────────────────────────────────────────────────────────────────
+const calcHomelessScore = (isHomeless, months) => {
+ if (!isHomeless) return 0;
+ const years = Math.floor((months || 0) / 12);
+ return Math.min((years + 1) * 2, 32);
+};
+
+const calcSavingsScore = (months) => {
+ const m = months || 0;
+ if (m < 6) return 1;
+ if (m < 12) return 2;
+ return Math.min(Math.floor(m / 12) + 2, 17);
+};
+
+const calcDependentScore = (count) => Math.min(5 + (count || 0) * 5, 35);
+
+const calcTotalScore = (p) => (
+ calcHomelessScore(p.isHomeless, p.homelessPeriod) +
+ calcSavingsScore(p.savingsMonths) +
+ calcDependentScore(p.dependents)
+);
+
+// ── 요건 비교 ─────────────────────────────────────────────────────────────────
+const checkRequirements = (item, profile) => {
+ const results = [];
+
+ if (item.req_householdHead) {
+ results.push({
+ label: '세대주',
+ required: '세대주 필요',
+ mine: profile.isHouseholdHead ? '세대주' : '세대원',
+ pass: profile.isHouseholdHead,
+ });
+ }
+
+ if (item.req_noHomePeriod > 0) {
+ const reqYr = Math.floor(item.req_noHomePeriod / 12);
+ const myYr = Math.floor((profile.homelessPeriod || 0) / 12);
+ results.push({
+ label: '무주택 기간',
+ required: `${reqYr}년 이상`,
+ mine: profile.isHomeless ? `${myYr}년` : '유주택',
+ pass: profile.isHomeless && (profile.homelessPeriod || 0) >= item.req_noHomePeriod,
+ });
+ }
+
+ if (item.req_savingsCount > 0) {
+ results.push({
+ label: '청약통장 납입',
+ required: `${item.req_savingsCount}회 이상`,
+ mine: `${profile.savingsCount || 0}회`,
+ pass: (profile.savingsCount || 0) >= item.req_savingsCount,
+ });
+ }
+
+ if (item.req_residencyArea && item.req_residencyArea !== '전국') {
+ const areaMap = {
+ '수도권': ['서울', '경기', '인천'],
+ '서울': ['서울'],
+ };
+ const valid = areaMap[item.req_residencyArea] || [item.req_residencyArea];
+ const pass = valid.some((a) => (profile.residencyArea || '').includes(a) || (profile.residencyArea || '') === a);
+ results.push({
+ label: '거주 지역',
+ required: `${item.req_residencyArea} 거주`,
+ mine: profile.residencyArea || '-',
+ pass,
+ });
+ }
+
+ if (item.req_dependentMin > 0) {
+ results.push({
+ label: '부양가족',
+ required: `${item.req_dependentMin}명 이상`,
+ mine: `${profile.dependents || 0}명`,
+ pass: (profile.dependents || 0) >= item.req_dependentMin,
+ });
+ }
+
+ if (item.type === '특공' && item.specialType) {
+ const hasQual = (profile.specialQuals || []).includes(item.specialType);
+ results.push({
+ label: '특공 자격',
+ required: item.specialType,
+ mine: hasQual ? item.specialType : '해당없음',
+ pass: hasQual,
+ });
+ if (item.specialType === '신혼부부') {
+ const reqMonths = 84; // 7년
+ results.push({
+ label: '혼인 기간',
+ required: '7년 이내',
+ mine: profile.marriageMonths ? `${Math.floor(profile.marriageMonths / 12)}년` : '-',
+ pass: (profile.marriageMonths || 0) <= reqMonths,
+ });
+ }
+ }
+
+ return results;
+};
+
+// ── 유틸 ──────────────────────────────────────────────────────────────────────
+const fmt = (d) => {
+ if (!d) return '-';
+ return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
+};
+
+const fmtFull = (d) => {
+ if (!d) return '-';
+ return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
+};
+
+const fmtPrice = (v) => v ? `${Number(v).toLocaleString()}만원` : '-';
+
+const getDDays = (d) => {
+ if (!d) return null;
+ const diff = Math.ceil((new Date(d) - new Date().setHours(0, 0, 0, 0)) / 86400000);
+ if (diff === 0) return 'D-Day';
+ return diff > 0 ? `D-${diff}` : `D+${Math.abs(diff)}`;
+};
+
+// ── 내 조건 카드 ───────────────────────────────────────────────────────────────
+const ProfileCard = ({ profile, onEdit }) => {
+ const total = calcTotalScore(profile);
+ const hScore = calcHomelessScore(profile.isHomeless, profile.homelessPeriod);
+ const sScore = calcSavingsScore(profile.savingsMonths);
+ const dScore = calcDependentScore(profile.dependents);
+
+ const ScoreBar = ({ value, max, color }) => (
+
+ );
+
+ return (
+
+
+
+
+
+ {profile.isHouseholdHead ? '세대주' : '세대원'}
+
+
+ {profile.isHomeless ? `무주택 ${Math.floor((profile.homelessPeriod || 0) / 12)}년` : '유주택'}
+
+ {profile.residencyArea}
+ 통장 {Math.floor((profile.savingsMonths || 0) / 12)}년
+ 부양 {profile.dependents}명
+ {(profile.specialQuals || []).map((q) => (
+ {q}
+ ))}
+
+
+
+
+
+
+ 무주택
+
+ {hScore}/32
+
+
+ 부양가족
+
+ {dScore}/35
+
+
+ 통장기간
+
+ {sScore}/17
+
+
+
+
+ );
+};
+
+// ── 청약 카드 ─────────────────────────────────────────────────────────────────
+const SubCard = ({ item, isSelected, onClick, profile }) => {
+ const scfg = STATUS_CONFIG[item.status] || STATUS_CONFIG['검토중'];
+ const tcfg = TYPE_CONFIG[item.type] || TYPE_CONFIG['일반'];
+ const checks = checkRequirements(item, profile);
+ const passCount = checks.filter((c) => c.pass).length;
+ const allPass = checks.length > 0 && passCount === checks.length;
+ const dday = getDDays(item.applicationStart);
+
+ return (
+
+
+
+ {item.status}
+
+
+
+ {item.type}
+
+ {item.specialType && {item.specialType}}
+
+
+
{item.complexName}
+
{item.address}
+
+ {item.pyeong || '-'}
+ ·
+ {fmtPrice(item.totalPrice)}
+
+
+ {checks.length > 0 && (
+
+ {allPass ? `✓ 요건 충족 (${passCount}/${checks.length})` : `△ ${passCount}/${checks.length} 충족`}
+
+ )}
+ {dday && {dday}}
+
+
+ );
+};
+
+// ── 요건 비교표 ────────────────────────────────────────────────────────────────
+const RequirementComparison = ({ item, profile }) => {
+ const checks = checkRequirements(item, profile);
+ if (checks.length === 0) {
+ return 요건 정보가 입력되지 않았습니다.
;
+ }
+ return (
+
+ {checks.map((c) => (
+
+ {c.label}
+ {c.required}
+ {c.mine}
+ {c.pass ? '✓' : '✗'}
+
+ ))}
+ {item.req_notes && (
+
📌 {item.req_notes}
+ )}
+
+ );
+};
+
+// ── 일정 섹션 (상세 패널) ─────────────────────────────────────────────────────
+const ScheduleSection = ({ item }) => {
+ const events = [
+ { label: '청약 시작', date: item.applicationStart, type: 'start' },
+ { label: '청약 마감', date: item.applicationEnd, type: 'mid' },
+ { label: '당첨 발표', date: item.winnerAnnouncement, type: 'winner' },
+ { label: '계약 시작', date: item.contractStart, type: 'contract' },
+ { label: '계약 마감', date: item.contractEnd, type: 'mid' },
+ item.interim1Date && { label: `중도금 1차 (${item.interim1Ratio}%)`, date: item.interim1Date, type: 'interim' },
+ item.interim2Date && { label: `중도금 2차 (${item.interim2Ratio}%)`, date: item.interim2Date, type: 'interim' },
+ item.interim3Date && { label: `중도금 3차 (${item.interim3Ratio}%)`, date: item.interim3Date, type: 'interim' },
+ { label: '잔금 납부', date: item.balanceDate, type: 'balance' },
+ ].filter(Boolean).filter((e) => e.date);
+
+ const typeColor = {
+ start: '#00d4ff', mid: '#94a3b8', winner: '#34d399',
+ contract: '#8b5cf6', interim: '#f59e0b', balance: '#f43f5e',
+ };
+
+ return (
+
+ {events.map((e, i) => (
+
+
+
+ {e.label}
+ {fmtFull(e.date)}
+
+
+ {getDDays(e.date) || ''}
+
+
+ ))}
+
+ );
+};
+
+// ── 자금 섹션 (상세 패널) ─────────────────────────────────────────────────────
+const FinanceSection = ({ item }) => {
+ const total = Number(item.totalPrice) || 0;
+ const deposit = Math.round(total * (item.depositRatio || 10) / 100);
+ const interim = Math.round(total * (item.interimRatio || 60) / 100);
+ const balance = total - deposit - interim;
+ const loan = Number(item.loanAmount) || 0;
+ const own = Number(item.ownFunds) || 0;
+ const loanRatio = total > 0 ? Math.round((loan / total) * 100) : 0;
+ const ownRatio = total > 0 ? Math.round((own / total) * 100) : 0;
+
+ return (
+
+
+ 분양가
+ {fmtPrice(total)}
+
+
+
+
+ 계약금 {item.depositRatio}% — {fmtPrice(deposit)}
+ 중도금 {item.interimRatio}% — {fmtPrice(interim)}
+ 잔금 {item.balanceRatio}% — {fmtPrice(balance)}
+
+
+
+
+ 대출 계획
+ {item.loanType}
+ {fmtPrice(loan)} ({loanRatio}%)
+
+
+ 자기자금
+
+ {fmtPrice(own)} ({ownRatio}%)
+
+ {total > 0 && loan + own !== total && (
+
+ 미확보 자금
+
+ {fmtPrice(total - loan - own)}
+
+ )}
+
+
+ );
+};
+
+// ── 상세 패널 ─────────────────────────────────────────────────────────────────
+const SubDetail = ({ item, profile, onEdit, onDelete }) => {
+ const [section, setSection] = useState('요건');
+ const sections = ['요건', '일정', '자금'];
+
+ if (!item) {
+ return (
+
+
📋
+
청약 항목을 선택하면
상세 정보가 표시됩니다
+
+ );
+ }
+
+ const scfg = STATUS_CONFIG[item.status] || STATUS_CONFIG['검토중'];
+ const tcfg = TYPE_CONFIG[item.type] || TYPE_CONFIG['일반'];
+ const checks = checkRequirements(item, profile);
+ const allPass = checks.length > 0 && checks.every((c) => c.pass);
+
+ return (
+
+
+
+
+
+ {item.status}
+
+
+ {item.type}{item.specialType ? ` · ${item.specialType}` : ''}
+
+ {checks.length > 0 && (
+
+ {allPass ? '✓ 요건충족' : '△ 확인필요'}
+
+ )}
+
+
{item.complexName}
+
{item.address} · {item.pyeong || '-'}
+
+
+
+
+
+
+
+
+ 분양가
+ {fmtPrice(item.totalPrice)}
+ 대출 {fmtPrice(item.loanAmount)} · 자기자금 {fmtPrice(item.ownFunds)}
+
+
+
+
+ {sections.map((s) => (
+
+ ))}
+
+
+ {section === '요건' && }
+ {section === '일정' && }
+ {section === '자금' && }
+
+
+
+ {item.memo && (
+
+ )}
+
+ );
+};
+
+// ── 일정 탭 ────────────────────────────────────────────────────────────────────
+const ScheduleTab = ({ items }) => {
+ const eventTypeColor = {
+ start: '#00d4ff', end: '#94a3b8', winner: '#34d399',
+ contract: '#8b5cf6', interim: '#f59e0b', balance: '#f43f5e',
+ };
+
+ const events = items.flatMap((item) => {
+ const list = [
+ { date: item.applicationStart, label: '청약 시작', type: 'start', item },
+ { date: item.applicationEnd, label: '청약 마감', type: 'end', item },
+ { date: item.winnerAnnouncement, label: '당첨 발표', type: 'winner', item },
+ { date: item.contractStart, label: '계약 시작', type: 'contract', item },
+ { date: item.contractEnd, label: '계약 마감', type: 'end', item },
+ item.interim1Date && { date: item.interim1Date, label: `중도금 1차 (${item.interim1Ratio}%)`, type: 'interim', item },
+ item.interim2Date && { date: item.interim2Date, label: `중도금 2차 (${item.interim2Ratio}%)`, type: 'interim', item },
+ item.interim3Date && { date: item.interim3Date, label: `중도금 3차 (${item.interim3Ratio}%)`, type: 'interim', item },
+ { date: item.balanceDate, label: '잔금 납부', type: 'balance', item },
+ ].filter(Boolean).filter((e) => e.date);
+ return list;
+ }).sort((a, b) => new Date(a.date) - new Date(b.date));
+
+ const today = new Date().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 EventRow = ({ e }) => {
+ const color = eventTypeColor[e.type];
+ const scfg = STATUS_CONFIG[e.item.status] || STATUS_CONFIG['검토중'];
+ return (
+
+
+ {getDDays(e.date)}
+ {fmtFull(e.date)}
+
+
+
+ {e.item.complexName}
+ {e.label}
+
+ {e.item.status}
+
+
+
+ );
+ };
+
+ return (
+
+ {upcoming.length > 0 && (
+
+
예정 일정
+ {upcoming.map((e, i) => )}
+
+ )}
+ {past.length > 0 && (
+
+
지난 일정
+ {past.map((e, i) => )}
+
+ )}
+ {events.length === 0 &&
등록된 일정이 없습니다.
}
+
+ );
+};
+
+// ── 자금 탭 ────────────────────────────────────────────────────────────────────
+const FinanceTab = ({ items }) => {
+ const totalAll = items.reduce((s, i) => s + (Number(i.totalPrice) || 0), 0);
+ const loanAll = items.reduce((s, i) => s + (Number(i.loanAmount) || 0), 0);
+ const ownAll = items.reduce((s, i) => s + (Number(i.ownFunds) || 0), 0);
+
+ return (
+
+
+
+
총 분양가 합계
+
{fmtPrice(totalAll)}
+
+
+
총 대출 계획
+
{fmtPrice(loanAll)}
+
+
+
총 자기자금
+
{fmtPrice(ownAll)}
+
+
+
+ {items.map((item) => {
+ const total = Number(item.totalPrice) || 0;
+ const deposit = Math.round(total * (item.depositRatio || 10) / 100);
+ const interim = Math.round(total * (item.interimRatio || 60) / 100);
+ const balance = total - deposit - interim;
+ const loan = Number(item.loanAmount) || 0;
+ const own = Number(item.ownFunds) || 0;
+ const scfg = STATUS_CONFIG[item.status] || STATUS_CONFIG['검토중'];
+
+ return (
+
+
+
+
자금 계획
+
{item.complexName}
+
{item.address} · {item.pyeong}
+
+
+ {item.status}
+
+
+
+
+
+ 분양가
+ {fmtPrice(total)}
+
+
+
+
+ 계약금 {item.depositRatio}% — {fmtPrice(deposit)}
+ 중도금 {item.interimRatio}% — {fmtPrice(interim)}
+ 잔금 {item.balanceRatio}% — {fmtPrice(balance)}
+
+
+
+
+ 대출 ({item.loanType})
+
+ {fmtPrice(loan)}
+
+
+ 자기자금
+
+ {fmtPrice(own)}
+
+ {total > 0 && loan + own !== total && (
+
+ 미확보 자금
+
+ {fmtPrice(total - loan - own)}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+};
+
+// ── 내 조건 편집 모달 ─────────────────────────────────────────────────────────
+const ProfileModal = ({ profile, onClose, onSave }) => {
+ const [form, setForm] = useState({ ...profile });
+ const set = (f) => (e) => setForm((p) => ({ ...p, [f]: e.target.value }));
+ const setNum = (f) => (e) => setForm((p) => ({ ...p, [f]: Number(e.target.value) }));
+ const setBool = (f) => (e) => setForm((p) => ({ ...p, [f]: e.target.checked }));
+ const toggleQual = (q) => setForm((p) => ({
+ ...p,
+ specialQuals: p.specialQuals.includes(q)
+ ? p.specialQuals.filter((x) => x !== q)
+ : [...p.specialQuals, q],
+ }));
+
+ const handleSubmit = (e) => { e.preventDefault(); onSave(form); };
+ const score = calcTotalScore(form);
+
+ return (
+
+
e.stopPropagation()}>
+
+
내 청약 조건 편집
+
+ 가점 {score}점
+
+
+
+
+
+
+ );
+};
+
+// ── 청약 추가/편집 모달 ───────────────────────────────────────────────────────
+const SubModal = ({ item, onClose, onSave }) => {
+ const [form, setForm] = useState(item ? { ...item, totalPrice: String(item.totalPrice || ''), loanAmount: String(item.loanAmount || ''), ownFunds: String(item.ownFunds || '') } : { ...EMPTY_ITEM });
+ const [tab, setTab] = useState('기본');
+ const MODAL_TABS = ['기본', '요건', '일정', '자금'];
+
+ const set = (f) => (e) => setForm((p) => ({ ...p, [f]: e.target.value }));
+ const setNum = (f) => (e) => setForm((p) => ({ ...p, [f]: Number(e.target.value) }));
+ const setBool = (f) => (e) => setForm((p) => ({ ...p, [f]: e.target.checked }));
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSave({ ...form, totalPrice: Number(form.totalPrice) || 0, loanAmount: Number(form.loanAmount) || 0, ownFunds: Number(form.ownFunds) || 0 });
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
{item ? '청약 편집' : '새 청약 추가'}
+
+
+
+ {MODAL_TABS.map((t) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────────
+const Subscription = () => {
+ const [profile, setProfile] = useState(DEFAULT_PROFILE);
+ const [items, setItems] = useState(SAMPLE_ITEMS);
+ const [selected, setSelected] = useState(null);
+ const [activeTab, setActiveTab] = useState('청약 목록');
+ const [filterStatus, setFilterStatus] = useState('전체');
+ const [showProfileModal, setShowProfileModal] = useState(false);
+ const [showSubModal, setShowSubModal] = useState(false);
+ const [editingItem, setEditingItem] = useState(null);
+
+ // API 연동 (실패 시 localStorage + 샘플 데이터 fallback)
+ useEffect(() => {
+ const saved = localStorage.getItem('sub_profile');
+ if (saved) setProfile(JSON.parse(saved));
+ apiGet('/api/subscription/profile').then((d) => { if (d) setProfile(d); }).catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ const saved = localStorage.getItem('sub_items');
+ if (saved) { const parsed = JSON.parse(saved); if (parsed.length > 0) setItems(parsed); }
+ apiGet('/api/subscription/items').then((d) => { if (Array.isArray(d) && d.length > 0) setItems(d); }).catch(() => {});
+ }, []);
+
+ const saveProfile = async (data) => {
+ setProfile(data);
+ localStorage.setItem('sub_profile', JSON.stringify(data));
+ setShowProfileModal(false);
+ try { await apiPut('/api/subscription/profile', data); } catch {}
+ };
+
+ const persist = (newItems) => {
+ setItems(newItems);
+ localStorage.setItem('sub_items', JSON.stringify(newItems));
+ };
+
+ const handleAdd = async (data) => {
+ const n = { ...data, id: Date.now() };
+ const next = [...items, n];
+ persist(next);
+ setShowSubModal(false);
+ try { await apiPost('/api/subscription/items', data); } catch {}
+ };
+
+ const handleUpdate = async (data) => {
+ const next = items.map((i) => (i.id === data.id ? data : i));
+ persist(next);
+ if (selected?.id === data.id) setSelected(data);
+ setEditingItem(null);
+ setShowSubModal(false);
+ try { await apiPut(`/api/subscription/items/${data.id}`, data); } catch {}
+ };
+
+ const handleDelete = async (id) => {
+ if (!confirm('삭제하시겠습니까?')) return;
+ const next = items.filter((i) => i.id !== id);
+ persist(next);
+ if (selected?.id === id) setSelected(null);
+ try { await apiDelete(`/api/subscription/items/${id}`); } catch {}
+ };
+
+ const handleModalSave = (data) => {
+ if (editingItem) handleUpdate({ ...editingItem, ...data });
+ else handleAdd(data);
+ };
+
+ const filtered = useMemo(() => {
+ if (filterStatus === '전체') return items;
+ return items.filter((i) => i.status === filterStatus);
+ }, [items, filterStatus]);
+
+ const stats = useMemo(() => ({
+ total: items.length,
+ upcoming: items.filter((i) => i.status === '신청예정').length,
+ won: items.filter((i) => i.status === '당첨').length,
+ passed: items.filter((i) => checkRequirements(i, profile).every((c) => c.pass)).length,
+ }), [items, profile]);
+
+ return (
+
+ {/* 헤더 */}
+
+
+ {/* 내 조건 카드 */}
+
setShowProfileModal(true)} />
+
+ {/* 탭 바 */}
+
+
+ {TABS.map((t) => (
+
+ ))}
+
+ {activeTab === '청약 목록' && (
+
+ {['전체', ...Object.keys(STATUS_CONFIG)].map((s) => (
+
+ ))}
+
+ )}
+
+
+ {/* 청약 목록 탭 */}
+ {activeTab === '청약 목록' && (
+
+
+ {filtered.length === 0 ?
등록된 청약이 없습니다.
: (
+ filtered.map((item) => (
+
setSelected(item)}
+ profile={profile}
+ />
+ ))
+ )}
+
+
+ { setEditingItem(selected); setShowSubModal(true); }}
+ onDelete={() => handleDelete(selected.id)}
+ />
+
+
+ )}
+
+ {activeTab === '일정' && (
+
+
+
+
타임라인
+
전체 청약 일정
+
청약 접수부터 잔금 납부까지 모든 일정을 확인합니다.
+
+
+
+
+ )}
+
+ {activeTab === '자금' && }
+
+ {showProfileModal && (
+ setShowProfileModal(false)} onSave={saveProfile} />
+ )}
+ {showSubModal && (
+ { setShowSubModal(false); setEditingItem(null); }}
+ onSave={handleModalSave}
+ />
+ )}
+
+ );
+};
+
+export default Subscription;
diff --git a/src/routes.jsx b/src/routes.jsx
index d0c217b..095ca85 100644
--- a/src/routes.jsx
+++ b/src/routes.jsx
@@ -4,6 +4,7 @@ import {
IconBlog,
IconLotto,
IconStock,
+ IconBuilding,
IconTravel,
IconLab,
IconTodo,
@@ -15,6 +16,8 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
const Travel = lazy(() => import('./pages/travel/Travel'));
const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
+const RealEstate = lazy(() => import('./pages/realestate/RealEstate'));
+const Subscription = lazy(() => import('./pages/subscription/Subscription'));
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
const Todo = lazy(() => import('./pages/todo/Todo'));
@@ -55,6 +58,15 @@ export const navLinks = [
icon: ,
accent: '#60a5fa',
},
+ {
+ id: 'realestate',
+ label: 'Realestate',
+ path: '/realestate',
+ subtitle: '부동산',
+ description: '청약 자격 비교, 일정 관리, 관심 단지 정보를 관리하는 공간',
+ icon: ,
+ accent: '#f43f5e',
+ },
{
id: 'travel',
label: 'Travel',
@@ -105,6 +117,14 @@ export const appRoutes = [
path: 'stock/trade',
element: ,
},
+ {
+ path: 'realestate',
+ element: ,
+ },
+ {
+ path: 'realestate/property',
+ element: ,
+ },
{
path: 'travel',
element: ,