This commit is contained in:
parent
d58e80a962
commit
70d763beca
|
|
@ -28,48 +28,130 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Trash2, Eye, RefreshCw, TrendingUp, FileText, Download } from "lucide-react";
|
import {
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
TrendingUp,
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getReportsByMarketType,
|
getReportsByMarketType,
|
||||||
deleteReport,
|
deleteReport,
|
||||||
getReportCountByMarketType,
|
getReportCountByMarketType,
|
||||||
type SavedReport,
|
type SavedReport,
|
||||||
} from "@/lib/reports-db";
|
} from "@/lib/reports-db";
|
||||||
import { getCloudReports, deleteCloudReport, saveCloudReport, isCloudSyncEnabled } from "@/lib/user-api";
|
import {
|
||||||
|
getCloudReports,
|
||||||
|
deleteCloudReport,
|
||||||
|
saveCloudReport,
|
||||||
|
isCloudSyncEnabled,
|
||||||
|
} from "@/lib/user-api";
|
||||||
// import { LoginPrompt } from "@/components/auth/login-button";
|
// import { LoginPrompt } from "@/components/auth/login-button";
|
||||||
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
|
import { PendingTaskRecovery } from "@/components/PendingTaskRecovery";
|
||||||
|
|
||||||
// Analyst definitions for download
|
// Analyst definitions for download
|
||||||
const ANALYSTS = [
|
const ANALYSTS = [
|
||||||
{ key: "market", label: "市場分析師", reportKey: "market_report", description: "技術分析與市場趨勢評估" },
|
{
|
||||||
{ key: "social", label: "社群媒體分析師", reportKey: "sentiment_report", description: "社群情緒與市場氛圍分析" },
|
key: "market",
|
||||||
{ key: "news", label: "新聞分析師", reportKey: "news_report", description: "新聞事件與影響分析" },
|
label: "市場分析師",
|
||||||
{ key: "fundamentals", label: "基本面分析師", reportKey: "fundamentals_report", description: "財務數據與基本面分析" },
|
reportKey: "market_report",
|
||||||
{ key: "bull", label: "看漲研究員", reportKey: "investment_debate_state.bull_history", description: "看漲觀點與投資論據" },
|
description: "技術分析與市場趨勢評估",
|
||||||
{ key: "bear", label: "看跌研究員", reportKey: "investment_debate_state.bear_history", description: "看跌觀點與風險警告" },
|
},
|
||||||
{ key: "research_manager", label: "研究經理", reportKey: "investment_debate_state.judge_decision", description: "研究團隊綜合決策" },
|
{
|
||||||
{ key: "trader", label: "交易員", reportKey: "trader_investment_plan", description: "交易執行計劃與策略" },
|
key: "social",
|
||||||
{ key: "risky", label: "激進分析師", reportKey: "risk_debate_state.risky_history", description: "高風險高回報策略分析" },
|
label: "社群媒體分析師",
|
||||||
{ key: "safe", label: "保守分析師", reportKey: "risk_debate_state.safe_history", description: "穩健保守策略分析" },
|
reportKey: "sentiment_report",
|
||||||
{ key: "neutral", label: "中立分析師", reportKey: "risk_debate_state.neutral_history", description: "中立平衡策略分析" },
|
description: "社群情緒與市場氛圍分析",
|
||||||
{ key: "risk_manager", label: "風險經理", reportKey: "risk_debate_state.judge_decision", description: "風險管理綜合決策" },
|
},
|
||||||
|
{
|
||||||
|
key: "news",
|
||||||
|
label: "新聞分析師",
|
||||||
|
reportKey: "news_report",
|
||||||
|
description: "新聞事件與影響分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fundamentals",
|
||||||
|
label: "基本面分析師",
|
||||||
|
reportKey: "fundamentals_report",
|
||||||
|
description: "財務數據與基本面分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bull",
|
||||||
|
label: "看漲研究員",
|
||||||
|
reportKey: "investment_debate_state.bull_history",
|
||||||
|
description: "看漲觀點與投資論據",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bear",
|
||||||
|
label: "看跌研究員",
|
||||||
|
reportKey: "investment_debate_state.bear_history",
|
||||||
|
description: "看跌觀點與風險警告",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "research_manager",
|
||||||
|
label: "研究經理",
|
||||||
|
reportKey: "investment_debate_state.judge_decision",
|
||||||
|
description: "研究團隊綜合決策",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "trader",
|
||||||
|
label: "交易員",
|
||||||
|
reportKey: "trader_investment_plan",
|
||||||
|
description: "交易執行計劃與策略",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "risky",
|
||||||
|
label: "激進分析師",
|
||||||
|
reportKey: "risk_debate_state.risky_history",
|
||||||
|
description: "高風險高回報策略分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "safe",
|
||||||
|
label: "保守分析師",
|
||||||
|
reportKey: "risk_debate_state.safe_history",
|
||||||
|
description: "穩健保守策略分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "neutral",
|
||||||
|
label: "中立分析師",
|
||||||
|
reportKey: "risk_debate_state.neutral_history",
|
||||||
|
description: "中立平衡策略分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "risk_manager",
|
||||||
|
label: "風險經理",
|
||||||
|
reportKey: "risk_debate_state.judge_decision",
|
||||||
|
description: "風險管理綜合決策",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Market type labels - dynamic function to support translations
|
// Market type labels - dynamic function to support translations
|
||||||
const getMarketLabels = (t: ReturnType<typeof useLanguage>['t']) => ({
|
const getMarketLabels = (t: ReturnType<typeof useLanguage>["t"]) => ({
|
||||||
us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS },
|
us: { label: `🇺🇸 ${t.form.usMarket}`, description: t.form.tickerDescUS },
|
||||||
twse: { label: `🇹🇼 ${t.form.twseMarket}`, description: t.form.tickerDescTWSE },
|
twse: {
|
||||||
tpex: { label: `🇹🇼 ${t.form.tpexMarket}`, description: t.form.tickerDescTPEX },
|
label: `🇹🇼 ${t.form.twseMarket}`,
|
||||||
|
description: t.form.tickerDescTWSE,
|
||||||
|
},
|
||||||
|
tpex: {
|
||||||
|
label: `🇹🇼 ${t.form.tpexMarket}`,
|
||||||
|
description: t.form.tickerDescTPEX,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to extract decision from Risk Manager's final decision
|
// Helper function to extract decision from Risk Manager's final decision
|
||||||
const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => {
|
const extractDecisionFromReport = (
|
||||||
|
report: SavedReport,
|
||||||
|
): { action: string; color: string } => {
|
||||||
// DEBUG: Log the actual data structure to diagnose issues
|
// DEBUG: Log the actual data structure to diagnose issues
|
||||||
console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker);
|
console.log("📊 DEBUG extractDecisionFromReport for:", report.ticker);
|
||||||
console.log(" - result type:", typeof report.result);
|
console.log(" - result type:", typeof report.result);
|
||||||
console.log(" - result.reports exists:", !!report.result?.reports);
|
console.log(" - result.reports exists:", !!report.result?.reports);
|
||||||
console.log(" - trader_investment_plan exists:", !!report.result?.reports?.trader_investment_plan);
|
console.log(
|
||||||
|
" - trader_investment_plan exists:",
|
||||||
|
!!report.result?.reports?.trader_investment_plan,
|
||||||
|
);
|
||||||
console.log(" - decision.action exists:", !!report.result?.decision?.action);
|
console.log(" - decision.action exists:", !!report.result?.decision?.action);
|
||||||
|
|
||||||
if (report.result?.reports?.trader_investment_plan) {
|
if (report.result?.reports?.trader_investment_plan) {
|
||||||
|
|
@ -85,64 +167,83 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
|
||||||
console.log(" - trader_investment_plan is NULL or undefined");
|
console.log(" - trader_investment_plan is NULL or undefined");
|
||||||
}
|
}
|
||||||
// Helper function to find "最終交易提案" or "Final Trading Proposal"
|
// Helper function to find "最終交易提案" or "Final Trading Proposal"
|
||||||
const findFinalProposal = (text: string): { action: string; color: string } | null => {
|
const findFinalProposal = (
|
||||||
if (!text || typeof text !== 'string') return null;
|
text: string,
|
||||||
|
): { action: string; color: string } | null => {
|
||||||
|
if (!text || typeof text !== "string") return null;
|
||||||
|
|
||||||
// === CHINESE PATTERN ===
|
// === CHINESE PATTERN ===
|
||||||
// Match "最終交易提案:持有" - handle markdown ** bold markers
|
// Match "最終交易提案:持有" - handle markdown ** bold markers
|
||||||
// Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有**
|
// Pattern handles: 最終交易提案:持有, 最終交易提案:**持有**, **最終交易提案:持有**
|
||||||
// Use global flag to find ALL matches, then take the LAST one (final decision)
|
// Use global flag to find ALL matches, then take the LAST one (final decision)
|
||||||
const zhRegex = /\*{0,2}最終交易提案[::]\s*\*{0,2}(買入|賣出|持有)\*{0,2}/g;
|
const zhRegex =
|
||||||
|
/\*{0,2}最終交易提案[::]\s*\*{0,2}(買入|賣出|持有)\*{0,2}/g;
|
||||||
const zhMatches = [...text.matchAll(zhRegex)];
|
const zhMatches = [...text.matchAll(zhRegex)];
|
||||||
|
|
||||||
if (zhMatches.length > 0) {
|
if (zhMatches.length > 0) {
|
||||||
// Take the LAST match (the final decision at the end of the report)
|
// Take the LAST match (the final decision at the end of the report)
|
||||||
const lastMatch = zhMatches[zhMatches.length - 1];
|
const lastMatch = zhMatches[zhMatches.length - 1];
|
||||||
const decision = lastMatch[1];
|
const decision = lastMatch[1];
|
||||||
console.log(` ✅ Matched ZH pattern: "${lastMatch[0]}" -> decision: "${decision}"`);
|
console.log(
|
||||||
if (decision === "買入") return { action: "買入", color: "text-green-600" };
|
` ✅ Matched ZH pattern: "${lastMatch[0]}" -> decision: "${decision}"`,
|
||||||
|
);
|
||||||
|
if (decision === "買入")
|
||||||
|
return { action: "買入", color: "text-green-600" };
|
||||||
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
|
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
|
||||||
if (decision === "持有") return { action: "持有", color: "text-yellow-600" };
|
if (decision === "持有")
|
||||||
|
return { action: "持有", color: "text-yellow-600" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// === ENGLISH PATTERN ===
|
// === ENGLISH PATTERN ===
|
||||||
// Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers
|
// Match "Final Trading Proposal: BUY/SELL/HOLD" - handle markdown ** bold markers
|
||||||
// Pattern handles: Final Trading Proposal: Buy, **Final Trading Proposal**: Hold, etc.
|
// Pattern handles: Final Trading Proposal: Buy, **Final Trading Proposal**: Hold, etc.
|
||||||
const enRegex = /\*{0,2}Final Trading Proposal\*{0,2}[::]\s*\*{0,2}(BUY|SELL|HOLD|Buy|Sell|Hold)\*{0,2}/gi;
|
const enRegex =
|
||||||
|
/\*{0,2}Final Trading Proposal\*{0,2}[::]\s*\*{0,2}(BUY|SELL|HOLD|Buy|Sell|Hold)\*{0,2}/gi;
|
||||||
const enMatches = [...text.matchAll(enRegex)];
|
const enMatches = [...text.matchAll(enRegex)];
|
||||||
|
|
||||||
if (enMatches.length > 0) {
|
if (enMatches.length > 0) {
|
||||||
const lastMatch = enMatches[enMatches.length - 1];
|
const lastMatch = enMatches[enMatches.length - 1];
|
||||||
const decision = lastMatch[1].toUpperCase();
|
const decision = lastMatch[1].toUpperCase();
|
||||||
console.log(` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`);
|
console.log(
|
||||||
|
` ✅ Matched EN pattern: "${lastMatch[0]}" -> decision: "${decision}"`,
|
||||||
|
);
|
||||||
if (decision === "BUY") return { action: "BUY", color: "text-green-600" };
|
if (decision === "BUY") return { action: "BUY", color: "text-green-600" };
|
||||||
if (decision === "SELL") return { action: "SELL", color: "text-red-600" };
|
if (decision === "SELL") return { action: "SELL", color: "text-red-600" };
|
||||||
if (decision === "HOLD") return { action: "HOLD", color: "text-yellow-600" };
|
if (decision === "HOLD")
|
||||||
|
return { action: "HOLD", color: "text-yellow-600" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to find other decision patterns
|
// Helper function to find other decision patterns
|
||||||
const findOtherDecision = (text: string): { action: string; color: string } | null => {
|
const findOtherDecision = (
|
||||||
if (!text || typeof text !== 'string') return null;
|
text: string,
|
||||||
|
): { action: string; color: string } | null => {
|
||||||
|
if (!text || typeof text !== "string") return null;
|
||||||
|
|
||||||
const lowerText = text.toLowerCase();
|
const lowerText = text.toLowerCase();
|
||||||
|
|
||||||
// Look for "最終決策" or "最終建議"
|
// Look for "最終決策" or "最終建議"
|
||||||
const finalDecisionMatch = text.match(/最終(?:決策|建議)[::]\s*(買入|賣出|持有)/);
|
const finalDecisionMatch = text.match(
|
||||||
|
/最終(?:決策|建議)[::]\s*(買入|賣出|持有)/,
|
||||||
|
);
|
||||||
if (finalDecisionMatch) {
|
if (finalDecisionMatch) {
|
||||||
const decision = finalDecisionMatch[1];
|
const decision = finalDecisionMatch[1];
|
||||||
if (decision === "買入") return { action: "買入", color: "text-green-600" };
|
if (decision === "買入")
|
||||||
|
return { action: "買入", color: "text-green-600" };
|
||||||
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
|
if (decision === "賣出") return { action: "賣出", color: "text-red-600" };
|
||||||
if (decision === "持有") return { action: "持有", color: "text-yellow-600" };
|
if (decision === "持有")
|
||||||
|
return { action: "持有", color: "text-yellow-600" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// English patterns
|
// English patterns
|
||||||
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) {
|
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(buy|long)/i)) {
|
||||||
return { action: "買入", color: "text-green-600" };
|
return { action: "買入", color: "text-green-600" };
|
||||||
}
|
}
|
||||||
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i)) {
|
if (
|
||||||
|
lowerText.match(/(?:final|recommendation|decision)[:\s]*(sell|short)/i)
|
||||||
|
) {
|
||||||
return { action: "賣出", color: "text-red-600" };
|
return { action: "賣出", color: "text-red-600" };
|
||||||
}
|
}
|
||||||
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) {
|
if (lowerText.match(/(?:final|recommendation|decision)[:\s]*(hold)/i)) {
|
||||||
|
|
@ -165,7 +266,9 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
|
||||||
// ====== PRIORITY 2: Check final_trade_decision ======
|
// ====== PRIORITY 2: Check final_trade_decision ======
|
||||||
const finalTradeDecision = report.result.reports?.final_trade_decision;
|
const finalTradeDecision = report.result.reports?.final_trade_decision;
|
||||||
if (finalTradeDecision) {
|
if (finalTradeDecision) {
|
||||||
const decision = findFinalProposal(finalTradeDecision) || findOtherDecision(finalTradeDecision);
|
const decision =
|
||||||
|
findFinalProposal(finalTradeDecision) ||
|
||||||
|
findOtherDecision(finalTradeDecision);
|
||||||
if (decision) return decision;
|
if (decision) return decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +299,7 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
|
||||||
allReports.sentiment_report,
|
allReports.sentiment_report,
|
||||||
allReports.news_report,
|
allReports.news_report,
|
||||||
allReports.fundamentals_report,
|
allReports.fundamentals_report,
|
||||||
].filter(t => t && typeof t === 'string');
|
].filter((t) => t && typeof t === "string");
|
||||||
|
|
||||||
for (const text of reportTexts) {
|
for (const text of reportTexts) {
|
||||||
const decision = findFinalProposal(text);
|
const decision = findFinalProposal(text);
|
||||||
|
|
@ -213,33 +316,33 @@ const extractDecisionFromReport = (report: SavedReport): { action: string; color
|
||||||
*/
|
*/
|
||||||
const detectReportLanguage = (reports: any): "en" | "zh-TW" => {
|
const detectReportLanguage = (reports: any): "en" | "zh-TW" => {
|
||||||
const traderPlan = reports?.trader_investment_plan;
|
const traderPlan = reports?.trader_investment_plan;
|
||||||
if (!traderPlan || typeof traderPlan !== 'string') {
|
if (!traderPlan || typeof traderPlan !== "string") {
|
||||||
// If no trader plan, check other reports for Chinese characters
|
// If no trader plan, check other reports for Chinese characters
|
||||||
const allText = JSON.stringify(reports || {});
|
const allText = JSON.stringify(reports || {});
|
||||||
const chineseRegex = /[\u4e00-\u9fa5]/;
|
const chineseRegex = /[\u4e00-\u9fa5]/;
|
||||||
return chineseRegex.test(allText) ? 'zh-TW' : 'en';
|
return chineseRegex.test(allText) ? "zh-TW" : "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Chinese decision keywords
|
// Check for Chinese decision keywords
|
||||||
const chineseKeywords = ['買入', '賣出', '持有', '最終交易提案'];
|
const chineseKeywords = ["買入", "賣出", "持有", "最終交易提案"];
|
||||||
for (const keyword of chineseKeywords) {
|
for (const keyword of chineseKeywords) {
|
||||||
if (traderPlan.includes(keyword)) {
|
if (traderPlan.includes(keyword)) {
|
||||||
return 'zh-TW';
|
return "zh-TW";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for English decision keywords
|
// Check for English decision keywords
|
||||||
const englishKeywords = ['buy', 'sell', 'hold', 'Final Trading Proposal'];
|
const englishKeywords = ["buy", "sell", "hold", "Final Trading Proposal"];
|
||||||
const lowerPlan = traderPlan.toLowerCase();
|
const lowerPlan = traderPlan.toLowerCase();
|
||||||
for (const keyword of englishKeywords) {
|
for (const keyword of englishKeywords) {
|
||||||
if (lowerPlan.includes(keyword.toLowerCase())) {
|
if (lowerPlan.includes(keyword.toLowerCase())) {
|
||||||
return 'en';
|
return "en";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check for Chinese characters in the content
|
// Fallback: check for Chinese characters in the content
|
||||||
const chineseRegex = /[\u4e00-\u9fa5]/;
|
const chineseRegex = /[\u4e00-\u9fa5]/;
|
||||||
return chineseRegex.test(traderPlan) ? 'zh-TW' : 'en';
|
return chineseRegex.test(traderPlan) ? "zh-TW" : "en";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HistoryPage() {
|
export default function HistoryPage() {
|
||||||
|
|
@ -260,7 +363,7 @@ export default function HistoryPage() {
|
||||||
// Delete confirmation dialog
|
// Delete confirmation dialog
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
|
const [reportToDelete, setReportToDelete] = useState<SavedReport | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
|
@ -272,16 +375,20 @@ export default function HistoryPage() {
|
||||||
loadReports();
|
loadReports();
|
||||||
}, [activeTab, isAuthenticated, locale]);
|
}, [activeTab, isAuthenticated, locale]);
|
||||||
|
|
||||||
// Load counts on mount or auth change
|
// Load counts on mount, auth change, or language change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCounts();
|
loadCounts();
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated, locale]);
|
||||||
|
|
||||||
// Auto-sync local reports to cloud when page loads (if authenticated)
|
// Auto-sync local reports to cloud when page loads (if authenticated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const autoSync = async () => {
|
const autoSync = async () => {
|
||||||
// Only sync once per session, and only if authenticated
|
// Only sync once per session, and only if authenticated
|
||||||
if (hasAutoSyncedRef.current || !isAuthenticated || !isCloudSyncEnabled()) {
|
if (
|
||||||
|
hasAutoSyncedRef.current ||
|
||||||
|
!isAuthenticated ||
|
||||||
|
!isCloudSyncEnabled()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,12 +408,12 @@ export default function HistoryPage() {
|
||||||
// Get cloud reports to check for duplicates
|
// Get cloud reports to check for duplicates
|
||||||
const cloudReports = await getCloudReports();
|
const cloudReports = await getCloudReports();
|
||||||
const cloudKeys = new Set(
|
const cloudKeys = new Set(
|
||||||
cloudReports.map(r => `${r.ticker}_${r.analysis_date}`)
|
cloudReports.map((r) => `${r.ticker}_${r.analysis_date}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find local-only reports to upload
|
// Find local-only reports to upload
|
||||||
const toUpload = allLocal.filter(
|
const toUpload = allLocal.filter(
|
||||||
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`)
|
(r) => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (toUpload.length === 0) {
|
if (toUpload.length === 0) {
|
||||||
|
|
@ -314,7 +421,9 @@ export default function HistoryPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`);
|
console.log(
|
||||||
|
`☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Upload each report silently
|
// Upload each report silently
|
||||||
let success = 0;
|
let success = 0;
|
||||||
|
|
@ -358,9 +467,9 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
// Convert cloud reports to SavedReport format and filter by market type
|
// Convert cloud reports to SavedReport format and filter by market type
|
||||||
const cloudFiltered = cloudReports
|
const cloudFiltered = cloudReports
|
||||||
.filter(r => r.market_type === activeTab)
|
.filter((r) => r.market_type === activeTab)
|
||||||
.map(r => ({
|
.map((r) => ({
|
||||||
id: parseInt(r.id.replace(/-/g, '').slice(0, 8), 16), // Convert UUID to number
|
id: parseInt(r.id.replace(/-/g, "").slice(0, 8), 16), // Convert UUID to number
|
||||||
cloudId: r.id, // Keep cloud ID for deletion
|
cloudId: r.id, // Keep cloud ID for deletion
|
||||||
ticker: r.ticker,
|
ticker: r.ticker,
|
||||||
market_type: r.market_type as "us" | "twse" | "tpex",
|
market_type: r.market_type as "us" | "twse" | "tpex",
|
||||||
|
|
@ -374,24 +483,25 @@ export default function HistoryPage() {
|
||||||
// Merge: prefer cloud data, but include local-only reports
|
// Merge: prefer cloud data, but include local-only reports
|
||||||
// Create a Set of cloud report keys (ticker + date) for deduplication
|
// Create a Set of cloud report keys (ticker + date) for deduplication
|
||||||
const cloudKeys = new Set(
|
const cloudKeys = new Set(
|
||||||
cloudFiltered.map(r => `${r.ticker}_${r.analysis_date}`)
|
cloudFiltered.map((r) => `${r.ticker}_${r.analysis_date}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find local reports that don't exist in cloud
|
// Find local reports that don't exist in cloud
|
||||||
const localOnly = localData.filter(
|
const localOnly = localData.filter(
|
||||||
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`)
|
(r) => !cloudKeys.has(`${r.ticker}_${r.analysis_date}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine: cloud reports + local-only reports
|
// Combine: cloud reports + local-only reports
|
||||||
const merged = [...cloudFiltered, ...localOnly];
|
const merged = [...cloudFiltered, ...localOnly];
|
||||||
|
|
||||||
// Sort by saved_at descending
|
// Sort by saved_at descending
|
||||||
merged.sort((a, b) =>
|
merged.sort(
|
||||||
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime()
|
(a, b) =>
|
||||||
|
new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by current language
|
// Filter by current language
|
||||||
const languageFiltered = merged.filter(report => {
|
const languageFiltered = merged.filter((report) => {
|
||||||
// Use stored language if available
|
// Use stored language if available
|
||||||
if (report.language) {
|
if (report.language) {
|
||||||
return report.language === locale;
|
return report.language === locale;
|
||||||
|
|
@ -408,7 +518,7 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
// If no cloud data or not authenticated, use local only
|
// If no cloud data or not authenticated, use local only
|
||||||
// Filter by current language
|
// Filter by current language
|
||||||
const languageFiltered = localData.filter(report => {
|
const languageFiltered = localData.filter((report) => {
|
||||||
if (report.language) {
|
if (report.language) {
|
||||||
return report.language === locale;
|
return report.language === locale;
|
||||||
}
|
}
|
||||||
|
|
@ -420,7 +530,7 @@ export default function HistoryPage() {
|
||||||
console.error("Failed to load reports:", error);
|
console.error("Failed to load reports:", error);
|
||||||
// Fall back to local on error
|
// Fall back to local on error
|
||||||
const data = await getReportsByMarketType(activeTab);
|
const data = await getReportsByMarketType(activeTab);
|
||||||
const languageFiltered = data.filter(report => {
|
const languageFiltered = data.filter((report) => {
|
||||||
if (report.language) {
|
if (report.language) {
|
||||||
return report.language === locale;
|
return report.language === locale;
|
||||||
}
|
}
|
||||||
|
|
@ -435,8 +545,15 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
const loadCounts = async () => {
|
const loadCounts = async () => {
|
||||||
try {
|
try {
|
||||||
// Always get local counts first
|
// Helper to filter reports by language
|
||||||
const localCounts = await getReportCountByMarketType();
|
const filterByLanguage = (reports: SavedReport[]) => {
|
||||||
|
return reports.filter(report => {
|
||||||
|
if (report.language) {
|
||||||
|
return report.language === locale;
|
||||||
|
}
|
||||||
|
return detectReportLanguage(report.result?.reports) === locale;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isAuthenticated && isCloudSyncEnabled()) {
|
if (isAuthenticated && isCloudSyncEnabled()) {
|
||||||
const cloudReports = await getCloudReports();
|
const cloudReports = await getCloudReports();
|
||||||
|
|
@ -454,25 +571,39 @@ export default function HistoryPage() {
|
||||||
cloudReports.map(r => `${r.ticker}_${r.analysis_date}_${r.market_type}`)
|
cloudReports.map(r => `${r.ticker}_${r.analysis_date}_${r.market_type}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Count local-only reports (not in cloud)
|
// Convert cloud reports to SavedReport format for language filtering
|
||||||
const usLocalOnly = usLocal.filter(
|
const cloudAsSaved = cloudReports.map(r => ({
|
||||||
|
id: 0,
|
||||||
|
ticker: r.ticker,
|
||||||
|
market_type: r.market_type as "us" | "twse" | "tpex",
|
||||||
|
analysis_date: r.analysis_date,
|
||||||
|
saved_at: new Date(r.created_at),
|
||||||
|
result: r.result,
|
||||||
|
language: r.language,
|
||||||
|
})) as SavedReport[];
|
||||||
|
|
||||||
|
// Filter cloud reports by language
|
||||||
|
const cloudFiltered = filterByLanguage(cloudAsSaved);
|
||||||
|
|
||||||
|
// Count local-only reports (not in cloud) and filter by language
|
||||||
|
const usLocalOnly = filterByLanguage(usLocal.filter(
|
||||||
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_us`)
|
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_us`)
|
||||||
).length;
|
)).length;
|
||||||
const twseLocalOnly = twseLocal.filter(
|
const twseLocalOnly = filterByLanguage(twseLocal.filter(
|
||||||
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_twse`)
|
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_twse`)
|
||||||
).length;
|
)).length;
|
||||||
const tpexLocalOnly = tpexLocal.filter(
|
const tpexLocalOnly = filterByLanguage(tpexLocal.filter(
|
||||||
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_tpex`)
|
r => !cloudKeys.has(`${r.ticker}_${r.analysis_date}_tpex`)
|
||||||
).length;
|
)).length;
|
||||||
|
|
||||||
// Cloud counts
|
// Cloud counts (already filtered by language)
|
||||||
const usCoud = cloudReports.filter(r => r.market_type === "us").length;
|
const usCloud = cloudFiltered.filter(r => r.market_type === "us").length;
|
||||||
const twseCloud = cloudReports.filter(r => r.market_type === "twse").length;
|
const twseCloud = cloudFiltered.filter(r => r.market_type === "twse").length;
|
||||||
const tpexCloud = cloudReports.filter(r => r.market_type === "tpex").length;
|
const tpexCloud = cloudFiltered.filter(r => r.market_type === "tpex").length;
|
||||||
|
|
||||||
// Merged counts: cloud + local-only
|
// Merged counts: cloud + local-only (both filtered by language)
|
||||||
setCounts({
|
setCounts({
|
||||||
us: usCoud + usLocalOnly,
|
us: usCloud + usLocalOnly,
|
||||||
twse: twseCloud + twseLocalOnly,
|
twse: twseCloud + twseLocalOnly,
|
||||||
tpex: tpexCloud + tpexLocalOnly,
|
tpex: tpexCloud + tpexLocalOnly,
|
||||||
});
|
});
|
||||||
|
|
@ -480,7 +611,18 @@ export default function HistoryPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCounts(localCounts);
|
// If no cloud data or not authenticated, use local only with language filter
|
||||||
|
const [usLocal, twseLocal, tpexLocal] = await Promise.all([
|
||||||
|
getReportsByMarketType("us"),
|
||||||
|
getReportsByMarketType("twse"),
|
||||||
|
getReportsByMarketType("tpex"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setCounts({
|
||||||
|
us: filterByLanguage(usLocal).length,
|
||||||
|
twse: filterByLanguage(twseLocal).length,
|
||||||
|
tpex: filterByLanguage(tpexLocal).length,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load counts:", error);
|
console.error("Failed to load counts:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -517,10 +659,13 @@ export default function HistoryPage() {
|
||||||
// 2. Always try to delete from local IndexedDB as well
|
// 2. Always try to delete from local IndexedDB as well
|
||||||
// Find matching local report by ticker + analysis_date
|
// Find matching local report by ticker + analysis_date
|
||||||
try {
|
try {
|
||||||
const localReports = await getReportsByMarketType(reportToDelete.market_type);
|
const localReports = await getReportsByMarketType(
|
||||||
|
reportToDelete.market_type,
|
||||||
|
);
|
||||||
const matchingLocal = localReports.find(
|
const matchingLocal = localReports.find(
|
||||||
r => r.ticker === reportToDelete.ticker &&
|
(r) =>
|
||||||
r.analysis_date === reportToDelete.analysis_date
|
r.ticker === reportToDelete.ticker &&
|
||||||
|
r.analysis_date === reportToDelete.analysis_date,
|
||||||
);
|
);
|
||||||
if (matchingLocal && matchingLocal.id) {
|
if (matchingLocal && matchingLocal.id) {
|
||||||
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id);
|
console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id);
|
||||||
|
|
@ -550,57 +695,58 @@ export default function HistoryPage() {
|
||||||
try {
|
try {
|
||||||
// Get all available analyst keys
|
// Get all available analyst keys
|
||||||
const getNestedValue = (obj: any, path: string) => {
|
const getNestedValue = (obj: any, path: string) => {
|
||||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
return path.split(".").reduce((current, key) => current?.[key], obj);
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableAnalystKeys = ANALYSTS
|
const availableAnalystKeys = ANALYSTS.filter((analyst) => {
|
||||||
.filter(analyst => {
|
const reportContent = getNestedValue(
|
||||||
const reportContent = getNestedValue(report.result.reports, analyst.reportKey);
|
report.result.reports,
|
||||||
|
analyst.reportKey,
|
||||||
|
);
|
||||||
return reportContent && reportContent.trim().length > 0;
|
return reportContent && reportContent.trim().length > 0;
|
||||||
})
|
}).map((a) => a.key);
|
||||||
.map(a => a.key);
|
|
||||||
|
|
||||||
if (availableAnalystKeys.length === 0) {
|
if (availableAnalystKeys.length === 0) {
|
||||||
alert('此報告沒有可下載的分析師報告');
|
alert("此報告沒有可下載的分析師報告");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect report language based on trader's final decision keywords
|
// Detect report language based on trader's final decision keywords
|
||||||
// This ensures PDF language matches the report content, not UI language
|
// This ensures PDF language matches the report content, not UI language
|
||||||
const detectReportLanguage = (reports: any): 'zh-TW' | 'en' => {
|
const detectReportLanguage = (reports: any): "zh-TW" | "en" => {
|
||||||
const traderPlan = reports?.trader_investment_plan;
|
const traderPlan = reports?.trader_investment_plan;
|
||||||
if (!traderPlan || typeof traderPlan !== 'string') {
|
if (!traderPlan || typeof traderPlan !== "string") {
|
||||||
return 'zh-TW'; // Default to Chinese
|
return "zh-TW"; // Default to Chinese
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chinese decision keywords: 買入, 賣出, 持有
|
// Chinese decision keywords: 買入, 賣出, 持有
|
||||||
const chineseKeywords = ['買入', '賣出', '持有'];
|
const chineseKeywords = ["買入", "賣出", "持有"];
|
||||||
// English decision keywords: buy, sell, hold (case insensitive)
|
// English decision keywords: buy, sell, hold (case insensitive)
|
||||||
const englishKeywords = ['buy', 'sell', 'hold'];
|
const englishKeywords = ["buy", "sell", "hold"];
|
||||||
|
|
||||||
const lowerPlan = traderPlan.toLowerCase();
|
const lowerPlan = traderPlan.toLowerCase();
|
||||||
|
|
||||||
// Check for Chinese keywords first
|
// Check for Chinese keywords first
|
||||||
for (const keyword of chineseKeywords) {
|
for (const keyword of chineseKeywords) {
|
||||||
if (traderPlan.includes(keyword)) {
|
if (traderPlan.includes(keyword)) {
|
||||||
return 'zh-TW';
|
return "zh-TW";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for English keywords
|
// Check for English keywords
|
||||||
for (const keyword of englishKeywords) {
|
for (const keyword of englishKeywords) {
|
||||||
if (lowerPlan.includes(keyword)) {
|
if (lowerPlan.includes(keyword)) {
|
||||||
return 'en';
|
return "en";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check for any Chinese characters
|
// Fallback: check for any Chinese characters
|
||||||
const chineseRegex = /[\u4e00-\u9fa5]/;
|
const chineseRegex = /[\u4e00-\u9fa5]/;
|
||||||
if (chineseRegex.test(traderPlan)) {
|
if (chineseRegex.test(traderPlan)) {
|
||||||
return 'zh-TW';
|
return "zh-TW";
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'en';
|
return "en";
|
||||||
};
|
};
|
||||||
|
|
||||||
const reportLanguage = detectReportLanguage(report.result.reports);
|
const reportLanguage = detectReportLanguage(report.result.reports);
|
||||||
|
|
@ -616,10 +762,10 @@ export default function HistoryPage() {
|
||||||
language: reportLanguage, // Use detected language based on trader decision
|
language: reportLanguage, // Use detected language based on trader decision
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('/api/download/reports', {
|
const response = await fetch("/api/download/reports", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
@ -633,7 +779,7 @@ export default function HistoryPage() {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
|
||||||
// Get filename from header
|
// Get filename from header
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
const contentDisposition = response.headers.get("Content-Disposition");
|
||||||
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
|
let filename = `${report.ticker}_Combined_Report_${report.analysis_date}.pdf`;
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const filenameMatch = contentDisposition.match(/filename=(.+)/);
|
const filenameMatch = contentDisposition.match(/filename=(.+)/);
|
||||||
|
|
@ -644,7 +790,7 @@ export default function HistoryPage() {
|
||||||
|
|
||||||
// Create download link
|
// Create download link
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
|
|
@ -654,8 +800,8 @@ export default function HistoryPage() {
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Download error:', error);
|
console.error("Download error:", error);
|
||||||
alert(error.message || '下載失敗,請稍後再試');
|
alert(error.message || "下載失敗,請稍後再試");
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingId(null);
|
setDownloadingId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -672,7 +818,10 @@ export default function HistoryPage() {
|
||||||
{t.history.title}
|
{t.history.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
{t.history.noHistory.replace("尚無分析紀錄", "瀏覽已儲存的分析報告")}
|
{t.history.noHistory.replace(
|
||||||
|
"尚無分析紀錄",
|
||||||
|
"瀏覽已儲存的分析報告",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -685,25 +834,26 @@ export default function HistoryPage() {
|
||||||
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
onValueChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||||
className="w-full animate-slide-up animate-delay-200"
|
className="w-full animate-slide-up animate-delay-200"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-3 h-auto gap-2">
|
<TabsList className="grid w-full grid-cols-1 sm:grid-cols-3 h-auto gap-2">
|
||||||
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
|
{(
|
||||||
(key) => (
|
Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
|
||||||
|
).map((key) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={key}
|
key={key}
|
||||||
value={key}
|
value={key}
|
||||||
className="py-3 text-base transition-all duration-300 hover:scale-105"
|
className="py-2 sm:py-3 text-sm sm:text-base transition-all duration-300 hover:scale-105"
|
||||||
>
|
>
|
||||||
<span className="mr-2">{MARKET_LABELS[key].label}</span>
|
<span className="mr-1 sm:mr-2">{MARKET_LABELS[key].label}</span>
|
||||||
<span className="px-2 py-0.5 rounded-full bg-white/20 text-xs">
|
<span className="px-1.5 sm:px-2 py-0.5 rounded-full bg-white/20 text-xs">
|
||||||
{counts[key]}
|
{counts[key]}
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{(Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>).map(
|
{(
|
||||||
(marketType) => (
|
Object.keys(MARKET_LABELS) as Array<keyof typeof MARKET_LABELS>
|
||||||
|
).map((marketType) => (
|
||||||
<TabsContent key={marketType} value={marketType} className="mt-6">
|
<TabsContent key={marketType} value={marketType} className="mt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Refresh button */}
|
{/* Refresh button */}
|
||||||
|
|
@ -733,7 +883,8 @@ export default function HistoryPage() {
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
<TrendingUp className="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
{t.history.noReportsFor} {MARKET_LABELS[marketType].label}
|
{t.history.noReportsFor}{" "}
|
||||||
|
{MARKET_LABELS[marketType].label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||||
{t.history.afterAnalysisSave}
|
{t.history.afterAnalysisSave}
|
||||||
|
|
@ -773,15 +924,20 @@ export default function HistoryPage() {
|
||||||
{format(
|
{format(
|
||||||
new Date(report.saved_at),
|
new Date(report.saved_at),
|
||||||
"yyyy/MM/dd HH:mm",
|
"yyyy/MM/dd HH:mm",
|
||||||
{ locale: zhTW }
|
{ locale: zhTW },
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const decision = extractDecisionFromReport(report);
|
const decision =
|
||||||
|
extractDecisionFromReport(report);
|
||||||
return (
|
return (
|
||||||
<p className="text-sm mt-2">
|
<p className="text-sm mt-2">
|
||||||
<span className="font-medium">{t.history.decision}:</span>
|
<span className="font-medium">
|
||||||
<span className={`ml-1 font-semibold ${decision.color}`}>
|
{t.history.decision}:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1 font-semibold ${decision.color}`}
|
||||||
|
>
|
||||||
{decision.action}
|
{decision.action}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -833,8 +989,7 @@ export default function HistoryPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -845,7 +1000,8 @@ export default function HistoryPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t.history.confirmDeleteTitle}</DialogTitle>
|
<DialogTitle>{t.history.confirmDeleteTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t.history.confirmDeleteDesc} <strong>{reportToDelete?.ticker}</strong> {t.history.on}{" "}
|
{t.history.confirmDeleteDesc}{" "}
|
||||||
|
<strong>{reportToDelete?.ticker}</strong> {t.history.on}{" "}
|
||||||
<strong>{reportToDelete?.analysis_date}</strong>?
|
<strong>{reportToDelete?.analysis_date}</strong>?
|
||||||
<br />
|
<br />
|
||||||
<span className="text-red-500">{t.history.cannotUndo}</span>
|
<span className="text-red-500">{t.history.cannotUndo}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue