feat: reliability enhancements for AI services and capital planning
1. Health Scores — separate operating/reserve refresh
- Added POST /health-scores/calculate/operating and /calculate/reserve
- Each health card now has its own Refresh button
- On failure, shows cached (last good) data with "last analysis failed"
watermark instead of blank "Error calculating score"
- Backend getLatestScores returns latest complete score + failure flag
2. Investment Planning — increased AI timeout to 5 minutes
- Backend callAI timeout: 180s → 300s
- Frontend axios timeout: set explicitly to 300s (was browser default)
- Host nginx proxy_read_timeout: 180s → 300s
- Loading message updated to reflect longer wait times
3. Capital Planning — Unscheduled column moved to rightmost position
- Kanban column order: current year → future → unscheduled (was leftmost)
- Puts immediate/near-term projects front and center
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ export class HealthScoresController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate')
|
@Post('calculate')
|
||||||
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
|
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculate(@Req() req: any) {
|
async calculate(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.user?.orgSchema;
|
||||||
@@ -29,4 +29,22 @@ export class HealthScoresController {
|
|||||||
]);
|
]);
|
||||||
return { operating, reserve };
|
return { operating, reserve };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('calculate/operating')
|
||||||
|
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateOperating(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
const operating = await this.service.calculateScore(schema, 'operating');
|
||||||
|
return { operating };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate/reserve')
|
||||||
|
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
|
||||||
|
@AllowViewer()
|
||||||
|
async calculateReserve(@Req() req: any) {
|
||||||
|
const schema = req.user?.orgSchema;
|
||||||
|
const reserve = await this.service.calculateScore(schema, 'reserve');
|
||||||
|
return { reserve };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,23 +47,49 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Public API ──
|
// ── Public API ──
|
||||||
|
|
||||||
async getLatestScores(schema: string): Promise<{ operating: HealthScore | null; reserve: HealthScore | null }> {
|
async getLatestScores(schema: string): Promise<{
|
||||||
|
operating: HealthScore | null;
|
||||||
|
reserve: HealthScore | null;
|
||||||
|
operating_last_failed: boolean;
|
||||||
|
reserve_last_failed: boolean;
|
||||||
|
}> {
|
||||||
const qr = this.dataSource.createQueryRunner();
|
const qr = this.dataSource.createQueryRunner();
|
||||||
try {
|
try {
|
||||||
await qr.connect();
|
await qr.connect();
|
||||||
await qr.query(`SET search_path TO "${schema}"`);
|
await qr.query(`SET search_path TO "${schema}"`);
|
||||||
|
|
||||||
const operating = await qr.query(
|
// For each score type, return the latest *successful* score for display,
|
||||||
`SELECT * FROM health_scores WHERE score_type = 'operating' ORDER BY calculated_at DESC LIMIT 1`,
|
// and flag whether the most recent attempt (any status) was an error.
|
||||||
);
|
const result = { operating: null as HealthScore | null, reserve: null as HealthScore | null, operating_last_failed: false, reserve_last_failed: false };
|
||||||
const reserve = await qr.query(
|
|
||||||
`SELECT * FROM health_scores WHERE score_type = 'reserve' ORDER BY calculated_at DESC LIMIT 1`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
for (const scoreType of ['operating', 'reserve'] as const) {
|
||||||
operating: operating[0] || null,
|
// Most recent row (any status)
|
||||||
reserve: reserve[0] || null,
|
const latest = await qr.query(
|
||||||
};
|
`SELECT * FROM health_scores WHERE score_type = $1 ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
[scoreType],
|
||||||
|
);
|
||||||
|
const latestRow = latest[0] || null;
|
||||||
|
|
||||||
|
if (!latestRow) {
|
||||||
|
// No scores at all
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestRow.status === 'error') {
|
||||||
|
// Most recent attempt failed — return the latest *complete* score instead
|
||||||
|
const lastGood = await qr.query(
|
||||||
|
`SELECT * FROM health_scores WHERE score_type = $1 AND status = 'complete' ORDER BY calculated_at DESC LIMIT 1`,
|
||||||
|
[scoreType],
|
||||||
|
);
|
||||||
|
result[scoreType] = lastGood[0] || latestRow; // fall back to error row if no good score exists
|
||||||
|
result[`${scoreType}_last_failed`] = true;
|
||||||
|
} else {
|
||||||
|
result[scoreType] = latestRow;
|
||||||
|
result[`${scoreType}_last_failed`] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
await qr.release();
|
await qr.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -873,7 +873,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 180000, // 3 minute timeout
|
timeout: 300000, // 5 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -887,7 +887,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error(`Request timed out after 180s`));
|
reject(new Error(`Request timed out after 300s`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -430,13 +430,13 @@ export function CapitalProjectsPage() {
|
|||||||
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
// Merge base years with any extra years from projects (excluding FUTURE_YEAR for now)
|
||||||
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
const regularYears = [...new Set([...baseYears, ...projectYears.filter((y) => y !== FUTURE_YEAR)])].sort();
|
||||||
const years = [
|
const years = [
|
||||||
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
|
||||||
...regularYears,
|
...regularYears,
|
||||||
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
...(hasFutureProjects ? [FUTURE_YEAR] : []),
|
||||||
|
...(hasUnscheduledProjects ? [UNSCHEDULED] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Kanban columns: Unscheduled + current..current+4 + Future
|
// Kanban columns: current..current+4 + Future + Unscheduled (rightmost)
|
||||||
const kanbanYears = [UNSCHEDULED, ...baseYears, FUTURE_YEAR];
|
const kanbanYears = [...baseYears, FUTURE_YEAR, UNSCHEDULED];
|
||||||
|
|
||||||
// ---- Loading state ----
|
// ---- Loading state ----
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -39,6 +40,8 @@ interface HealthScore {
|
|||||||
interface HealthScoresData {
|
interface HealthScoresData {
|
||||||
operating: HealthScore | null;
|
operating: HealthScore | null;
|
||||||
reserve: HealthScore | null;
|
reserve: HealthScore | null;
|
||||||
|
operating_last_failed?: boolean;
|
||||||
|
reserve_last_failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScoreColor(score: number): string {
|
function getScoreColor(score: number): string {
|
||||||
@@ -55,13 +58,36 @@ function TrajectoryIcon({ trajectory }: { trajectory: string | null }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) {
|
function HealthScoreCard({
|
||||||
|
score,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isRefreshing,
|
||||||
|
onRefresh,
|
||||||
|
lastFailed,
|
||||||
|
}: {
|
||||||
|
score: HealthScore | null;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
lastFailed?: boolean;
|
||||||
|
}) {
|
||||||
|
// No score at all yet
|
||||||
if (!score) {
|
if (!score) {
|
||||||
return (
|
return (
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
{icon}
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Center h={100}>
|
<Center h={100}>
|
||||||
<Text c="dimmed" size="sm">No health score yet</Text>
|
<Text c="dimmed" size="sm">No health score yet</Text>
|
||||||
@@ -70,6 +96,7 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending — missing data, can't calculate
|
||||||
if (score.status === 'pending') {
|
if (score.status === 'pending') {
|
||||||
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
const missingItems = Array.isArray(score.missing_data) ? score.missing_data :
|
||||||
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
(typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []);
|
||||||
@@ -77,7 +104,15 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
{icon}
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -92,20 +127,38 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (score.status === 'error') {
|
// For error status, we still render the score data (cached from the previous
|
||||||
|
// successful run) rather than blanking the card with "Error calculating score".
|
||||||
|
// A small watermark under the timestamp tells the user it's stale.
|
||||||
|
const showAsError = score.status === 'error' && score.score === 0 && !score.summary;
|
||||||
|
|
||||||
|
// Pure error with no cached data to fall back on
|
||||||
|
if (showAsError) {
|
||||||
return (
|
return (
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
{icon}
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Retry ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Retry</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Center h={100}>
|
<Center h={100}>
|
||||||
<Badge color="red" variant="light">Error calculating score</Badge>
|
<Stack align="center" gap={4}>
|
||||||
|
<Badge color="red" variant="light">Error calculating score</Badge>
|
||||||
|
<Text size="xs" c="dimmed">Click Retry to try again</Text>
|
||||||
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normal display — works for both 'complete' and 'error' (with cached data)
|
||||||
const color = getScoreColor(score.score);
|
const color = getScoreColor(score.score);
|
||||||
const factors = Array.isArray(score.factors) ? score.factors :
|
const factors = Array.isArray(score.factors) ? score.factors :
|
||||||
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
(typeof score.factors === 'string' ? JSON.parse(score.factors) : []);
|
||||||
@@ -116,7 +169,15 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>{title} Health</Text>
|
||||||
{icon}
|
<Group gap={6}>
|
||||||
|
{onRefresh && (
|
||||||
|
<Tooltip label={`Recalculate ${title.toLowerCase()} score`}>
|
||||||
|
<Button variant="subtle" size="compact-xs" leftSection={<IconRefresh size={14} />}
|
||||||
|
loading={isRefreshing} onClick={onRefresh}>Refresh</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{icon}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Group align="flex-start" gap="lg">
|
<Group align="flex-start" gap="lg">
|
||||||
<RingProgress
|
<RingProgress
|
||||||
@@ -215,9 +276,16 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
{score.calculated_at && (
|
{score.calculated_at && (
|
||||||
<Text size="10px" c="dimmed" ta="right" mt={6} style={{ opacity: 0.7 }}>
|
<Stack gap={0} mt={6} align="flex-end">
|
||||||
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
<Text size="10px" c="dimmed" style={{ opacity: 0.7 }}>
|
||||||
</Text>
|
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{lastFailed && (
|
||||||
|
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
||||||
|
last analysis failed — showing cached data
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -245,6 +313,10 @@ export function DashboardPage() {
|
|||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track whether last refresh attempt failed (per score type)
|
||||||
|
const [operatingFailed, setOperatingFailed] = useState(false);
|
||||||
|
const [reserveFailed, setReserveFailed] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
|
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
|
||||||
@@ -257,9 +329,28 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recalcMutation = useMutation({
|
// Separate mutations for each score type
|
||||||
mutationFn: () => api.post('/health-scores/calculate'),
|
const recalcOperatingMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/health-scores/calculate/operating'),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setOperatingFailed(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setOperatingFailed(true);
|
||||||
|
// Still refresh to get whatever the backend saved (could be cached data)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recalcReserveMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/health-scores/calculate/reserve'),
|
||||||
|
onSuccess: () => {
|
||||||
|
setReserveFailed(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setReserveFailed(true);
|
||||||
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -290,20 +381,7 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center">
|
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||||
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
|
||||||
<Tooltip label="Recalculate health scores now">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-xs"
|
|
||||||
leftSection={<IconRefresh size={14} />}
|
|
||||||
loading={recalcMutation.isPending}
|
|
||||||
onClick={() => recalcMutation.mutate()}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.operating || null}
|
score={healthScores?.operating || null}
|
||||||
@@ -313,6 +391,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
|
isRefreshing={recalcOperatingMutation.isPending}
|
||||||
|
onRefresh={() => recalcOperatingMutation.mutate()}
|
||||||
|
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.reserve || null}
|
score={healthScores?.reserve || null}
|
||||||
@@ -322,6 +403,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
|
isRefreshing={recalcReserveMutation.isPending}
|
||||||
|
onRefresh={() => recalcReserveMutation.mutate()}
|
||||||
|
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export function InvestmentPlanningPage() {
|
|||||||
// AI recommendation (on-demand)
|
// AI recommendation (on-demand)
|
||||||
const aiMutation = useMutation({
|
const aiMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data } = await api.post('/investment-planning/recommendations');
|
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
|
||||||
return data as AIResponse;
|
return data as AIResponse;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -663,7 +663,7 @@ export function InvestmentPlanningPage() {
|
|||||||
Analyzing your financial data and market rates...
|
Analyzing your financial data and market rates...
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
This may take up to 30 seconds
|
This may take a few minutes for complex tenant data
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ server {
|
|||||||
proxy_send_timeout 15s;
|
proxy_send_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints — longer timeouts (LLM calls can take 30-120s)
|
# AI endpoints — longer timeouts (LLM calls can take minutes)
|
||||||
location /api/investment-planning/recommendations {
|
location /api/investment-planning/recommendations {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_read_timeout 180s;
|
proxy_read_timeout 300s;
|
||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
proxy_send_timeout 30s;
|
proxy_send_timeout 30s;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user