diff --git a/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java
index 6620636..0b8cad7 100644
--- a/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java
+++ b/backend/src/main/java/com/goldenchart/controller/TradeSignalController.java
@@ -12,8 +12,11 @@ import java.util.List;
* 매매 시그널 이력 REST API.
*
*
- * GET /api/trade-signals → 전체 이력
- * GET /api/trade-signals?market= → 종목별 이력
+ * GET /api/trade-signals → 전체 이력
+ * GET /api/trade-signals?market= → 종목별 이력
+ * DELETE /api/trade-signals/{id} → 단건 삭제
+ * DELETE /api/trade-signals → 전체 삭제
+ * POST /api/trade-signals/delete-batch → ID 목록 삭제
*
*/
@RestController
@@ -36,6 +39,32 @@ public class TradeSignalController {
return ResponseEntity.ok(result);
}
+ @DeleteMapping("/{id}")
+ public ResponseEntity 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> 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> deleteBatch(
+ @RequestBody java.util.List 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) {
if (h == null || h.isBlank()) return null;
try { return Long.parseLong(h); } catch (NumberFormatException e) { return null; }
diff --git a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java
index 0b291ac..c264d32 100644
--- a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java
+++ b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java
@@ -2,6 +2,9 @@ package com.goldenchart.repository;
import com.goldenchart.entity.GcTradeSignal;
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;
@@ -10,4 +13,13 @@ public interface GcTradeSignalRepository extends JpaRepository findByDeviceIdOrderByCreatedAtDesc(String deviceId);
List findByUserIdOrderByCreatedAtDesc(Long userId);
List 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);
+
}
diff --git a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java
index 86efedc..12e11a5 100644
--- a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java
+++ b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java
@@ -69,6 +69,52 @@ public class TradeSignalService {
.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 ids, Long userId, String deviceId) {
+ if (ids == null || ids.isEmpty()) return 0;
+ List 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) {
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 76394c8..3561c16 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -11317,6 +11317,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
.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-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 {
font-size: 12px;
padding: 4px 10px;
diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx
index 308acc6..b83badf 100644
--- a/frontend/src/components/TradeNotificationListPage.tsx
+++ b/frontend/src/components/TradeNotificationListPage.tsx
@@ -17,6 +17,15 @@ import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook';
+const IcTrash = () => (
+
+);
+
interface Props {
theme: Theme;
onGoToChart: (market: string) => void;
@@ -52,6 +61,9 @@ export const TradeNotificationListPage: React.FC = ({
unreadCount,
markAsRead,
markAllAsRead,
+ deleteNotification,
+ deleteUnreadNotifications,
+ deleteAllNotifications,
openDetail,
refreshHistory,
} = useTradeNotification();
@@ -127,6 +139,27 @@ export const TradeNotificationListPage: React.FC = ({
setRightTab('trade');
}, [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') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
@@ -174,6 +207,27 @@ export const TradeNotificationListPage: React.FC = ({
모두 읽음
)}
+
+
+
@@ -213,6 +267,15 @@ export const TradeNotificationListPage: React.FC = ({
{!item.isRead && }
+