mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(miniapp): add finance overview visualizations
This commit is contained in:
@@ -100,6 +100,8 @@ type PaymentDraft = {
|
|||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
|
||||||
|
|
||||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'demo',
|
mode: 'demo',
|
||||||
@@ -160,14 +162,6 @@ function joinDeepLink(): string | null {
|
|||||||
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
|
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashboardMemberCount(dashboard: MiniAppDashboard | null): string {
|
|
||||||
return dashboard ? String(dashboard.members.length) : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function dashboardLedgerCount(dashboard: MiniAppDashboard | null): string {
|
|
||||||
return dashboard ? String(dashboard.ledger.length) : '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCyclePeriod(): string {
|
function defaultCyclePeriod(): string {
|
||||||
return new Date().toISOString().slice(0, 7)
|
return new Date().toISOString().slice(0, 7)
|
||||||
}
|
}
|
||||||
@@ -193,6 +187,10 @@ function minorToMajorString(value: bigint): string {
|
|||||||
return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
|
return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function absoluteMinor(value: bigint): bigint {
|
||||||
|
return value < 0n ? -value : value
|
||||||
|
}
|
||||||
|
|
||||||
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
|
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
|
||||||
return minorToMajorString(
|
return minorToMajorString(
|
||||||
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
|
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
|
||||||
@@ -412,6 +410,147 @@ function App() {
|
|||||||
const paymentLedger = createMemo(() =>
|
const paymentLedger = createMemo(() =>
|
||||||
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment')
|
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment')
|
||||||
)
|
)
|
||||||
|
const utilityTotalMajor = createMemo(() =>
|
||||||
|
minorToMajorString(
|
||||||
|
utilityLedger().reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const purchaseTotalMajor = createMemo(() =>
|
||||||
|
minorToMajorString(
|
||||||
|
purchaseLedger().reduce(
|
||||||
|
(sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor),
|
||||||
|
0n
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const memberBalanceVisuals = createMemo(() => {
|
||||||
|
const data = dashboard()
|
||||||
|
if (!data) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = data.members.map((member) => {
|
||||||
|
const rentMinor = absoluteMinor(majorStringToMinor(member.rentShareMajor))
|
||||||
|
const utilityMinor = absoluteMinor(majorStringToMinor(member.utilityShareMajor))
|
||||||
|
const purchaseMinor = absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor))
|
||||||
|
|
||||||
|
return {
|
||||||
|
member,
|
||||||
|
totalMinor: rentMinor + utilityMinor + purchaseMinor,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
key: 'rent',
|
||||||
|
label: copy().shareRent,
|
||||||
|
amountMajor: member.rentShareMajor,
|
||||||
|
amountMinor: rentMinor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'utilities',
|
||||||
|
label: copy().shareUtilities,
|
||||||
|
amountMajor: member.utilityShareMajor,
|
||||||
|
amountMinor: utilityMinor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key:
|
||||||
|
majorStringToMinor(member.purchaseOffsetMajor) < 0n
|
||||||
|
? 'purchase-credit'
|
||||||
|
: 'purchase-debit',
|
||||||
|
label: copy().shareOffset,
|
||||||
|
amountMajor: member.purchaseOffsetMajor,
|
||||||
|
amountMinor: purchaseMinor
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxTotalMinor = totals.reduce(
|
||||||
|
(max, item) => (item.totalMinor > max ? item.totalMinor : max),
|
||||||
|
0n
|
||||||
|
)
|
||||||
|
|
||||||
|
return totals
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftRemaining = majorStringToMinor(left.member.remainingMajor)
|
||||||
|
const rightRemaining = majorStringToMinor(right.member.remainingMajor)
|
||||||
|
|
||||||
|
if (rightRemaining === leftRemaining) {
|
||||||
|
return left.member.displayName.localeCompare(right.member.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rightRemaining > leftRemaining ? 1 : -1
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
barWidthPercent:
|
||||||
|
maxTotalMinor > 0n ? (Number(item.totalMinor) / Number(maxTotalMinor)) * 100 : 0,
|
||||||
|
segments: item.segments.map((segment) => ({
|
||||||
|
...segment,
|
||||||
|
widthPercent:
|
||||||
|
item.totalMinor > 0n ? (Number(segment.amountMinor) / Number(item.totalMinor)) * 100 : 0
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const purchaseInvestmentChart = createMemo(() => {
|
||||||
|
const data = dashboard()
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
totalMajor: '0.00',
|
||||||
|
slices: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersById = new Map(data.members.map((member) => [member.memberId, member.displayName]))
|
||||||
|
const totals = new Map<string, { label: string; amountMinor: bigint }>()
|
||||||
|
|
||||||
|
for (const entry of purchaseLedger()) {
|
||||||
|
const key = entry.memberId ?? entry.actorDisplayName ?? entry.id
|
||||||
|
const label =
|
||||||
|
(entry.memberId ? membersById.get(entry.memberId) : null) ??
|
||||||
|
entry.actorDisplayName ??
|
||||||
|
copy().ledgerActorFallback
|
||||||
|
const current = totals.get(key) ?? {
|
||||||
|
label,
|
||||||
|
amountMinor: 0n
|
||||||
|
}
|
||||||
|
|
||||||
|
totals.set(key, {
|
||||||
|
label,
|
||||||
|
amountMinor:
|
||||||
|
current.amountMinor + absoluteMinor(majorStringToMinor(entry.displayAmountMajor))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...totals.entries()]
|
||||||
|
.map(([key, value], index) => ({
|
||||||
|
key,
|
||||||
|
label: value.label,
|
||||||
|
amountMinor: value.amountMinor,
|
||||||
|
amountMajor: minorToMajorString(value.amountMinor),
|
||||||
|
color: chartPalette[index % chartPalette.length]!
|
||||||
|
}))
|
||||||
|
.filter((item) => item.amountMinor > 0n)
|
||||||
|
.sort((left, right) => (right.amountMinor > left.amountMinor ? 1 : -1))
|
||||||
|
|
||||||
|
const totalMinor = items.reduce((sum, item) => sum + item.amountMinor, 0n)
|
||||||
|
const circumference = 2 * Math.PI * 42
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMajor: minorToMajorString(totalMinor),
|
||||||
|
slices: items.map((item) => {
|
||||||
|
const ratio = totalMinor > 0n ? Number(item.amountMinor) / Number(totalMinor) : 0
|
||||||
|
const dash = ratio * circumference
|
||||||
|
const slice = {
|
||||||
|
...item,
|
||||||
|
percentage: Math.round(ratio * 100),
|
||||||
|
dasharray: `${dash} ${Math.max(circumference - dash, 0)}`,
|
||||||
|
dashoffset: `${-offset}`
|
||||||
|
}
|
||||||
|
offset += dash
|
||||||
|
return slice
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
const webApp = getTelegramWebApp()
|
const webApp = getTelegramWebApp()
|
||||||
|
|
||||||
function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string {
|
function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string {
|
||||||
@@ -1585,6 +1724,142 @@ function App() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFinanceSummaryCards(data: MiniAppDashboard): JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().remainingLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{data.totalRemainingMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().shareRent}</span>
|
||||||
|
<strong>
|
||||||
|
{data.rentDisplayAmountMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().shareUtilities}</span>
|
||||||
|
<strong>
|
||||||
|
{utilityTotalMajor()} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span>{copy().purchasesTitle}</span>
|
||||||
|
<strong>
|
||||||
|
{purchaseTotalMajor()} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFinanceVisuals(data: MiniAppDashboard): JSX.Element {
|
||||||
|
const purchaseChart = purchaseInvestmentChart()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<article class="balance-item balance-item--wide">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().financeVisualsTitle}</strong>
|
||||||
|
<span>
|
||||||
|
{copy().membersCount}: {String(data.members.length)}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{copy().financeVisualsBody}</p>
|
||||||
|
<div class="member-visual-list">
|
||||||
|
{memberBalanceVisuals().map((item) => (
|
||||||
|
<article class="member-visual-card">
|
||||||
|
<header>
|
||||||
|
<strong>{item.member.displayName}</strong>
|
||||||
|
<span class={`balance-status ${memberRemainingClass(item.member)}`}>
|
||||||
|
{item.member.remainingMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div class="member-visual-bar">
|
||||||
|
<div
|
||||||
|
class="member-visual-bar__track"
|
||||||
|
style={{ width: `${item.barWidthPercent}%` }}
|
||||||
|
>
|
||||||
|
{item.segments.map((segment) => (
|
||||||
|
<span
|
||||||
|
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
|
||||||
|
style={{ width: `${segment.widthPercent}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-visual-meta">
|
||||||
|
{item.segments.map((segment) => (
|
||||||
|
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
|
||||||
|
{segment.label}: {segment.amountMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="balance-item balance-item--wide">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
||||||
|
<span>
|
||||||
|
{copy().purchaseTotalLabel}: {purchaseChart.totalMajor} {data.currency}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<p>{copy().purchaseInvestmentsBody}</p>
|
||||||
|
{purchaseChart.slices.length === 0 ? (
|
||||||
|
<p>{copy().purchaseInvestmentsEmpty}</p>
|
||||||
|
) : (
|
||||||
|
<div class="purchase-chart">
|
||||||
|
<div class="purchase-chart__figure">
|
||||||
|
<svg class="purchase-chart__donut" viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<circle class="purchase-chart__ring" cx="60" cy="60" r="42" />
|
||||||
|
{purchaseChart.slices.map((slice) => (
|
||||||
|
<circle
|
||||||
|
class="purchase-chart__slice"
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="42"
|
||||||
|
stroke={slice.color}
|
||||||
|
stroke-dasharray={slice.dasharray}
|
||||||
|
stroke-dashoffset={slice.dashoffset}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div class="purchase-chart__center">
|
||||||
|
<span>{copy().purchaseTotalLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{purchaseChart.totalMajor} {data.currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="purchase-chart__legend">
|
||||||
|
{purchaseChart.slices.map((slice) => (
|
||||||
|
<article class="purchase-chart__legend-item">
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="purchase-chart__legend-swatch"
|
||||||
|
style={{ 'background-color': slice.color }}
|
||||||
|
/>
|
||||||
|
<strong>{slice.label}</strong>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{slice.amountMajor} {data.currency} · {copy().purchaseShareLabel}{' '}
|
||||||
|
{slice.percentage}%
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
switch (activeNav()) {
|
switch (activeNav()) {
|
||||||
case 'balances':
|
case 'balances':
|
||||||
@@ -1638,6 +1913,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div class="home-grid home-grid--summary">{renderFinanceSummaryCards(data)}</div>
|
||||||
|
{renderFinanceVisuals(data)}
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().householdBalancesTitle}</strong>
|
<strong>{copy().householdBalancesTitle}</strong>
|
||||||
@@ -3132,36 +3409,30 @@ function App() {
|
|||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div class="home-grid home-grid--summary">
|
<div class="home-grid home-grid--summary">
|
||||||
<article class="stat-card">
|
<ShowDashboard
|
||||||
<span>{copy().totalDue}</span>
|
dashboard={dashboard()}
|
||||||
<strong>
|
fallback={
|
||||||
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
|
<>
|
||||||
</strong>
|
<article class="stat-card">
|
||||||
</article>
|
<span>{copy().remainingLabel}</span>
|
||||||
<article class="stat-card">
|
<strong>—</strong>
|
||||||
<span>{copy().paidLabel}</span>
|
</article>
|
||||||
<strong>
|
<article class="stat-card">
|
||||||
{dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
|
<span>{copy().shareRent}</span>
|
||||||
</strong>
|
<strong>—</strong>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>{copy().remainingLabel}</span>
|
<span>{copy().shareUtilities}</span>
|
||||||
<strong>
|
<strong>—</strong>
|
||||||
{dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
|
</article>
|
||||||
</strong>
|
<article class="stat-card">
|
||||||
</article>
|
<span>{copy().purchasesTitle}</span>
|
||||||
<article class="stat-card">
|
<strong>—</strong>
|
||||||
<span>{copy().membersCount}</span>
|
</article>
|
||||||
<strong>{dashboardMemberCount(dashboard())}</strong>
|
</>
|
||||||
</article>
|
}
|
||||||
<article class="stat-card">
|
render={(data) => renderFinanceSummaryCards(data)}
|
||||||
<span>{copy().ledgerEntries}</span>
|
/>
|
||||||
<strong>{dashboardLedgerCount(dashboard())}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card">
|
|
||||||
<span>{copy().purchasesTitle}</span>
|
|
||||||
<strong>{String(purchaseLedger().length)}</strong>
|
|
||||||
</article>
|
|
||||||
{readySession()?.member.isAdmin ? (
|
{readySession()?.member.isAdmin ? (
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>{copy().pendingRequests}</span>
|
<span>{copy().pendingRequests}</span>
|
||||||
@@ -3232,6 +3503,12 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ShowDashboard
|
||||||
|
dashboard={dashboard()}
|
||||||
|
fallback={null}
|
||||||
|
render={(data) => renderFinanceVisuals(data)}
|
||||||
|
/>
|
||||||
|
|
||||||
<article class="balance-item balance-item--wide">
|
<article class="balance-item balance-item--wide">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().latestActivityTitle}</strong>
|
<strong>{copy().latestActivityTitle}</strong>
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export const dictionary = {
|
|||||||
finalDue: 'Final due',
|
finalDue: 'Final due',
|
||||||
householdBalancesTitle: 'Household balances',
|
householdBalancesTitle: 'Household balances',
|
||||||
householdBalancesBody: 'Everyone’s current split for this cycle.',
|
householdBalancesBody: 'Everyone’s current split for this cycle.',
|
||||||
|
financeVisualsTitle: 'Visual balance split',
|
||||||
|
financeVisualsBody:
|
||||||
|
'Use the bars to see how rent, utilities, and shared-buy adjustments shape each member balance.',
|
||||||
|
purchaseInvestmentsTitle: 'Who fronted shared purchases',
|
||||||
|
purchaseInvestmentsBody:
|
||||||
|
'This donut shows how much each member invested into shared purchases this cycle.',
|
||||||
|
purchaseInvestmentsEmpty:
|
||||||
|
'Purchase contributions will appear here after shared buys are logged.',
|
||||||
|
purchaseShareLabel: 'Share',
|
||||||
|
purchaseTotalLabel: 'Total shared buys',
|
||||||
purchasesTitle: 'Shared purchases',
|
purchasesTitle: 'Shared purchases',
|
||||||
purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
|
purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
|
||||||
utilityLedgerTitle: 'Utility bills',
|
utilityLedgerTitle: 'Utility bills',
|
||||||
@@ -242,6 +252,16 @@ export const dictionary = {
|
|||||||
finalDue: 'Итог к оплате',
|
finalDue: 'Итог к оплате',
|
||||||
householdBalancesTitle: 'Баланс household',
|
householdBalancesTitle: 'Баланс household',
|
||||||
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
||||||
|
financeVisualsTitle: 'Визуальный разбор баланса',
|
||||||
|
financeVisualsBody:
|
||||||
|
'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.',
|
||||||
|
purchaseInvestmentsTitle: 'Кто оплачивал общие покупки',
|
||||||
|
purchaseInvestmentsBody:
|
||||||
|
'Кольцевая диаграмма показывает, сколько каждый участник вложил в общие покупки в этом цикле.',
|
||||||
|
purchaseInvestmentsEmpty:
|
||||||
|
'Вклады в общие покупки появятся здесь после первых записанных покупок.',
|
||||||
|
purchaseShareLabel: 'Доля',
|
||||||
|
purchaseTotalLabel: 'Всего общих покупок',
|
||||||
purchasesTitle: 'Общие покупки',
|
purchasesTitle: 'Общие покупки',
|
||||||
purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
|
purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
|
||||||
utilityLedgerTitle: 'Коммунальные платежи',
|
utilityLedgerTitle: 'Коммунальные платежи',
|
||||||
|
|||||||
@@ -308,6 +308,170 @@ button {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-visual-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-card {
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgb(255 255 255 / 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-card header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar__track {
|
||||||
|
display: flex;
|
||||||
|
min-height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(255 255 255 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar__segment--rent {
|
||||||
|
background: #f7b389;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar__segment--utilities {
|
||||||
|
background: #6fd3c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar__segment--purchase-debit {
|
||||||
|
background: #f06a8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-bar__segment--purchase-credit {
|
||||||
|
background: #94a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgb(255 255 255 / 0.05);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-chip--rent {
|
||||||
|
border: 1px solid rgb(247 179 137 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-chip--utilities {
|
||||||
|
border: 1px solid rgb(111 211 192 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-chip--purchase-debit {
|
||||||
|
border: 1px solid rgb(240 106 141 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-visual-chip--purchase-credit {
|
||||||
|
border: 1px solid rgb(148 168 255 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__figure {
|
||||||
|
position: relative;
|
||||||
|
width: min(220px, 100%);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__donut {
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__ring,
|
||||||
|
.purchase-chart__slice {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__ring {
|
||||||
|
stroke: rgb(255 255 255 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__slice {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
stroke-linecap: butt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__center {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__center span {
|
||||||
|
color: #c6c2bb;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__center strong {
|
||||||
|
font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
|
||||||
|
font-size: clamp(1.15rem, 4vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend-item {
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgb(255 255 255 / 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend-item div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend-item p {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend-swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card span {
|
.stat-card span {
|
||||||
color: #c6c2bb;
|
color: #c6c2bb;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -503,6 +667,15 @@ button {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.purchase-chart {
|
||||||
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-chart__legend {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-summary-grid {
|
.admin-summary-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -560,6 +733,10 @@ button {
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-visual-card header {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-section__header {
|
.admin-section__header {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user