Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ FROM nginx:alpine

WORKDIR /usr/share/nginx/html

ARG VERSION="9.6.0"
ARG VERSION="10.0.0"

# Add metadata
LABEL maintainer="José Valdiviesso <me@zmiguel.me>"
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "myfin-web",
"private": true,
"version": "9.6.0",
"version": "10.0.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
4 changes: 4 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@
"fundingAmount": "Amount",
"fundingPercentage": "Percentage",
"underfundedTooltip": "This goal is underfunded because another goal with higher priority took the funds",
"unallocatedFunding": "Unallocated funding",
"unallocatedFundingDescription": "Money from goal funding accounts that is not assigned to any goal yet.",
"availableToAllocate": "available to allocate",
"accountFallback": "Account #{{id}}",
"percentageMustBeBetween0And100": "Percentage must be between 0 and 100",
"amountMustBePositive": "Amount must be positive",
"amountCannotExceedGoal": "Amount cannot exceed the goal target",
Expand Down
4 changes: 4 additions & 0 deletions public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@
"fundingAmount": "Montant",
"fundingPercentage": "Pourcentage",
"underfundedTooltip": "Cet objectif est sous-financé car un objectif de priorité plus élevée a utilisé les fonds disponibles",
"unallocatedFunding": "Financement non attribué",
"unallocatedFundingDescription": "Argent des comptes de financement des objectifs qui n'est encore attribué à aucun objectif.",
"availableToAllocate": "disponible à attribuer",
"accountFallback": "Compte n°{{id}}",
"percentageMustBeBetween0And100": "Le pourcentage doit être compris entre 0 et 100",
"amountMustBePositive": "Le montant doit être positif",
"amountCannotExceedGoal": "Le montant ne peut pas dépasser la cible de l'objectif",
Expand Down
4 changes: 4 additions & 0 deletions public/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@
"fundingAmount": "Montante",
"fundingPercentage": "Percentagem",
"underfundedTooltip": "Este objetivo está subfinanciado porque outro objetivo com maior prioridade utilizou os fundos",
"unallocatedFunding": "Financiamento por alocar",
"unallocatedFundingDescription": "Dinheiro das contas de financiamento dos objetivos que ainda não está atribuído a nenhum objetivo.",
"availableToAllocate": "disponível para alocar",
"accountFallback": "Conta #{{id}}",
"percentageMustBeBetween0And100": "A percentagem deve estar entre 0 e 100",
"amountMustBePositive": "O montante deve ser positivo",
"amountCannotExceedGoal": "O montante não pode exceder o objetivo",
Expand Down
146 changes: 140 additions & 6 deletions src/features/goals/Goals.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AccountBalanceWallet,
AddCircleOutline,
CheckCircle,
Delete,
Expand Down Expand Up @@ -47,11 +48,14 @@ import {
AlertSeverity,
useSnackbar,
} from '../../providers/SnackbarProvider.tsx';
import { useGetAccounts } from '../../services/account/accountHooks.ts';
import { useDeleteGoal, useGetGoals } from '../../services/goal/goalHooks.ts';
import { Goal } from '../../services/goal/goalServices.ts';
import { Goal, GoalFundingSummary } from '../../services/goal/goalServices.ts';
import { useFormatNumberAsCurrency } from '../../utils/textHooks.ts';
import AddEditGoalDialog from './AddEditGoalDialog.tsx';

type UnallocatedFunding = GoalFundingSummary['unallocated_funding'];

type UiState = {
goals?: Goal[];
filteredGoals?: Goal[];
Expand Down Expand Up @@ -401,6 +405,100 @@ const GoalCard = ({
);
};

const UnallocatedFundingCard = ({
unallocatedFunding,
accountNameLookup,
formatCurrency,
}: {
unallocatedFunding: UnallocatedFunding;
accountNameLookup: Map<number, string>;
formatCurrency: (value: number) => string;
}) => {
const theme = useTheme();
const { t } = useTranslation();

if (unallocatedFunding.total_amount <= 0) return null;

const accountBreakdown = unallocatedFunding.accounts
.map((account) => {
const accountName =
accountNameLookup.get(account.account_id) ||
t('goals.accountFallback', { id: account.account_id });

return `${accountName}: ${formatCurrency(account.amount)}`;
})
.join('\n');

return (
<Card
sx={{
height: '100%',
minHeight: 280,
display: 'flex',
flexDirection: 'column',
borderTop: `4px solid ${theme.palette.info.main}`,
backgroundColor: alpha(theme.palette.info.main, 0.04),
}}
>
<CardContent
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<AccountBalanceWallet color="info" />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{t('goals.unallocatedFunding')}
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary">
{t('goals.unallocatedFundingDescription')}
</Typography>
</Box>

<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{formatCurrency(unallocatedFunding.total_amount)}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('goals.availableToAllocate')}
</Typography>
</Box>

<Tooltip
title={
<span style={{ whiteSpace: 'pre-line' }}>{accountBreakdown}</span>
}
>
<Stack direction="row" flexWrap="wrap" gap={1}>
{unallocatedFunding.accounts.map((account) => {
const accountName =
accountNameLookup.get(account.account_id) ||
t('goals.accountFallback', { id: account.account_id });

return (
<Chip
key={account.account_id}
size="small"
color="info"
variant="outlined"
label={`${accountName}: ${formatCurrency(account.amount)}`}
sx={{ maxWidth: '100%' }}
/>
);
})}
</Stack>
</Tooltip>
</CardContent>
</Card>
);
};

const Goals = () => {
const theme = useTheme();
const loader = useLoading();
Expand All @@ -409,38 +507,65 @@ const Goals = () => {

const [showArchived, setShowArchived] = useState(false);
const getGoalsRequest = useGetGoals(!showArchived);
const getAccountsRequest = useGetAccounts();
const deleteGoalRequest = useDeleteGoal();
const formatNumberAsCurrency = useFormatNumberAsCurrency();
const [state, dispatch] = useReducer(reduceState, null, createInitialState);

// Loading
useEffect(() => {
if (getGoalsRequest.isFetching || deleteGoalRequest.isPending) {
if (
getGoalsRequest.isFetching ||
getAccountsRequest.isFetching ||
deleteGoalRequest.isPending
) {
loader.showLoading();
} else {
loader.hideLoading();
}
}, [getGoalsRequest.isFetching, deleteGoalRequest.isPending]);
}, [
getGoalsRequest.isFetching,
getAccountsRequest.isFetching,
deleteGoalRequest.isPending,
]);

// Error
useEffect(() => {
if (getGoalsRequest.isError || deleteGoalRequest.isError) {
if (
getGoalsRequest.isError ||
getAccountsRequest.isError ||
deleteGoalRequest.isError
) {
snackbar.showSnackbar(
t('common.somethingWentWrongTryAgain'),
AlertSeverity.ERROR,
);
}
}, [getGoalsRequest.isError, deleteGoalRequest.isError]);
}, [
getGoalsRequest.isError,
getAccountsRequest.isError,
deleteGoalRequest.isError,
]);

// Success
useEffect(() => {
if (!getGoalsRequest.data) return;
dispatch({
type: StateActionType.DataLoaded,
payload: getGoalsRequest.data,
payload: getGoalsRequest.data.goals,
});
}, [getGoalsRequest.data]);

const unallocatedFunding = getGoalsRequest.data?.unallocated_funding;
const accountNameLookup = useMemo(() => {
return new Map(
(getAccountsRequest.data || []).map((account) => [
Number(account.account_id),
account.name,
]),
);
}, [getAccountsRequest.data]);

const formatDueDate = (timestamp: number | null) => {
if (!timestamp) return '-';
const date = new Date(timestamp * 1000);
Expand Down Expand Up @@ -713,6 +838,15 @@ const Goals = () => {
{/* Visual Goal Cards Section - Only active goals, sorted by due date */}
<Box sx={{ mb: 3 }}>
<Grid container spacing={2}>
{unallocatedFunding && unallocatedFunding.total_amount > 0 && (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<UnallocatedFundingCard
unallocatedFunding={unallocatedFunding}
accountNameLookup={accountNameLookup}
formatCurrency={formatNumberAsCurrency.invoke}
/>
</Grid>
)}
{sortedGoalsForCards.map((goal) => (
<Grid
key={String(goal.goal_id)}
Expand Down
13 changes: 12 additions & 1 deletion src/services/goal/goalServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export type Goal = {
funding_accounts: FundingAccount[];
};

export type GoalFundingSummary = {
goals: Goal[];
unallocated_funding: {
total_amount: number;
accounts: Array<{
account_id: number;
amount: number;
}>;
};
};

export type CreateGoalRequest = {
name: string;
description: string;
Expand All @@ -34,7 +45,7 @@ export type UpdateGoalRequest = CreateGoalRequest & {
};

const getGoals = (onlyActive: boolean = false) => {
return axios.get<Goal[]>(`/goals`, {
return axios.get<GoalFundingSummary>(`/goals`, {
params: onlyActive ? { only_active: true } : undefined,
});
};
Expand Down
Loading