Display in a chip the number of unmatched inbox entries

This commit is contained in:
Pierre HUBERT 2025-05-12 18:46:40 +02:00
parent 0b586039c3
commit 1e8064946a
5 changed files with 138 additions and 41 deletions

View File

@ -21,6 +21,17 @@ export interface InboxEntryUpdate {
} }
export class InboxApi { export class InboxApi {
/**
* Get the number of unmatched entries
*/
static async CountUnmatched(): Promise<number> {
return (
await APIClient.exec({
uri: `/inbox/count?include_attached=false`,
method: "GET",
})
).data.count;
}
/** /**
* Create a new inbox entry * Create a new inbox entry
*/ */

View File

@ -0,0 +1,67 @@
import React from "react";
import { InboxApi } from "../api/InboxApi";
import { AsyncWidget } from "../widgets/AsyncWidget";
interface UnmatchedInboxEntriesCountContext {
count: number;
reload: () => Promise<void>;
}
const UnmatchedInboxEntriesCountContextK =
React.createContext<UnmatchedInboxEntriesCountContext | null>(null);
export function UnmatchedInboxEntriesCountProvider(
p: React.PropsWithChildren
): React.ReactElement {
const [count, setCount] = React.useState<number | null>(null);
const loadKey = React.useRef(1);
const loadPromise = React.useRef<(() => void) | null>(null);
const load = async () => {
setCount(await InboxApi.CountUnmatched());
};
const onReload = async () => {
loadKey.current += 1;
load();
return new Promise<void>((res) => {
loadPromise.current = () => {
res();
};
});
};
return (
<AsyncWidget
ready={true}
loadKey={loadKey.current}
load={load}
errMsg="Failed to get the number of unread inbox entries!"
build={() => {
if (loadPromise.current != null) {
loadPromise.current();
loadPromise.current = null;
}
return (
<UnmatchedInboxEntriesCountContextK
value={{
count: count ?? 0,
reload: onReload,
}}
>
{p.children}
</UnmatchedInboxEntriesCountContextK>
);
}}
/>
);
}
export function useUnmatchedInboxEntriesCount(): UnmatchedInboxEntriesCountContext {
return React.use(UnmatchedInboxEntriesCountContextK)!;
}

View File

@ -7,11 +7,14 @@ import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProv
import { AsyncWidget } from "../widgets/AsyncWidget"; import { AsyncWidget } from "../widgets/AsyncWidget";
import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer"; import { MoneyMgrWebRouteContainer } from "../widgets/MoneyMgrWebRouteContainer";
import { NewMovementWidget } from "../widgets/NewMovementWidget"; import { NewMovementWidget } from "../widgets/NewMovementWidget";
import { useUnmatchedInboxEntriesCount } from "../hooks/UnmatchedInboxEntriesCountProvider";
export function InboxRoute(): React.ReactElement { export function InboxRoute(): React.ReactElement {
const loadingMessage = useLoadingMessage(); const loadingMessage = useLoadingMessage();
const alert = useAlert(); const alert = useAlert();
const unmatched = useUnmatchedInboxEntriesCount();
const [entries, setEntries] = React.useState<InboxEntry[] | undefined>(); const [entries, setEntries] = React.useState<InboxEntry[] | undefined>();
const [includeAttached, setIncludeAttached] = React.useState(false); const [includeAttached, setIncludeAttached] = React.useState(false);
@ -24,7 +27,7 @@ export function InboxRoute(): React.ReactElement {
const reload = async (skipEntries: boolean) => { const reload = async (skipEntries: boolean) => {
try { try {
loadingMessage.show("Refreshing the list of inbox entries..."); loadingMessage.show("Refreshing the list of inbox entries...");
// TODO : trigger reload number of inbox entries unmatched.reload();
if (!skipEntries) await load(); if (!skipEntries) await load();
} catch (e) { } catch (e) {
console.error("Failed to load list of inbox entries!", e); console.error("Failed to load list of inbox entries!", e);

View File

@ -1,13 +1,14 @@
import { Box, Button } from "@mui/material"; import { Box, Button } from "@mui/material";
import * as React from "react"; import * as React from "react";
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { AuthApi, AuthInfo } from "../api/AuthApi"; import { AuthApi, AuthInfo } from "../api/AuthApi";
import { useAuth } from "../App";
import { AccountsListProvider } from "../hooks/AccountsListProvider"; import { AccountsListProvider } from "../hooks/AccountsListProvider";
import { ChooseAccountDialogProvider } from "../hooks/context_providers/ChooseAccountDialogProvider";
import { UnmatchedInboxEntriesCountProvider } from "../hooks/UnmatchedInboxEntriesCountProvider";
import { AsyncWidget } from "./AsyncWidget"; import { AsyncWidget } from "./AsyncWidget";
import { MoneyNavList } from "./MoneyNavList"; import { MoneyNavList } from "./MoneyNavList";
import { MoneyWebAppBar } from "./MoneyWebAppBar"; import { MoneyWebAppBar } from "./MoneyWebAppBar";
import { ChooseAccountDialogProvider } from "../hooks/context_providers/ChooseAccountDialogProvider";
interface AuthInfoContext { interface AuthInfoContext {
info: AuthInfo; info: AuthInfo;
@ -48,48 +49,50 @@ export function BaseAuthenticatedPage(): React.ReactElement {
reloadAuthInfo: load, reloadAuthInfo: load,
}} }}
> >
<AccountsListProvider> <UnmatchedInboxEntriesCountProvider>
<ChooseAccountDialogProvider> <AccountsListProvider>
<Box <ChooseAccountDialogProvider>
component="div"
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
color: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[900]
: theme.palette.grey[100],
}}
>
<MoneyWebAppBar onSignOut={signOut} />
<Box <Box
component="div"
sx={{ sx={{
minHeight: "100vh",
display: "flex", display: "flex",
flex: "2", flexDirection: "column",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
color: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[900]
: theme.palette.grey[100],
}} }}
> >
<MoneyNavList /> <MoneyWebAppBar onSignOut={signOut} />
<div
style={{ <Box
flexGrow: 1, sx={{
flexShrink: 0,
flexBasis: 0,
minWidth: 0,
display: "flex", display: "flex",
flex: "2",
}} }}
> >
<Outlet /> <MoneyNavList />
</div> <div
style={{
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
minWidth: 0,
display: "flex",
}}
>
<Outlet />
</div>
</Box>
</Box> </Box>
</Box> </ChooseAccountDialogProvider>
</ChooseAccountDialogProvider> </AccountsListProvider>
</AccountsListProvider> </UnmatchedInboxEntriesCountProvider>
</AuthInfoContextK> </AuthInfoContextK>
)} )}
/> />

View File

@ -1,8 +1,10 @@
import { mdiCashMultiple, mdiHome, mdiInbox } from "@mdi/js"; import { mdiCashMultiple, mdiHome, mdiInbox } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import { import {
Chip,
Divider, Divider,
List, List,
ListItem,
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
@ -15,10 +17,12 @@ import { usePublicMode } from "../hooks/context_providers/PublicModeProvider";
import { AccountWidget } from "./AccountWidget"; import { AccountWidget } from "./AccountWidget";
import { AmountWidget } from "./AmountWidget"; import { AmountWidget } from "./AmountWidget";
import { RouterLink } from "./RouterLink"; import { RouterLink } from "./RouterLink";
import { useUnmatchedInboxEntriesCount } from "../hooks/UnmatchedInboxEntriesCountProvider";
export function MoneyNavList(): React.ReactElement { export function MoneyNavList(): React.ReactElement {
const publicMode = usePublicMode(); const publicMode = usePublicMode();
const accounts = useAccounts(); const accounts = useAccounts();
const unmatched = useUnmatchedInboxEntriesCount();
return ( return (
<List <List
dense dense
@ -35,11 +39,17 @@ export function MoneyNavList(): React.ReactElement {
uri="/accounts" uri="/accounts"
icon={<Icon path={mdiCashMultiple} size={1} />} icon={<Icon path={mdiCashMultiple} size={1} />}
/> />
{/* TODO : show number of unmatched */}
<NavLink <NavLink
label="Inbox" label="Inbox"
uri="/inbox" uri="/inbox"
icon={<Icon path={mdiInbox} size={1} />} icon={<Icon path={mdiInbox} size={1} />}
secondary={
unmatched.count > 0 ? (
<Chip label={unmatched.count} size="small" />
) : (
<></>
)
}
/> />
<Divider /> <Divider />
{accounts.list.isEmpty && ( {accounts.list.isEmpty && (
@ -76,14 +86,17 @@ function NavLink(p: {
uri: string; uri: string;
label: string | React.ReactElement; label: string | React.ReactElement;
secondaryLabel?: string | React.ReactElement; secondaryLabel?: string | React.ReactElement;
secondary?: React.ReactElement;
}): React.ReactElement { }): React.ReactElement {
const location = useLocation(); const location = useLocation();
return ( return (
<RouterLink to={p.uri}> <RouterLink to={p.uri}>
<ListItemButton selected={p.uri === location.pathname}> <ListItem secondaryAction={p.secondary} disablePadding>
<ListItemIcon>{p.icon}</ListItemIcon> <ListItemButton selected={p.uri === location.pathname}>
<ListItemText primary={p.label} secondary={p.secondaryLabel} /> <ListItemIcon>{p.icon}</ListItemIcon>
</ListItemButton> <ListItemText primary={p.label} secondary={p.secondaryLabel} />
</ListItemButton>
</ListItem>
</RouterLink> </RouterLink>
); );
} }