Compare commits
21 Commits
696c2ade15
...
feat/reale
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c7394a94 | |||
| 690964c994 | |||
| 17b1348f5d | |||
| 0431ae6887 | |||
| 9fc023da9d | |||
| ffd306859f | |||
| 4869e3c97a | |||
| ed81f73f13 | |||
| a5e33b70df | |||
| a087c3d1e5 | |||
| dec696b16e | |||
| d57f9b9b65 | |||
| 970c8164e0 | |||
| cb15ae1d24 | |||
| 6bf36f34f0 | |||
| 3656ee9a59 | |||
| e8091a0391 | |||
| a52fd0db8f | |||
| ae33aa4def | |||
| 3e73077b29 | |||
| 6e415b3e45 |
13
CLAUDE.md
13
CLAUDE.md
@@ -16,10 +16,11 @@
|
||||
| `/blog` | `Blog` | 마크다운 기반 블로그 |
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 (포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭) |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (폼 ↔ n8n 스타일 캔버스 모드 토글, 점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||
@@ -102,6 +103,10 @@ proxy: {
|
||||
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||
@@ -134,6 +139,12 @@ proxy: {
|
||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||
| 부동산-매물 | GET | `/api/realestate/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` — { listings: [...] } |
|
||||
| 부동산-매물 | POST/GET | `/api/realestate/listings/collect`, `/collect/status` |
|
||||
| 부동산-매물 | GET/PUT | `/api/realestate/listings/criteria` (부분 업데이트) |
|
||||
| 부동산-매물 | GET | `/api/realestate/listings/matches` — { matches: [...] } |
|
||||
| 부동산-안전마진 | POST | `/api/realestate/safety-check` — body { area, deal_type, amount(만원), dong? } → { median, ratio, tier, sample, is_toheo, disclaimer } |
|
||||
| 부동산-예산 | POST | `/api/realestate/budget` — body { equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? } → { jeonse, purchase, region, disclaimer } |
|
||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
|
||||
@@ -64,14 +64,15 @@
|
||||
- 미국 10년물 금리, WTI/Brent 유가 등 매크로 지표
|
||||
- 국내 / 해외 주식 뉴스 탭 분류 및 필터링
|
||||
|
||||
### Stock Trade (`/stock/trade`) — 7 컴포넌트
|
||||
### Stock Trade (`/stock/trade`) — 8 컴포넌트
|
||||
|
||||
포트폴리오 관리 및 트레이딩 데스크.
|
||||
포트폴리오 관리 및 트레이딩 데스크 (5탭).
|
||||
|
||||
- **포트폴리오 탭**: 보유 종목 수익률, 자산 구성 차트 (파이/바 차트)
|
||||
- **AI 탭**: AI 시장 분석 요약 및 투자 인사이트
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이
|
||||
- **리포트 탭**: 자산 스냅샷 히스토리 및 수익 추이 + AI 코치
|
||||
- **어드바이저 탭**: 투자 조언 및 리밸런싱 제안
|
||||
- **보유종목 인텔 탭**: 스크리너 엔진 기반 기술분석·매도룰 신호 (어드바이저리)
|
||||
- **관심종목 탭**: 관심종목 CRUD + 실시간 매매 시그널 알림 이력 (매수/매도 시그널, 1D/7D/30D 필터) — 실시간 매매 알림 파이프라인(BE 엔드포인트 + web-ai `trade-monitor` 워커) 연동
|
||||
- 종목 추가/편집/삭제 CRUD, 현금 잔고(예수금) 관리
|
||||
- 매도 히스토리 드로어 (실현손익 추적)
|
||||
|
||||
|
||||
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
906
docs/superpowers/plans/2026-07-03-watchlist-tab.md
Normal file
@@ -0,0 +1,906 @@
|
||||
# 관심종목 탭 (Watchlist Tab) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `/stock/trade` 거래 데스크에 관심종목 CRUD + 최근 매매 시그널 알림 이력을 보여주는 "관심종목" 탭을 추가한다.
|
||||
|
||||
**Architecture:** 순수 헬퍼(`watchlistUtils.js`) → API 헬퍼(`api.js`) → 상태 훅(`useWatchlist.js`) → 표현 컴포넌트(`WatchlistTab.jsx`) → 탭 등재(`StockTrade.jsx`). 기존 `HoldingsIntelTab`/`usePortfolio` 패턴(훅을 `StockTrade`에서 인스턴스화해 탭에 props로 전달)을 그대로 따른다.
|
||||
|
||||
**Tech Stack:** React 18 (함수형 + hooks), Vite, Vitest + @testing-library/react, 기존 `apiGet/apiPost/apiDelete` 헬퍼.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API는 항상 상대경로** (`/api/...`). 절대 URL 금지 (Mixed Content).
|
||||
- **모든 fetch는 `src/api.js`의 `apiGet/apiPost/apiDelete` 경유.**
|
||||
- 테스트: `import { describe, it, expect } from 'vitest'`. 실행 `npm run test:run`. 파일 컨벤션 `*.test.js(x)` 동일 디렉토리 배치.
|
||||
- 색상: 매수 `#22c55e`, 매도 `#ef4444` (기존 `ACTION_MAP` 팔레트 일치).
|
||||
- CSS 토큰 재사용: `--line`, `--surface`, `--radius-lg`, `--muted`, `--accent-stock`. 카드 관례: `background: rgba(255,255,255,0.03); border: 1px solid rgba(148,163,184,0.12); border-radius: 10px`.
|
||||
- 커밋은 `web-ui` 경로에서만. `.env`·무관 파일 커밋 금지 (변경 파일만 명시적 `git add`).
|
||||
- BE 계약 (소비 대상):
|
||||
- `GET /api/stock/watchlist` → `{ watchlist: [{ ticker, name, note, params, added_at }] }`
|
||||
- `POST /api/stock/watchlist` body `{ ticker, name?, note? }` → `{ ok: true }`
|
||||
- `DELETE /api/stock/watchlist/{ticker}` → 200/404
|
||||
- `GET /api/stock/trade-alerts?days=N` → `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }`
|
||||
- `kind`: `buy`|`sell`. `condition`: `buy_ma20_pullback`/`buy_breakout`/`buy_rsi_bounce`/`sell_stop_loss`/`sell_ma_break`/`sell_take_profit`/`sell_climax`/`sell_trailing_stop`.
|
||||
- 응답은 방어적 파싱: 배열 직접 반환 / 래핑(`watchlist`·`alerts`) 둘 다 허용.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 순수 헬퍼 `watchlistUtils.js` (라벨/색/시간 매핑)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/watchlistUtils.js`
|
||||
- Test: `src/pages/stock/watchlistUtils.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `KIND_META: { buy: {label,color,bg}, sell: {label,color,bg} }`
|
||||
- `kindMeta(kind: string) => { label, color, bg }` (미정의 → 회색 폴백 + 원문 label)
|
||||
- `CONDITION_LABEL: Record<string,string>`
|
||||
- `conditionLabel(cond: string) => string` (미정의 → 원문 폴백)
|
||||
- `normalizeTicker(str) => string` (trim만)
|
||||
- `relativeTime(iso: string, now?: number) => string`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js';
|
||||
|
||||
describe('kindMeta', () => {
|
||||
it('buy/sell 라벨과 색을 반환', () => {
|
||||
expect(kindMeta('buy').label).toBe('매수');
|
||||
expect(kindMeta('buy').color).toBe('#22c55e');
|
||||
expect(kindMeta('sell').label).toBe('매도');
|
||||
expect(kindMeta('sell').color).toBe('#ef4444');
|
||||
});
|
||||
it('미정의 kind는 회색 폴백 + 원문 label', () => {
|
||||
const m = kindMeta('weird');
|
||||
expect(m.label).toBe('weird');
|
||||
expect(m.color).toBe('#94a3b8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionLabel', () => {
|
||||
it('정의된 8종을 한글로 매핑', () => {
|
||||
expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등');
|
||||
expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파');
|
||||
expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등');
|
||||
expect(conditionLabel('sell_stop_loss')).toBe('손절 라인');
|
||||
expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈');
|
||||
expect(conditionLabel('sell_take_profit')).toBe('목표가 도달');
|
||||
expect(conditionLabel('sell_climax')).toBe('과열 소진');
|
||||
expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱');
|
||||
});
|
||||
it('미정의 condition은 원문 폴백', () => {
|
||||
expect(conditionLabel('buy_unknown')).toBe('buy_unknown');
|
||||
expect(conditionLabel(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTicker', () => {
|
||||
it('공백 trim', () => {
|
||||
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
||||
expect(normalizeTicker(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
||||
it('60초 미만은 방금', () => {
|
||||
expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금');
|
||||
});
|
||||
it('분/시간/어제/일 경계', () => {
|
||||
expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전');
|
||||
expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전');
|
||||
expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제');
|
||||
expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전');
|
||||
});
|
||||
it('잘못된/빈 값은 빈 문자열', () => {
|
||||
expect(relativeTime('', now)).toBe('');
|
||||
expect(relativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./watchlistUtils.js"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `src/pages/stock/watchlistUtils.js`:
|
||||
|
||||
```js
|
||||
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
||||
|
||||
export const KIND_META = {
|
||||
buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
||||
};
|
||||
|
||||
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
||||
|
||||
export const kindMeta = (kind) => {
|
||||
const meta = KIND_META[kind];
|
||||
if (meta) return meta;
|
||||
return { ...FALLBACK_KIND, label: kind ?? '' };
|
||||
};
|
||||
|
||||
export const CONDITION_LABEL = {
|
||||
buy_ma20_pullback: 'MA20 눌림 반등',
|
||||
buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등',
|
||||
sell_stop_loss: '손절 라인',
|
||||
sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달',
|
||||
sell_climax: '과열 소진',
|
||||
sell_trailing_stop: '트레일링 스톱',
|
||||
};
|
||||
|
||||
export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? '';
|
||||
|
||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||
|
||||
export const relativeTime = (iso, now = Date.now()) => {
|
||||
if (!iso) return '';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '방금';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return '방금';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return '어제';
|
||||
if (day < 7) return `${day}일 전`;
|
||||
return new Date(iso).toLocaleDateString('ko-KR');
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/watchlistUtils.test.js`
|
||||
Expected: PASS (4 describe 블록 전부 통과).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/watchlistUtils.js src/pages/stock/watchlistUtils.test.js
|
||||
git commit -m "feat(stock): 관심종목 탭 순수 헬퍼(watchlistUtils) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API 헬퍼 + `useWatchlist` 훅
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api.js` (파일 끝에 추가)
|
||||
- Create: `src/pages/stock/hooks/useWatchlist.js`
|
||||
- Test: `src/pages/stock/hooks/useWatchlist.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `normalizeTicker`
|
||||
- Produces:
|
||||
- `api.js`: `getWatchlist()`, `addWatchlist(body)`, `removeWatchlist(ticker)`, `getTradeAlerts(days=7)`
|
||||
- `useWatchlist() => { items, alerts, alertDays, setAlertDays, loading, error, alertError, adding, add, remove, reload }`
|
||||
- `add({ ticker, name?, note? })` — 낙관적 추가 후 `reload`, 실패 시 롤백
|
||||
- `remove(ticker)` — 낙관적 제거, 실패 시 롤백
|
||||
|
||||
- [ ] **Step 1: Add API helpers**
|
||||
|
||||
`src/api.js` 파일 맨 끝(마지막 `compatDeleteReading` 함수 뒤)에 추가:
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing hook test**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.test.js`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getWatchlist: vi.fn(),
|
||||
addWatchlist: vi.fn(),
|
||||
removeWatchlist: vi.fn(),
|
||||
getTradeAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import useWatchlist from './useWatchlist';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] });
|
||||
getTradeAlerts.mockResolvedValue({ alerts: [] });
|
||||
addWatchlist.mockResolvedValue({ ok: true });
|
||||
removeWatchlist.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe('useWatchlist', () => {
|
||||
it('마운트 시 watchlist를 로드', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('005930');
|
||||
});
|
||||
|
||||
it('배열 직접 반환도 방어적으로 파싱', async () => {
|
||||
getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]);
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('000660');
|
||||
});
|
||||
|
||||
it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => {
|
||||
getWatchlist
|
||||
.mockResolvedValueOnce({ watchlist: [] })
|
||||
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
||||
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
||||
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
||||
});
|
||||
|
||||
it('add 실패 시 롤백 + error', async () => {
|
||||
getWatchlist.mockResolvedValue({ watchlist: [] });
|
||||
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { await result.current.add({ ticker: '000660' }); });
|
||||
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
||||
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
||||
});
|
||||
|
||||
it('중복 ticker는 add 차단', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.add({ ticker: '005930' }); });
|
||||
expect(addWatchlist).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toContain('이미');
|
||||
});
|
||||
|
||||
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.remove('005930'); });
|
||||
expect(removeWatchlist).toHaveBeenCalledWith('005930');
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => {
|
||||
getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: FAIL — `Failed to resolve import "./useWatchlist"` (파일 없음).
|
||||
|
||||
- [ ] **Step 4: Write the hook**
|
||||
|
||||
Create `src/pages/stock/hooks/useWatchlist.js`:
|
||||
|
||||
```js
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import { normalizeTicker } from '../watchlistUtils';
|
||||
|
||||
const asArray = (data, key) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data[key])) return data[key];
|
||||
return [];
|
||||
};
|
||||
|
||||
const byFiredAtDesc = (a, b) =>
|
||||
new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime();
|
||||
|
||||
export default function useWatchlist() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [alertDays, setAlertDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [alertError, setAlertError] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getWatchlist();
|
||||
setItems(asArray(data, 'watchlist'));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlerts = useCallback(async (days) => {
|
||||
setAlertError('');
|
||||
try {
|
||||
const data = await getTradeAlerts(days);
|
||||
setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc));
|
||||
} catch (e) {
|
||||
setAlertError(e?.message ?? String(e));
|
||||
setAlerts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWatchlist(); }, [loadWatchlist]);
|
||||
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
||||
|
||||
const add = useCallback(async ({ ticker, name, note }) => {
|
||||
const t = normalizeTicker(ticker);
|
||||
if (!t) return;
|
||||
if (items.some((it) => it.ticker === t)) {
|
||||
setError(`이미 관심종목에 있습니다: ${t}`);
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
setError('');
|
||||
const cleanName = (name ?? '').trim();
|
||||
const cleanNote = (note ?? '').trim();
|
||||
const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() };
|
||||
setItems((prev) => [optimistic, ...prev]);
|
||||
try {
|
||||
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
||||
await loadWatchlist();
|
||||
} catch (e) {
|
||||
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [items, loadWatchlist]);
|
||||
|
||||
const remove = useCallback(async (ticker) => {
|
||||
const prev = items;
|
||||
setItems((cur) => cur.filter((it) => it.ticker !== ticker));
|
||||
setError('');
|
||||
try {
|
||||
await removeWatchlist(ticker);
|
||||
} catch (e) {
|
||||
setItems(prev); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
items, alerts, alertDays, setAlertDays,
|
||||
loading, error, alertError, adding,
|
||||
add, remove, reload: loadWatchlist,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/hooks/useWatchlist.test.js`
|
||||
Expected: PASS (7 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.js src/pages/stock/hooks/useWatchlist.js src/pages/stock/hooks/useWatchlist.test.js
|
||||
git commit -m "feat(stock): watchlist API 헬퍼 + useWatchlist 훅(낙관적 CRUD·알림) + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `WatchlistTab.jsx` 컴포넌트 + 스타일
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/stock/components/WatchlistTab.jsx`
|
||||
- Modify: `src/pages/stock/Stock.css` (파일 끝에 `wl-*` 섹션 추가)
|
||||
- Test: `src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 1): `kindMeta`, `conditionLabel`, `relativeTime`; (stockUtils) `formatNumber`; (Task 2) `useWatchlist` 반환 형태 — 단, 컴포넌트는 훅 결과를 `wl` **prop**으로 받는다(테스트/뱃지 용이).
|
||||
- Produces: `WatchlistTab({ wl })` 기본 export (React 컴포넌트).
|
||||
|
||||
- [ ] **Step 1: Write the failing smoke test**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.test.jsx`:
|
||||
|
||||
```jsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import WatchlistTab from './WatchlistTab.jsx';
|
||||
|
||||
const baseWl = {
|
||||
items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(),
|
||||
loading: false, error: '', alertError: '', adding: false,
|
||||
add: vi.fn(), remove: vi.fn(), reload: vi.fn(),
|
||||
};
|
||||
|
||||
describe('WatchlistTab', () => {
|
||||
it('빈 상태: 헤딩과 빈 안내 노출', () => {
|
||||
render(<WatchlistTab wl={baseWl} />);
|
||||
expect(screen.getByText('관심종목 관리')).toBeInTheDocument();
|
||||
expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('종목·알림이 있으면 렌더', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }],
|
||||
alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('삼성전자')).toBeInTheDocument();
|
||||
expect(screen.getByText('매수')).toBeInTheDocument();
|
||||
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
> 참고: `toBeInTheDocument` 매처는 `@testing-library/jest-dom`(devDependency)에서 제공된다. 기존 테스트 셋업에서 전역 등록이 안 되어 있으면 테스트 파일 상단에 `import '@testing-library/jest-dom';` 한 줄을 추가한다.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: FAIL — `Failed to resolve import "./WatchlistTab.jsx"` (파일 없음).
|
||||
|
||||
- [ ] **Step 3: Write the component**
|
||||
|
||||
Create `src/pages/stock/components/WatchlistTab.jsx`:
|
||||
|
||||
```jsx
|
||||
import React, { useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils';
|
||||
import { formatNumber } from '../stockUtils';
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1D' },
|
||||
{ value: 7, label: '7D' },
|
||||
{ value: 30, label: '30D' },
|
||||
];
|
||||
|
||||
const AlertCard = ({ a }) => {
|
||||
const meta = kindMeta(a.kind);
|
||||
return (
|
||||
<div className="wl-alert">
|
||||
<div className="wl-alert__head">
|
||||
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
|
||||
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
|
||||
<span className="wl-alert__ticker">{a.ticker}</span>
|
||||
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
|
||||
</div>
|
||||
<div className="wl-alert__body">
|
||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||
</div>
|
||||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchlistTab = ({ wl }) => {
|
||||
const [form, setForm] = useState({ ticker: '', name: '', note: '' });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.ticker.trim()) return;
|
||||
await wl.add(form);
|
||||
setForm({ ticker: '', name: '', note: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 관심종목 관리 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">관심종목</p>
|
||||
<h3>관심종목 관리</h3>
|
||||
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
|
||||
</div>
|
||||
|
||||
<form className="wl-form" onSubmit={submit}>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목코드 (예: 005930)"
|
||||
value={form.ticker}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목명 (선택)"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="메모 (선택)"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
|
||||
{wl.adding ? '추가 중…' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{wl.error && <p className="stock-error">{wl.error}</p>}
|
||||
|
||||
{wl.items.length === 0 ? (
|
||||
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
|
||||
) : (
|
||||
<ul className="wl-list">
|
||||
{wl.items.map((it) => (
|
||||
<li key={it.ticker} className="wl-row">
|
||||
<div className="wl-row__meta">
|
||||
<strong className="wl-row__name">{it.name || it.ticker}</strong>
|
||||
<span className="wl-row__ticker">{it.ticker}</span>
|
||||
{it.note && <span className="wl-row__note">{it.note}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="wl-del"
|
||||
type="button"
|
||||
aria-label={`${it.ticker} 삭제`}
|
||||
onClick={() => wl.remove(it.ticker)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 최근 시그널 알림 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">시그널</p>
|
||||
<h3>최근 매매 알림</h3>
|
||||
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
|
||||
</div>
|
||||
<div className="wl-period-toggle">
|
||||
{DAYS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
|
||||
onClick={() => wl.setAlertDays(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
|
||||
|
||||
{wl.alerts.length === 0 ? (
|
||||
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
|
||||
) : (
|
||||
<div className="wl-alerts">
|
||||
{wl.alerts.map((a) => (
|
||||
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistTab;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Append styles to `Stock.css`**
|
||||
|
||||
`src/pages/stock/Stock.css` 파일 맨 끝에 추가:
|
||||
|
||||
```css
|
||||
/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */
|
||||
.wl-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wl-form__input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wl-form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.wl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.wl-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wl-row__name { font-size: 14px; }
|
||||
.wl-row__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; }
|
||||
.wl-del {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.wl-period-toggle { display: flex; gap: 4px; }
|
||||
.wl-period {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wl-period.is-active {
|
||||
color: var(--accent-stock);
|
||||
border-color: var(--accent-stock);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.wl-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wl-alert {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wl-alert__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-kind-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wl-alert__name { font-size: 14px; }
|
||||
.wl-alert__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.wl-alert__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `npm run test:run -- src/pages/stock/components/WatchlistTab.test.jsx`
|
||||
Expected: PASS (2 케이스 통과).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/components/WatchlistTab.jsx src/pages/stock/components/WatchlistTab.test.jsx src/pages/stock/Stock.css
|
||||
git commit -m "feat(stock): WatchlistTab 컴포넌트 + wl-* 스타일 + 스모크 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `StockTrade`에 탭 등재 + 문서 갱신
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/stock/stockUtils.js:152` (TAB 상수 추가)
|
||||
- Modify: `src/pages/stock/StockTrade.jsx` (import·훅·탭 배열·렌더)
|
||||
- Modify: `CLAUDE.md` (API 엔드포인트 테이블 — web-ui 루트가 아닌 `web-ui/CLAUDE.md`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes (Task 2·3): `useWatchlist`, `WatchlistTab`
|
||||
- Produces: 없음 (통합 지점, 최종 배선)
|
||||
|
||||
- [ ] **Step 1: Add TAB constant**
|
||||
|
||||
`src/pages/stock/stockUtils.js` 맨 끝(`export const TAB_HOLDINGS_INTEL = 'holdings_intel';` 뒤)에 추가:
|
||||
|
||||
```js
|
||||
export const TAB_WATCHLIST = 'watchlist';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire into StockTrade.jsx**
|
||||
|
||||
`src/pages/stock/StockTrade.jsx` 수정 — 4곳:
|
||||
|
||||
(a) stockUtils import에 `TAB_WATCHLIST` 추가 (기존 import 블록 line 6-10):
|
||||
|
||||
```js
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
|
||||
} from './stockUtils';
|
||||
```
|
||||
|
||||
(b) 탭 컴포넌트 import 추가 (기존 `import HoldingsIntelTab ...` 뒤, line 25 근처):
|
||||
|
||||
```js
|
||||
import HoldingsIntelTab from './components/HoldingsIntelTab';
|
||||
import WatchlistTab from './components/WatchlistTab';
|
||||
```
|
||||
|
||||
(c) 훅 인스턴스화 + `TAB_ORDER`/`tabLabels` 확장. `const [activeTab, ...]` 아래(line 31 근처)와 hooks 블록에 추가:
|
||||
|
||||
```js
|
||||
const wl = useWatchlist();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
|
||||
```
|
||||
|
||||
그리고 파일 상단 hooks import 목록에 훅 import 추가 (line 19 `import useAdvisor ...` 뒤):
|
||||
|
||||
```js
|
||||
import useAdvisor from './hooks/useAdvisor';
|
||||
import useWatchlist from './hooks/useWatchlist';
|
||||
```
|
||||
|
||||
`const wl = useWatchlist();` 는 다른 훅들(`const advisor = useAdvisor({...});`) 뒤에 배치.
|
||||
|
||||
(d) 모바일 SwipeableView content 분기에 watchlist 추가. 기존 `: <HoldingsIntelTab />,` 를 다음으로 교체:
|
||||
|
||||
```js
|
||||
content: tabId === TAB_PORTFOLIO
|
||||
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
|
||||
: tabId === TAB_REPORT
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: tabId === TAB_ADVISOR
|
||||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||||
: tabId === TAB_HOLDINGS_INTEL
|
||||
? <HoldingsIntelTab />
|
||||
: <WatchlistTab wl={wl} />,
|
||||
```
|
||||
|
||||
(e) 데스크탑 탭 버튼 배열에 항목 추가. 기존 `{ id: TAB_HOLDINGS_INTEL, ... }` 항목 뒤에 추가:
|
||||
|
||||
```js
|
||||
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
|
||||
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
|
||||
```
|
||||
|
||||
(f) 데스크탑 조건부 렌더 추가. 기존 `{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}` 뒤에 추가:
|
||||
|
||||
```js
|
||||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `npm run test:run`
|
||||
Expected: PASS — 신규 3개 테스트 파일 포함 전체 통과 (기존 테스트 회귀 없음).
|
||||
|
||||
- [ ] **Step 4: Lint + build**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 신규 파일 관련 에러 0. (기존 코드의 사전 경고는 무시하되, 신규 파일이 새 에러를 만들지 않을 것.)
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 빌드 성공 (`dist/` 생성, 에러 없음).
|
||||
|
||||
- [ ] **Step 5: Manual verification (dev server)**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
브라우저에서 `http://localhost:3007/stock/trade` 접속 → "관심종목" 탭이 데스크탑 탭바(⭐)와 모바일 스와이프에 노출되는지 확인. 종목코드 입력 후 추가 → 목록 반영, 삭제 버튼 동작, 기간 토글(1D/7D/30D) 확인. (BE 미배포 시 알림 패널은 에러/빈 상태로 표시되고 CRUD는 독립 동작해야 함.)
|
||||
|
||||
- [ ] **Step 6: Update `web-ui/CLAUDE.md` API 테이블**
|
||||
|
||||
`CLAUDE.md` (web-ui 프로젝트 루트) 의 "API 엔드포인트 목록" 테이블에 행 추가 (스크리너 관련 행 근처):
|
||||
|
||||
```markdown
|
||||
| 관심종목 | GET | `/api/stock/watchlist` — { watchlist: [{ ticker, name, note, params, added_at }] } |
|
||||
| 관심종목 | POST | `/api/stock/watchlist` — body: { ticker, name?, note? } |
|
||||
| 관심종목 | DELETE | `/api/stock/watchlist/:ticker` |
|
||||
| 매매 시그널 | GET | `/api/stock/trade-alerts?days=N` — { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] } |
|
||||
```
|
||||
|
||||
그리고 페이지 구조 표의 `/stock/trade` 행 설명에 "(포트폴리오·리포트·어드바이저·보유종목 인텔·관심종목 5탭)" 취지를 반영.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/stock/stockUtils.js src/pages/stock/StockTrade.jsx CLAUDE.md
|
||||
git commit -m "feat(stock): 거래 데스크에 관심종목 탭 등재 + API 문서 갱신"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
**Spec coverage** (설계 §1–§10 대비):
|
||||
- §2 계약 4종 → Task 2 (api 헬퍼) ✅
|
||||
- §3 탭 등재 → Task 4 ✅
|
||||
- §4 컴포넌트 구조(훅+자립형 탭+utils) → Task 1/2/3 ✅
|
||||
- §5 API 레이어 → Task 2 ✅
|
||||
- §6 UX(낙관적 갱신·중복 차단·기간 토글·정렬) → Task 2(훅)·Task 3(뷰) ✅
|
||||
- §7 스타일 `wl-*` → Task 3 ✅
|
||||
- §8 테스트 → Task 1(utils)·Task 2(훅)·Task 3(컴포넌트) ✅
|
||||
- §9 완료 기준 → Task 4 Step 3–6 ✅
|
||||
- §10 리스크(방어적 파싱·알림 독립) → `asArray` + `alertError` 분리 ✅
|
||||
|
||||
**Placeholder scan:** 모든 코드/명령/기대출력 구체값 명시. TBD/TODO 없음. ✅
|
||||
|
||||
**Type consistency:** `kindMeta`/`conditionLabel`/`relativeTime`/`normalizeTicker` (Task1) ↔ 훅/컴포넌트 사용처 일치. `useWatchlist` 반환 키(`items/alerts/alertDays/setAlertDays/loading/error/alertError/adding/add/remove/reload`) ↔ `WatchlistTab` prop 사용처 일치. `getWatchlist/addWatchlist/removeWatchlist/getTradeAlerts` (api) ↔ 훅 import 일치. ✅
|
||||
|
||||
**참고 — StockTrade 라인 번호:** 현재 파일 기준 근사치. 실제 편집 시 앵커 문자열(기존 코드 스니펫)로 위치 확인 후 삽입.
|
||||
1071
docs/superpowers/plans/2026-07-09-realestate-listings.md
Normal file
1071
docs/superpowers/plans/2026-07-09-realestate-listings.md
Normal file
File diff suppressed because it is too large
Load Diff
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
174
docs/superpowers/specs/2026-07-03-watchlist-tab-design.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 관심종목 탭 (Watchlist Tab) — FE 설계
|
||||
|
||||
- **작성일**: 2026-07-03
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **상위 스펙(BE)**: `web-page-backend/docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §2·§5.3
|
||||
- **상위 플랜(BE)**: `web-page-backend/docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md`
|
||||
- **범위**: FE(web-ui)만. BE 계약(§5.3)을 소비하는 "관심종목" 탭 구현. 워커(web-ai)·BE는 별도 세션.
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
실시간 매매 알림 시스템의 매수 유니버스는 **"watchlist(사용자 관리) ∪ 당일 스크리너 후보"** 로 정의된다(BE 스펙 §2). 관심종목 관리 수단은 **"텔레그램 봇 명령 + web-ui 탭 둘 다"** 로 결정되었다. 본 문서는 그중 **web-ui 탭**을 정의한다.
|
||||
|
||||
목표:
|
||||
1. 사용자가 관심종목을 웹에서 추가/조회/삭제(CRUD)할 수 있다.
|
||||
2. 최근 발생한 매수·매도 시그널 알림 이력을 웹에서 확인할 수 있다.
|
||||
|
||||
비목표(YAGNI, v1 제외):
|
||||
- 종목별 조건 오버라이드(`params_json`: trailing_pct, stop_pct 등) 편집 — BE POST/PUT params 계약 미확정.
|
||||
- 실시간 WebSocket 알림 스트림 — 폴링/수동 새로고침으로 충분.
|
||||
- 텔레그램 설정 UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. 소비할 BE 계약 (§5.3)
|
||||
|
||||
| 메서드 | 경로 | 요청 | 응답 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/stock/watchlist` | — | `{ watchlist: [{ ticker, name, note, params, added_at }] }` |
|
||||
| POST | `/api/stock/watchlist` | `{ ticker, name?, note? }` | 201 `{ ok: true }` |
|
||||
| DELETE | `/api/stock/watchlist/{ticker}` | — | 200 / 404 |
|
||||
| GET | `/api/stock/trade-alerts?days=N` | — | `{ alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }` |
|
||||
|
||||
**알림 필드 enum (BE 스펙 §5.3):**
|
||||
|
||||
- `kind`: `buy` | `sell`
|
||||
- `condition` (buy): `buy_ma20_pullback` · `buy_breakout` · `buy_rsi_bounce`
|
||||
- `condition` (sell): `sell_stop_loss` · `sell_ma_break` · `sell_take_profit` · `sell_climax` · `sell_trailing_stop`
|
||||
|
||||
> 응답 래핑 키(`watchlist`/`alerts`)와 `params` 필드는 BE 스펙 문구 기준. FE는 방어적으로 파싱한다(배열 직접 반환 / 래핑 둘 다 허용, `params` 미사용이면 무시).
|
||||
|
||||
---
|
||||
|
||||
## 3. 배치 & 탭 등재
|
||||
|
||||
`/stock/trade` (거래 데스크)에 5번째 메인 탭 **"관심종목"** 추가. 기존 탭 등재 패턴을 그대로 확장한다.
|
||||
|
||||
- `src/pages/stock/stockUtils.js`: `export const TAB_WATCHLIST = 'watchlist';`
|
||||
- `src/pages/stock/StockTrade.jsx`:
|
||||
- `TAB_ORDER` 배열에 `TAB_WATCHLIST` 추가
|
||||
- `tabLabels` 에 `'관심종목'` 추가
|
||||
- 데스크탑 탭 버튼 배열에 `{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: <count> }` 추가
|
||||
- 모바일 `SwipeableView` content 분기에 `WatchlistTab` 추가
|
||||
- 데스크탑 조건부 렌더 `{activeTab === TAB_WATCHLIST && <WatchlistTab />}` 추가
|
||||
- 탭 뱃지 = 관심종목 개수(훅에서 노출).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 구조 (접근안 A: 훅 + 자립형 탭)
|
||||
|
||||
기존 `HoldingsIntelTab` 패턴(자립형 탭 컴포넌트 + api 헬퍼)에 상태 로직을 훅으로 분리한 형태.
|
||||
|
||||
```
|
||||
src/pages/stock/
|
||||
├── hooks/
|
||||
│ └── useWatchlist.js # CRUD + 알림 이력 상태·액션
|
||||
├── components/
|
||||
│ └── WatchlistTab.jsx # 표현 (내부 소형 컴포넌트: WatchlistForm/Row, AlertCard)
|
||||
├── watchlistUtils.js # 순수 헬퍼 (라벨/색/시간 매핑)
|
||||
└── watchlistUtils.test.js # 헬퍼 유닛 테스트
|
||||
```
|
||||
|
||||
### 4.1 `useWatchlist.js` (훅)
|
||||
|
||||
상태:
|
||||
- `items: []` — 관심종목 목록
|
||||
- `alerts: []` — 알림 이력
|
||||
- `alertDays: 7` — 알림 기간 필터(1/7/30)
|
||||
- `loading`, `error`, `adding` (폼 제출 중)
|
||||
|
||||
액션:
|
||||
- `load()` — `getWatchlist()` + `getTradeAlerts(alertDays)` 병렬 로드
|
||||
- `add({ ticker, name, note })` — 낙관적 추가 → 성공 시 `load()` 재조회, 실패 시 롤백 + 에러
|
||||
- `remove(ticker)` — 낙관적 제거 → 실패 시 롤백
|
||||
- `setAlertDays(days)` — 변경 시 알림만 재조회
|
||||
|
||||
노출: `{ items, alerts, alertDays, setAlertDays, loading, error, adding, add, remove, load }`
|
||||
|
||||
### 4.2 `WatchlistTab.jsx` (표현)
|
||||
|
||||
- 마운트 시 `load()`.
|
||||
- **상단 패널 — 관심종목 관리**: 인라인 추가 폼(ticker 필수, name·note 선택) + 목록. 각 행: 종목명/코드/메모/등록일 + 삭제 버튼. 빈 상태 안내.
|
||||
- **하단 패널 — 최근 시그널**: 기간 토글(1D/7D/30D) + 알림 카드. 카드: `kind` 뱃지, `condition` 한글 라벨, `ticker`/`name`, `price`, `detail`, `fired_at` 상대시간.
|
||||
- 로딩/에러/빈 상태: `stock-panel` · `stock-error` · `stock-empty` 등 기존 클래스 재사용.
|
||||
- 하단 면책 문구(`hi-disclaimer` 유사): "※ 어드바이저리 알림이며 자동매매가 아닙니다."
|
||||
|
||||
### 4.3 `watchlistUtils.js` (순수 헬퍼 — 테스트 대상)
|
||||
|
||||
```js
|
||||
KIND_META = { buy: { label: '매수', color, bg }, sell: { label: '매도', color, bg } }
|
||||
CONDITION_LABEL = { buy_ma20_pullback: 'MA20 눌림 반등', buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등', sell_stop_loss: '손절 라인', sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달', sell_climax: '과열 소진', sell_trailing_stop: '트레일링 스톱' }
|
||||
|
||||
kindMeta(kind) // 미정의 → 회색 폴백 + 원문 label
|
||||
conditionLabel(cond) // 미정의 → 원문 그대로 반환
|
||||
normalizeTicker(str) // trim만 수행(한국 종목코드=6자리 숫자, 대문자화 불필요)
|
||||
relativeTime(iso) // '3분 전' / '2시간 전' / '어제' 등, 잘못된 값 → '' 폴백
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 레이어 (`src/api.js` 추가)
|
||||
|
||||
```js
|
||||
// ── Stock Watchlist / Trade Alerts ──
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body); // { ticker, name?, note? }
|
||||
export const removeWatchlist= (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7)=> apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
```
|
||||
|
||||
전부 상대경로, 기존 `apiGet/apiPost/apiDelete` 재사용. `getWatchlist`/`getTradeAlerts` 응답은 훅에서 `data.watchlist ?? data ?? []`, `data.alerts ?? data ?? []` 로 방어적 파싱.
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / 상호작용 세부
|
||||
|
||||
- **추가 폼**: ticker 미입력 시 제출 비활성. 제출 중 `adding` → 버튼 로딩. 성공 시 폼 초기화.
|
||||
- **낙관적 갱신**: add/remove 즉시 UI 반영, 실패 시 이전 상태 롤백 + `stock-error` 메시지.
|
||||
- **중복 방지**: 이미 목록에 있는 ticker면 폼에서 안내(추가 차단).
|
||||
- **알림 카드 정렬**: `fired_at` 내림차순(최신 우선).
|
||||
- **빈 상태**: 관심종목 0개 / 알림 0개 각각 안내 문구.
|
||||
- **반응형**: 데스크탑 2열/모바일 1열은 기존 `stock-panel` 그리드 관례 따름.
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
`src/pages/stock/Stock.css` 하단에 `wl-*` 프리픽스 섹션 추가 (기존 `hi-*` 패턴과 동일 구성):
|
||||
- `.wl-form`, `.wl-list`, `.wl-row`, `.wl-row__meta`, `.wl-del`
|
||||
- `.wl-alerts`, `.wl-alert`, `.wl-kind-badge`, `.wl-cond`, `.wl-period-toggle`
|
||||
- 색상: 매수 초록 `#22c55e`, 매도 빨강 `#ef4444` (기존 `ACTION_MAP` 팔레트와 일치).
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 (TDD)
|
||||
|
||||
`watchlistUtils.test.js` — 순수 헬퍼 검증:
|
||||
1. `conditionLabel`: 정의된 8종 매핑 정확, 미정의 값은 원문 폴백.
|
||||
2. `kindMeta`: buy/sell 라벨·색, 미정의 kind 회색 폴백.
|
||||
3. `relativeTime`: 방금/분/시간/일 경계, 잘못된 입력 `''` 폴백.
|
||||
4. `normalizeTicker`: 공백 trim.
|
||||
|
||||
컴포넌트/훅은 수동 검증(개발 서버 3007 + BE 계약) + 빌드/lint 통과로 확인. (기존 스크리너 훅 테스트처럼 필요 시 훅 테스트 추가 가능하나 v1 필수 아님.)
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] 거래 데스크에 "관심종목" 탭 노출(데스크탑·모바일), 뱃지에 개수 표시.
|
||||
- [ ] 종목 추가/삭제가 BE 계약대로 동작(낙관적 갱신 + 실패 롤백).
|
||||
- [ ] 최근 알림 이력이 기간 토글별로 조회되고, kind/condition 한글 라벨·색으로 표시.
|
||||
- [ ] `watchlistUtils.test.js` 통과.
|
||||
- [ ] `npm run lint` · `npm run build` 통과.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 / 오픈 이슈
|
||||
|
||||
- **응답 래핑 형태 미확정**: BE가 `{ watchlist: [...] }` 인지 배열 직접인지 문구 기준 불확실 → 방어적 파싱으로 흡수.
|
||||
- **알림 엔드포인트 미배포 가능성**: BE 세션 미완 시 GET `/api/stock/trade-alerts` 404/네트워크 오류 → 알림 패널은 에러 상태를 조용히 표시하고 관심종목 CRUD는 독립 동작하도록 분리.
|
||||
- **params 편집**: v1 제외. 추후 BE POST/PUT params 계약 확정 후 별도 스펙으로 확장.
|
||||
157
docs/superpowers/specs/2026-07-09-realestate-listings-design.md
Normal file
157
docs/superpowers/specs/2026-07-09-realestate-listings-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 실매물 매물·안전마진 UI (`/realestate/listings`) — FE 설계
|
||||
|
||||
- **작성일**: 2026-07-09
|
||||
- **역할/저장소**: FE (`web-ui`)
|
||||
- **상위(BE)**: realestate-lab 매물 알림 + 안전마진 배포 완료(main a7be8f7), `web-backend` §9 realestate / `service_realestate.md`. co-gahusb BE→FE msg id 3.
|
||||
- **범위**: FE(web-ui)만. BE 계약(8 엔드포인트)을 소비하는 신규 전용 페이지. 매물 수집은 NAS 전담(web-ai 워커 불필요).
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
BE가 실매물(매매/전세) **매물 수집 + 안전마진(전세가율)·적정성(호가율) 판정 + 예산 계산** 기능을 배포했다. 이는 기존 `/realestate`(청약, Subscription)와 **다른 도메인**이므로 신규 전용 라우트 `/realestate/listings`에 구현한다.
|
||||
|
||||
목표(v1, 하위 4기능 전체):
|
||||
1. 매물 목록 조회 + 필터 + 판정 tier 뱃지 + 수동 수집 트리거.
|
||||
2. 조건(criteria) 편집 — 특히 `equity`/`annual_income`(만원) 입력 UI(예산상한·전세 max_deposit 매칭 반영).
|
||||
3. 안전마진 단건 체커(safety-check) — 즉시 전세가율/호가율 판정.
|
||||
4. 예산 계산기(budget) — 자기자본·소득 기반 전세/매매/지역 한도.
|
||||
|
||||
비목표(YAGNI): 매물 상세 모달·지도, 실시간 알림 UI(텔레그램은 BE, FE는 `notify_enabled` 토글만), `/announcements`·청약 관련.
|
||||
|
||||
---
|
||||
|
||||
## 2. 소비할 BE 계약 (8 엔드포인트, 전부 `/api/realestate/*` 상대경로)
|
||||
|
||||
| 메서드 | 경로 | 요청/응답 |
|
||||
|--------|------|-----------|
|
||||
| GET | `/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` | → `{ listings: [ <매물아이템> ] }` (+판정 join) |
|
||||
| POST | `/listings/collect` | 수동 수집 트리거 |
|
||||
| GET | `/listings/collect/status` | → `{ status, collected_at?, new_count?, total_count?, error? }` (예: `{status:"never_run"}`) |
|
||||
| GET | `/listings/criteria` | → 아래 criteria shape |
|
||||
| PUT | `/listings/criteria` | body=변경 필드만(부분 업데이트) → 갱신된 criteria |
|
||||
| GET | `/listings/matches` | → `{ matches: [ <매칭+판정> ] }` |
|
||||
| POST | `/safety-check` | body `{ area:float, deal_type, amount:만원, dong_code?, complex_name?, dong? }` → `{ median, ratio, tier, sample, is_toheo, disclaimer }` |
|
||||
| POST | `/budget` | body `{ equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? }` → `{ jeonse:{loan_limit,max_deposit,notes}, purchase:{ltv_pct,loan_cap,max_price,dsr_note,regulation_flags}, region:{is_toheo,is_regulated,notes}, disclaimer }` |
|
||||
|
||||
**criteria shape (프로덕션 확인):**
|
||||
```
|
||||
{ id, dongs:string[], deal_types:string[]("전세"|"반전세"|"매매"), max_deposit:int(만원),
|
||||
max_sale_price:int|null(만원), min_area:float, house_types:string[],
|
||||
min_safety_tier:string|null, notify_enabled:0|1, equity:int|null(만원),
|
||||
annual_income:int|null(만원), is_homeless:0|1, is_householder:0|1, is_first_home:0|1, updated_at }
|
||||
```
|
||||
|
||||
**판정 tier (표시 규칙):**
|
||||
- 임차(전세/반전세) `safety_tier`(전세가율): 안전🟢 / 주의🟡 / 위험🔴 / 보류⚪
|
||||
- 매매 `valuation_tier`(호가율): 저평가🟢 / 시세🟡 / 고가🔴 / 보류⚪
|
||||
- 표본(sample) < 3 → tier="보류".
|
||||
|
||||
**매물 아이템 shape (추론 — 수집 전이라 실샘플 없음, FE는 방어적 렌더):** BE "매물 목록(+판정 join)" 설명 기준 예상 필드 = `{ id, complex_name?, dong?, deal_type?, area?, price?/deposit?/monthly?(만원), safety_tier?, valuation_tier?, ratio?, median?, regulation_flags?:[], reasons?:[], matched?, url? }`. 존재하는 필드만 렌더하고 없는 필드는 생략. 수집 실행 후 실데이터로 필드명 확인·조정(구현 단계 verify).
|
||||
|
||||
> ⚠️ **`regulation_flags`/`reasons`는 이미 배열(list)로 파싱되어 옴 — `JSON.parse` 금지, 그대로 렌더.** (BE 명시. Subscription의 옛 `JSON.parse` 패턴 답습 금지.)
|
||||
|
||||
---
|
||||
|
||||
## 3. 배치 & 라우팅
|
||||
|
||||
- 신규 라우트 `realestate/listings` → `Listings` (routes.jsx `appRoutes`에 추가, `lazy` import). `realestate/property`(RealEstate) 서브라우트 패턴과 동일.
|
||||
- 교차 링크: Subscription(청약) 헤더에 "실매물 →"(`/realestate/listings`), Listings 헤더에 "청약으로"(`/realestate`). 홈 허브 `realestate` 카드 description은 유지(선택적으로 "매물" 언급).
|
||||
|
||||
---
|
||||
|
||||
## 4. 컴포넌트 구조 (전용 모듈 — 접근안 A)
|
||||
|
||||
기존 Subscription.jsx(1642줄)에 얹지 않고 독립 모듈. 각 파일 단일 책임.
|
||||
|
||||
```
|
||||
src/pages/listings/
|
||||
├── Listings.jsx # 페이지 shell + 내부 탭바(매물/판정도구/조건) + useIsMobile
|
||||
├── Listings.css # lst-* (Subscription의 --text-*/--surface/--line/--radius-* 토큰 재사용)
|
||||
├── components/
|
||||
│ ├── ListingsTab.jsx # 매물 목록 + 필터(동/거래유형/tier/매칭만) + 페이지네이션 + tier 뱃지 + 수집
|
||||
│ ├── ToolsTab.jsx # 안전마진 체커 카드 + 예산 계산기 카드 (입력→결과, disclaimer)
|
||||
│ └── CriteriaTab.jsx # 조건 편집 폼 (equity·annual_income 포함, 부분 PUT)
|
||||
├── listingsUtils.js # 순수헬퍼 (tier 매핑·금액 포맷)
|
||||
└── listingsUtils.test.js
|
||||
```
|
||||
|
||||
내부 탭 3개(기능 4종 수용): **매물 / 판정 도구(안전마진+예산) / 조건**.
|
||||
|
||||
### 4.1 `listingsUtils.js` (순수헬퍼 — 테스트 대상)
|
||||
```
|
||||
SAFETY_TIER_META = { 안전:{emoji:'🟢',color}, 주의:{emoji:'🟡',color}, 위험:{emoji:'🔴',color}, 보류:{emoji:'⚪',color} }
|
||||
VALUATION_TIER_META = { 저평가:{'🟢'}, 시세:{'🟡'}, 고가:{'🔴'}, 보류:{'⚪'} }
|
||||
tierMeta(kind, tier) // kind: 'safety'|'valuation'; 미정의 tier → 회색⚪ + 원문
|
||||
formatMoney(만원) // int(만원) → "X억 Y만"/"X억"/"X만" 정밀 표기(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'
|
||||
formatRatio(ratio) // 0.812 → "81.2%", null→'-'
|
||||
```
|
||||
|
||||
### 4.2 컴포넌트 데이터 규칙
|
||||
- 응답 방어적 파싱: `data.listings ?? []`, `data.matches ?? []`, `data.criteria ?? data`.
|
||||
- `regulation_flags`/`reasons`: 배열 그대로 `.map` (JSON.parse 금지).
|
||||
- 어떤 필드든 객체면 직접 JSX 렌더 금지(watchlist detail 교훈) — tier/금액/문자열만 렌더, 배열은 칩/리스트.
|
||||
|
||||
---
|
||||
|
||||
## 5. API 레이어 (`src/api.js` 추가)
|
||||
|
||||
```js
|
||||
// ── Realestate Listings / 안전마진 / 예산 ──
|
||||
export const getListings = ({dong,deal_type,tier,matched_only,page=1,size=20}={}) => { /* querystring, 빈값 제외 */ };
|
||||
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body); // 부분 업데이트
|
||||
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UX / 안전장치 (BE 강조)
|
||||
|
||||
- **disclaimer 항상 노출**: safety-check·budget 결과 카드 하단, matches/판정 영역에 disclaimer 문자열 표시. 등기부 선순위·토허·정책수치 근사 경고 → 잘못된 금융/안전 조언 방지. disclaimer가 응답에 있으면 반드시 렌더.
|
||||
- **보류⚪**: 표본<3 등 판정 불가 시 tier="보류" 그대로 표시(회색).
|
||||
- **금액 만원 단위**: 입력·표시 모두 만원. 큰 값은 억 환산 표시.
|
||||
- **is_toheo(토지거래허가)/regulation_flags**: budget/safety 결과에 플래그 칩으로 표시.
|
||||
- **조건 저장**: 부분 PUT(변경 필드만). equity·annual_income 미입력 시 null 유지(매칭에 미반영됨을 힌트로 안내).
|
||||
- 로딩/에러/빈 상태(수집 전 `never_run`이면 "수집을 실행해 매물을 모아보세요" 안내).
|
||||
|
||||
---
|
||||
|
||||
## 7. 스타일
|
||||
|
||||
`Listings.css`에 `lst-*` 프리픽스. Subscription의 CSS 변수(`--text-bright/--text-dim/--text-muted/--surface/--surface-raised/--line/--radius-sm`)·다크 테마 재사용. tier 뱃지 색은 판정 규칙 색(🟢#34d399/🟡#f59e0b/🔴#f87171/⚪#94a3b8).
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 (TDD)
|
||||
|
||||
`listingsUtils.test.js` — 순수헬퍼:
|
||||
1. `tierMeta('safety', ...)` 4종 + `tierMeta('valuation', ...)` 4종 이모지·색.
|
||||
2. 미정의 tier → 회색⚪ + 원문 폴백.
|
||||
3. `formatMoney`: 만/억 경계(9999→"9,999만", 10000→"1억", 15000→"1억 5,000만"), null→'-'.
|
||||
4. `formatRatio`: 0.812→"81.2%", null→'-'.
|
||||
|
||||
컴포넌트는 스모크(빈 상태 렌더 + 객체 필드 크래시 없음) + 빌드/lint 통과. safety-check/budget 폼 제출→결과 렌더는 mock 훅으로 스모크 가능(선택).
|
||||
|
||||
---
|
||||
|
||||
## 9. 완료 기준 (Acceptance)
|
||||
|
||||
- [ ] `/realestate/listings` 라우트 노출, Subscription↔Listings 교차 링크.
|
||||
- [ ] 매물 탭: 목록/필터/페이지네이션/tier 뱃지/수집 동작(빈 상태 안내 포함).
|
||||
- [ ] 판정 도구 탭: 안전마진·예산 입력→결과 + **disclaimer 노출**.
|
||||
- [ ] 조건 탭: criteria GET/부분 PUT, equity·annual_income 입력.
|
||||
- [ ] `regulation_flags`/`reasons` 배열 그대로 렌더(JSON.parse 없음).
|
||||
- [ ] `listingsUtils.test.js` 통과, `npm run lint`·`npm run build` 통과.
|
||||
|
||||
---
|
||||
|
||||
## 10. 리스크 / 오픈 이슈
|
||||
|
||||
- **매물 아이템 실스키마 미확정**: 수집(`collect`)이 아직 안 돌아 `/listings`가 빈 배열. FE는 방어적 렌더(존재 필드만)로 대응하고, 수집 실행 후 실데이터로 필드명 확인·조정(구현 verify 단계). 필요 시 BE에 아이템 필드 목록 확인 요청.
|
||||
- **safety-check dong_code**: 동 코드(예 "11590")/이름 매핑 필요 시 criteria.dongs 활용. 입력은 동 이름 select + 선택적 dong_code.
|
||||
- **matches vs listings(matched_only)**: 매칭 결과는 `matched_only=true` 필터 또는 `/matches` 중 택1 — v1은 매물 탭의 "매칭만" 토글로 통일, `/matches`는 보조.
|
||||
35
src/api.js
35
src/api.js
@@ -14,6 +14,11 @@ export async function apiGet(path) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드)
|
||||
export async function getNodeStatus() {
|
||||
return apiGet("/api/agent-office/nodes");
|
||||
}
|
||||
|
||||
export async function apiDelete(path) {
|
||||
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
@@ -847,3 +852,33 @@ export function compatPatchReading(id, body) {
|
||||
export function compatDeleteReading(id) {
|
||||
return apiDelete(`/api/saju/compat/readings/${id}`);
|
||||
}
|
||||
|
||||
// ── Stock Watchlist / Trade Alerts (관심종목·매매 시그널) ──
|
||||
// GET /api/stock/watchlist → { watchlist: [{ ticker, name, note, params, added_at }] }
|
||||
// POST /api/stock/watchlist body { ticker, name?, note? } → { ok: true }
|
||||
// DELETE /api/stock/watchlist/{ticker} → 200/404
|
||||
// GET /api/stock/trade-alerts?days=N → { alerts: [{ id, ticker, name, kind, condition, price, detail, fired_at }] }
|
||||
export const getWatchlist = () => apiGet('/api/stock/watchlist');
|
||||
export const addWatchlist = (body) => apiPost('/api/stock/watchlist', body);
|
||||
export const removeWatchlist = (ticker) => apiDelete(`/api/stock/watchlist/${encodeURIComponent(ticker)}`);
|
||||
export const getTradeAlerts = (days = 7) => apiGet(`/api/stock/trade-alerts?days=${days}`);
|
||||
|
||||
// ── Realestate Listings / 안전마진 / 예산 (실매물) ──
|
||||
// GET /api/realestate/listings → { listings: [...] } (+판정 join)
|
||||
export const getListings = ({ dong, deal_type, tier, matched_only, page = 1, size = 20 } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (dong) q.set('dong', dong);
|
||||
if (deal_type) q.set('deal_type', deal_type);
|
||||
if (tier) q.set('tier', tier);
|
||||
if (matched_only) q.set('matched_only', 'true');
|
||||
q.set('page', String(page));
|
||||
q.set('size', String(size));
|
||||
return apiGet(`/api/realestate/listings?${q.toString()}`);
|
||||
};
|
||||
export const collectListings = () => apiPost('/api/realestate/listings/collect');
|
||||
export const getListingsCollectStatus = () => apiGet('/api/realestate/listings/collect/status');
|
||||
export const getListingsCriteria = () => apiGet('/api/realestate/listings/criteria');
|
||||
export const putListingsCriteria = (body) => apiPut('/api/realestate/listings/criteria', body);
|
||||
export const getListingsMatches = () => apiGet('/api/realestate/listings/matches');
|
||||
export const safetyCheck = (body) => apiPost('/api/realestate/safety-check', body);
|
||||
export const budgetCalc = (body) => apiPost('/api/realestate/budget', body);
|
||||
|
||||
359
src/pages/infra/InfraMonitor.css
Normal file
359
src/pages/infra/InfraMonitor.css
Normal file
@@ -0,0 +1,359 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
InfraMonitor — NAS↔Windows 워커 파이프라인 관측 콘솔
|
||||
다크 미션컨트롤 / 텔레메트리 미학 (index.css 토큰 재사용)
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.infra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── 상태 바 ───────────────────────────────────────────────────────── */
|
||||
.infra-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-bar__stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.infra-chip {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-chip b {
|
||||
color: var(--text-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
.infra-chip.is-ok {
|
||||
color: #00d4ff;
|
||||
border-color: rgba(0, 212, 255, 0.35);
|
||||
box-shadow: 0 0 16px rgba(0, 212, 255, 0.12) inset;
|
||||
}
|
||||
.infra-chip.is-warn {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.infra-chip.is-danger {
|
||||
color: #fb923c;
|
||||
border-color: rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
.infra-chip.is-down {
|
||||
color: #f43f5e;
|
||||
border-color: rgba(244, 63, 94, 0.4);
|
||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.1) inset;
|
||||
}
|
||||
|
||||
.infra-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.infra-updated {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.infra-toggle button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-toggle button.is-active {
|
||||
background: var(--neon-cyan-muted);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.infra-refresh {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease-out);
|
||||
}
|
||||
.infra-refresh:hover {
|
||||
color: var(--neon-cyan);
|
||||
border-color: var(--line-bright);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* ── 에러 / 경고 / 로딩 ────────────────────────────────────────────── */
|
||||
.infra-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: rgba(244, 63, 94, 0.08);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
}
|
||||
.infra-error b {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-error span {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
.infra-error button {
|
||||
background: rgba(244, 63, 94, 0.18);
|
||||
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||
color: #ffd2da;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.infra-warn-banner {
|
||||
padding: 12px 18px;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.28);
|
||||
border-radius: var(--radius-md);
|
||||
color: #ffb3bf;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.infra-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── 3D 스테이지 ───────────────────────────────────────────────────── */
|
||||
.infra-stage {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 90% 60% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 80% 60% at 85% 100%, rgba(139, 92, 246, 0.07) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #060a16 0%, #04060f 100%);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.infra-stage::before {
|
||||
/* 미세 그리드 텍스처 */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(0, 212, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 100% 80% at 50% 50%, #000 40%, transparent 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pipeline-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 58vh;
|
||||
min-height: 440px;
|
||||
}
|
||||
.pipeline-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pipeline-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
background: rgba(6, 10, 22, 0.78);
|
||||
border: 1px solid color-mix(in srgb, var(--pl-color, #00d4ff) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.pipeline-label .pl-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--pl-color, #00d4ff);
|
||||
box-shadow: 0 0 8px var(--pl-color, #00d4ff);
|
||||
}
|
||||
.pipeline-label .pl-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.pipeline-label .pl-state {
|
||||
font-size: 10.5px;
|
||||
color: var(--pl-color, #8892b0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pipeline-label--anchor .pl-name {
|
||||
color: var(--pl-color, #e8f0fe);
|
||||
}
|
||||
|
||||
.infra-legend {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 12px;
|
||||
background: rgba(6, 10, 22, 0.6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.infra-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.infra-legend i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ── 워커 카드 그리드 ──────────────────────────────────────────────── */
|
||||
.infra-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.infra-grid--compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
}
|
||||
.infra-card {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--c, #4a5572);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.2s var(--ease-out), border-color 0.2s;
|
||||
}
|
||||
.infra-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--c) 40%, var(--line));
|
||||
}
|
||||
.infra-card--down {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.infra-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.infra-card__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--c);
|
||||
box-shadow: 0 0 10px var(--c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.infra-card__id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.infra-card__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
.infra-card__kind {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.infra-card__state {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--c);
|
||||
padding: 3px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.infra-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.infra-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 7px 4px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.infra-metric__v {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.infra-metric__l {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.infra-metric--warn .infra-metric__v {
|
||||
color: #fbbf24;
|
||||
}
|
||||
.infra-metric--danger .infra-metric__v {
|
||||
color: #f43f5e;
|
||||
}
|
||||
.infra-card__foot {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pipeline-canvas {
|
||||
height: 46vh;
|
||||
min-height: 340px;
|
||||
}
|
||||
.infra-bar {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
141
src/pages/infra/InfraMonitor.jsx
Normal file
141
src/pages/infra/InfraMonitor.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
// src/pages/infra/InfraMonitor.jsx
|
||||
// /infra — NAS↔Windows 분산 워커 파이프라인 실시간 관측.
|
||||
// 3D 파이프라인(Three.js) + 2D 워커 카드. WebGL 미지원 시 카드만.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import PipelineScene from './PipelineScene';
|
||||
import { workerStateLabel, workerColor, workerTitle, kindLabel } from './statusVisual';
|
||||
import './InfraMonitor.css';
|
||||
|
||||
function hasWebGL() {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl')));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }) {
|
||||
return (
|
||||
<div className={`infra-metric${tone ? ` infra-metric--${tone}` : ''}`}>
|
||||
<span className="infra-metric__v">{value ?? 0}</span>
|
||||
<span className="infra-metric__l">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerCard({ w }) {
|
||||
const color = workerColor(w);
|
||||
return (
|
||||
<div className={`infra-card${w.alive ? '' : ' infra-card--down'}`} style={{ '--c': color }}>
|
||||
<div className="infra-card__head">
|
||||
<span className="infra-card__dot" />
|
||||
<div className="infra-card__id">
|
||||
<div className="infra-card__title">{workerTitle(w.name)}</div>
|
||||
<div className="infra-card__kind">{kindLabel(w.kind)}</div>
|
||||
</div>
|
||||
<span className="infra-card__state">{workerStateLabel(w)}</span>
|
||||
</div>
|
||||
<div className="infra-card__metrics">
|
||||
<Metric label="큐" value={w.queue_depth} tone={w.queue_depth > 0 ? 'warn' : null} />
|
||||
<Metric label="실패" value={w.dead_letter} tone={w.dead_letter > 0 ? 'danger' : null} />
|
||||
<Metric label="처리중" value={w.processing} />
|
||||
<Metric label="완료" value={w.jobs_done} />
|
||||
</div>
|
||||
<div className="infra-card__foot">
|
||||
{w.alive
|
||||
? `last beat ${w.last_beat_age_s ?? '?'}s 전`
|
||||
: '비콘 없음 (오프라인)'}
|
||||
{w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InfraMonitor() {
|
||||
const { data, error, loading, updatedAt, refresh } = useNodeStatus(3000);
|
||||
const webgl = useMemo(() => hasWebGL(), []);
|
||||
const [view, setView] = useState(webgl ? '3d' : 'grid');
|
||||
|
||||
const workers = data?.workers || [];
|
||||
const online = workers.filter((w) => w.alive).length;
|
||||
const total = workers.length;
|
||||
const deadLetters = workers.reduce((a, w) => a + (w.dead_letter || 0), 0);
|
||||
const redisOk = data ? data.redis_ok : null;
|
||||
|
||||
return (
|
||||
<div className="infra">
|
||||
<div className="infra-bar">
|
||||
<div className="infra-bar__stats">
|
||||
<span className={`infra-chip ${online === total && total > 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}>
|
||||
<b>{online}</b>/{total || '–'} 온라인
|
||||
</span>
|
||||
<span className={`infra-chip ${redisOk === false ? 'is-down' : redisOk ? 'is-ok' : ''}`}>
|
||||
Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'}
|
||||
</span>
|
||||
{data?.paused && (
|
||||
<span className="infra-chip is-warn">
|
||||
⏸ 일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{deadLetters > 0 && <span className="infra-chip is-danger">❌ 실패 {deadLetters}</span>}
|
||||
</div>
|
||||
<div className="infra-bar__actions">
|
||||
{updatedAt && (
|
||||
<span className="infra-updated">
|
||||
{new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신
|
||||
</span>
|
||||
)}
|
||||
{webgl && (
|
||||
<div className="infra-toggle">
|
||||
<button className={view === '3d' ? 'is-active' : ''} onClick={() => setView('3d')}>
|
||||
3D
|
||||
</button>
|
||||
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>
|
||||
그리드
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="infra-refresh" onClick={refresh} title="새로고침">
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !data && (
|
||||
<div className="infra-error">
|
||||
<b>집계 서버 연결 끊김</b>
|
||||
<span>{String(error.message || error)}</span>
|
||||
<button onClick={refresh}>다시 시도</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redisOk === false && (
|
||||
<div className="infra-warn-banner">
|
||||
⚠ Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 수 없습니다. 파이프라인이 전면 중단 상태입니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !data && <div className="infra-loading">노드 상태 수집 중…</div>}
|
||||
|
||||
{view === '3d' && webgl && (
|
||||
<div className="infra-stage">
|
||||
<PipelineScene status={data} />
|
||||
<div className="infra-legend">
|
||||
<span><i style={{ background: '#00d4ff' }} /> 정상·흐름</span>
|
||||
<span><i style={{ background: '#fbbf24' }} /> 일시정지</span>
|
||||
<span><i style={{ background: '#fb923c' }} /> 실패누적</span>
|
||||
<span><i style={{ background: '#f43f5e' }} /> 다운·끊김</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`infra-grid${view === '3d' ? ' infra-grid--compact' : ''}`}>
|
||||
{workers.map((w) => (
|
||||
<WorkerCard key={w.name} w={w} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getNodeStatus } from '../../api';
|
||||
import InfraMonitor from './InfraMonitor';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
const sample = {
|
||||
redis_ok: true,
|
||||
paused: false,
|
||||
paused_reason: null,
|
||||
workers: [
|
||||
{ name: 'image-render', kind: 'render', alive: true, state: 'idle', queue_depth: 0, dead_letter: 0, processing: 0, jobs_done: 5, jobs_failed: 0, last_beat_age_s: 3 },
|
||||
{ name: 'insta-render', kind: 'render', alive: false, state: null, queue_depth: 3, dead_letter: 0, processing: 0, jobs_done: 0, jobs_failed: 0, last_beat_age_s: null },
|
||||
],
|
||||
links: [],
|
||||
};
|
||||
|
||||
describe('InfraMonitor', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders worker cards from /nodes (grid mode in jsdom — no WebGL)', async () => {
|
||||
getNodeStatus.mockResolvedValue(sample);
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('Image Render')).toBeInTheDocument());
|
||||
expect(screen.getByText('Insta Render')).toBeInTheDocument();
|
||||
// alive 워커(image-render, idle)는 '대기' 상태 라벨
|
||||
expect(screen.getByText('대기')).toBeInTheDocument();
|
||||
// 오프라인 워커(insta-render)는 '오프라인' 라벨
|
||||
expect(screen.getByText('오프라인')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when /nodes fails', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('down'));
|
||||
render(<InfraMonitor />);
|
||||
await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
340
src/pages/infra/PipelineScene.jsx
Normal file
340
src/pages/infra/PipelineScene.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// src/pages/infra/PipelineScene.jsx
|
||||
// NAS ↔ Redis 큐 버스 ↔ Windows 워커 6종을 raw three.js로 그린 실시간 파이프라인.
|
||||
// 정상: 시안 파티클이 흐름 / busy: 빠르게 / paused: 앰버 정지 / degraded: 주황 흐름 / down: 빨강·흐름 멈춤.
|
||||
// status(/nodes)는 statusRef로 RAF 루프에 최신값 주입. 라벨은 3D→화면 투영 HTML 오버레이.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { linkColor, workerStatus, workerStateLabel, workerTitle } from './statusVisual';
|
||||
|
||||
const NODES = [
|
||||
{ name: 'music-render', kind: 'render' },
|
||||
{ name: 'video-render', kind: 'render' },
|
||||
{ name: 'image-render', kind: 'render' },
|
||||
{ name: 'insta-render', kind: 'render' },
|
||||
{ name: 'task-watcher', kind: 'watcher' },
|
||||
{ name: 'ai_trade', kind: 'trader' },
|
||||
];
|
||||
|
||||
const hexToColor = (hex) => new THREE.Color(hex);
|
||||
|
||||
function workerByName(status, name) {
|
||||
if (!status || !Array.isArray(status.workers)) return null;
|
||||
return status.workers.find((w) => w.name === name) || null;
|
||||
}
|
||||
|
||||
// 링크의 현재 상태 문자열 → 'healthy'|'paused'|'degraded'|'down'|null
|
||||
function linkStatusOf(status, link) {
|
||||
if (!status) return null;
|
||||
if (link.kind === 'trunk') return status.redis_ok ? 'healthy' : 'down';
|
||||
const w = workerByName(status, link.worker);
|
||||
if (link.kind === 'branch' && !status.redis_ok) return 'down';
|
||||
if (!w) return 'down';
|
||||
return workerStatus(w);
|
||||
}
|
||||
|
||||
export default function PipelineScene({ status }) {
|
||||
const mountRef = useRef(null);
|
||||
const statusRef = useRef(status);
|
||||
statusRef.current = status;
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return undefined;
|
||||
|
||||
let width = mount.clientWidth || 900;
|
||||
let height = mount.clientHeight || 520;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(52, width / height, 0.1, 200);
|
||||
camera.position.set(0, 1.4, 20.5);
|
||||
camera.lookAt(0, -0.3, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.domElement.style.display = 'block';
|
||||
mount.appendChild(renderer.domElement);
|
||||
|
||||
// ── lights ──
|
||||
scene.add(new THREE.AmbientLight(0x5577aa, 0.65));
|
||||
const l1 = new THREE.PointLight(0x00d4ff, 1.3, 80);
|
||||
l1.position.set(-10, 7, 14);
|
||||
scene.add(l1);
|
||||
const l2 = new THREE.PointLight(0x8b5cf6, 1.1, 80);
|
||||
l2.position.set(10, -7, 12);
|
||||
scene.add(l2);
|
||||
|
||||
// ── positions ──
|
||||
const nasPos = new THREE.Vector3(-9, 0, 0);
|
||||
const redisPos = new THREE.Vector3(-1.5, 0, 0);
|
||||
const colX = 8;
|
||||
const ys = [6.25, 3.75, 1.25, -1.25, -3.75, -6.25];
|
||||
const nodePositions = NODES.map((n, i) => new THREE.Vector3(colX, ys[i], 0));
|
||||
|
||||
const disposables = [];
|
||||
const track = (obj) => {
|
||||
if (obj.geometry) disposables.push(obj.geometry);
|
||||
if (obj.material) disposables.push(obj.material);
|
||||
return obj;
|
||||
};
|
||||
|
||||
// ── NAS node (left monolith) ──
|
||||
const nasMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2.2, 3.2, 1.4),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x0a2a44,
|
||||
emissiveIntensity: 0.9,
|
||||
metalness: 0.5,
|
||||
roughness: 0.35,
|
||||
})
|
||||
)
|
||||
);
|
||||
nasMesh.position.copy(nasPos);
|
||||
scene.add(nasMesh);
|
||||
|
||||
// ── Redis bus (vertical glowing spine) ──
|
||||
const busMesh = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.55, 0.55, 13.2, 24, 1, true),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
)
|
||||
);
|
||||
busMesh.position.copy(redisPos);
|
||||
scene.add(busMesh);
|
||||
const busCore = track(
|
||||
new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.18, 0.18, 13.2, 16),
|
||||
new THREE.MeshBasicMaterial({ color: 0xe8f0fe, transparent: true, opacity: 0.9 })
|
||||
)
|
||||
);
|
||||
busCore.position.copy(redisPos);
|
||||
scene.add(busCore);
|
||||
|
||||
// ── worker nodes ──
|
||||
const nodeMeshes = NODES.map((n, i) => {
|
||||
const geo =
|
||||
n.kind === 'trader'
|
||||
? new THREE.IcosahedronGeometry(0.95, 0)
|
||||
: n.kind === 'watcher'
|
||||
? new THREE.OctahedronGeometry(1.0, 0)
|
||||
: new THREE.BoxGeometry(1.7, 1.4, 1.4);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x0d1530,
|
||||
emissive: 0x111a3a,
|
||||
emissiveIntensity: 1.0,
|
||||
metalness: 0.45,
|
||||
roughness: 0.4,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.copy(nodePositions[i]);
|
||||
scene.add(mesh);
|
||||
disposables.push(geo, mat);
|
||||
return mesh;
|
||||
});
|
||||
|
||||
// ── links (curves) ──
|
||||
const particleGeo = new THREE.SphereGeometry(0.13, 8, 8);
|
||||
disposables.push(particleGeo);
|
||||
const PARTICLES_PER_LINK = 6;
|
||||
|
||||
function makeLink(curve, kind, worker) {
|
||||
const pts = curve.getPoints(60);
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x2a3a66,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
});
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
scene.add(line);
|
||||
disposables.push(lineGeo, lineMat);
|
||||
|
||||
const pMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
disposables.push(pMat);
|
||||
const particles = [];
|
||||
for (let k = 0; k < PARTICLES_PER_LINK; k += 1) {
|
||||
const pm = new THREE.Mesh(particleGeo, pMat);
|
||||
scene.add(pm);
|
||||
particles.push({ mesh: pm, t: k / PARTICLES_PER_LINK });
|
||||
}
|
||||
return { curve, kind, worker, line, lineMat, pMat, particles };
|
||||
}
|
||||
|
||||
const links = [];
|
||||
// trunk: NAS → Redis
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nasPos.clone().add(new THREE.Vector3(1.2, 0, 0)),
|
||||
new THREE.Vector3((nasPos.x + redisPos.x) / 2, 0.6, 1.2),
|
||||
redisPos.clone()
|
||||
),
|
||||
'trunk'
|
||||
)
|
||||
);
|
||||
// branches: Redis → render/watcher (indices 0..4)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const start = new THREE.Vector3(redisPos.x, ys[i] * 0.45, 0);
|
||||
const end = nodePositions[i].clone().add(new THREE.Vector3(-1.0, 0, 0));
|
||||
const ctrl = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, 1.6);
|
||||
links.push(makeLink(new THREE.QuadraticBezierCurve3(start, ctrl, end), 'branch', NODES[i].name));
|
||||
}
|
||||
// ai_trade: node → NAS directly (http-pull, bypasses Redis bus)
|
||||
links.push(
|
||||
makeLink(
|
||||
new THREE.QuadraticBezierCurve3(
|
||||
nodePositions[5].clone().add(new THREE.Vector3(-0.9, -0.2, 0)),
|
||||
new THREE.Vector3(0, -9.5, 4.5),
|
||||
nasPos.clone().add(new THREE.Vector3(0.4, -1.4, 0))
|
||||
),
|
||||
'pull',
|
||||
'ai_trade'
|
||||
)
|
||||
);
|
||||
|
||||
// ── HTML label overlay ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'pipeline-labels';
|
||||
mount.appendChild(overlay);
|
||||
const makeLabel = (title, sub) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pipeline-label';
|
||||
el.innerHTML = `<span class="pl-dot"></span><span class="pl-name">${title}</span><span class="pl-state">${sub}</span>`;
|
||||
overlay.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
const nasLabel = makeLabel('NAS', '게이트웨이');
|
||||
nasLabel.classList.add('pipeline-label--anchor');
|
||||
const busLabel = makeLabel('Redis Bus', '큐');
|
||||
busLabel.classList.add('pipeline-label--anchor');
|
||||
const nodeLabels = NODES.map((n) => makeLabel(workerTitle(n.name), '—'));
|
||||
|
||||
const projectTo = (pos, el, dx = 0, dy = 0) => {
|
||||
const v = pos.clone().project(camera);
|
||||
const x = (v.x * 0.5 + 0.5) * width + dx;
|
||||
const y = (-v.y * 0.5 + 0.5) * height + dy;
|
||||
el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px)`;
|
||||
el.style.opacity = v.z < 1 ? '1' : '0';
|
||||
};
|
||||
|
||||
// ── animation ──
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const clock = { t: 0 };
|
||||
|
||||
const speedFor = (st) => {
|
||||
if (st === 'down' || st === 'paused' || st == null) return 0;
|
||||
return 0.16; // healthy/degraded base
|
||||
};
|
||||
|
||||
function frame(now) {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
clock.t += dt;
|
||||
const status = statusRef.current;
|
||||
|
||||
// Redis bus color/pulse
|
||||
const redisOk = !status || status.redis_ok;
|
||||
const busColor = redisOk ? 0x00d4ff : 0xf43f5e;
|
||||
const pulse = 0.7 + Math.sin(clock.t * 2.2) * 0.18;
|
||||
busMesh.material.color.setHex(busColor);
|
||||
busMesh.material.opacity = 0.45 + pulse * 0.3;
|
||||
busCore.material.opacity = redisOk ? 0.55 + pulse * 0.35 : 0.5;
|
||||
|
||||
// per-link
|
||||
links.forEach((lk) => {
|
||||
const st = linkStatusOf(status, lk);
|
||||
const col = hexToColor(st ? linkColor(st) : '#2a3a66');
|
||||
lk.lineMat.color.copy(col);
|
||||
lk.lineMat.opacity = st === 'down' ? 0.5 : 0.55;
|
||||
lk.pMat.color.copy(col);
|
||||
|
||||
let speed = speedFor(st);
|
||||
// busy 워커는 빠르게
|
||||
if (lk.worker && status) {
|
||||
const w = workerByName(status, lk.worker);
|
||||
if (w && w.state === 'busy') speed = 0.42;
|
||||
}
|
||||
const showParticles = st !== 'down';
|
||||
lk.pMat.opacity = showParticles ? 0.95 : 0.0;
|
||||
lk.particles.forEach((p) => {
|
||||
p.t = (p.t + speed * dt) % 1;
|
||||
const pos = lk.curve.getPoint(p.t);
|
||||
p.mesh.position.copy(pos);
|
||||
p.mesh.visible = showParticles;
|
||||
const s = st === 'paused' ? 0.8 : 1 + Math.sin((p.t + clock.t) * 6) * 0.25;
|
||||
p.mesh.scale.setScalar(s);
|
||||
});
|
||||
});
|
||||
|
||||
// worker node color/pulse + labels
|
||||
NODES.forEach((n, i) => {
|
||||
const w = workerByName(status, n.name);
|
||||
const stt = workerStatus(w);
|
||||
const c = hexToColor(linkColor(stt));
|
||||
const mesh = nodeMeshes[i];
|
||||
mesh.material.emissive.copy(c);
|
||||
const alive = w && w.alive;
|
||||
const beat = alive ? 1.05 + Math.sin(clock.t * 3 + i) * 0.06 : 0.92;
|
||||
mesh.material.emissiveIntensity = alive ? 0.9 + Math.sin(clock.t * 3 + i) * 0.25 : 0.35;
|
||||
mesh.scale.setScalar(beat);
|
||||
mesh.rotation.y += dt * (n.kind === 'render' ? 0.15 : 0.4);
|
||||
|
||||
// label
|
||||
const el = nodeLabels[i];
|
||||
el.style.setProperty('--pl-color', linkColor(stt));
|
||||
const sub = el.querySelector('.pl-state');
|
||||
if (sub) sub.textContent = workerStateLabel(w);
|
||||
projectTo(nodePositions[i].clone().add(new THREE.Vector3(0, 1.5, 0)), el);
|
||||
});
|
||||
|
||||
// NAS / bus labels
|
||||
nasLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
projectTo(nasPos.clone().add(new THREE.Vector3(0, 2.2, 0)), nasLabel);
|
||||
busLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e');
|
||||
const busSub = busLabel.querySelector('.pl-state');
|
||||
if (busSub) busSub.textContent = redisOk ? '정상' : '연결 끊김';
|
||||
projectTo(redisPos.clone().add(new THREE.Vector3(0, 7.3, 0)), busLabel);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
|
||||
// ── resize ──
|
||||
const onResize = () => {
|
||||
width = mount.clientWidth || width;
|
||||
height = mount.clientHeight || height;
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(mount);
|
||||
|
||||
// ── cleanup ──
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
disposables.forEach((d) => d.dispose && d.dispose());
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} className="pipeline-canvas" />;
|
||||
}
|
||||
69
src/pages/infra/statusVisual.js
Normal file
69
src/pages/infra/statusVisual.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/pages/infra/statusVisual.js
|
||||
// 상태 → 색/라벨 매핑. 2D 패널과 Three.js 파이프라인이 공유하는 단일 진실원천.
|
||||
// 색은 index.css 테마 팔레트와 일치(neon-cyan healthy, amber paused, orange degraded, red down).
|
||||
|
||||
export const LINK_COLORS = {
|
||||
healthy: '#00d4ff', // neon-cyan — 통신이 흐름
|
||||
paused: '#fbbf24', // amber — 작업중(트레이딩) 일시정지
|
||||
degraded: '#fb923c', // orange — dead-letter 누적
|
||||
down: '#f43f5e', // red — 워커 다운/링크 끊김
|
||||
};
|
||||
|
||||
const NEUTRAL = '#4a5572';
|
||||
|
||||
export function linkColor(status) {
|
||||
return LINK_COLORS[status] || NEUTRAL;
|
||||
}
|
||||
|
||||
// 워커 객체 → 사람이 읽는 상태 라벨
|
||||
export function workerStateLabel(w) {
|
||||
if (!w || !w.alive) return '오프라인';
|
||||
switch (w.state) {
|
||||
case 'paused':
|
||||
return '일시정지';
|
||||
case 'busy':
|
||||
return '처리 중';
|
||||
case 'idle':
|
||||
return '대기';
|
||||
case 'market_open':
|
||||
return '장중';
|
||||
case 'market_closed':
|
||||
return '휴장';
|
||||
default:
|
||||
return '온라인';
|
||||
}
|
||||
}
|
||||
|
||||
// 워커 객체 → 링크 status 도출(2D/3D 공통). collect_status의 link 산정과 동일 규칙.
|
||||
export function workerStatus(w) {
|
||||
if (!w || !w.alive) return 'down';
|
||||
if (w.state === 'paused') return 'paused';
|
||||
if ((w.dead_letter || 0) > 0) return 'degraded';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function workerColor(w) {
|
||||
return linkColor(workerStatus(w));
|
||||
}
|
||||
|
||||
// 워커 내부명 → 표시 타이틀
|
||||
export const WORKER_TITLES = {
|
||||
'music-render': 'Music Render',
|
||||
'video-render': 'Video Render',
|
||||
'image-render': 'Image Render',
|
||||
'insta-render': 'Insta Render',
|
||||
'task-watcher': 'Task Watcher',
|
||||
ai_trade: 'AI Trade',
|
||||
};
|
||||
|
||||
export function workerTitle(name) {
|
||||
return WORKER_TITLES[name] || name;
|
||||
}
|
||||
|
||||
// kind → 한 줄 역할
|
||||
export function kindLabel(kind) {
|
||||
if (kind === 'render') return '렌더 워커';
|
||||
if (kind === 'watcher') return '작업 감시';
|
||||
if (kind === 'trader') return '트레이딩';
|
||||
return kind || '';
|
||||
}
|
||||
42
src/pages/infra/statusVisual.test.js
Normal file
42
src/pages/infra/statusVisual.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle } from './statusVisual';
|
||||
|
||||
describe('statusVisual', () => {
|
||||
it('maps link status to theme colors', () => {
|
||||
expect(linkColor('healthy')).toBe('#00d4ff');
|
||||
expect(linkColor('paused')).toBe('#fbbf24');
|
||||
expect(linkColor('degraded')).toBe('#fb923c');
|
||||
expect(linkColor('down')).toBe('#f43f5e');
|
||||
expect(linkColor('???')).toBe('#4a5572');
|
||||
});
|
||||
|
||||
it('labels a dead worker offline', () => {
|
||||
expect(workerStateLabel({ alive: false })).toBe('오프라인');
|
||||
expect(workerStateLabel(null)).toBe('오프라인');
|
||||
});
|
||||
|
||||
it('labels alive workers by state', () => {
|
||||
expect(workerStateLabel({ alive: true, state: 'idle' })).toBe('대기');
|
||||
expect(workerStateLabel({ alive: true, state: 'busy' })).toBe('처리 중');
|
||||
expect(workerStateLabel({ alive: true, state: 'paused' })).toBe('일시정지');
|
||||
expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중');
|
||||
});
|
||||
|
||||
it('derives worker status with dead-letter and paused precedence', () => {
|
||||
expect(workerStatus({ alive: false })).toBe('down');
|
||||
expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 3 })).toBe('degraded');
|
||||
expect(workerStatus({ alive: true, state: 'idle', dead_letter: 0 })).toBe('healthy');
|
||||
});
|
||||
|
||||
it('workerColor follows workerStatus', () => {
|
||||
expect(workerColor({ alive: false })).toBe('#f43f5e');
|
||||
expect(workerColor({ alive: true, state: 'idle' })).toBe('#00d4ff');
|
||||
});
|
||||
|
||||
it('humanizes worker names', () => {
|
||||
expect(workerTitle('insta-render')).toBe('Insta Render');
|
||||
expect(workerTitle('ai_trade')).toBe('AI Trade');
|
||||
expect(workerTitle('unknown-x')).toBe('unknown-x');
|
||||
});
|
||||
});
|
||||
39
src/pages/infra/useNodeStatus.js
Normal file
39
src/pages/infra/useNodeStatus.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/pages/infra/useNodeStatus.js
|
||||
// /api/agent-office/nodes 를 주기 폴링하는 훅. 3초 권장(Three.js 흐름과 동기).
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
export function useNodeStatus(intervalMs = 4000) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatedAt, setUpdatedAt] = useState(null);
|
||||
const aliveRef = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
try {
|
||||
const d = await getNodeStatus();
|
||||
if (!aliveRef.current) return;
|
||||
setData(d);
|
||||
setError(null);
|
||||
setUpdatedAt(Date.now());
|
||||
} catch (e) {
|
||||
if (!aliveRef.current) return;
|
||||
setError(e);
|
||||
} finally {
|
||||
if (aliveRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
tick();
|
||||
const id = setInterval(tick, intervalMs);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [tick, intervalMs]);
|
||||
|
||||
return { data, error, loading, updatedAt, refresh: tick };
|
||||
}
|
||||
26
src/pages/infra/useNodeStatus.test.js
Normal file
26
src/pages/infra/useNodeStatus.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useNodeStatus } from './useNodeStatus';
|
||||
import { getNodeStatus } from '../../api';
|
||||
|
||||
vi.mock('../../api', () => ({ getNodeStatus: vi.fn() }));
|
||||
|
||||
describe('useNodeStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetches node status on mount', async () => {
|
||||
getNodeStatus.mockResolvedValue({ redis_ok: true, workers: [], links: [] });
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.data).toBeTruthy());
|
||||
expect(result.current.data.redis_ok).toBe(true);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('captures fetch error', async () => {
|
||||
getNodeStatus.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => useNodeStatus(100000));
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.error.message).toBe('boom');
|
||||
});
|
||||
});
|
||||
65
src/pages/listings/Listings.css
Normal file
65
src/pages/listings/Listings.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* 실매물 매물·안전마진 (lst-*) — Subscription 다크 토큰 재사용 */
|
||||
.lst { display: grid; gap: 18px; padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.lst-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.lst-kicker { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #f43f5e; }
|
||||
.lst-header h1 { margin: 0; font-size: 24px; color: var(--text-bright); }
|
||||
.lst-sub { margin: 6px 0 0; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.lst-tabbar { display: flex; gap: 6px; border-bottom: 1px solid var(--line); }
|
||||
.lst-tabbtn { border: none; background: none; color: var(--text-dim); padding: 10px 16px; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
|
||||
.lst-tabbtn.is-active { color: var(--text-bright); border-bottom-color: #f43f5e; }
|
||||
|
||||
.lst-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.lst-toolbar__right { display: flex; gap: 6px; }
|
||||
.lst-filter { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.lst-filter-btn { border: 1px solid var(--line); background: var(--surface); color: var(--text-dim); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; text-decoration: none; }
|
||||
.lst-filter-btn.is-active { color: var(--text-bright); border-color: #f43f5e; }
|
||||
.lst-filter-btn.is-primary { color: #fff; background: #f43f5e; border-color: #f43f5e; }
|
||||
.lst-filter-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.lst-status { font-size: 12px; color: var(--text-muted); margin: 0; }
|
||||
.lst-error { color: #f9b6b1; border: 1px solid rgba(249,182,177,0.4); border-radius: 12px; padding: 10px; background: rgba(249,182,177,0.1); margin: 0; font-size: 13px; }
|
||||
.lst-ok { color: #34d399; font-size: 12px; }
|
||||
.lst-empty { color: var(--text-muted); text-align: center; padding: 32px 0; }
|
||||
|
||||
.lst-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.lst-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; display: grid; gap: 6px; }
|
||||
.lst-card__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.lst-tier { font-size: 12px; font-weight: 700; }
|
||||
.lst-card__name { font-size: 14px; color: var(--text-bright); }
|
||||
.lst-card__deal { font-size: 11px; color: #60a5fa; }
|
||||
.lst-card__dong { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-card__body { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
|
||||
.lst-card__price { color: #f59e0b; font-weight: 600; }
|
||||
.lst-card__reasons { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-flags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.lst-flag { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(244,63,94,0.12); color: #f87171; }
|
||||
|
||||
.lst-tools { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
.lst-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; display: grid; gap: 10px; }
|
||||
.lst-panel__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||
.lst-panel__actions { display: flex; gap: 8px; align-items: center; }
|
||||
.lst-panel__title { margin: 0; font-size: 15px; color: var(--text-bright); }
|
||||
.lst-panel__sub { margin: 0; font-size: 12px; color: var(--text-muted); }
|
||||
.lst-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.lst-input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: inherit; font-size: 13px; min-width: 100px; flex: 1 1 100px; }
|
||||
.lst-check { font-size: 12px; color: var(--text-dim); display: inline-flex; gap: 4px; align-items: center; }
|
||||
.lst-chks { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.lst-result { display: grid; gap: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
|
||||
.lst-result__row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; align-items: center; }
|
||||
.lst-result__block { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; }
|
||||
.lst-result__block strong { color: var(--text-bright); }
|
||||
.lst-note { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-disclaimer { font-size: 11px; color: #cbd5e1; margin: 0; line-height: 1.45; }
|
||||
|
||||
.lst-criteria { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.lst-field { display: grid; gap: 4px; font-size: 12px; color: var(--text-dim); }
|
||||
.lst-field > span { font-size: 11px; color: var(--text-muted); }
|
||||
.lst-field--chks { grid-column: 1 / -1; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.lst-foot-disclaimer { font-size: 11px; color: #64748b; text-align: center; margin: 8px 0 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lst { padding: 12px; }
|
||||
.lst-tools { grid-template-columns: 1fr; }
|
||||
}
|
||||
46
src/pages/listings/Listings.jsx
Normal file
46
src/pages/listings/Listings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ListingsTab from './components/ListingsTab';
|
||||
import ToolsTab from './components/ToolsTab';
|
||||
import CriteriaTab from './components/CriteriaTab';
|
||||
import './Listings.css';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'listings', label: '매물' },
|
||||
{ id: 'tools', label: '판정 도구' },
|
||||
{ id: 'criteria', label: '조건' },
|
||||
];
|
||||
|
||||
const Listings = () => {
|
||||
const [tab, setTab] = useState('listings');
|
||||
return (
|
||||
<div className="lst">
|
||||
<header className="lst-header">
|
||||
<div>
|
||||
<p className="lst-kicker">실매물 · 안전마진</p>
|
||||
<h1>매물 · 안전마진</h1>
|
||||
<p className="lst-sub">매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.</p>
|
||||
</div>
|
||||
<Link className="lst-filter-btn" to="/realestate">청약으로 →</Link>
|
||||
</header>
|
||||
|
||||
<div className="lst-tabbar">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} type="button"
|
||||
className={`lst-tabbtn ${tab === t.id ? 'is-active' : ''}`}
|
||||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'listings' && <ListingsTab />}
|
||||
{tab === 'tools' && <ToolsTab />}
|
||||
{tab === 'criteria' && <CriteriaTab />}
|
||||
|
||||
<p className="lst-foot-disclaimer">
|
||||
※ 모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Listings;
|
||||
19
src/pages/listings/Listings.test.jsx
Normal file
19
src/pages/listings/Listings.test.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('./components/ListingsTab.jsx', () => ({ default: () => <div>LISTINGS_TAB</div> }));
|
||||
vi.mock('./components/ToolsTab.jsx', () => ({ default: () => <div>TOOLS_TAB</div> }));
|
||||
vi.mock('./components/CriteriaTab.jsx', () => ({ default: () => <div>CRITERIA_TAB</div> }));
|
||||
vi.mock('react-router-dom', () => ({ Link: ({ children }) => <a>{children}</a> }));
|
||||
import Listings from './Listings.jsx';
|
||||
|
||||
describe('Listings', () => {
|
||||
it('기본 매물 탭 렌더 + 탭 전환', () => {
|
||||
render(<Listings />);
|
||||
expect(screen.getByText('LISTINGS_TAB')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '판정 도구' }));
|
||||
expect(screen.getByText('TOOLS_TAB')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '조건' }));
|
||||
expect(screen.getByText('CRITERIA_TAB')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
148
src/pages/listings/components/CriteriaTab.jsx
Normal file
148
src/pages/listings/components/CriteriaTab.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getListingsCriteria, putListingsCriteria } from '../../../api';
|
||||
|
||||
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||
const HOUSE_TYPES = ['아파트', '오피스텔', '빌라'];
|
||||
const SAFETY_TIERS = ['', '안전', '주의', '위험'];
|
||||
|
||||
const toList = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const numOrNull = (v) => (v === '' || v == null ? null : Number(v));
|
||||
|
||||
const CriteriaTab = () => {
|
||||
const [c, setC] = useState(null); // 편집 중 폼 상태 (dongs/house_types는 문자열/배열)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getListingsCriteria();
|
||||
setC({ ...data, dongs: (data.dongs ?? []).join(', ') });
|
||||
} catch (e) {
|
||||
setMsg('불러오기 실패: ' + (e?.message ?? e));
|
||||
} finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const set = (k, v) => setC((prev) => ({ ...prev, [k]: v }));
|
||||
const toggleArr = (k, val) => setC((prev) => {
|
||||
const arr = Array.isArray(prev[k]) ? prev[k] : [];
|
||||
return { ...prev, [k]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] };
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
const payload = {
|
||||
dongs: toList(c.dongs),
|
||||
deal_types: c.deal_types ?? [],
|
||||
house_types: c.house_types ?? [],
|
||||
max_deposit: numOrNull(c.max_deposit),
|
||||
max_sale_price: numOrNull(c.max_sale_price),
|
||||
min_area: numOrNull(c.min_area),
|
||||
equity: numOrNull(c.equity),
|
||||
annual_income: numOrNull(c.annual_income),
|
||||
min_safety_tier: c.min_safety_tier || null,
|
||||
notify_enabled: c.notify_enabled ? 1 : 0,
|
||||
is_homeless: c.is_homeless ? 1 : 0,
|
||||
is_householder: c.is_householder ? 1 : 0,
|
||||
is_first_home: c.is_first_home ? 1 : 0,
|
||||
};
|
||||
const updated = await putListingsCriteria(payload);
|
||||
setC({ ...updated, dongs: (updated.dongs ?? []).join(', ') });
|
||||
setMsg('저장 완료');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e) {
|
||||
setMsg('저장 실패: ' + (e?.message ?? e));
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <p className="lst-empty">불러오는 중…</p>;
|
||||
if (!c) return <p className="lst-error">{msg || '조건을 불러오지 못했습니다.'}</p>;
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<div className="lst-panel__head">
|
||||
<div>
|
||||
<h3 className="lst-panel__title">매물 매칭 조건</h3>
|
||||
<p className="lst-panel__sub">자기자본·연소득을 넣어야 매매 예산상한·전세 max_deposit이 매칭에 반영됩니다.</p>
|
||||
</div>
|
||||
<div className="lst-panel__actions">
|
||||
{msg && <span className={msg.startsWith('저장 완료') ? 'lst-ok' : 'lst-error'}>{msg}</span>}
|
||||
<button className="lst-filter-btn is-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lst-criteria">
|
||||
<label className="lst-field">
|
||||
<span>관심 동 (쉼표 구분)</span>
|
||||
<input className="lst-input" value={c.dongs} onChange={(e) => set('dongs', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="lst-field">
|
||||
<span>거래 유형</span>
|
||||
<div className="lst-chks">
|
||||
{DEAL_TYPES.map((d) => (
|
||||
<label key={d} className="lst-check"><input type="checkbox"
|
||||
checked={(c.deal_types ?? []).includes(d)} onChange={() => toggleArr('deal_types', d)} /> {d}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lst-field">
|
||||
<span>주택 유형</span>
|
||||
<div className="lst-chks">
|
||||
{HOUSE_TYPES.map((h) => (
|
||||
<label key={h} className="lst-check"><input type="checkbox"
|
||||
checked={(c.house_types ?? []).includes(h)} onChange={() => toggleArr('house_types', h)} /> {h}</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="lst-field">
|
||||
<span>전세 최대 보증금 (만원)</span>
|
||||
<input className="lst-input" value={c.max_deposit ?? ''} onChange={(e) => set('max_deposit', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>매매 예산 상한 (만원)</span>
|
||||
<input className="lst-input" value={c.max_sale_price ?? ''} onChange={(e) => set('max_sale_price', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>최소 면적 (m²)</span>
|
||||
<input className="lst-input" value={c.min_area ?? ''} onChange={(e) => set('min_area', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>자기자본 (만원)</span>
|
||||
<input className="lst-input" value={c.equity ?? ''} onChange={(e) => set('equity', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>연소득 (만원)</span>
|
||||
<input className="lst-input" value={c.annual_income ?? ''} onChange={(e) => set('annual_income', e.target.value)} />
|
||||
</label>
|
||||
<label className="lst-field">
|
||||
<span>최소 안전등급</span>
|
||||
<select className="lst-input" value={c.min_safety_tier ?? ''} onChange={(e) => set('min_safety_tier', e.target.value)}>
|
||||
{SAFETY_TIERS.map((t) => <option key={t} value={t}>{t || '제한 없음'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="lst-field lst-field--chks">
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_homeless}
|
||||
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_householder}
|
||||
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.is_first_home}
|
||||
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={!!c.notify_enabled}
|
||||
onChange={(e) => set('notify_enabled', e.target.checked)} /> 텔레그램 알림</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriteriaTab;
|
||||
31
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
31
src/pages/listings/components/CriteriaTab.test.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({ getListingsCriteria: vi.fn(), putListingsCriteria: vi.fn() }));
|
||||
import { getListingsCriteria } from '../../../api';
|
||||
import CriteriaTab from './CriteriaTab.jsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getListingsCriteria.mockResolvedValue({
|
||||
id: 1, dongs: ['신대방동', '상도동'], deal_types: ['전세', '매매'], max_deposit: 32000,
|
||||
max_sale_price: null, min_area: 40, house_types: ['아파트'], min_safety_tier: null,
|
||||
notify_enabled: 1, equity: null, annual_income: null, is_homeless: 1, is_householder: 0, is_first_home: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('CriteriaTab', () => {
|
||||
it('criteria를 로드해 자기자본·연소득 입력과 동 목록을 렌더', async () => {
|
||||
render(<CriteriaTab />);
|
||||
await waitFor(() => expect(screen.getByLabelText(/자기자본/)).toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/연소득/)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('신대방동, 상도동')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('로드 실패 시 무한 로딩 대신 에러를 표시', async () => {
|
||||
getListingsCriteria.mockRejectedValue(new Error('HTTP 500'));
|
||||
render(<CriteriaTab />);
|
||||
await waitFor(() => expect(screen.getByText(/불러오지 못했|HTTP 500/)).toBeInTheDocument());
|
||||
expect(screen.queryByText('불러오는 중…')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
138
src/pages/listings/components/ListingsTab.jsx
Normal file
138
src/pages/listings/components/ListingsTab.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getListings, collectListings, getListingsCollectStatus } from '../../../api';
|
||||
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||
|
||||
const DEAL_TYPES = ['전체', '전세', '반전세', '매매'];
|
||||
|
||||
const ListingCard = ({ l }) => {
|
||||
const isSale = l.deal_type === '매매';
|
||||
const tv = isSale ? l.valuation_tier : l.safety_tier;
|
||||
const meta = tierMeta(isSale ? 'valuation' : 'safety', tv);
|
||||
const price = l.price ?? l.deposit ?? l.amount;
|
||||
const flags = Array.isArray(l.regulation_flags) ? l.regulation_flags : [];
|
||||
const reasons = Array.isArray(l.reasons) ? l.reasons : [];
|
||||
return (
|
||||
<div className="lst-card">
|
||||
<div className="lst-card__head">
|
||||
{tv && <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>}
|
||||
<strong className="lst-card__name">{l.complex_name || l.name || l.dong || '(매물)'}</strong>
|
||||
{l.deal_type && <span className="lst-card__deal">{l.deal_type}</span>}
|
||||
{l.dong && <span className="lst-card__dong">{l.dong}</span>}
|
||||
</div>
|
||||
<div className="lst-card__body">
|
||||
{price != null && <span className="lst-card__price">{formatMoney(price)}</span>}
|
||||
{l.area != null && <span className="lst-card__area">{l.area}m²</span>}
|
||||
{l.ratio != null && <span className="lst-card__ratio">비율 {formatRatio(l.ratio)}</span>}
|
||||
</div>
|
||||
{flags.length > 0 && (
|
||||
<div className="lst-flags">{flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||
)}
|
||||
{reasons.length > 0 && <div className="lst-card__reasons">{reasons.join(' · ')}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListingsTab = () => {
|
||||
const [listings, setListings] = useState([]);
|
||||
const [dealType, setDealType] = useState('전체');
|
||||
const [matchedOnly, setMatchedOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [collectStatus, setCollectStatus] = useState(null);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getListings({
|
||||
deal_type: dealType === '전체' ? undefined : dealType,
|
||||
matched_only: matchedOnly,
|
||||
size: 50,
|
||||
});
|
||||
setListings(Array.isArray(data) ? data : (data.listings ?? []));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
setListings([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
try { setCollectStatus(await getListingsCollectStatus()); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [dealType, matchedOnly]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { loadStatus(); }, []);
|
||||
|
||||
const handleCollect = async () => {
|
||||
setCollecting(true);
|
||||
setError('');
|
||||
const prevAt = collectStatus?.collected_at ?? null;
|
||||
try {
|
||||
await collectListings();
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
setCollecting(false);
|
||||
return;
|
||||
}
|
||||
let tries = 0;
|
||||
const poll = async () => {
|
||||
tries += 1;
|
||||
let st = null;
|
||||
try { st = await getListingsCollectStatus(); } catch { /* noop */ }
|
||||
if (st) setCollectStatus(st);
|
||||
const done = st && (st.collected_at !== prevAt || st.error);
|
||||
if (done || tries >= 12) {
|
||||
setCollecting(false);
|
||||
load();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(poll, 5000);
|
||||
};
|
||||
window.setTimeout(poll, 5000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lst-tab">
|
||||
<div className="lst-toolbar">
|
||||
<div className="lst-filter">
|
||||
{DEAL_TYPES.map((d) => (
|
||||
<button key={d} type="button" className={`lst-filter-btn ${dealType === d ? 'is-active' : ''}`}
|
||||
onClick={() => setDealType(d)}>{d}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="lst-toolbar__right">
|
||||
<button type="button" className={`lst-filter-btn ${matchedOnly ? 'is-active' : ''}`}
|
||||
onClick={() => setMatchedOnly((v) => !v)}>매칭만</button>
|
||||
<button type="button" className="lst-filter-btn is-primary" onClick={handleCollect} disabled={collecting}>
|
||||
{collecting ? '수집 중…' : '수집 실행'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectStatus && (
|
||||
<p className="lst-status">
|
||||
수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status}
|
||||
{collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
|
||||
{collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="lst-empty">불러오는 중…</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="lst-empty">조건에 맞는 매물이 없습니다. ‘수집 실행’으로 매물을 모아보세요.</p>
|
||||
) : (
|
||||
<div className="lst-grid">
|
||||
{listings.map((l) => <ListingCard key={l.id ?? `${l.complex_name}-${l.dong}`} l={l} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingsTab;
|
||||
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
36
src/pages/listings/components/ListingsTab.test.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getListings: vi.fn(),
|
||||
collectListings: vi.fn(),
|
||||
getListingsCollectStatus: vi.fn(),
|
||||
}));
|
||||
import { getListings, getListingsCollectStatus } from '../../../api';
|
||||
import ListingsTab from './ListingsTab.jsx';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getListingsCollectStatus.mockResolvedValue({ status: 'never_run' });
|
||||
});
|
||||
|
||||
describe('ListingsTab', () => {
|
||||
it('빈 목록이면 안내 문구', async () => {
|
||||
getListings.mockResolvedValue({ listings: [] });
|
||||
render(<ListingsTab />);
|
||||
await waitFor(() => expect(screen.getByText(/매물이 없습니다/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('매물+판정 tier·배열 필드를 크래시 없이 렌더 (매매=valuation, 배열 그대로)', async () => {
|
||||
getListings.mockResolvedValue({ listings: [{
|
||||
id: 1, complex_name: '상도더샵', dong: '상도동', deal_type: '매매',
|
||||
price: 95000, valuation_tier: '고가', ratio: 1.08,
|
||||
regulation_flags: ['토지거래허가'], reasons: ['호가가 최근 실거래 대비 8% 높음'],
|
||||
}] });
|
||||
render(<ListingsTab />);
|
||||
await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument());
|
||||
expect(screen.getByText(/고가/)).toBeInTheDocument();
|
||||
expect(screen.getByText('토지거래허가')).toBeInTheDocument();
|
||||
expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
161
src/pages/listings/components/ToolsTab.jsx
Normal file
161
src/pages/listings/components/ToolsTab.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { safetyCheck, budgetCalc } from '../../../api';
|
||||
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';
|
||||
|
||||
const DEAL_TYPES = ['전세', '반전세', '매매'];
|
||||
|
||||
/* ── 안전마진 체커 ── */
|
||||
const SafetyCard = () => {
|
||||
const [form, setForm] = useState({ area: '', deal_type: '전세', amount: '', dong: '' });
|
||||
const [res, setRes] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.area || !form.amount) return;
|
||||
setLoading(true); setError(''); setRes(null);
|
||||
try {
|
||||
const r = await safetyCheck({
|
||||
area: Number(form.area), deal_type: form.deal_type,
|
||||
amount: Number(form.amount), dong: form.dong || undefined,
|
||||
});
|
||||
setRes(r);
|
||||
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const isSale = form.deal_type === '매매';
|
||||
const meta = res ? tierMeta(isSale ? 'valuation' : 'safety', res.tier) : null;
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<h3 className="lst-panel__title">안전마진 체커</h3>
|
||||
<p className="lst-panel__sub">면적·거래유형·금액으로 전세가율/호가율 적정성을 즉시 판정합니다.</p>
|
||||
<form className="lst-form" onSubmit={submit}>
|
||||
<input className="lst-input" placeholder="면적(m²)" value={form.area}
|
||||
onChange={(e) => set('area', e.target.value)} />
|
||||
<select className="lst-input" value={form.deal_type} onChange={(e) => set('deal_type', e.target.value)}>
|
||||
{DEAL_TYPES.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<input className="lst-input" placeholder="금액(만원)" value={form.amount}
|
||||
onChange={(e) => set('amount', e.target.value)} />
|
||||
<input className="lst-input" placeholder="동(선택)" value={form.dong}
|
||||
onChange={(e) => set('dong', e.target.value)} />
|
||||
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.area || !form.amount}>
|
||||
{loading ? '판정 중…' : '판정'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
{res && (
|
||||
<div className="lst-result">
|
||||
<div className="lst-result__row">
|
||||
<span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>
|
||||
{res.ratio != null && <span>비율 {formatRatio(res.ratio)}</span>}
|
||||
{res.median != null && <span>중앙값 {formatMoney(res.median)}</span>}
|
||||
{res.sample != null && <span>표본 {res.sample}건</span>}
|
||||
{res.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||
</div>
|
||||
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 예산 계산기 ── */
|
||||
const BudgetCard = () => {
|
||||
const [form, setForm] = useState({
|
||||
equity: '', annual_income: '', is_homeless: true, is_householder: true, is_first_home: false, target_dong: '',
|
||||
});
|
||||
const [res, setRes] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.equity) return;
|
||||
setLoading(true); setError(''); setRes(null);
|
||||
try {
|
||||
const r = await budgetCalc({
|
||||
equity: Number(form.equity),
|
||||
annual_income: form.annual_income ? Number(form.annual_income) : null,
|
||||
is_homeless: form.is_homeless ? 1 : 0,
|
||||
is_householder: form.is_householder ? 1 : 0,
|
||||
is_first_home: form.is_first_home ? 1 : 0,
|
||||
target_dong: form.target_dong || undefined,
|
||||
});
|
||||
setRes(r);
|
||||
} catch (e2) { setError(e2?.message ?? String(e2)); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="lst-panel">
|
||||
<h3 className="lst-panel__title">예산 계산기</h3>
|
||||
<p className="lst-panel__sub">자기자본·소득 기준 전세/매매 한도와 지역 규제를 계산합니다.</p>
|
||||
<form className="lst-form" onSubmit={submit}>
|
||||
<input className="lst-input" placeholder="자기자본(만원)" value={form.equity}
|
||||
onChange={(e) => set('equity', e.target.value)} />
|
||||
<input className="lst-input" placeholder="연소득(만원, 선택)" value={form.annual_income}
|
||||
onChange={(e) => set('annual_income', e.target.value)} />
|
||||
<input className="lst-input" placeholder="대상 동(선택)" value={form.target_dong}
|
||||
onChange={(e) => set('target_dong', e.target.value)} />
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_homeless}
|
||||
onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_householder}
|
||||
onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
|
||||
<label className="lst-check"><input type="checkbox" checked={form.is_first_home}
|
||||
onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
|
||||
<button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.equity}>
|
||||
{loading ? '계산 중…' : '계산'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="lst-error">{error}</p>}
|
||||
{res && (
|
||||
<div className="lst-result">
|
||||
{res.jeonse && (
|
||||
<div className="lst-result__block">
|
||||
<strong>전세</strong>
|
||||
<span>대출한도 {formatMoney(res.jeonse.loan_limit)}</span>
|
||||
<span>최대 보증금 {formatMoney(res.jeonse.max_deposit)}</span>
|
||||
{res.jeonse.notes && <span className="lst-note">{res.jeonse.notes}</span>}
|
||||
</div>
|
||||
)}
|
||||
{res.purchase && (
|
||||
<div className="lst-result__block">
|
||||
<strong>매매</strong>
|
||||
{res.purchase.ltv_pct != null && <span>LTV {res.purchase.ltv_pct}%</span>}
|
||||
<span>대출한도 {formatMoney(res.purchase.loan_cap)}</span>
|
||||
<span>최대 매수가 {formatMoney(res.purchase.max_price)}</span>
|
||||
{res.purchase.dsr_note && <span className="lst-note">{res.purchase.dsr_note}</span>}
|
||||
{Array.isArray(res.purchase.regulation_flags) && res.purchase.regulation_flags.length > 0 && (
|
||||
<div className="lst-flags">{res.purchase.regulation_flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{res.region && (
|
||||
<div className="lst-result__block">
|
||||
<strong>지역</strong>
|
||||
{res.region.is_toheo && <span className="lst-flag">토지거래허가</span>}
|
||||
{res.region.is_regulated && <span className="lst-flag">규제지역</span>}
|
||||
{res.region.notes && <span className="lst-note">{res.region.notes}</span>}
|
||||
</div>
|
||||
)}
|
||||
{res.disclaimer && <p className="lst-disclaimer">※ {res.disclaimer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolsTab = () => (
|
||||
<div className="lst-tools">
|
||||
<SafetyCard />
|
||||
<BudgetCard />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ToolsTab;
|
||||
44
src/pages/listings/components/ToolsTab.test.jsx
Normal file
44
src/pages/listings/components/ToolsTab.test.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() }));
|
||||
import { safetyCheck, budgetCalc } from '../../../api';
|
||||
import ToolsTab from './ToolsTab.jsx';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ToolsTab', () => {
|
||||
it('안전마진·예산 카드 헤딩과 disclaimer 안내를 렌더', () => {
|
||||
render(<ToolsTab />);
|
||||
expect(screen.getByText('안전마진 체커')).toBeInTheDocument();
|
||||
expect(screen.getByText('예산 계산기')).toBeInTheDocument();
|
||||
// 제출 버튼 존재
|
||||
expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('안전마진 제출 → tier·비율·disclaimer 렌더 (객체 아님)', async () => {
|
||||
safetyCheck.mockResolvedValue({ median: 45000, ratio: 0.72, tier: '안전', sample: 7, is_toheo: false, disclaimer: '등기부 선순위·근사치 주의' });
|
||||
render(<ToolsTab />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/면적/), { target: { value: '84' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/금액/), { target: { value: '45000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '판정' }));
|
||||
await waitFor(() => expect(screen.getByText(/등기부 선순위/)).toBeInTheDocument());
|
||||
// "안전마진 체커" 헤딩과 구분하기 위해 "마진"이 뒤따르지 않는 "안전"만 매칭
|
||||
expect(screen.getByText(/안전(?!마진)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('예산 제출 → 전세/매매 금액·disclaimer 렌더 (중첩 객체 필드별 렌더)', async () => {
|
||||
budgetCalc.mockResolvedValue({
|
||||
jeonse: { loan_limit: 30000, max_deposit: 40000, notes: '' },
|
||||
purchase: { ltv_pct: 70, loan_cap: 50000, max_price: 90000, dsr_note: 'DSR 40%', regulation_flags: ['규제지역'] },
|
||||
region: { is_toheo: true, is_regulated: true, notes: '토허 구역' },
|
||||
disclaimer: '정책 수치는 근사치입니다',
|
||||
});
|
||||
render(<ToolsTab />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/자기자본/), { target: { value: '20000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '계산' }));
|
||||
await waitFor(() => expect(screen.getByText(/정책 수치는 근사치/)).toBeInTheDocument());
|
||||
// purchase.regulation_flags 와 region.is_regulated 플래그 둘 다 "규제지역" 텍스트를 렌더함
|
||||
expect(screen.getAllByText('규제지역').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
40
src/pages/listings/listingsUtils.js
Normal file
40
src/pages/listings/listingsUtils.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/* 실매물 판정 tier / 금액 포맷 순수 헬퍼 */
|
||||
const C = { green: '#34d399', yellow: '#f59e0b', red: '#f87171', gray: '#94a3b8' };
|
||||
|
||||
export const SAFETY_TIER_META = Object.freeze({
|
||||
'안전': { emoji: '🟢', color: C.green },
|
||||
'주의': { emoji: '🟡', color: C.yellow },
|
||||
'위험': { emoji: '🔴', color: C.red },
|
||||
'보류': { emoji: '⚪', color: C.gray },
|
||||
});
|
||||
|
||||
export const VALUATION_TIER_META = Object.freeze({
|
||||
'저평가': { emoji: '🟢', color: C.green },
|
||||
'시세': { emoji: '🟡', color: C.yellow },
|
||||
'고가': { emoji: '🔴', color: C.red },
|
||||
'보류': { emoji: '⚪', color: C.gray },
|
||||
});
|
||||
|
||||
export const tierMeta = (kind, tier) => {
|
||||
const map = kind === 'valuation' ? VALUATION_TIER_META : SAFETY_TIER_META;
|
||||
if (Object.hasOwn(map, tier)) return { ...map[tier], label: tier };
|
||||
return { emoji: '⚪', color: C.gray, label: tier ?? '' };
|
||||
};
|
||||
|
||||
export const formatMoney = (manwon) => {
|
||||
if (manwon == null || manwon === '') return '-';
|
||||
const n = Number(manwon);
|
||||
if (Number.isNaN(n)) return '-';
|
||||
const eok = Math.floor(n / 10000);
|
||||
const rest = n % 10000;
|
||||
if (eok > 0 && rest > 0) return `${eok}억 ${rest.toLocaleString('ko-KR')}만`;
|
||||
if (eok > 0) return `${eok}억`;
|
||||
return `${n.toLocaleString('ko-KR')}만`;
|
||||
};
|
||||
|
||||
export const formatRatio = (ratio) => {
|
||||
if (ratio == null) return '-';
|
||||
const n = Number(ratio);
|
||||
if (Number.isNaN(n)) return '-';
|
||||
return `${(n * 100).toFixed(1)}%`;
|
||||
};
|
||||
44
src/pages/listings/listingsUtils.test.js
Normal file
44
src/pages/listings/listingsUtils.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tierMeta, formatMoney, formatRatio } from './listingsUtils.js';
|
||||
|
||||
describe('tierMeta', () => {
|
||||
it('safety 4종 이모지·색·label', () => {
|
||||
expect(tierMeta('safety', '안전')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '안전' });
|
||||
expect(tierMeta('safety', '주의').emoji).toBe('🟡');
|
||||
expect(tierMeta('safety', '위험').color).toBe('#f87171');
|
||||
expect(tierMeta('safety', '보류').emoji).toBe('⚪');
|
||||
});
|
||||
it('valuation 4종', () => {
|
||||
expect(tierMeta('valuation', '저평가')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '저평가' });
|
||||
expect(tierMeta('valuation', '시세').emoji).toBe('🟡');
|
||||
expect(tierMeta('valuation', '고가').color).toBe('#f87171');
|
||||
});
|
||||
it('미정의 tier는 회색⚪ + 원문 label, undefined는 빈 label', () => {
|
||||
expect(tierMeta('safety', '없는거')).toMatchObject({ emoji: '⚪', color: '#94a3b8', label: '없는거' });
|
||||
expect(tierMeta('valuation', undefined).label).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMoney (만원 정밀 표기)', () => {
|
||||
it('만/억 경계', () => {
|
||||
expect(formatMoney(9999)).toBe('9,999만');
|
||||
expect(formatMoney(10000)).toBe('1억');
|
||||
expect(formatMoney(15000)).toBe('1억 5,000만');
|
||||
expect(formatMoney(32000)).toBe('3억 2,000만');
|
||||
});
|
||||
it('null/빈값/NaN → -', () => {
|
||||
expect(formatMoney(null)).toBe('-');
|
||||
expect(formatMoney('')).toBe('-');
|
||||
expect(formatMoney('abc')).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRatio', () => {
|
||||
it('비율 → 퍼센트 1자리', () => {
|
||||
expect(formatRatio(0.812)).toBe('81.2%');
|
||||
expect(formatRatio(1.05)).toBe('105.0%');
|
||||
});
|
||||
it('null → -', () => {
|
||||
expect(formatRatio(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -3232,3 +3232,122 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 관심종목 탭 (Watchlist) ───────────────────────────────── */
|
||||
.wl-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wl-form__input {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wl-form__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-stock);
|
||||
}
|
||||
|
||||
.wl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.wl-row__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wl-row__name { font-size: 14px; }
|
||||
.wl-row__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-row__note { font-size: 12px; color: var(--muted); opacity: 0.85; }
|
||||
.wl-del {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wl-del:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.wl-period-toggle { display: flex; gap: 4px; }
|
||||
.wl-period {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wl-period.is-active {
|
||||
color: var(--accent-stock);
|
||||
border-color: var(--accent-stock);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.wl-alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.wl-alert {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wl-alert__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-kind-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wl-alert__name { font-size: 14px; }
|
||||
.wl-alert__ticker { font-size: 12px; color: var(--muted); }
|
||||
.wl-alert__time { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.wl-alert__body {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wl-cond { font-size: 13px; font-weight: 600; }
|
||||
.wl-alert__price { font-size: 13px; color: var(--muted); }
|
||||
.wl-alert__reason { font-size: 12.5px; line-height: 1.45; color: #cbd5e1; }
|
||||
.wl-alert__detail { font-size: 12px; color: var(--muted); }
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwipeableView from '../../components/SwipeableView';
|
||||
import {
|
||||
formatNumber, formatPercent,
|
||||
toNumeric, profitColorClass,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL,
|
||||
TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST,
|
||||
} from './stockUtils';
|
||||
|
||||
/* ── hooks ──────────────────────────────────────────────────────── */
|
||||
@@ -17,12 +17,14 @@ import useAssetHistory from './hooks/useAssetHistory';
|
||||
import useMarketContext from './hooks/useMarketContext';
|
||||
import useReportData from './hooks/useReportData';
|
||||
import useAdvisor from './hooks/useAdvisor';
|
||||
import useWatchlist from './hooks/useWatchlist';
|
||||
|
||||
/* ── tab components ─────────────────────────────────────────────── */
|
||||
import PortfolioTab from './components/PortfolioTab';
|
||||
import ReportTab from './components/ReportTab';
|
||||
import AdvisorTab from './components/AdvisorTab';
|
||||
import HoldingsIntelTab from './components/HoldingsIntelTab';
|
||||
import WatchlistTab from './components/WatchlistTab';
|
||||
import SellHistoryDrawer from './components/SellHistoryDrawer';
|
||||
|
||||
/* ── component ───────────────────────────────────────────────────── */
|
||||
@@ -31,8 +33,8 @@ const StockTrade = () => {
|
||||
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔'];
|
||||
const TAB_ORDER = [TAB_PORTFOLIO, TAB_REPORT, TAB_ADVISOR, TAB_HOLDINGS_INTEL, TAB_WATCHLIST];
|
||||
const tabLabels = ['포트폴리오', '리포트', '어드바이저', '보유종목 인텔', '관심종목'];
|
||||
const tabIndex = TAB_ORDER.indexOf(activeTab);
|
||||
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -62,6 +64,7 @@ const StockTrade = () => {
|
||||
totalAssets: pf.totalAssets,
|
||||
marketCtx,
|
||||
});
|
||||
const wl = useWatchlist();
|
||||
|
||||
/* ── sell history filter derived ─────────────────────────────── */
|
||||
const sellHistoryBrokers = useMemo(() => {
|
||||
@@ -169,7 +172,9 @@ const StockTrade = () => {
|
||||
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
|
||||
: tabId === TAB_ADVISOR
|
||||
? <AdvisorTab pf={pf} advisor={advisor} />
|
||||
: <HoldingsIntelTab />,
|
||||
: tabId === TAB_HOLDINGS_INTEL
|
||||
? <HoldingsIntelTab />
|
||||
: <WatchlistTab wl={wl} />,
|
||||
}))}
|
||||
activeIndex={tabIndex}
|
||||
onTabChange={handleTabChange}
|
||||
@@ -182,6 +187,7 @@ const StockTrade = () => {
|
||||
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
|
||||
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
|
||||
{ id: TAB_HOLDINGS_INTEL, icon: '🔍', label: '보유종목 인텔', sub: '신호·이슈', className: 'stock-main-tab--holdings-intel' },
|
||||
{ id: TAB_WATCHLIST, icon: '⭐', label: '관심종목', sub: '관리·시그널', badge: wl.items.length || null },
|
||||
].map(({ id, icon, label, sub, badge, className: cls }) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -203,6 +209,7 @@ const StockTrade = () => {
|
||||
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
|
||||
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
|
||||
{activeTab === TAB_HOLDINGS_INTEL && <HoldingsIntelTab />}
|
||||
{activeTab === TAB_WATCHLIST && <WatchlistTab wl={wl} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
147
src/pages/stock/components/WatchlistTab.jsx
Normal file
147
src/pages/stock/components/WatchlistTab.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from 'react';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { kindMeta, conditionLabel, relativeTime, formatDetail } from '../watchlistUtils';
|
||||
import { formatNumber } from '../stockUtils';
|
||||
|
||||
const DAYS_OPTIONS = [
|
||||
{ value: 1, label: '1D' },
|
||||
{ value: 7, label: '7D' },
|
||||
{ value: 30, label: '30D' },
|
||||
];
|
||||
|
||||
const AlertCard = ({ a }) => {
|
||||
const meta = kindMeta(a.kind);
|
||||
const detailText = formatDetail(a.detail);
|
||||
return (
|
||||
<div className="wl-alert">
|
||||
<div className="wl-alert__head">
|
||||
<span className="wl-kind-badge" style={{ color: meta.color, background: meta.bg }}>{meta.label}</span>
|
||||
<strong className="wl-alert__name">{a.name || a.ticker}</strong>
|
||||
<span className="wl-alert__ticker">{a.ticker}</span>
|
||||
<span className="wl-alert__time">{relativeTime(a.fired_at)}</span>
|
||||
</div>
|
||||
<div className="wl-alert__body">
|
||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||
</div>
|
||||
{a.reason && <div className="wl-alert__reason">{a.reason}</div>}
|
||||
{detailText && <div className="wl-alert__detail">{detailText}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchlistTab = ({ wl }) => {
|
||||
const [form, setForm] = useState({ ticker: '', name: '', note: '' });
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.ticker.trim()) return;
|
||||
const ok = await wl.add(form);
|
||||
if (ok) setForm({ ticker: '', name: '', note: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 관심종목 관리 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">관심종목</p>
|
||||
<h3>관심종목 관리</h3>
|
||||
<p className="stock-panel__sub">등록한 종목은 매매 시그널 감시 유니버스에 포함됩니다.</p>
|
||||
</div>
|
||||
<div className="stock-panel__actions">{wl.loading && <Loading type="spinner" message="" />}</div>
|
||||
</div>
|
||||
|
||||
<form className="wl-form" onSubmit={submit}>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목코드 (예: 005930)"
|
||||
value={form.ticker}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ticker: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="종목명 (선택)"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="wl-form__input"
|
||||
placeholder="메모 (선택)"
|
||||
value={form.note}
|
||||
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
|
||||
/>
|
||||
<button className="button" type="submit" disabled={!form.ticker.trim() || wl.adding}>
|
||||
{wl.adding ? '추가 중…' : '추가'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{wl.error && <p className="stock-error">{wl.error}</p>}
|
||||
|
||||
{wl.items.length === 0 ? (
|
||||
<p className="stock-empty">아직 관심종목이 없습니다. 종목코드를 추가해 보세요.</p>
|
||||
) : (
|
||||
<ul className="wl-list">
|
||||
{wl.items.map((it) => (
|
||||
<li key={it.ticker} className="wl-row">
|
||||
<div className="wl-row__meta">
|
||||
<strong className="wl-row__name">{it.name || it.ticker}</strong>
|
||||
<span className="wl-row__ticker">{it.ticker}</span>
|
||||
{it.note && <span className="wl-row__note">{it.note}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="wl-del"
|
||||
type="button"
|
||||
aria-label={`${it.ticker} 삭제`}
|
||||
onClick={() => wl.remove(it.ticker)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 최근 시그널 알림 */}
|
||||
<section className="stock-panel stock-panel--wide wl-panel">
|
||||
<div className="stock-panel__head">
|
||||
<div>
|
||||
<p className="stock-panel__eyebrow">시그널</p>
|
||||
<h3>최근 매매 알림</h3>
|
||||
<p className="stock-panel__sub">감시 종목에서 발생한 매수·매도 시그널 이력입니다.</p>
|
||||
</div>
|
||||
<div className="wl-period-toggle">
|
||||
{DAYS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`wl-period ${wl.alertDays === o.value ? 'is-active' : ''}`}
|
||||
onClick={() => wl.setAlertDays(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{wl.alertError && <p className="stock-error">{wl.alertError}</p>}
|
||||
|
||||
{wl.alerts.length === 0 ? (
|
||||
<p className="stock-empty">해당 기간에 발생한 알림이 없습니다.</p>
|
||||
) : (
|
||||
<div className="wl-alerts">
|
||||
{wl.alerts.map((a) => (
|
||||
<AlertCard key={a.id ?? `${a.ticker}-${a.fired_at}`} a={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="hi-disclaimer">※ 어드바이저리 알림이며 자동매매가 아닙니다. 최종 판단은 본인 책임입니다.</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchlistTab;
|
||||
62
src/pages/stock/components/WatchlistTab.test.jsx
Normal file
62
src/pages/stock/components/WatchlistTab.test.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import WatchlistTab from './WatchlistTab.jsx';
|
||||
|
||||
const baseWl = {
|
||||
items: [], alerts: [], alertDays: 7, setAlertDays: vi.fn(),
|
||||
loading: false, error: '', alertError: '', adding: false,
|
||||
add: vi.fn(), remove: vi.fn(), reload: vi.fn(),
|
||||
};
|
||||
|
||||
describe('WatchlistTab', () => {
|
||||
it('빈 상태: 헤딩과 빈 안내 노출', () => {
|
||||
render(<WatchlistTab wl={baseWl} />);
|
||||
expect(screen.getByText('관심종목 관리')).toBeInTheDocument();
|
||||
expect(screen.getByText(/아직 관심종목이 없습니다/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/발생한 알림이 없습니다/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('종목·알림이 있으면 렌더', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
items: [{ ticker: '005930', name: '삼성전자', note: '반도체 대장', added_at: '2026-07-01T00:00:00Z' }],
|
||||
alerts: [{ id: 1, ticker: '005930', name: '삼성전자', kind: 'buy', condition: 'buy_breakout', price: 81000, detail: '박스권 돌파', fired_at: '2026-07-03T01:00:00Z' }],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
// '삼성전자'는 관심종목 목록 행과 알림 카드 양쪽에 렌더되므로 getAllByText로 확인
|
||||
expect(screen.getAllByText('삼성전자')).toHaveLength(2);
|
||||
expect(screen.getByText('매수')).toBeInTheDocument();
|
||||
expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('detail이 객체인 알림도 크래시 없이 렌더 (React #31 회귀 방지)', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
alerts: [{
|
||||
id: 20, ticker: '381170', name: null, kind: 'sell', condition: 'sell_ma_break',
|
||||
price: 32715, detail: { ma50: 33309.8, ma200: null, severity: 'normal' },
|
||||
fired_at: '2026-07-03T07:02:59Z',
|
||||
}],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('매도')).toBeInTheDocument();
|
||||
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
|
||||
// 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출)
|
||||
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alert.reason(근거 문자열)을 condition 라벨과 함께 렌더', () => {
|
||||
const wl = {
|
||||
...baseWl,
|
||||
alerts: [{
|
||||
id: 40, ticker: '000660', name: 'SK하이닉스', kind: 'sell', condition: 'sell_ma_break',
|
||||
price: 2120000, detail: { ma50: 2123160, ma200: null, severity: 'normal' },
|
||||
fired_at: '2026-07-09T02:55:00Z',
|
||||
reason: '주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토',
|
||||
}],
|
||||
};
|
||||
render(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토')).toBeInTheDocument();
|
||||
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
94
src/pages/stock/hooks/useWatchlist.js
Normal file
94
src/pages/stock/hooks/useWatchlist.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import { normalizeTicker } from '../watchlistUtils';
|
||||
|
||||
const asArray = (data, key) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data[key])) return data[key];
|
||||
return [];
|
||||
};
|
||||
|
||||
const byFiredAtDesc = (a, b) =>
|
||||
new Date(b?.fired_at ?? 0).getTime() - new Date(a?.fired_at ?? 0).getTime();
|
||||
|
||||
export default function useWatchlist() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [alertDays, setAlertDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [alertError, setAlertError] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const loadWatchlist = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await getWatchlist();
|
||||
setItems(asArray(data, 'watchlist'));
|
||||
} catch (e) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAlerts = useCallback(async (days) => {
|
||||
setAlertError('');
|
||||
try {
|
||||
const data = await getTradeAlerts(days);
|
||||
setAlerts(asArray(data, 'alerts').slice().sort(byFiredAtDesc));
|
||||
} catch (e) {
|
||||
setAlertError(e?.message ?? String(e));
|
||||
setAlerts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWatchlist(); }, [loadWatchlist]);
|
||||
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
||||
|
||||
const add = useCallback(async ({ ticker, name, note }) => {
|
||||
if (adding) return false;
|
||||
const t = normalizeTicker(ticker);
|
||||
if (!t) return false;
|
||||
if (items.some((it) => it.ticker === t)) {
|
||||
setError(`이미 관심종목에 있습니다: ${t}`);
|
||||
return false;
|
||||
}
|
||||
setAdding(true);
|
||||
setError('');
|
||||
const cleanName = (name ?? '').trim();
|
||||
const cleanNote = (note ?? '').trim();
|
||||
const optimistic = { ticker: t, name: cleanName, note: cleanNote, added_at: new Date().toISOString() };
|
||||
setItems((prev) => [optimistic, ...prev]);
|
||||
try {
|
||||
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
||||
await loadWatchlist();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [items, loadWatchlist, adding]);
|
||||
|
||||
const remove = useCallback(async (ticker) => {
|
||||
const prev = items;
|
||||
setItems((cur) => cur.filter((it) => it.ticker !== ticker));
|
||||
setError('');
|
||||
try {
|
||||
await removeWatchlist(ticker);
|
||||
} catch (e) {
|
||||
setItems(prev); // 롤백
|
||||
setError(e?.message ?? String(e));
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
items, alerts, alertDays, setAlertDays,
|
||||
loading, error, alertError, adding,
|
||||
add, remove, reload: loadWatchlist,
|
||||
};
|
||||
}
|
||||
97
src/pages/stock/hooks/useWatchlist.test.js
Normal file
97
src/pages/stock/hooks/useWatchlist.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
getWatchlist: vi.fn(),
|
||||
addWatchlist: vi.fn(),
|
||||
removeWatchlist: vi.fn(),
|
||||
getTradeAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getWatchlist, addWatchlist, removeWatchlist, getTradeAlerts } from '../../../api';
|
||||
import useWatchlist from './useWatchlist';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getWatchlist.mockResolvedValue({ watchlist: [{ ticker: '005930', name: '삼성전자', note: '', added_at: '2026-07-01T00:00:00Z' }] });
|
||||
getTradeAlerts.mockResolvedValue({ alerts: [] });
|
||||
addWatchlist.mockResolvedValue({ ok: true });
|
||||
removeWatchlist.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe('useWatchlist', () => {
|
||||
it('마운트 시 watchlist를 로드', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('005930');
|
||||
});
|
||||
|
||||
it('배열 직접 반환도 방어적으로 파싱', async () => {
|
||||
getWatchlist.mockResolvedValue([{ ticker: '000660', name: 'SK하이닉스' }]);
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
expect(result.current.items[0].ticker).toBe('000660');
|
||||
});
|
||||
|
||||
it('add: 낙관적 추가 후 재조회 + POST 페이로드', async () => {
|
||||
getWatchlist
|
||||
.mockResolvedValueOnce({ watchlist: [] })
|
||||
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
let ok;
|
||||
await act(async () => { ok = await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
||||
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
||||
expect(ok).toBe(true);
|
||||
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
||||
});
|
||||
|
||||
it('add 실패 시 롤백 + error', async () => {
|
||||
getWatchlist.mockResolvedValue({ watchlist: [] });
|
||||
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
let ok;
|
||||
await act(async () => { ok = await result.current.add({ ticker: '000660' }); });
|
||||
expect(ok).toBe(false);
|
||||
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
||||
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
||||
});
|
||||
|
||||
it('중복 ticker는 add 차단', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
let ok;
|
||||
await act(async () => { ok = await result.current.add({ ticker: '005930' }); });
|
||||
expect(addWatchlist).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toContain('이미');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await act(async () => { await result.current.remove('005930'); });
|
||||
expect(removeWatchlist).toHaveBeenCalledWith('005930');
|
||||
expect(result.current.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alerts 로드 실패해도 watchlist는 독립 동작 (alertError 세팅)', async () => {
|
||||
getTradeAlerts.mockRejectedValue(new Error('HTTP 404 missing'));
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
||||
expect(result.current.alerts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('alerts를 fired_at 내림차순으로 정렬', async () => {
|
||||
getTradeAlerts.mockResolvedValue({ alerts: [
|
||||
{ id: 1, ticker: 'A', fired_at: '2026-07-01T00:00:00Z' },
|
||||
{ id: 2, ticker: 'B', fired_at: '2026-07-03T00:00:00Z' },
|
||||
{ id: 3, ticker: 'C', fired_at: '2026-07-02T00:00:00Z' },
|
||||
] });
|
||||
const { result } = renderHook(() => useWatchlist());
|
||||
await waitFor(() => expect(result.current.alerts).toHaveLength(3));
|
||||
expect(result.current.alerts.map((a) => a.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
});
|
||||
@@ -150,3 +150,4 @@ export const TAB_PORTFOLIO = 'portfolio';
|
||||
export const TAB_REPORT = 'report';
|
||||
export const TAB_ADVISOR = 'advisor';
|
||||
export const TAB_HOLDINGS_INTEL = 'holdings_intel';
|
||||
export const TAB_WATCHLIST = 'watchlist';
|
||||
|
||||
80
src/pages/stock/watchlistUtils.js
Normal file
80
src/pages/stock/watchlistUtils.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
||||
|
||||
export const KIND_META = Object.freeze({
|
||||
buy: Object.freeze({ label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }),
|
||||
sell: Object.freeze({ label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }),
|
||||
});
|
||||
|
||||
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
||||
|
||||
export const kindMeta = (kind) => {
|
||||
if (Object.hasOwn(KIND_META, kind)) return KIND_META[kind];
|
||||
return { ...FALLBACK_KIND, label: kind ?? '' };
|
||||
};
|
||||
|
||||
export const CONDITION_LABEL = Object.freeze({
|
||||
buy_ma20_pullback: 'MA20 눌림 반등',
|
||||
buy_breakout: '박스 상단 돌파',
|
||||
buy_rsi_bounce: 'RSI 과매도 반등',
|
||||
sell_stop_loss: '손절 라인',
|
||||
sell_ma_break: '이평선 이탈',
|
||||
sell_take_profit: '목표가 도달',
|
||||
sell_climax: '과열 소진',
|
||||
sell_trailing_stop: '트레일링 스톱',
|
||||
});
|
||||
|
||||
export const conditionLabel = (cond) =>
|
||||
Object.hasOwn(CONDITION_LABEL, cond) ? CONDITION_LABEL[cond] : (cond ?? '');
|
||||
|
||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||
|
||||
/* 알림 detail 은 condition 마다 스키마가 다른 객체(또는 문자열).
|
||||
객체를 JSX 자식으로 직접 렌더하면 React #31 크래시 → 항상 문자열로 변환한다.
|
||||
특정 필드 존재를 가정하지 않고(스키마 가변) 알려진 키만 한글 라벨, 나머지는 원문 키. */
|
||||
const DETAIL_KEY_LABEL = Object.freeze({
|
||||
avg_price: '평단', pnl_pct: '손익', stop_pct: '손절', take_pct: '목표',
|
||||
holding_high: '보유고점', trailing_pct: '트레일링', drawdown_pct: '낙폭',
|
||||
ma50: 'MA50', ma200: 'MA200', severity: '강도', vol: '거래량', rsi: 'RSI',
|
||||
breakout_high: '돌파고점', pullback_pct: '눌림',
|
||||
});
|
||||
|
||||
const formatDetailValue = (key, v) => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') {
|
||||
if (key.endsWith('_pct')) return `${(v * 100).toFixed(2)}%`;
|
||||
return Number.isInteger(v) ? v.toLocaleString('ko-KR') : String(v);
|
||||
}
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
};
|
||||
|
||||
export const formatDetail = (detail) => {
|
||||
if (detail == null) return '';
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (typeof detail !== 'object') return String(detail);
|
||||
return Object.entries(detail)
|
||||
.map(([k, v]) => {
|
||||
const fv = formatDetailValue(k, v);
|
||||
return fv == null ? null : `${DETAIL_KEY_LABEL[k] ?? k} ${fv}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
};
|
||||
|
||||
export const relativeTime = (iso, now = Date.now()) => {
|
||||
if (!iso) return '';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const diffMs = now - then;
|
||||
if (diffMs < 0) return '방금';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return '방금';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return '어제';
|
||||
if (day < 7) return `${day}일 전`;
|
||||
return new Date(iso).toLocaleDateString('ko-KR');
|
||||
};
|
||||
99
src/pages/stock/watchlistUtils.test.js
Normal file
99
src/pages/stock/watchlistUtils.test.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } from './watchlistUtils.js';
|
||||
|
||||
describe('kindMeta', () => {
|
||||
it('buy/sell 라벨과 색을 반환', () => {
|
||||
expect(kindMeta('buy').label).toBe('매수');
|
||||
expect(kindMeta('buy').color).toBe('#22c55e');
|
||||
expect(kindMeta('sell').label).toBe('매도');
|
||||
expect(kindMeta('sell').color).toBe('#ef4444');
|
||||
});
|
||||
it('미정의 kind는 회색 폴백 + 원문 label', () => {
|
||||
const m = kindMeta('weird');
|
||||
expect(m.label).toBe('weird');
|
||||
expect(m.color).toBe('#94a3b8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditionLabel', () => {
|
||||
it('정의된 8종을 한글로 매핑', () => {
|
||||
expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등');
|
||||
expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파');
|
||||
expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등');
|
||||
expect(conditionLabel('sell_stop_loss')).toBe('손절 라인');
|
||||
expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈');
|
||||
expect(conditionLabel('sell_take_profit')).toBe('목표가 도달');
|
||||
expect(conditionLabel('sell_climax')).toBe('과열 소진');
|
||||
expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱');
|
||||
});
|
||||
it('미정의 condition은 원문 폴백', () => {
|
||||
expect(conditionLabel('buy_unknown')).toBe('buy_unknown');
|
||||
expect(conditionLabel(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('프로토타입 키 방어 (render-safe)', () => {
|
||||
it('conditionLabel은 상속 키에도 문자열을 반환', () => {
|
||||
expect(conditionLabel('toString')).toBe('toString');
|
||||
expect(typeof conditionLabel('toString')).toBe('string');
|
||||
expect(typeof conditionLabel('constructor')).toBe('string');
|
||||
});
|
||||
it('kindMeta는 상속 키에도 문자열 label + 회색 폴백', () => {
|
||||
const m = kindMeta('constructor');
|
||||
expect(typeof m.label).toBe('string');
|
||||
expect(m.label).toBe('constructor');
|
||||
expect(m.color).toBe('#94a3b8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTicker', () => {
|
||||
it('공백 trim', () => {
|
||||
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
||||
expect(normalizeTicker(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDetail (알림 detail 객체 → 안전 문자열, React #31 방지)', () => {
|
||||
it('객체 detail을 읽기 좋은 문자열로 변환하고 절대 객체를 반환하지 않는다', () => {
|
||||
const s = formatDetail({ avg_price: 75325, pnl_pct: -0.1012, stop_pct: 0.08 });
|
||||
expect(typeof s).toBe('string');
|
||||
expect(s).toContain('75,325'); // avg_price 천단위 포맷
|
||||
expect(s).toContain('-10.12%'); // *_pct 는 퍼센트로
|
||||
expect(s).toContain('8.00%');
|
||||
});
|
||||
it('값이 null인 필드는 건너뛴다', () => {
|
||||
const s = formatDetail({ ma50: 33309.8, ma200: null, severity: 'normal' });
|
||||
expect(typeof s).toBe('string');
|
||||
expect(s).toContain('normal');
|
||||
expect(s).not.toContain('null');
|
||||
});
|
||||
it('문자열 detail은 그대로 반환', () => {
|
||||
expect(formatDetail('박스권 돌파')).toBe('박스권 돌파');
|
||||
});
|
||||
it('null/undefined 는 빈 문자열', () => {
|
||||
expect(formatDetail(null)).toBe('');
|
||||
expect(formatDetail(undefined)).toBe('');
|
||||
});
|
||||
it('미정의 키(스키마 가정 없음)도 크래시 없이 문자열로', () => {
|
||||
const s = formatDetail({ some_new_field: 42 });
|
||||
expect(typeof s).toBe('string');
|
||||
expect(s).toContain('42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = new Date('2026-07-03T12:00:00Z').getTime();
|
||||
it('60초 미만은 방금', () => {
|
||||
expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금');
|
||||
});
|
||||
it('분/시간/어제/일 경계', () => {
|
||||
expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전');
|
||||
expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전');
|
||||
expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제');
|
||||
expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전');
|
||||
});
|
||||
it('잘못된/빈 값은 빈 문자열', () => {
|
||||
expect(relativeTime('', now)).toBe('');
|
||||
expect(relativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||
@@ -1598,6 +1599,11 @@ function Subscription() {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className="sub">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
|
||||
실매물 · 안전마진 →
|
||||
</Link>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="sub-header">
|
||||
<div>
|
||||
|
||||
@@ -24,6 +24,7 @@ const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||
const Listings = lazy(() => import('./pages/listings/Listings'));
|
||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc'));
|
||||
@@ -41,6 +42,7 @@ const SajuToday = lazy(() => import('./pages/saju/Today'));
|
||||
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
|
||||
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
|
||||
const SajuMe = lazy(() => import('./pages/saju/Me'));
|
||||
const InfraMonitor = lazy(() => import('./pages/infra/InfraMonitor'));
|
||||
|
||||
export const navLinks = [
|
||||
{
|
||||
@@ -142,6 +144,15 @@ export const navLinks = [
|
||||
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||
accent: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'infra',
|
||||
label: 'Infra',
|
||||
path: '/infra',
|
||||
subtitle: 'NODE PIPELINE',
|
||||
description: 'NAS↔Windows 워커 파이프라인 실시간 관측',
|
||||
icon: <span style={{fontSize:'1.2em'}}>🛰️</span>,
|
||||
accent: '#22d3ee',
|
||||
},
|
||||
{
|
||||
id: 'lab',
|
||||
label: 'Lab',
|
||||
@@ -204,6 +215,10 @@ export const appRoutes = [
|
||||
path: 'realestate',
|
||||
element: <Subscription />,
|
||||
},
|
||||
{
|
||||
path: 'realestate/listings',
|
||||
element: <Listings />,
|
||||
},
|
||||
{
|
||||
path: 'travel',
|
||||
element: <Travel />,
|
||||
@@ -240,6 +255,10 @@ export const appRoutes = [
|
||||
path: 'agent-office',
|
||||
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||
},
|
||||
{
|
||||
path: 'infra',
|
||||
element: <InfraMonitor />,
|
||||
},
|
||||
{
|
||||
path: 'tarot',
|
||||
element: <Tarot />,
|
||||
|
||||
Reference in New Issue
Block a user