알림삭제 기능 추가
This commit is contained in:
@@ -12,8 +12,11 @@ import java.util.List;
|
|||||||
* 매매 시그널 이력 REST API.
|
* 매매 시그널 이력 REST API.
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* GET /api/trade-signals → 전체 이력
|
* GET /api/trade-signals → 전체 이력
|
||||||
* GET /api/trade-signals?market= → 종목별 이력
|
* GET /api/trade-signals?market= → 종목별 이력
|
||||||
|
* DELETE /api/trade-signals/{id} → 단건 삭제
|
||||||
|
* DELETE /api/trade-signals → 전체 삭제
|
||||||
|
* POST /api/trade-signals/delete-batch → ID 목록 삭제
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -36,6 +39,32 @@ public class TradeSignalController {
|
|||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteOne(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||||
|
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||||
|
service.deleteOne(id, parseUserId(userIdHeader), deviceId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping
|
||||||
|
public ResponseEntity<java.util.Map<String, Integer>> deleteAll(
|
||||||
|
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||||
|
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||||
|
int deleted = service.deleteAll(parseUserId(userIdHeader), deviceId);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("deleted", deleted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/delete-batch")
|
||||||
|
public ResponseEntity<java.util.Map<String, Integer>> deleteBatch(
|
||||||
|
@RequestBody java.util.List<Long> ids,
|
||||||
|
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||||
|
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
|
||||||
|
int deleted = service.deleteBatch(ids, parseUserId(userIdHeader), deviceId);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("deleted", deleted));
|
||||||
|
}
|
||||||
|
|
||||||
private Long parseUserId(String h) {
|
private Long parseUserId(String h) {
|
||||||
if (h == null || h.isBlank()) return null;
|
if (h == null || h.isBlank()) return null;
|
||||||
try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; }
|
try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; }
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.goldenchart.repository;
|
|||||||
|
|
||||||
import com.goldenchart.entity.GcTradeSignal;
|
import com.goldenchart.entity.GcTradeSignal;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -10,4 +13,13 @@ public interface GcTradeSignalRepository extends JpaRepository<GcTradeSignal, Lo
|
|||||||
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
List<GcTradeSignal> findByDeviceIdOrderByCreatedAtDesc(String deviceId);
|
||||||
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
|
List<GcTradeSignal> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
List<GcTradeSignal> findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId")
|
||||||
|
void deleteByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM GcTradeSignal e WHERE e.deviceId = :deviceId AND (e.userId IS NULL OR e.userId = 0)")
|
||||||
|
void deleteByDeviceId(@Param("deviceId") String deviceId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,52 @@ public class TradeSignalService {
|
|||||||
.stream().map(this::toDto).toList();
|
.stream().map(this::toDto).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 삭제 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public void deleteOne(Long id, Long userId, String deviceId) {
|
||||||
|
if (id == null) return;
|
||||||
|
repo.findById(id).ifPresent(entity -> {
|
||||||
|
if (!owns(entity, userId, deviceId)) return;
|
||||||
|
repo.delete(entity);
|
||||||
|
log.info("[TradeSignal] deleted id={} market={}", id, entity.getMarket());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int deleteBatch(List<Long> ids, Long userId, String deviceId) {
|
||||||
|
if (ids == null || ids.isEmpty()) return 0;
|
||||||
|
List<GcTradeSignal> toDelete = ids.stream()
|
||||||
|
.distinct()
|
||||||
|
.map(repo::findById)
|
||||||
|
.filter(java.util.Optional::isPresent)
|
||||||
|
.map(java.util.Optional::get)
|
||||||
|
.filter(e -> owns(e, userId, deviceId))
|
||||||
|
.toList();
|
||||||
|
if (toDelete.isEmpty()) return 0;
|
||||||
|
repo.deleteAll(toDelete);
|
||||||
|
log.info("[TradeSignal] batch deleted count={}", toDelete.size());
|
||||||
|
return toDelete.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int deleteAll(Long userId, String deviceId) {
|
||||||
|
if (userId != null) {
|
||||||
|
long count = repo.findByUserIdOrderByCreatedAtDesc(userId).size();
|
||||||
|
repo.deleteByUserId(userId);
|
||||||
|
log.info("[TradeSignal] deleted all for userId={} count≈{}", userId, count);
|
||||||
|
return (int) count;
|
||||||
|
}
|
||||||
|
String dev = deviceId != null ? deviceId : "";
|
||||||
|
long count = repo.findByDeviceIdOrderByCreatedAtDesc(dev).size();
|
||||||
|
repo.deleteByDeviceId(dev);
|
||||||
|
log.info("[TradeSignal] deleted all for deviceId={} count≈{}", dev, count);
|
||||||
|
return (int) count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean owns(GcTradeSignal entity, Long userId, String deviceId) {
|
||||||
|
if (userId != null && userId.equals(entity.getUserId())) return true;
|
||||||
|
if (deviceId == null || deviceId.isBlank()) return false;
|
||||||
|
return deviceId.equals(entity.getDeviceId());
|
||||||
|
}
|
||||||
|
|
||||||
// ── 변환 ─────────────────────────────────────────────────────────────────
|
// ── 변환 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private TradeSignalDto toDto(GcTradeSignal e) {
|
private TradeSignalDto toDto(GcTradeSignal e) {
|
||||||
|
|||||||
@@ -11317,6 +11317,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
.tnl-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
|
.tnl-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
|
||||||
.tnl-sub { font-size: 13px; color: var(--text3); margin: 0 0 14px; }
|
.tnl-sub { font-size: 13px; color: var(--text3); margin: 0 0 14px; }
|
||||||
.tnl-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
.tnl-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||||
|
.tnl-toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tnl-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tnl-icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg4);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent, #7aa2f7);
|
||||||
|
}
|
||||||
|
.tnl-icon-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.tnl-icon-btn--danger:hover:not(:disabled) {
|
||||||
|
color: #f7768e;
|
||||||
|
border-color: rgba(247, 118, 142, 0.55);
|
||||||
|
}
|
||||||
|
.tnl-row-icon-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tnl-row-icon-btn:hover {
|
||||||
|
background: rgba(247, 118, 142, 0.12);
|
||||||
|
color: #f7768e;
|
||||||
|
}
|
||||||
.tnl-chip {
|
.tnl-chip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import '../styles/virtualTradingDashboard.css';
|
|||||||
|
|
||||||
type RightTab = 'trade' | 'orderbook';
|
type RightTab = 'trade' | 'orderbook';
|
||||||
|
|
||||||
|
const IcTrash = () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17" />
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
onGoToChart: (market: string) => void;
|
onGoToChart: (market: string) => void;
|
||||||
@@ -52,6 +61,9 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
unreadCount,
|
unreadCount,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
deleteUnreadNotifications,
|
||||||
|
deleteAllNotifications,
|
||||||
openDetail,
|
openDetail,
|
||||||
refreshHistory,
|
refreshHistory,
|
||||||
} = useTradeNotification();
|
} = useTradeNotification();
|
||||||
@@ -127,6 +139,27 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
setRightTab('trade');
|
setRightTab('trade');
|
||||||
}, [markAsRead, handleSelectMarket]);
|
}, [markAsRead, handleSelectMarket]);
|
||||||
|
|
||||||
|
const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!window.confirm('이 알림을 삭제하시겠습니까?')) return;
|
||||||
|
await deleteNotification(item.id);
|
||||||
|
if (selectedNotifyId === item.id) setSelectedNotifyId(null);
|
||||||
|
}, [deleteNotification, selectedNotifyId]);
|
||||||
|
|
||||||
|
const handleDeleteUnread = useCallback(async () => {
|
||||||
|
if (unreadCount === 0) return;
|
||||||
|
if (!window.confirm(`읽지 않은 알림 ${unreadCount}건을 삭제하시겠습니까?`)) return;
|
||||||
|
await deleteUnreadNotifications();
|
||||||
|
setSelectedNotifyId(null);
|
||||||
|
}, [unreadCount, deleteUnreadNotifications]);
|
||||||
|
|
||||||
|
const handleDeleteAll = useCallback(async () => {
|
||||||
|
if (allNotifications.length === 0) return;
|
||||||
|
if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return;
|
||||||
|
await deleteAllNotifications();
|
||||||
|
setSelectedNotifyId(null);
|
||||||
|
}, [allNotifications.length, deleteAllNotifications]);
|
||||||
|
|
||||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||||
setRightTab('trade');
|
setRightTab('trade');
|
||||||
const side = rowType === 'bid' ? 'buy' : 'sell';
|
const side = rowType === 'bid' ? 'buy' : 'sell';
|
||||||
@@ -174,6 +207,27 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
모두 읽음
|
모두 읽음
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<span className="tnl-toolbar-divider" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-icon-btn"
|
||||||
|
title="미확인 알림 삭제"
|
||||||
|
aria-label="미확인 알림 삭제"
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
onClick={() => void handleDeleteUnread()}
|
||||||
|
>
|
||||||
|
<IcTrash />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-icon-btn tnl-icon-btn--danger"
|
||||||
|
title="모두 삭제"
|
||||||
|
aria-label="모두 삭제"
|
||||||
|
disabled={allNotifications.length === 0}
|
||||||
|
onClick={() => void handleDeleteAll()}
|
||||||
|
>
|
||||||
|
<IcTrash />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,6 +267,15 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
</span>
|
</span>
|
||||||
{!item.isRead && <span className="tnl-dot" aria-hidden />}
|
{!item.isRead && <span className="tnl-dot" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tnl-row-icon-btn"
|
||||||
|
title="삭제"
|
||||||
|
aria-label="알림 삭제"
|
||||||
|
onClick={e => void handleDeleteOne(item, e)}
|
||||||
|
>
|
||||||
|
<IcTrash />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tnl-row-action"
|
className="tnl-row-action"
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { TradeSignalInfo } from '../components/TradeAlertModal';
|
import type { TradeSignalInfo } from '../components/TradeAlertModal';
|
||||||
import { loadTradeSignals, type TradeSignalDto } from '../utils/backendApi';
|
import {
|
||||||
|
deleteAllTradeSignals,
|
||||||
|
deleteTradeSignal,
|
||||||
|
deleteTradeSignalsBatch,
|
||||||
|
loadTradeSignals,
|
||||||
|
type TradeSignalDto,
|
||||||
|
} from '../utils/backendApi';
|
||||||
import {
|
import {
|
||||||
normalizeTradeAlertSoundId,
|
normalizeTradeAlertSoundId,
|
||||||
playTradeAlertSound,
|
playTradeAlertSound,
|
||||||
@@ -22,6 +28,7 @@ import {
|
|||||||
} from '../utils/tradeAlertSound';
|
} from '../utils/tradeAlertSound';
|
||||||
|
|
||||||
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
||||||
|
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
|
||||||
const MAX_HISTORY = 300;
|
const MAX_HISTORY = 300;
|
||||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||||
const MAX_TOAST_QUEUE = 50;
|
const MAX_TOAST_QUEUE = 50;
|
||||||
@@ -48,6 +55,12 @@ interface TradeNotificationContextValue {
|
|||||||
dismissAllToasts: () => void;
|
dismissAllToasts: () => void;
|
||||||
markAsRead: (id: string) => void;
|
markAsRead: (id: string) => void;
|
||||||
markAllAsRead: () => void;
|
markAllAsRead: () => void;
|
||||||
|
/** 알림 목록에서 단건 삭제 */
|
||||||
|
deleteNotification: (id: string) => Promise<void>;
|
||||||
|
/** 읽지 않은(미확인) 알림만 삭제 */
|
||||||
|
deleteUnreadNotifications: () => Promise<void>;
|
||||||
|
/** 알림 목록 전체 삭제 */
|
||||||
|
deleteAllNotifications: () => Promise<void>;
|
||||||
refreshHistory: () => Promise<void>;
|
refreshHistory: () => Promise<void>;
|
||||||
detailSignal: TradeSignalInfo | null;
|
detailSignal: TradeSignalInfo | null;
|
||||||
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
/** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */
|
||||||
@@ -96,6 +109,22 @@ function saveReadIds(ids: Set<string>) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadHiddenIds(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
return new Set(JSON.parse(raw) as string[]);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHiddenIds(ids: Set<string>) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...ids]));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function useTradeNotification(): TradeNotificationContextValue {
|
export function useTradeNotification(): TradeNotificationContextValue {
|
||||||
const ctx = useContext(TradeNotificationContext);
|
const ctx = useContext(TradeNotificationContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
@@ -141,7 +170,10 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
const dtos = await loadTradeSignals();
|
const dtos = await loadTradeSignals();
|
||||||
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
|
/** dismiss 직후 비동기 완료 시 state readIds가 늦게 반영되는 것 방지 */
|
||||||
const read = loadReadIds();
|
const read = loadReadIds();
|
||||||
const items = dtos.map(d => dtoToItem(d, read));
|
const hidden = loadHiddenIds();
|
||||||
|
const items = dtos
|
||||||
|
.map(d => dtoToItem(d, read))
|
||||||
|
.filter(item => !hidden.has(item.id));
|
||||||
|
|
||||||
let newlyArrived: TradeNotificationItem[] = [];
|
let newlyArrived: TradeNotificationItem[] = [];
|
||||||
|
|
||||||
@@ -156,10 +188,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
map.set(n.id, wasRead ? { ...n, isRead: true } : n);
|
map.set(n.id, wasRead ? { ...n, isRead: true } : n);
|
||||||
}
|
}
|
||||||
for (const n of prev) {
|
for (const n of prev) {
|
||||||
|
if (hidden.has(n.id)) continue;
|
||||||
if (!map.has(n.id)) map.set(n.id, n);
|
if (!map.has(n.id)) map.set(n.id, n);
|
||||||
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true });
|
||||||
}
|
}
|
||||||
return [...map.values()]
|
return [...map.values()]
|
||||||
|
.filter(n => !hidden.has(n.id))
|
||||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||||
.slice(0, MAX_HISTORY);
|
.slice(0, MAX_HISTORY);
|
||||||
});
|
});
|
||||||
@@ -312,6 +346,86 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
setToastNotifications([]);
|
setToastNotifications([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const purgeNotifications = useCallback((ids: string[]) => {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const hidden = loadHiddenIds();
|
||||||
|
ids.forEach(id => hidden.add(id));
|
||||||
|
saveHiddenIds(hidden);
|
||||||
|
|
||||||
|
setReadIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
ids.forEach(id => next.delete(id));
|
||||||
|
saveReadIds(next);
|
||||||
|
readIdsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setToastNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
||||||
|
setAllNotifications(prev => prev.filter(n => !idSet.has(n.id)));
|
||||||
|
if (detailSignal) {
|
||||||
|
const detailId = makeId({
|
||||||
|
market: detailSignal.market,
|
||||||
|
candleTime: detailSignal.candleTime,
|
||||||
|
signalType: detailSignal.signalType,
|
||||||
|
});
|
||||||
|
if (idSet.has(detailId)) {
|
||||||
|
setDetailSignal(null);
|
||||||
|
setDetailCentered(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [detailSignal]);
|
||||||
|
|
||||||
|
const findNotificationById = useCallback((id: string): TradeNotificationItem | undefined => {
|
||||||
|
return allNotifications.find(n => n.id === id)
|
||||||
|
?? toastNotificationsRef.current.find(n => n.id === id);
|
||||||
|
}, [allNotifications]);
|
||||||
|
|
||||||
|
const deleteNotification = useCallback(async (id: string) => {
|
||||||
|
const item = findNotificationById(id);
|
||||||
|
purgeNotifications([id]);
|
||||||
|
if (item?.dbId != null) {
|
||||||
|
try {
|
||||||
|
await deleteTradeSignal(item.dbId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[TradeNotification] 서버 삭제 실패:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [findNotificationById, purgeNotifications]);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
await deleteTradeSignalsBatch(dbIds);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[TradeNotification] 미확인 일괄 삭제 실패:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [allNotifications, purgeNotifications]);
|
||||||
|
|
||||||
|
const deleteAllNotifications = useCallback(async () => {
|
||||||
|
const ids = [
|
||||||
|
...new Set([
|
||||||
|
...allNotifications.map(n => n.id),
|
||||||
|
...toastNotificationsRef.current.map(n => n.id),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
purgeNotifications(ids);
|
||||||
|
setToastNotifications([]);
|
||||||
|
saveReadIds(new Set());
|
||||||
|
saveHiddenIds(new Set());
|
||||||
|
try {
|
||||||
|
await deleteAllTradeSignals();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[TradeNotification] 전체 삭제 실패:', e);
|
||||||
|
}
|
||||||
|
}, [allNotifications, purgeNotifications]);
|
||||||
|
|
||||||
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
|
const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => {
|
||||||
setDetailCentered(options?.centered ?? false);
|
setDetailCentered(options?.centered ?? false);
|
||||||
setDetailSignal({
|
setDetailSignal({
|
||||||
@@ -342,6 +456,9 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
|
|||||||
dismissAllToasts,
|
dismissAllToasts,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
deleteUnreadNotifications,
|
||||||
|
deleteAllNotifications,
|
||||||
refreshHistory,
|
refreshHistory,
|
||||||
detailSignal,
|
detailSignal,
|
||||||
detailCentered,
|
detailCentered,
|
||||||
|
|||||||
@@ -654,6 +654,27 @@ export async function loadTradeSignals(market?: string): Promise<TradeSignalDto[
|
|||||||
return (await request<TradeSignalDto[]>(`/trade-signals${q}`)) ?? [];
|
return (await request<TradeSignalDto[]>(`/trade-signals${q}`)) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 매매 시그널 단건 삭제 */
|
||||||
|
export async function deleteTradeSignal(id: number): Promise<void> {
|
||||||
|
await requestOrThrow<void>(`/trade-signals/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매매 시그널 ID 목록 일괄 삭제 */
|
||||||
|
export async function deleteTradeSignalsBatch(ids: number[]): Promise<number> {
|
||||||
|
if (ids.length === 0) return 0;
|
||||||
|
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals/delete-batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(ids),
|
||||||
|
});
|
||||||
|
return res?.deleted ?? ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매매 시그널 이력 전체 삭제 */
|
||||||
|
export async function deleteAllTradeSignals(): Promise<number> {
|
||||||
|
const res = await requestOrThrow<{ deleted?: number }>('/trade-signals', { method: 'DELETE' });
|
||||||
|
return res?.deleted ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱 시작 시 기본 설정 로드.
|
* 앱 시작 시 기본 설정 로드.
|
||||||
* DB 에 없으면 백엔드 엔티티 기본값(KRW-BTC / 1D / dark 등)을 반환.
|
* DB 에 없으면 백엔드 엔티티 기본값(KRW-BTC / 1D / dark 등)을 반환.
|
||||||
|
|||||||
Reference in New Issue
Block a user