mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
feat(miniapp): add finance overview visualizations
This commit is contained in:
@@ -100,6 +100,8 @@ type PaymentDraft = {
|
||||
currency: 'USD' | 'GEL'
|
||||
}
|
||||
|
||||
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
|
||||
|
||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||
status: 'ready',
|
||||
mode: 'demo',
|
||||
@@ -160,14 +162,6 @@ function joinDeepLink(): string | null {
|
||||
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 {
|
||||
return new Date().toISOString().slice(0, 7)
|
||||
}
|
||||
@@ -193,6 +187,10 @@ function minorToMajorString(value: bigint): string {
|
||||
return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
|
||||
}
|
||||
|
||||
function absoluteMinor(value: bigint): bigint {
|
||||
return value < 0n ? -value : value
|
||||
}
|
||||
|
||||
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
|
||||
return minorToMajorString(
|
||||
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
|
||||
@@ -412,6 +410,147 @@ function App() {
|
||||
const paymentLedger = createMemo(() =>
|
||||
(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()
|
||||
|
||||
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 = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
@@ -1638,6 +1913,8 @@ function App() {
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
<div class="home-grid home-grid--summary">{renderFinanceSummaryCards(data)}</div>
|
||||
{renderFinanceVisuals(data)}
|
||||
<article class="balance-item">
|
||||
<header>
|
||||
<strong>{copy().householdBalancesTitle}</strong>
|
||||
@@ -3132,36 +3409,30 @@ function App() {
|
||||
default:
|
||||
return (
|
||||
<div class="home-grid home-grid--summary">
|
||||
<article class="stat-card">
|
||||
<span>{copy().totalDue}</span>
|
||||
<strong>
|
||||
{dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().paidLabel}</span>
|
||||
<strong>
|
||||
{dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().remainingLabel}</span>
|
||||
<strong>
|
||||
{dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().membersCount}</span>
|
||||
<strong>{dashboardMemberCount(dashboard())}</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().ledgerEntries}</span>
|
||||
<strong>{dashboardLedgerCount(dashboard())}</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().purchasesTitle}</span>
|
||||
<strong>{String(purchaseLedger().length)}</strong>
|
||||
</article>
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={
|
||||
<>
|
||||
<article class="stat-card">
|
||||
<span>{copy().remainingLabel}</span>
|
||||
<strong>—</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().shareRent}</span>
|
||||
<strong>—</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().shareUtilities}</span>
|
||||
<strong>—</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().purchasesTitle}</span>
|
||||
<strong>—</strong>
|
||||
</article>
|
||||
</>
|
||||
}
|
||||
render={(data) => renderFinanceSummaryCards(data)}
|
||||
/>
|
||||
{readySession()?.member.isAdmin ? (
|
||||
<article class="stat-card">
|
||||
<span>{copy().pendingRequests}</span>
|
||||
@@ -3232,6 +3503,12 @@ function App() {
|
||||
</article>
|
||||
)}
|
||||
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={null}
|
||||
render={(data) => renderFinanceVisuals(data)}
|
||||
/>
|
||||
|
||||
<article class="balance-item balance-item--wide">
|
||||
<header>
|
||||
<strong>{copy().latestActivityTitle}</strong>
|
||||
|
||||
@@ -57,6 +57,16 @@ export const dictionary = {
|
||||
finalDue: 'Final due',
|
||||
householdBalancesTitle: 'Household balances',
|
||||
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',
|
||||
purchasesEmpty: 'No shared purchases recorded for this cycle yet.',
|
||||
utilityLedgerTitle: 'Utility bills',
|
||||
@@ -242,6 +252,16 @@ export const dictionary = {
|
||||
finalDue: 'Итог к оплате',
|
||||
householdBalancesTitle: 'Баланс household',
|
||||
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
||||
financeVisualsTitle: 'Визуальный разбор баланса',
|
||||
financeVisualsBody:
|
||||
'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.',
|
||||
purchaseInvestmentsTitle: 'Кто оплачивал общие покупки',
|
||||
purchaseInvestmentsBody:
|
||||
'Кольцевая диаграмма показывает, сколько каждый участник вложил в общие покупки в этом цикле.',
|
||||
purchaseInvestmentsEmpty:
|
||||
'Вклады в общие покупки появятся здесь после первых записанных покупок.',
|
||||
purchaseShareLabel: 'Доля',
|
||||
purchaseTotalLabel: 'Всего общих покупок',
|
||||
purchasesTitle: 'Общие покупки',
|
||||
purchasesEmpty: 'Пока нет общих покупок в этом цикле.',
|
||||
utilityLedgerTitle: 'Коммунальные платежи',
|
||||
|
||||
@@ -308,6 +308,170 @@ button {
|
||||
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 {
|
||||
color: #c6c2bb;
|
||||
font-size: 0.82rem;
|
||||
@@ -503,6 +667,15 @@ button {
|
||||
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 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -560,6 +733,10 @@ button {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.member-visual-card header {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-section__header {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user