검증게시판 단계 추가

This commit is contained in:
Macbook
2026-05-28 16:24:14 +09:00
parent f64dc1e983
commit 7e3644cb62
375 changed files with 4539 additions and 251294 deletions
@@ -31,6 +31,23 @@ function StageGlyph({ stage }: { stage: VerificationIssueStage }) {
<path d="M5.5 8l1.8 1.8L10.8 6.2" />
</svg>
);
case 'CONFIRM_REQUEST':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="8" cy="8" r="5.5" />
<path d="M8 5.5v3" />
<circle cx="8" cy="11" r="0.75" fill="currentColor" stroke="none" />
</svg>
);
case 'RE_VERIFY_REQUEST':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M11 2.5h2.5V5" />
<path d="M4.5 13.5H2V11" />
<path d="M12.8 5.2A4.5 4.5 0 1 0 6.5 11.5" />
<path d="M8 7v2l1.2 1.2" />
</svg>
);
case 'RE_REGISTERED':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
@@ -28,6 +28,7 @@ import {
} from '../utils/tradeAlertSound';
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
import { useAppSettings } from '../hooks/useAppSettings';
const MAX_HISTORY = 300;
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
const MAX_TOAST_QUEUE = 50;
@@ -60,7 +61,7 @@ interface TradeNotificationContextValue {
deleteUnreadNotifications: () => Promise<void>;
/** 알림 목록 전체 삭제 */
deleteAllNotifications: () => Promise<void>;
refreshHistory: () => Promise<void>;
refreshHistory: (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => Promise<void>;
detailSignal: TradeSignalInfo | null;
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
detailCentered: boolean;
@@ -126,6 +127,8 @@ interface ProviderProps {
soundEnabled?: boolean;
/** 알림음 ID */
soundId?: string;
/** 로그인·게스트 전환 시 app-settings 재로드 키 */
settingsSessionKey?: number;
}
export const TradeNotificationProvider: React.FC<ProviderProps> = ({
@@ -133,7 +136,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
popupEnabled,
soundEnabled = true,
soundId = 'bell',
settingsSessionKey = 0,
}) => {
const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId;
const [readIds, setReadIds] = useState<Set<string>>(() => loadReadIds());
const readIdsRef = useRef(readIds);
@@ -150,15 +155,15 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
[allNotifications],
);
const refreshHistory = useCallback(async () => {
const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => {
try {
const dtos = await loadTradeSignals();
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
const read = loadReadIds();
const hidden = loadHiddenIds();
const read = opts?.rawServerList ? new Set<string>() : loadReadIds();
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
const items = dtos
.map(d => dtoToItem(d, read))
.filter(item => !hidden.has(item.id));
.filter(item => opts?.rawServerList || !hidden.has(item.id));
let newlyArrived: TradeNotificationItem[] = [];
@@ -167,6 +172,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
item => !item.isRead && !prev.some(p => p.id === item.id),
);
if (opts?.serverOnly || opts?.rawServerList) {
return items
.sort((a, b) => b.receivedAt - a.receivedAt)
.slice(0, MAX_HISTORY);
}
const map = new Map<string, TradeNotificationItem>();
for (const n of items) {
const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead;
@@ -199,8 +210,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
}, [popupEnabled, soundEnabled, alertSoundId]);
useEffect(() => {
void refreshHistory();
}, [refreshHistory]);
if (!settingsLoaded) return;
void refreshHistory({ serverOnly: true, rawServerList: true });
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
const markAsRead = useCallback((id: string) => {
setReadIds(prev => {
+6
View File
@@ -98,6 +98,12 @@ export function invalidateAppSettingsCache() {
_loadPromise = null;
}
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
export function reloadAppSettingsCache(): Promise<AppSettingsDto> {
invalidateAppSettingsCache();
return ensureLoaded();
}
// ─────────────────────────────────────────────────────────────────────────────
/** DB 또는 fallback 기본값 접근 헬퍼 */
+78 -18
View File
@@ -1,7 +1,7 @@
/**
* 가상매매 공통 상태·동기화 (웹 VirtualTradingPage · 모바일 앱 공유)
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TickerData } from './useMarketTicker';
import {
loadLiveStrategySettings,
@@ -47,7 +47,13 @@ import {
virtualTargetLimitMessage,
} from '../utils/virtualTargetLimits';
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
import { useAppSettings, resolveAppDefaults } from './useAppSettings';
import {
useAppSettings,
resolveAppDefaults,
subscribeAppSettings,
reloadAppSettingsCache,
} from './useAppSettings';
import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate';
export interface UseVirtualTradingCoreOptions {
defaultMarket?: string;
@@ -71,7 +77,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
settingsSessionKey = 0,
} = options;
const { settings: appSettings } = useAppSettings(settingsSessionKey);
const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey);
const appChartDefaults = resolveAppDefaults(appSettings);
const paperTradingEnabled = paperTradingEnabledProp ?? appChartDefaults.paperTradingEnabled;
const paperAutoTradeEnabled = paperAutoTradeEnabledProp ?? appChartDefaults.paperAutoTradeEnabled;
@@ -86,20 +92,53 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
const [loading, setLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
/** 서버 ui_preferences 반영 전 빈 목록을 DB에 저장하지 않음 */
const preferencesHydratedRef = useRef(false);
useEffect(() => {
setLoading(true);
const reloadFromUiPreferences = useCallback(() => {
setTargets(loadVirtualTargets());
setSession(loadVirtualSession());
void Promise.all([
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
loadPaperSummary().then(setSummary),
loadPaperTrades().then(setTrades),
]).finally(() => setLoading(false));
}, [settingsSessionKey]);
}, []);
useEffect(() => {
if (!settingsLoaded) return;
let cancelled = false;
preferencesHydratedRef.current = false;
setLoading(true);
void (async () => {
reloadFromUiPreferences();
let nextTargets = loadVirtualTargets();
if (nextTargets.length === 0) {
nextTargets = await hydrateVirtualTargetsIfEmpty();
if (!cancelled && nextTargets.length > 0) {
setTargets(nextTargets);
saveVirtualTargets(nextTargets);
}
}
if (!cancelled) {
preferencesHydratedRef.current = true;
}
await Promise.all([
loadStrategies().then(s => { if (!cancelled) setStrategies(s); }).catch(() => { if (!cancelled) setStrategies([]); }),
loadPaperSummary().then(s => { if (!cancelled) setSummary(s); }),
loadPaperTrades().then(t => { if (!cancelled) setTrades(t); }),
]);
if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [settingsSessionKey, settingsLoaded, reloadFromUiPreferences]);
/** DB ui_preferences 반영(웹·모바일 동일 계정) */
useEffect(() => {
if (!settingsLoaded) return;
return subscribeAppSettings(() => {
reloadFromUiPreferences();
});
}, [settingsLoaded, reloadFromUiPreferences]);
/** DB is_pinned → uiPreferences.virtual.targets 동기화 */
useEffect(() => {
if (!settingsLoaded) return;
let cancelled = false;
const initial = loadVirtualTargets();
if (initial.length === 0) return;
@@ -119,10 +158,16 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
if (!cancelled) setTargets(merged);
})();
return () => { cancelled = true; };
}, []);
}, [settingsLoaded]);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
useEffect(() => {
if (!preferencesHydratedRef.current) return;
saveVirtualTargets(targets);
}, [targets]);
useEffect(() => {
if (!preferencesHydratedRef.current) return;
saveVirtualSession(session);
}, [session]);
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
useEffect(() => {
@@ -365,11 +410,26 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
void resyncRunningSession(next);
}, [session, resyncRunningSession]);
/** 서버·DB에서 targets 다시 로드 (탭 포커스 등) */
/** 서버·DB에서 targets 다시 로드 (↻ 동기화) */
const reloadTargetsFromStorage = useCallback(() => {
setTargets(loadVirtualTargets());
setSession(loadVirtualSession());
}, []);
setLoading(true);
preferencesHydratedRef.current = false;
void reloadAppSettingsCache()
.then(async () => {
reloadFromUiPreferences();
let next = loadVirtualTargets();
if (next.length === 0) {
next = await hydrateVirtualTargetsIfEmpty();
if (next.length > 0) {
setTargets(next);
saveVirtualTargets(next);
}
}
preferencesHydratedRef.current = true;
})
.catch(() => reloadFromUiPreferences())
.finally(() => setLoading(false));
}, [reloadFromUiPreferences]);
return {
targets,
@@ -3,6 +3,8 @@ export type VerificationIssueStage =
| 'REGISTERED'
| 'IN_FIX'
| 'FIX_COMPLETE'
| 'CONFIRM_REQUEST'
| 'RE_VERIFY_REQUEST'
| 'RE_REGISTERED'
| 'COMPLETE';
@@ -13,11 +15,13 @@ export interface VerificationIssueStageMeta {
}
export const VERIFICATION_ISSUE_STAGES: VerificationIssueStageMeta[] = [
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
{ value: 'CONFIRM_REQUEST', label: '확인요청', color: '#29b6f6' },
{ value: 'RE_VERIFY_REQUEST', label: '재검증 요청', color: '#ec407a' },
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
];
export const STAGE_BY_VALUE = Object.fromEntries(
@@ -0,0 +1,86 @@
/**
* ui_preferences.virtual.targets 가 비었을 때 서버·레거시에서 투자대상 복구
*/
import { loadActiveLiveStrategySettings, loadWatchlist } from './backendApi';
import {
loadVirtualSession,
loadVirtualTargets,
type VirtualTargetItem,
} from './virtualTradingStorage';
import { resolveVirtualTargetNames } from './virtualTargetNames';
function readLegacyTargets(): VirtualTargetItem[] {
try {
const raw = localStorage.getItem('gc_virtual_targets_v1');
if (!raw) return [];
const parsed = JSON.parse(raw) as VirtualTargetItem[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function fromLiveSettings(
list: Awaited<ReturnType<typeof loadActiveLiveStrategySettings>>,
globalStrategyId: number | null,
): VirtualTargetItem[] {
return list.map(s => {
const { koreanName, englishName } = resolveVirtualTargetNames(s.market);
return {
market: s.market,
strategyId: s.strategyId ?? globalStrategyId,
candleType: s.candleType,
koreanName,
englishName,
pinned: !!s.isPinned,
};
});
}
function fromWatchlist(
list: Awaited<ReturnType<typeof loadWatchlist>>,
globalStrategyId: number | null,
): VirtualTargetItem[] {
return list.map(w => {
const market = w.symbol;
const { koreanName, englishName } = resolveVirtualTargetNames(
market,
w.koreanName,
w.englishName,
);
return {
market,
strategyId: globalStrategyId,
koreanName,
englishName,
};
});
}
/** DB·로컬 비어 있을 때 서버 실시간 설정·관심종목에서 목록 구성 */
export async function hydrateVirtualTargetsIfEmpty(): Promise<VirtualTargetItem[]> {
const existing = loadVirtualTargets();
if (existing.length > 0) return existing;
const legacy = readLegacyTargets();
if (legacy.length > 0) return legacy;
const session = loadVirtualSession();
const globalId = session.globalStrategyId;
try {
const active = await loadActiveLiveStrategySettings();
if (active.length > 0) return fromLiveSettings(active, globalId);
} catch (e) {
console.warn('[virtualTargets] active live settings load failed', e);
}
try {
const watchlist = await loadWatchlist();
if (watchlist.length > 0) return fromWatchlist(watchlist, globalId);
} catch (e) {
console.warn('[virtualTargets] watchlist load failed', e);
}
return [];
}