Skip to content

Commit 8a47aec

Browse files
author
Cohen, Yohay
committed
introduce a comprehensive financial command center dashboard with anomaly alerts and supporting backend services.
1 parent b0cca2c commit 8a47aec

9 files changed

Lines changed: 489 additions & 45 deletions

File tree

client/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AppLockBanner } from './components/AppLockBanner';
1212
import { OnboardingWizard } from './components/onboarding/OnboardingWizard';
1313
import { OnboardingResumeBanner } from './components/onboarding/OnboardingResumeBanner';
1414
import { useOnboarding } from './contexts/OnboardingContext';
15+
import { DashboardAlertsDropdown } from './components/dashboard/DashboardAlertsDropdown';
1516

1617

1718
function App() {
@@ -105,6 +106,8 @@ function App() {
105106
</button>
106107
</div>
107108

109+
{view === 'dashboard' && <DashboardAlertsDropdown selectedMonth={selectedMonth} />}
110+
108111
{onboarding.completed && (
109112
<button
110113
type="button"

client/src/components/dashboard/AnomalyAlerts.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { CategoryIcon } from '../../utils/categoryIcons';
55

66
interface AnomalyAlertsProps {
77
anomalies?: AnomalyAlert[];
8+
className?: string;
89
}
910

10-
export function AnomalyAlerts({ anomalies = [] }: AnomalyAlertsProps) {
11+
export function AnomalyAlerts({ anomalies = [], className }: AnomalyAlertsProps) {
1112
const { t, i18n } = useTranslation();
1213
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
1314

@@ -123,7 +124,7 @@ export function AnomalyAlerts({ anomalies = [] }: AnomalyAlertsProps) {
123124
};
124125

125126
return (
126-
<div className="space-y-3 mb-6">
127+
<div className={className ?? 'space-y-3 mb-6'}>
127128
{activeAnomalies.map((anomaly) => {
128129
const config = getTypeConfig(anomaly.type);
129130
return (
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useUnifiedData } from '../../hooks/useUnifiedData';
4+
import { useDashboardConfig } from '../../hooks/useDashboardConfig';
5+
import { useFinancialSummary } from '../../hooks/useFinancialSummary';
6+
import { AnomalyAlerts } from './AnomalyAlerts';
7+
8+
type DashboardAlertsDropdownProps = {
9+
selectedMonth: string;
10+
};
11+
12+
export function DashboardAlertsDropdown({ selectedMonth }: DashboardAlertsDropdownProps) {
13+
const { t } = useTranslation();
14+
const [open, setOpen] = useState(false);
15+
const rootRef = useRef<HTMLDivElement>(null);
16+
17+
const { data: unifiedTransactions, isLoading } = useUnifiedData();
18+
const { config } = useDashboardConfig();
19+
const transactions = unifiedTransactions || [];
20+
const summary = useFinancialSummary(
21+
transactions,
22+
selectedMonth,
23+
config.ccPaymentDate,
24+
config.forecastMonths ?? 6,
25+
config.customCCKeywords ?? []
26+
);
27+
28+
const anomalies = summary.anomalies ?? [];
29+
const count = anomalies.length;
30+
31+
useEffect(() => {
32+
if (!open) return;
33+
const onDoc = (e: MouseEvent) => {
34+
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
35+
setOpen(false);
36+
}
37+
};
38+
const onKey = (e: KeyboardEvent) => {
39+
if (e.key === 'Escape') setOpen(false);
40+
};
41+
document.addEventListener('mousedown', onDoc);
42+
document.addEventListener('keydown', onKey);
43+
return () => {
44+
document.removeEventListener('mousedown', onDoc);
45+
document.removeEventListener('keydown', onKey);
46+
};
47+
}, [open]);
48+
49+
return (
50+
<div className="relative shrink-0" ref={rootRef}>
51+
<button
52+
type="button"
53+
onClick={() => setOpen((v) => !v)}
54+
className={`relative p-2.5 rounded-full transition-colors ${open ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:text-gray-600 hover:bg-gray-100'}`}
55+
title={t('dashboard.toggle_alerts')}
56+
aria-expanded={open}
57+
aria-haspopup="true"
58+
>
59+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
60+
<path
61+
strokeLinecap="round"
62+
strokeLinejoin="round"
63+
strokeWidth={2}
64+
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
65+
/>
66+
</svg>
67+
{count > 0 && !open && (
68+
<span className="absolute top-1.5 end-1.5 w-2 h-2 bg-red-500 rounded-full animate-pulse border border-white" />
69+
)}
70+
</button>
71+
72+
{open && (
73+
<div
74+
className="absolute end-0 top-full mt-1.5 z-50 w-[min(100vw-2rem,22rem)] rounded-xl border border-gray-200 bg-white shadow-lg ring-1 ring-black/5"
75+
role="menu"
76+
>
77+
<div className="border-b border-gray-100 px-3 py-2">
78+
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
79+
{t('dashboard.alerts_title')}
80+
</p>
81+
</div>
82+
<div className="max-h-[min(60vh,24rem)] overflow-y-auto p-2">
83+
{isLoading ? (
84+
<p className="px-2 py-6 text-center text-sm text-gray-400">{t('common.loading')}</p>
85+
) : transactions.length === 0 ? (
86+
<p className="px-2 py-6 text-center text-sm text-gray-500">{t('dashboard.select_data')}</p>
87+
) : count === 0 ? (
88+
<p className="px-2 py-6 text-center text-sm text-gray-500">{t('dashboard.no_alerts')}</p>
89+
) : (
90+
<AnomalyAlerts anomalies={anomalies} className="space-y-2 mb-0" />
91+
)}
92+
</div>
93+
</div>
94+
)}
95+
</div>
96+
);
97+
}

client/src/components/dashboard/FinancialCommandCenter.tsx

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { useDashboardConfig } from '../../hooks/useDashboardConfig';
88
import { ExpenseProgressCenter } from './ExpenseProgressCenter';
99
import { IncomeProgressCenter } from './IncomeProgressCenter';
1010
import { AnalyticsDashboard, AnalyticsDayFilter } from '../AnalyticsDashboard';
11-
import { AnomalyAlerts } from './AnomalyAlerts';
1211
import { CCPaymentDateSettings } from './CCPaymentDateSettings';
1312
import { DashboardAIChat } from './DashboardAIChat';
1413
import { CategoryDetailsModal } from './CategoryDetailsModal';
@@ -38,7 +37,6 @@ export function FinancialCommandCenter({
3837
onNavigateToLogs
3938
}: FinancialCommandCenterProps) {
4039
const { t, i18n } = useTranslation();
41-
const [showAnomalies, setShowAnomalies] = useState(false);
4240
const [isChatOpen, setIsChatOpen] = useState(false);
4341
const [selectedCategoryForModal, setSelectedCategoryForModal] = useState<string | null>(null);
4442
const [analyticsDayFilter, setAnalyticsDayFilter] = useState<AnalyticsDayFilter | null>(null);
@@ -177,34 +175,9 @@ export function FinancialCommandCenter({
177175
</div>
178176
<div className="flex items-center justify-center sm:justify-end gap-2 shrink-0">
179177
<CCPaymentDateSettings />
180-
<button
181-
type="button"
182-
onClick={() => setShowAnomalies(!showAnomalies)}
183-
className={`relative p-2.5 rounded-full transition-colors ${showAnomalies ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:text-gray-600 hover:bg-gray-100'}`}
184-
title={t('dashboard.toggle_alerts')}
185-
>
186-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
187-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
188-
</svg>
189-
{(summary.anomalies?.length || 0) > 0 && !showAnomalies && (
190-
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full animate-pulse border border-white" />
191-
)}
192-
</button>
193178
</div>
194179
</div>
195180

196-
{/* Anomaly Alerts Toggle */}
197-
{showAnomalies && (summary.anomalies?.length || 0) > 0 && (
198-
<div className="animate-fade-in-down" style={{ animationDelay: '50ms' }}>
199-
<AnomalyAlerts anomalies={summary.anomalies} />
200-
</div>
201-
)}
202-
{showAnomalies && (!summary.anomalies || summary.anomalies.length === 0) && (
203-
<div className="animate-fade-in-down p-4 mb-4 text-center text-gray-500 text-sm bg-gray-50 rounded-xl border border-gray-100">
204-
{t('dashboard.no_alerts')}
205-
</div>
206-
)}
207-
208181
<div className="animate-fade-in-up max-w-2xl mx-auto w-full" style={{ animationDelay: '90ms' }}>
209182
<TopInsightsCard />
210183
</div>

client/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@
663663
"category_spending_details": "Category spending details",
664664
"open_ai_chat": "Open AI Chat",
665665
"toggle_alerts": "Toggle alerts",
666+
"alerts_title": "Alerts",
666667
"transactions_for": "Transactions for {{label}}",
667668
"spending_for": "Spending for {{label}}",
668669
"projected_monthly": "Projected monthly",

client/src/locales/he.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@
663663
"category_spending_details": "Category spending details",
664664
"open_ai_chat": "Open AI Chat",
665665
"toggle_alerts": "Toggle alerts",
666+
"alerts_title": "התראות",
666667
"transactions_for": "Transactions for {{label}}",
667668
"spending_for": "Spending for {{label}}",
668669
"projected_monthly": "Projected monthly",

server/src/services/postScrapeService.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ export class PostScrapeService {
150150
}
151151
}
152152

153+
const sendTelegram = channels.includes('telegram');
154+
const channelsNoTelegram = channels.filter((c) => c !== 'telegram');
155+
153156
const headline =
154157
botLanguage === 'he'
155158
? `${items.length} תנועות חדשות: נא להוסיף הערה או לדייק קטגוריה (העברות / אחר).`
@@ -183,7 +186,18 @@ export class PostScrapeService {
183186
},
184187
};
185188

186-
await this.notifyWithTelegramAggregation(channels, payload, request);
189+
await this.notifyWithTelegramAggregation(channelsNoTelegram, payload, request);
190+
191+
if (sendTelegram) {
192+
try {
193+
await telegramBotService.sendMemoReplyPromptsForReview(items, request, botLanguage);
194+
} catch (e) {
195+
logger.warn('Transaction review: Telegram memo reply prompts failed', {
196+
error: (e as Error).message,
197+
});
198+
}
199+
}
200+
187201
logger.info('Transaction review reminder sent', { count: items.length });
188202
} catch (err) {
189203
logger.warn('maybeNotifyTransactionReview failed', { error: (err as Error).message });

server/src/services/storageService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,11 @@ export class StorageService {
481481
return true;
482482
}
483483

484+
/** Returns true if a transaction with this id exists in the unified DB. */
485+
transactionExists(transactionId: string): boolean {
486+
return this.dbService.transactionExists(transactionId);
487+
}
488+
484489
async updateTransactionMemoUnified(transactionId: string, memo: string): Promise<boolean> {
485490
// Update DB
486491
const success = this.dbService.updateTransactionMemo(transactionId, memo);

0 commit comments

Comments
 (0)