mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:44:03 +00:00
feat(miniapp): add finance dashboard view
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js'
|
||||
import { Match, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
|
||||
|
||||
import { dictionary, type Locale } from './i18n'
|
||||
import { fetchMiniAppSession } from './miniapp-api'
|
||||
import { fetchMiniAppDashboard, fetchMiniAppSession, type MiniAppDashboard } from './miniapp-api'
|
||||
import { getTelegramWebApp } from './telegram-webapp'
|
||||
|
||||
type SessionState =
|
||||
@@ -55,6 +55,7 @@ function App() {
|
||||
status: 'loading'
|
||||
})
|
||||
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
||||
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
||||
|
||||
const copy = createMemo(() => dictionary[locale()])
|
||||
const blockedSession = createMemo(() => {
|
||||
@@ -103,9 +104,58 @@ function App() {
|
||||
member: payload.member,
|
||||
telegramUser: payload.telegramUser
|
||||
})
|
||||
|
||||
try {
|
||||
setDashboard(await fetchMiniAppDashboard(initData))
|
||||
} catch {
|
||||
setDashboard(null)
|
||||
}
|
||||
} catch {
|
||||
if (import.meta.env.DEV) {
|
||||
setSession(demoSession)
|
||||
setDashboard({
|
||||
period: '2026-03',
|
||||
currency: 'USD',
|
||||
totalDueMajor: '820.00',
|
||||
members: [
|
||||
{
|
||||
memberId: 'alice',
|
||||
displayName: 'Alice',
|
||||
rentShareMajor: '350.00',
|
||||
utilityShareMajor: '60.00',
|
||||
purchaseOffsetMajor: '-15.00',
|
||||
netDueMajor: '395.00',
|
||||
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||
},
|
||||
{
|
||||
memberId: 'bob',
|
||||
displayName: 'Bob',
|
||||
rentShareMajor: '350.00',
|
||||
utilityShareMajor: '60.00',
|
||||
purchaseOffsetMajor: '15.00',
|
||||
netDueMajor: '425.00',
|
||||
explanations: ['Equal utility split']
|
||||
}
|
||||
],
|
||||
ledger: [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
kind: 'purchase',
|
||||
title: 'Soap',
|
||||
amountMajor: '30.00',
|
||||
actorDisplayName: 'Alice',
|
||||
occurredAt: '2026-03-12T11:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'utility-1',
|
||||
kind: 'utility',
|
||||
title: 'Electricity',
|
||||
amountMajor: '120.00',
|
||||
actorDisplayName: 'Alice',
|
||||
occurredAt: '2026-03-12T12:00:00.000Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,13 +169,74 @@ function App() {
|
||||
const renderPanel = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
return copy().balancesEmpty
|
||||
return (
|
||||
<div class="balance-list">
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={<p>{copy().emptyDashboard}</p>}
|
||||
render={(data) =>
|
||||
data.members.map((member) => (
|
||||
<article class="balance-item">
|
||||
<header>
|
||||
<strong>{member.displayName}</strong>
|
||||
<span>
|
||||
{member.netDueMajor} {data.currency}
|
||||
</span>
|
||||
</header>
|
||||
<p>
|
||||
{copy().shareRent}: {member.rentShareMajor} {data.currency}
|
||||
</p>
|
||||
<p>
|
||||
{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}
|
||||
</p>
|
||||
<p>
|
||||
{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}
|
||||
</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'ledger':
|
||||
return copy().ledgerEmpty
|
||||
return (
|
||||
<div class="ledger-list">
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={<p>{copy().emptyDashboard}</p>}
|
||||
render={(data) =>
|
||||
data.ledger.map((entry) => (
|
||||
<article class="ledger-item">
|
||||
<header>
|
||||
<strong>{entry.title}</strong>
|
||||
<span>
|
||||
{entry.amountMajor} {data.currency}
|
||||
</span>
|
||||
</header>
|
||||
<p>{entry.actorDisplayName ?? 'Household'}</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'house':
|
||||
return copy().houseEmpty
|
||||
default:
|
||||
return copy().summaryBody
|
||||
return (
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={<p>{copy().summaryBody}</p>}
|
||||
render={(data) => (
|
||||
<>
|
||||
<p>
|
||||
{copy().totalDue}: {data.totalDueMajor} {data.currency}
|
||||
</p>
|
||||
<p>{copy().summaryBody}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,4 +365,12 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function ShowDashboard(props: {
|
||||
dashboard: MiniAppDashboard | null
|
||||
fallback: JSX.Element
|
||||
render: (dashboard: MiniAppDashboard) => JSX.Element
|
||||
}) {
|
||||
return <>{props.dashboard ? props.render(props.dashboard) : props.fallback}</>
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -26,6 +26,12 @@ export const dictionary = {
|
||||
summaryTitle: 'Current shell',
|
||||
summaryBody:
|
||||
'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.',
|
||||
totalDue: 'Total due',
|
||||
shareRent: 'Rent',
|
||||
shareUtilities: 'Utilities',
|
||||
shareOffset: 'Shared buys',
|
||||
ledgerTitle: 'Included ledger',
|
||||
emptyDashboard: 'No billing cycle is ready yet.',
|
||||
cardAccess: 'Access',
|
||||
cardAccessBody: 'Telegram identity verified and matched to a household member.',
|
||||
cardLocale: 'Locale',
|
||||
@@ -64,6 +70,12 @@ export const dictionary = {
|
||||
summaryTitle: 'Текущая оболочка',
|
||||
summaryBody:
|
||||
'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.',
|
||||
totalDue: 'Итого к оплате',
|
||||
shareRent: 'Аренда',
|
||||
shareUtilities: 'Коммуналка',
|
||||
shareOffset: 'Общие покупки',
|
||||
ledgerTitle: 'Вошедшие операции',
|
||||
emptyDashboard: 'Пока нет готового billing cycle.',
|
||||
cardAccess: 'Доступ',
|
||||
cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.',
|
||||
cardLocale: 'Локаль',
|
||||
|
||||
@@ -210,6 +210,39 @@ button {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.balance-list,
|
||||
.ledger-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.balance-item,
|
||||
.ledger-item {
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
background: rgb(255 255 255 / 0.03);
|
||||
}
|
||||
|
||||
.balance-item header,
|
||||
.ledger-item header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.balance-item strong,
|
||||
.ledger-item strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.balance-item p,
|
||||
.ledger-item p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.panel--wide {
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,29 @@ export interface MiniAppSession {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface MiniAppDashboard {
|
||||
period: string
|
||||
currency: 'USD' | 'GEL'
|
||||
totalDueMajor: string
|
||||
members: {
|
||||
memberId: string
|
||||
displayName: string
|
||||
rentShareMajor: string
|
||||
utilityShareMajor: string
|
||||
purchaseOffsetMajor: string
|
||||
netDueMajor: string
|
||||
explanations: readonly string[]
|
||||
}[]
|
||||
ledger: {
|
||||
id: string
|
||||
kind: 'purchase' | 'utility'
|
||||
title: string
|
||||
amountMajor: string
|
||||
actorDisplayName: string | null
|
||||
occurredAt: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
function apiBaseUrl(): string {
|
||||
const runtimeConfigured = runtimeBotApiUrl()
|
||||
if (runtimeConfigured) {
|
||||
@@ -64,3 +87,28 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
|
||||
...(payload.reason ? { reason: payload.reason } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMiniAppDashboard(initData: string): Promise<MiniAppDashboard> {
|
||||
const response = await fetch(`${apiBaseUrl()}/api/miniapp/dashboard`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
initData
|
||||
})
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean
|
||||
authorized?: boolean
|
||||
dashboard?: MiniAppDashboard
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.authorized || !payload.dashboard) {
|
||||
throw new Error(payload.error ?? 'Failed to load dashboard')
|
||||
}
|
||||
|
||||
return payload.dashboard
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user