매매 시그널알림 화면 수정

This commit is contained in:
Macbook
2026-05-29 01:05:52 +09:00
parent e1ad1d626e
commit 19ae5f5fae
5 changed files with 130 additions and 58 deletions
+4
View File
@@ -11549,6 +11549,9 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
color: #f7768e; color: #f7768e;
border-color: rgba(247, 118, 142, 0.55); border-color: rgba(247, 118, 142, 0.55);
} }
.tnl-icon-btn--refreshing svg {
animation: spin 0.8s linear infinite;
}
.tnl-row-icon-btn { .tnl-row-icon-btn {
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
@@ -11576,6 +11579,7 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
background: var(--bg3); background: var(--bg3);
} }
.tnl-chip--warn { background: rgba(239,83,80,0.15); color: #f7768e; } .tnl-chip--warn { background: rgba(239,83,80,0.15); color: #f7768e; }
.tnl-chip--selected { background: rgba(122,162,247,0.18); color: var(--accent, #7aa2f7); font-weight: 600; }
.tnl-filter { display: flex; gap: 4px; } .tnl-filter { display: flex; gap: 4px; }
.tnl-filter button, .tnl-filter button,
.tnl-btn { .tnl-btn {
@@ -41,6 +41,14 @@ const IcTrash = () => (
</svg> </svg>
); );
const IcRefresh = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
interface Props { interface Props {
theme: Theme; theme: Theme;
onGoToChart: (market: string) => void; onGoToChart: (market: string) => void;
@@ -77,7 +85,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
deleteNotification, deleteNotification,
deleteUnreadNotifications, deleteNotifications,
deleteAllNotifications, deleteAllNotifications,
openDetail, openDetail,
refreshHistory, refreshHistory,
@@ -91,12 +99,13 @@ export const TradeNotificationListPage: React.FC<Props> = ({
() => loadTradeNotificationGridPreset(), () => loadTradeNotificationGridPreset(),
); );
const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null); const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [rightTab, setRightTab] = useState<RightTab>('trade'); const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null); const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null); const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null); const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null); const orderAnchorRef = useRef<HTMLDivElement>(null);
const [refreshing, setRefreshing] = useState(false);
const { settings: appSettings } = useAppSettings(); const { settings: appSettings } = useAppSettings();
const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true; const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true;
@@ -150,37 +159,54 @@ export const TradeNotificationListPage: React.FC<Props> = ({
setRightTab('trade'); setRightTab('trade');
}, [tickers]); }, [tickers]);
const selectedCount = selectedIds.size;
const handleNotificationSelect = useCallback((item: TradeNotificationItem) => { const handleNotificationSelect = useCallback((item: TradeNotificationItem) => {
if (!item.isRead) markAsRead(item.id); if (!item.isRead) markAsRead(item.id);
setSelectedNotifyId(item.id); setSelectedIds(prev => {
const price = coerceFiniteNumber(item.price); const adding = !prev.has(item.id);
if (price != null && price > 0) { const next = new Set(prev);
fillBothSides(item.market, price, setFillBuy, setFillSell); if (adding) {
} else { next.add(item.id);
handleSelectMarket(item.market); const price = coerceFiniteNumber(item.price);
} if (price != null && price > 0) {
setRightTab('trade'); fillBothSides(item.market, price, setFillBuy, setFillSell);
} else {
handleSelectMarket(item.market);
}
setRightTab('trade');
} else {
next.delete(item.id);
}
return next;
});
}, [markAsRead, handleSelectMarket]); }, [markAsRead, handleSelectMarket]);
const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => { const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return; if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
await deleteNotification(item.id); await deleteNotification(item.id);
if (selectedNotifyId === item.id) setSelectedNotifyId(null); setSelectedIds(prev => {
}, [deleteNotification, selectedNotifyId]); if (!prev.has(item.id)) return prev;
const next = new Set(prev);
next.delete(item.id);
return next;
});
}, [deleteNotification]);
const handleDeleteUnread = useCallback(async () => { const handleDeleteSelected = useCallback(async () => {
if (unreadCount === 0) return; if (selectedCount === 0) return;
if (!window.confirm(`읽지 않은 알림 ${unreadCount}건을 삭제하시겠습니까?`)) return; const ids = [...selectedIds];
await deleteUnreadNotifications(); if (!window.confirm(`선택한 알림 ${ids.length}건을 삭제하시겠습니까?`)) return;
setSelectedNotifyId(null); await deleteNotifications(ids);
}, [unreadCount, deleteUnreadNotifications]); setSelectedIds(new Set());
}, [selectedCount, selectedIds, deleteNotifications]);
const handleDeleteAll = useCallback(async () => { const handleDeleteAll = useCallback(async () => {
if (allNotifications.length === 0) return; if (allNotifications.length === 0) return;
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return; if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
await deleteAllNotifications(); await deleteAllNotifications();
setSelectedNotifyId(null); setSelectedIds(new Set());
}, [allNotifications.length, deleteAllNotifications]); }, [allNotifications.length, deleteAllNotifications]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
@@ -200,6 +226,19 @@ export const TradeNotificationListPage: React.FC<Props> = ({
saveTradeNotificationListLayout(layout); saveTradeNotificationListLayout(layout);
}, []); }, []);
const handlePageRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
try {
await Promise.all([
refreshHistory({ serverOnly: true }),
refreshPaperData(),
]);
} finally {
setRefreshing(false);
}
}, [refreshing, refreshHistory, refreshPaperData]);
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => { const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
setGridPreset(preset); setGridPreset(preset);
saveTradeNotificationGridPreset(preset); saveTradeNotificationGridPreset(preset);
@@ -214,13 +253,11 @@ export const TradeNotificationListPage: React.FC<Props> = ({
<div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}> <div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
<div className="tnl-header"> <div className="tnl-header">
<h1 className="tnl-title"> </h1> <h1 className="tnl-title"> </h1>
<p className="tnl-sub">
{isListView
? '각 알림 왼쪽은 가상매매와 동일한 신호 상세 카드, 오른쪽은 캔들·지표 차트입니다. 영역 우측 버튼으로 가로 스크롤할 수 있습니다.'
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 툴바 우측에서 배치를 변경할 수 있습니다.`}
</p>
<div className="tnl-toolbar"> <div className="tnl-toolbar">
<span className="tnl-chip tnl-chip--warn"> {unreadCount}</span> <span className="tnl-chip tnl-chip--warn"> {unreadCount}</span>
{selectedCount > 0 && (
<span className="tnl-chip tnl-chip--selected"> {selectedCount}</span>
)}
<div className="tnl-filter"> <div className="tnl-filter">
<button <button
type="button" type="button"
@@ -237,9 +274,6 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</button> </button>
</div> </div>
<button type="button" className="tnl-btn" onClick={() => void refreshHistory()}>
</button>
{unreadCount > 0 && ( {unreadCount > 0 && (
<button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}> <button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}>
@@ -262,6 +296,16 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</button> </button>
</div> </div>
<button
type="button"
className={['tnl-icon-btn', refreshing ? 'tnl-icon-btn--refreshing' : ''].filter(Boolean).join(' ')}
title="화면 새로고침"
aria-label="화면 새로고침"
disabled={refreshing}
onClick={() => void handlePageRefresh()}
>
<IcRefresh />
</button>
<TradeNotificationGridLayoutPicker <TradeNotificationGridLayoutPicker
value={gridPreset} value={gridPreset}
onChange={handleGridPresetChange} onChange={handleGridPresetChange}
@@ -271,10 +315,10 @@ export const TradeNotificationListPage: React.FC<Props> = ({
<button <button
type="button" type="button"
className="tnl-icon-btn" className="tnl-icon-btn"
title="미확인 알림 삭제" title={selectedCount > 0 ? `선택 알림 삭제 (${selectedCount}건)` : '선택 알림 삭제'}
aria-label="미확인 알림 삭제" aria-label={selectedCount > 0 ? `선택 알림 ${selectedCount}건 삭제` : '선택 알림 삭제'}
disabled={unreadCount === 0} disabled={selectedCount === 0}
onClick={() => void handleDeleteUnread()} onClick={() => void handleDeleteSelected()}
> >
<IcTrash /> <IcTrash />
</button> </button>
@@ -310,7 +354,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
item={item} item={item}
theme={theme} theme={theme}
layoutMode={listLayout} layoutMode={listLayout}
isSelected={selectedNotifyId === item.id} isSelected={selectedIds.has(item.id)}
chartsEnabled={isListView} chartsEnabled={isListView}
chartLiveReceiveHighlight={chartLiveReceiveHighlight} chartLiveReceiveHighlight={chartLiveReceiveHighlight}
onSelect={() => handleNotificationSelect(item)} onSelect={() => handleNotificationSelect(item)}
@@ -197,6 +197,16 @@ const TradeNotificationListRow: React.FC<Props> = ({
[chartCardCount], [chartCardCount],
); );
const handleRowClick = useCallback((e: React.MouseEvent) => {
const t = e.target as HTMLElement;
if (t.closest(
'button, a, input, select, textarea, .tnl-chart-view-btn, .tnl-hscroll-pane__rail-btn',
)) {
return;
}
onSelect();
}, [onSelect]);
const summaryCard = ( const summaryCard = (
<article <article
className={[ className={[
@@ -207,11 +217,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
aria-label="알림 요약" aria-label="알림 요약"
> >
<button <div className="tnl-summary-panel" role="presentation">
type="button"
className="tnl-summary-panel"
onClick={onSelect}
>
<div className="tnl-summary-head"> <div className="tnl-summary-head">
<div className="tnl-summary-title"> <div className="tnl-summary-title">
<span className="tnl-summary-ko">{primary}</span> <span className="tnl-summary-ko">{primary}</span>
@@ -264,7 +270,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
{strategyLabel} {strategyLabel}
</span> </span>
</div> </div>
</button> </div>
<div className="tnl-summary-card-actions"> <div className="tnl-summary-card-actions">
<button <button
@@ -297,6 +303,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
isSelected ? 'tnl-row--selected' : '', isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '', receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={handleRowClick}
> >
{summaryCard} {summaryCard}
</li> </li>
@@ -313,6 +320,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
isSelected ? 'tnl-row--selected' : '', isSelected ? 'tnl-row--selected' : '',
receiving ? 'tnl-row--receiving' : '', receiving ? 'tnl-row--receiving' : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={handleRowClick}
> >
<div <div
className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')} className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}
@@ -57,6 +57,8 @@ interface TradeNotificationContextValue {
markAllAsRead: () => void; markAllAsRead: () => void;
/** 알림 목록에서 단건 삭제 */ /** 알림 목록에서 단건 삭제 */
deleteNotification: (id: string) => Promise<void>; deleteNotification: (id: string) => Promise<void>;
/** 지정 id 알림 일괄 삭제 (목록 선택 삭제) */
deleteNotifications: (ids: string[]) => Promise<void>;
/** 읽지 않은(미확인) 알림만 삭제 */ /** 읽지 않은(미확인) 알림만 삭제 */
deleteUnreadNotifications: () => Promise<void>; deleteUnreadNotifications: () => Promise<void>;
/** 알림 목록 전체 삭제 */ /** 알림 목록 전체 삭제 */
@@ -377,33 +379,33 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
?? toastNotificationsRef.current.find(n => n.id === id); ?? toastNotificationsRef.current.find(n => n.id === id);
}, [allNotifications]); }, [allNotifications]);
const deleteNotification = useCallback(async (id: string) => { const deleteNotifications = useCallback(async (ids: string[]) => {
const item = findNotificationById(id); const unique = [...new Set(ids)].filter(Boolean);
purgeNotifications([id]); if (unique.length === 0) return;
if (item?.dbId != null) { const dbIds: number[] = [];
try { for (const id of unique) {
await deleteTradeSignal(item.dbId); const item = findNotificationById(id);
} catch (e) { if (item?.dbId != null) dbIds.push(item.dbId);
console.warn('[TradeNotification] 서버 삭제 실패:', e);
}
} }
}, [findNotificationById, purgeNotifications]); purgeNotifications(unique);
const deleteUnreadNotifications = useCallback(async () => {
const snapshot = [...allNotifications];
const unread = snapshot.filter(n => !n.isRead);
if (unread.length === 0) return;
const ids = unread.map(n => n.id);
const dbIds = unread.map(n => n.dbId).filter((x): x is number => x != null);
purgeNotifications(ids);
if (dbIds.length > 0) { if (dbIds.length > 0) {
try { try {
await deleteTradeSignalsBatch(dbIds); await deleteTradeSignalsBatch(dbIds);
} catch (e) { } catch (e) {
console.warn('[TradeNotification] 미확인 일괄 삭제 실패:', e); console.warn('[TradeNotification] 일괄 삭제 실패:', e);
} }
} }
}, [allNotifications, purgeNotifications]); }, [findNotificationById, purgeNotifications]);
const deleteNotification = useCallback(async (id: string) => {
await deleteNotifications([id]);
}, [deleteNotifications]);
const deleteUnreadNotifications = useCallback(async () => {
const unread = allNotifications.filter(n => !n.isRead);
if (unread.length === 0) return;
await deleteNotifications(unread.map(n => n.id));
}, [allNotifications, deleteNotifications]);
const deleteAllNotifications = useCallback(async () => { const deleteAllNotifications = useCallback(async () => {
const ids = [ const ids = [
@@ -454,6 +456,7 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
deleteNotification, deleteNotification,
deleteNotifications,
deleteUnreadNotifications, deleteUnreadNotifications,
deleteAllNotifications, deleteAllNotifications,
refreshHistory, refreshHistory,
@@ -1033,12 +1033,25 @@ ul.tnl-list.tnl-list--grid {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
} }
.tnl-row--gallery.tnl-row--selected,
.tnl-row--grid.tnl-row--selected {
border-color: var(--accent, #7aa2f7) !important;
box-shadow:
0 0 0 2px color-mix(in srgb, var(--accent, #7aa2f7) 45%, transparent),
0 0 12px 2px color-mix(in srgb, var(--accent, #7aa2f7) 18%, transparent);
}
.tnl-row--gallery.tnl-row--selected .tnl-hscroll-pane, .tnl-row--gallery.tnl-row--selected .tnl-hscroll-pane,
.tnl-row--gallery.tnl-row--selected .tnl-summary-card--selected { .tnl-row--gallery.tnl-row--selected .tnl-summary-card--selected {
border-color: var(--accent, #7aa2f7); border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
} }
.tnl-row--gallery,
.tnl-row--grid {
cursor: pointer;
}
.tnl-list--gallery .tnl-row--gallery { .tnl-list--gallery .tnl-row--gallery {
width: 100%; width: 100%;
} }