검증게시판 단계 추가
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -98,6 +98,12 @@ export function invalidateAppSettingsCache() {
|
||||
_loadPromise = null;
|
||||
}
|
||||
|
||||
/** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */
|
||||
export function reloadAppSettingsCache(): Promise<AppSettingsDto> {
|
||||
invalidateAppSettingsCache();
|
||||
return ensureLoaded();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user