feat(miniapp): add finance dashboard view

This commit is contained in:
2026-03-08 22:40:49 +04:00
parent f8478b717b
commit c5c356f2b2
17 changed files with 901 additions and 100 deletions

View File

@@ -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

View File

@@ -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: 'Локаль',

View File

@@ -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;
}

View File

@@ -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
}