mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
1013 lines
43 KiB
TypeScript
1013 lines
43 KiB
TypeScript
import { Show, For, createMemo, createSignal, Switch, Match } from 'solid-js'
|
|
import { Clock, ChevronDown, ChevronUp, Copy, Check, CreditCard } from 'lucide-solid'
|
|
import { useNavigate } from '@solidjs/router'
|
|
|
|
import { useSession } from '../contexts/session-context'
|
|
import { useI18n } from '../contexts/i18n-context'
|
|
import { useDashboard } from '../contexts/dashboard-context'
|
|
import { Card } from '../components/ui/card'
|
|
import { Badge } from '../components/ui/badge'
|
|
import { Button } from '../components/ui/button'
|
|
import { Field } from '../components/ui/field'
|
|
import { Input } from '../components/ui/input'
|
|
import { Modal } from '../components/ui/dialog'
|
|
import { Toast } from '../components/ui/toast'
|
|
import { Skeleton } from '../components/ui/skeleton'
|
|
import { ledgerPrimaryAmount } from '../lib/ledger-helpers'
|
|
import { majorStringToMinor, minorToMajorString } from '../lib/money'
|
|
import {
|
|
compareTodayToPeriodDay,
|
|
daysUntilPeriodDay,
|
|
formatPeriodDay,
|
|
nextCyclePeriod,
|
|
parseCalendarDate
|
|
} from '../lib/dates'
|
|
import { submitMiniAppUtilityBill, addMiniAppPayment } from '../miniapp-api'
|
|
import type { MiniAppDashboard } from '../miniapp-api'
|
|
|
|
function sumMemberPaymentsByKind(
|
|
data: MiniAppDashboard,
|
|
memberId: string,
|
|
kind: 'rent' | 'utilities'
|
|
): bigint {
|
|
return data.ledger.reduce((sum, entry) => {
|
|
if (entry.kind !== 'payment' || entry.memberId !== memberId || entry.paymentKind !== kind) {
|
|
return sum
|
|
}
|
|
|
|
return sum + majorStringToMinor(entry.amountMajor)
|
|
}, 0n)
|
|
}
|
|
|
|
function paymentProposalMinor(
|
|
data: MiniAppDashboard,
|
|
member: MiniAppDashboard['members'][number],
|
|
kind: 'rent' | 'utilities'
|
|
): bigint {
|
|
const purchaseOffsetMinor = majorStringToMinor(member.purchaseOffsetMajor)
|
|
const baseMinor =
|
|
kind === 'rent'
|
|
? majorStringToMinor(member.rentShareMajor)
|
|
: majorStringToMinor(member.utilityShareMajor)
|
|
|
|
if (data.paymentBalanceAdjustmentPolicy === kind) {
|
|
return baseMinor + purchaseOffsetMinor
|
|
}
|
|
|
|
return baseMinor
|
|
}
|
|
|
|
function paymentRemainingMinor(
|
|
data: MiniAppDashboard,
|
|
member: MiniAppDashboard['members'][number],
|
|
kind: 'rent' | 'utilities'
|
|
): bigint {
|
|
const proposalMinor = paymentProposalMinor(data, member, kind)
|
|
const paidMinor = sumMemberPaymentsByKind(data, member.memberId, kind)
|
|
const remainingMinor = proposalMinor - paidMinor
|
|
|
|
return remainingMinor > 0n ? remainingMinor : 0n
|
|
}
|
|
|
|
export default function HomeRoute() {
|
|
const navigate = useNavigate()
|
|
const { readySession, initData, refreshHouseholdData } = useSession()
|
|
const { copy, locale } = useI18n()
|
|
const {
|
|
dashboard,
|
|
loading,
|
|
currentMemberLine,
|
|
utilityLedger,
|
|
utilityTotalMajor,
|
|
testingPeriodOverride,
|
|
testingTodayOverride
|
|
} = useDashboard()
|
|
const [showAllActivity, setShowAllActivity] = createSignal(false)
|
|
const [utilityDraft, setUtilityDraft] = createSignal({
|
|
billName: '',
|
|
amountMajor: '',
|
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
|
})
|
|
const [submittingUtilities, setSubmittingUtilities] = createSignal(false)
|
|
const [copiedValue, setCopiedValue] = createSignal<string | null>(null)
|
|
const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false)
|
|
const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent')
|
|
const [quickPaymentContext, setQuickPaymentContext] = createSignal<'current' | 'overdue'>(
|
|
'current'
|
|
)
|
|
const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('')
|
|
const [submittingPayment, setSubmittingPayment] = createSignal(false)
|
|
const [toastState, setToastState] = createSignal<{
|
|
visible: boolean
|
|
message: string
|
|
type: 'success' | 'info' | 'error'
|
|
}>({ visible: false, message: '', type: 'info' })
|
|
|
|
async function copyText(value: string): Promise<boolean> {
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
return true
|
|
} catch {
|
|
try {
|
|
const element = document.createElement('textarea')
|
|
element.value = value
|
|
element.setAttribute('readonly', 'true')
|
|
element.style.position = 'absolute'
|
|
element.style.left = '-9999px'
|
|
document.body.appendChild(element)
|
|
element.select()
|
|
document.execCommand('copy')
|
|
document.body.removeChild(element)
|
|
return true
|
|
} catch {}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async function handleCopy(value: string) {
|
|
if (await copyText(value)) {
|
|
setCopiedValue(value)
|
|
setToastState({ visible: true, message: copy().copiedToast, type: 'success' })
|
|
setTimeout(() => {
|
|
if (copiedValue() === value) {
|
|
setCopiedValue(null)
|
|
}
|
|
}, 1400)
|
|
}
|
|
}
|
|
|
|
function dueStatusBadge() {
|
|
const data = dashboard()
|
|
if (!data) return null
|
|
|
|
const remaining = majorStringToMinor(data.totalRemainingMajor)
|
|
if (remaining <= 0n) return { label: copy().homeSettledTitle, variant: 'accent' as const }
|
|
return { label: copy().homeDueTitle, variant: 'danger' as const }
|
|
}
|
|
|
|
function paymentWindowStatus(input: {
|
|
period: string
|
|
timezone: string
|
|
reminderDay: number
|
|
dueDay: number
|
|
todayOverride?: ReturnType<typeof parseCalendarDate>
|
|
}): { active: boolean; daysUntilDue: number | null } {
|
|
if (!Number.isInteger(input.reminderDay) || !Number.isInteger(input.dueDay)) {
|
|
return { active: false, daysUntilDue: null }
|
|
}
|
|
|
|
const start = compareTodayToPeriodDay(
|
|
input.period,
|
|
input.reminderDay,
|
|
input.timezone,
|
|
input.todayOverride
|
|
)
|
|
const end = compareTodayToPeriodDay(
|
|
input.period,
|
|
input.dueDay,
|
|
input.timezone,
|
|
input.todayOverride
|
|
)
|
|
if (start === null || end === null) {
|
|
return { active: false, daysUntilDue: null }
|
|
}
|
|
|
|
const reminderPassed = start !== -1
|
|
const dueNotPassed = end !== 1
|
|
const daysUntilDue = daysUntilPeriodDay(
|
|
input.period,
|
|
input.dueDay,
|
|
input.timezone,
|
|
input.todayOverride
|
|
)
|
|
|
|
return {
|
|
active: reminderPassed && dueNotPassed,
|
|
daysUntilDue
|
|
}
|
|
}
|
|
|
|
const todayOverride = createMemo(() => {
|
|
const raw = testingTodayOverride()
|
|
if (!raw) return null
|
|
return parseCalendarDate(raw)
|
|
})
|
|
|
|
const effectivePeriod = createMemo(() => {
|
|
const data = dashboard()
|
|
if (!data) return null
|
|
const override = testingPeriodOverride()
|
|
if (!override) return data.period
|
|
const match = /^(\d{4})-(\d{2})$/.exec(override)
|
|
if (!match) return data.period
|
|
const month = Number.parseInt(match[2] ?? '', 10)
|
|
if (!Number.isInteger(month) || month < 1 || month > 12) return data.period
|
|
return override
|
|
})
|
|
|
|
const currentPaymentModes = createMemo(() => {
|
|
const data = dashboard()
|
|
const member = currentMemberLine()
|
|
if (!data || !member) return [] as ('rent' | 'utilities')[]
|
|
const period = effectivePeriod() ?? data.period
|
|
const today = todayOverride()
|
|
|
|
const utilities = paymentWindowStatus({
|
|
period,
|
|
timezone: data.timezone,
|
|
reminderDay: data.utilitiesReminderDay,
|
|
dueDay: data.utilitiesDueDay,
|
|
todayOverride: today
|
|
})
|
|
const rent = paymentWindowStatus({
|
|
period,
|
|
timezone: data.timezone,
|
|
reminderDay: data.rentWarningDay,
|
|
dueDay: data.rentDueDay,
|
|
todayOverride: today
|
|
})
|
|
const utilitiesDueMinor = paymentRemainingMinor(data, member, 'utilities')
|
|
const rentDueMinor = paymentRemainingMinor(data, member, 'rent')
|
|
const utilitiesActive = utilities.active && utilitiesDueMinor > 0n
|
|
const rentActive = rent.active && rentDueMinor > 0n
|
|
|
|
const modes: ('rent' | 'utilities')[] = []
|
|
if (utilitiesActive) {
|
|
modes.push('utilities')
|
|
}
|
|
if (rentActive) {
|
|
modes.push('rent')
|
|
}
|
|
|
|
return modes
|
|
})
|
|
|
|
function overduePaymentFor(kind: 'rent' | 'utilities') {
|
|
return currentMemberLine()?.overduePayments.find((payment) => payment.kind === kind) ?? null
|
|
}
|
|
|
|
async function handleSubmitUtilities() {
|
|
const data = initData()
|
|
const current = dashboard()
|
|
const draft = utilityDraft()
|
|
if (!data || !current || submittingUtilities()) return
|
|
if (!draft.billName.trim() || !draft.amountMajor.trim()) return
|
|
|
|
setSubmittingUtilities(true)
|
|
try {
|
|
await submitMiniAppUtilityBill(data, {
|
|
billName: draft.billName,
|
|
amountMajor: draft.amountMajor,
|
|
currency: draft.currency
|
|
})
|
|
setUtilityDraft({
|
|
billName: '',
|
|
amountMajor: '',
|
|
currency: current.currency
|
|
})
|
|
await refreshHouseholdData(true, true)
|
|
} finally {
|
|
setSubmittingUtilities(false)
|
|
}
|
|
}
|
|
|
|
function openQuickPayment(
|
|
type: 'rent' | 'utilities',
|
|
context: 'current' | 'overdue' = 'current'
|
|
) {
|
|
const data = dashboard()
|
|
if (!data || !currentMemberLine()) return
|
|
|
|
const member = currentMemberLine()!
|
|
const amount =
|
|
context === 'overdue'
|
|
? (overduePaymentFor(type)?.amountMajor ?? '0.00')
|
|
: minorToMajorString(paymentRemainingMinor(data, member, type))
|
|
|
|
setQuickPaymentType(type)
|
|
setQuickPaymentContext(context)
|
|
setQuickPaymentAmount(amount)
|
|
setQuickPaymentOpen(true)
|
|
}
|
|
|
|
async function handleQuickPaymentSubmit() {
|
|
const data = initData()
|
|
const amount = quickPaymentAmount()
|
|
const type = quickPaymentType()
|
|
|
|
if (!data || !amount.trim() || !currentMemberLine()) return
|
|
|
|
setSubmittingPayment(true)
|
|
try {
|
|
await addMiniAppPayment(data, {
|
|
memberId: currentMemberLine()!.memberId,
|
|
kind: type,
|
|
amountMajor: amount,
|
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
|
})
|
|
setQuickPaymentOpen(false)
|
|
setToastState({
|
|
visible: true,
|
|
message: copy().quickPaymentSuccess,
|
|
type: 'success'
|
|
})
|
|
await refreshHouseholdData(true, true)
|
|
} catch {
|
|
setToastState({
|
|
visible: true,
|
|
message: copy().quickPaymentFailed,
|
|
type: 'error'
|
|
})
|
|
} finally {
|
|
setSubmittingPayment(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div class="route route--home">
|
|
{/* ── Welcome hero ────────────────────────────── */}
|
|
<div class="home-hero">
|
|
<p class="home-hero__greeting">{copy().welcome},</p>
|
|
<h2 class="home-hero__name">{readySession()?.member.displayName}</h2>
|
|
</div>
|
|
|
|
{/* ── Dashboard stats ─────────────────────────── */}
|
|
<Switch>
|
|
<Match when={loading()}>
|
|
<Card>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<Skeleton style={{ width: '140px', height: '20px' }} />
|
|
</div>
|
|
<div class="balance-card__amounts" style={{ 'margin-top': '16px' }}>
|
|
<Skeleton style={{ width: '100%', height: '48px' }} />
|
|
<div style={{ height: '12px' }} />
|
|
<Skeleton style={{ width: '80%', height: '24px' }} />
|
|
<div style={{ height: '8px' }} />
|
|
<Skeleton style={{ width: '60%', height: '24px' }} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<Skeleton style={{ width: '120px', height: '20px' }} />
|
|
</div>
|
|
<div class="balance-card__amounts" style={{ 'margin-top': '16px' }}>
|
|
<Skeleton style={{ width: '70%', height: '24px' }} />
|
|
<div style={{ height: '8px' }} />
|
|
<Skeleton style={{ width: '50%', height: '24px' }} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Match>
|
|
|
|
<Match when={!dashboard()}>
|
|
<Card>
|
|
<p class="empty-state">{copy().emptyDashboard}</p>
|
|
</Card>
|
|
</Match>
|
|
|
|
<Match when={dashboard()}>
|
|
{(data) => (
|
|
<>
|
|
<Show when={currentMemberLine()}>
|
|
{(member) => {
|
|
const policy = () => data().paymentBalanceAdjustmentPolicy
|
|
const rentRemainingMinor = () => paymentRemainingMinor(data(), member(), 'rent')
|
|
const utilitiesRemainingMinor = () =>
|
|
paymentRemainingMinor(data(), member(), 'utilities')
|
|
|
|
const modes = () => currentPaymentModes()
|
|
const currency = () => data().currency
|
|
const timezone = () => data().timezone
|
|
const period = () => effectivePeriod() ?? data().period
|
|
const today = () => todayOverride()
|
|
|
|
function upcomingDay(day: number): {
|
|
dateLabel: string
|
|
daysUntil: number | null
|
|
} {
|
|
const withinPeriodDays = daysUntilPeriodDay(period(), day, timezone(), today())
|
|
if (withinPeriodDays === null) {
|
|
return { dateLabel: '—', daysUntil: null }
|
|
}
|
|
|
|
if (withinPeriodDays >= 0) {
|
|
return {
|
|
dateLabel: formatPeriodDay(period(), day, locale()),
|
|
daysUntil: withinPeriodDays
|
|
}
|
|
}
|
|
|
|
const next = nextCyclePeriod(period())
|
|
if (!next) {
|
|
return {
|
|
dateLabel: formatPeriodDay(period(), day, locale()),
|
|
daysUntil: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
dateLabel: formatPeriodDay(next, day, locale()),
|
|
daysUntil: daysUntilPeriodDay(next, day, timezone(), today())
|
|
}
|
|
}
|
|
|
|
const rentDueDate = () => formatPeriodDay(period(), data().rentDueDay, locale())
|
|
const utilitiesDueDate = () =>
|
|
formatPeriodDay(period(), data().utilitiesDueDay, locale())
|
|
|
|
const rentDaysUntilDue = () =>
|
|
daysUntilPeriodDay(period(), data().rentDueDay, timezone(), today())
|
|
const utilitiesDaysUntilDue = () =>
|
|
daysUntilPeriodDay(period(), data().utilitiesDueDay, timezone(), today())
|
|
|
|
const rentUpcoming = () => upcomingDay(data().rentWarningDay)
|
|
const utilitiesUpcoming = () => upcomingDay(data().utilitiesReminderDay)
|
|
|
|
const focusBadge = () => {
|
|
const badge = dueStatusBadge()
|
|
return badge ? <Badge variant={badge.variant}>{badge.label}</Badge> : null
|
|
}
|
|
|
|
const dueBadge = (days: number | null) => {
|
|
if (days === null) return null
|
|
if (days < 0) return <Badge variant="danger">{copy().overdueLabel}</Badge>
|
|
if (days === 0) return <Badge variant="danger">{copy().dueTodayLabel}</Badge>
|
|
return (
|
|
<Badge variant="muted">
|
|
{copy().daysLeftLabel.replace('{count}', String(days))}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Show when={overduePaymentFor('utilities')}>
|
|
{(overdue) => (
|
|
<Card accent>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">
|
|
{copy().homeOverdueUtilitiesTitle}
|
|
</span>
|
|
<div
|
|
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
|
|
>
|
|
<Badge variant="danger">{copy().overdueLabel}</Badge>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => openQuickPayment('utilities', 'overdue')}
|
|
>
|
|
<CreditCard size={14} />
|
|
{copy().quickPaymentSubmitAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<div class="balance-card__row balance-card__row--subtotal">
|
|
<span>{copy().finalDue}</span>
|
|
<strong>
|
|
{overdue().amountMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>
|
|
{copy().homeOverduePeriodsLabel.replace(
|
|
'{periods}',
|
|
overdue().periods.join(', ')
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</Show>
|
|
|
|
<Show when={overduePaymentFor('rent')}>
|
|
{(overdue) => (
|
|
<Card accent>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">
|
|
{copy().homeOverdueRentTitle}
|
|
</span>
|
|
<div
|
|
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
|
|
>
|
|
<Badge variant="danger">{copy().overdueLabel}</Badge>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => openQuickPayment('rent', 'overdue')}
|
|
>
|
|
<CreditCard size={14} />
|
|
{copy().quickPaymentSubmitAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<div class="balance-card__row balance-card__row--subtotal">
|
|
<span>{copy().finalDue}</span>
|
|
<strong>
|
|
{overdue().amountMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>
|
|
{copy().homeOverduePeriodsLabel.replace(
|
|
'{periods}',
|
|
overdue().periods.join(', ')
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</Show>
|
|
|
|
<Show when={modes().includes('utilities')}>
|
|
<Card accent>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">{copy().homeUtilitiesTitle}</span>
|
|
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
|
{focusBadge()}
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => openQuickPayment('utilities', 'current')}
|
|
>
|
|
<CreditCard size={14} />
|
|
{copy().quickPaymentSubmitAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<div class="balance-card__row balance-card__row--subtotal">
|
|
<span>{copy().finalDue}</span>
|
|
<strong>
|
|
{minorToMajorString(utilitiesRemainingMinor())} {currency()}
|
|
</strong>
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>
|
|
{copy().dueOnLabel.replace('{date}', utilitiesDueDate())}
|
|
</span>
|
|
{dueBadge(utilitiesDaysUntilDue())}
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>{copy().baseDue}</span>
|
|
<strong>
|
|
{member().utilityShareMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
<Show when={policy() === 'utilities'}>
|
|
<div class="balance-card__row">
|
|
<span>{copy().balanceAdjustmentLabel}</span>
|
|
<strong>
|
|
{member().purchaseOffsetMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
</Show>
|
|
<Show when={utilityLedger().length > 0}>
|
|
<div class="balance-card__row balance-card__row--subtotal">
|
|
<span>{copy().homeUtilitiesBillsTitle}</span>
|
|
<strong>
|
|
{utilityTotalMajor()} {currency()}
|
|
</strong>
|
|
</div>
|
|
<For each={utilityLedger()}>
|
|
{(entry) => (
|
|
<div class="balance-card__row">
|
|
<span>{entry.title}</span>
|
|
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
<Show when={modes().includes('rent')}>
|
|
<Card accent>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">{copy().homeRentTitle}</span>
|
|
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
|
|
{focusBadge()}
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => openQuickPayment('rent', 'current')}
|
|
>
|
|
<CreditCard size={14} />
|
|
{copy().quickPaymentSubmitAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<div class="balance-card__row balance-card__row--subtotal">
|
|
<span>{copy().finalDue}</span>
|
|
<strong>
|
|
{minorToMajorString(rentRemainingMinor())} {currency()}
|
|
</strong>
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>{copy().dueOnLabel.replace('{date}', rentDueDate())}</span>
|
|
{dueBadge(rentDaysUntilDue())}
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>{copy().baseDue}</span>
|
|
<strong>
|
|
{member().rentShareMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
<Show when={policy() === 'rent'}>
|
|
<div class="balance-card__row">
|
|
<span>{copy().balanceAdjustmentLabel}</span>
|
|
<strong>
|
|
{member().purchaseOffsetMajor} {currency()}
|
|
</strong>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
<Show
|
|
when={
|
|
modes().length === 0 &&
|
|
!overduePaymentFor('utilities') &&
|
|
!overduePaymentFor('rent')
|
|
}
|
|
>
|
|
<Card muted>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">{copy().homeNoPaymentTitle}</span>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<div class="balance-card__row">
|
|
<span>
|
|
{copy().homeUtilitiesUpcomingLabel.replace(
|
|
'{date}',
|
|
utilitiesUpcoming().dateLabel
|
|
)}
|
|
</span>
|
|
<strong>
|
|
{utilitiesUpcoming().daysUntil !== null
|
|
? copy().daysLeftLabel.replace(
|
|
'{count}',
|
|
String(utilitiesUpcoming().daysUntil)
|
|
)
|
|
: '—'}
|
|
</strong>
|
|
</div>
|
|
<div class="balance-card__row">
|
|
<span>
|
|
{copy().homeRentUpcomingLabel.replace(
|
|
'{date}',
|
|
rentUpcoming().dateLabel
|
|
)}
|
|
</span>
|
|
<strong>
|
|
{rentUpcoming().daysUntil !== null
|
|
? copy().daysLeftLabel.replace(
|
|
'{count}',
|
|
String(rentUpcoming().daysUntil)
|
|
)
|
|
: '—'}
|
|
</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
<Show when={modes().includes('utilities') && utilityLedger().length === 0}>
|
|
<Card>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">
|
|
{copy().homeFillUtilitiesTitle}
|
|
</span>
|
|
</div>
|
|
<p class="empty-state">{copy().homeFillUtilitiesBody}</p>
|
|
<div class="editor-grid">
|
|
<Field label={copy().utilityCategoryLabel} wide>
|
|
<Input
|
|
value={utilityDraft().billName}
|
|
onInput={(e) =>
|
|
setUtilityDraft((d) => ({
|
|
...d,
|
|
billName: e.currentTarget.value
|
|
}))
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={copy().utilityAmount} wide>
|
|
<Input
|
|
type="number"
|
|
value={utilityDraft().amountMajor}
|
|
onInput={(e) =>
|
|
setUtilityDraft((d) => ({
|
|
...d,
|
|
amountMajor: e.currentTarget.value
|
|
}))
|
|
}
|
|
/>
|
|
</Field>
|
|
<div style={{ display: 'flex', gap: '10px' }}>
|
|
<Button
|
|
variant="primary"
|
|
loading={submittingUtilities()}
|
|
disabled={
|
|
!utilityDraft().billName.trim() ||
|
|
!utilityDraft().amountMajor.trim()
|
|
}
|
|
onClick={() => void handleSubmitUtilities()}
|
|
>
|
|
{submittingUtilities()
|
|
? copy().homeFillUtilitiesSubmitting
|
|
: copy().homeFillUtilitiesSubmitAction}
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => navigate('/ledger')}>
|
|
{copy().homeFillUtilitiesOpenLedgerAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
<Show
|
|
when={modes().includes('rent') && data().rentPaymentDestinations?.length}
|
|
>
|
|
<div style={{ display: 'grid', gap: '12px' }}>
|
|
<For each={data().rentPaymentDestinations ?? []}>
|
|
{(destination) => (
|
|
<Card>
|
|
<div class="balance-card">
|
|
<div class="balance-card__header">
|
|
<span class="balance-card__label">{destination.label}</span>
|
|
</div>
|
|
<div class="balance-card__amounts">
|
|
<Show when={destination.recipientName}>
|
|
{(value) => (
|
|
<div class="balance-card__row">
|
|
<span>{copy().rentPaymentDestinationRecipient}</span>
|
|
<strong>
|
|
<button
|
|
class="copyable-detail"
|
|
classList={{ 'is-copied': copiedValue() === value() }}
|
|
type="button"
|
|
onClick={() => void handleCopy(value())}
|
|
>
|
|
<span>{value()}</span>
|
|
{copiedValue() === value() ? (
|
|
<Check size={14} />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
</button>
|
|
</strong>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<Show when={destination.bankName}>
|
|
{(value) => (
|
|
<div class="balance-card__row">
|
|
<span>{copy().rentPaymentDestinationBank}</span>
|
|
<strong>
|
|
<button
|
|
class="copyable-detail"
|
|
classList={{ 'is-copied': copiedValue() === value() }}
|
|
type="button"
|
|
onClick={() => void handleCopy(value())}
|
|
>
|
|
<span>{value()}</span>
|
|
{copiedValue() === value() ? (
|
|
<Check size={14} />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
</button>
|
|
</strong>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<div class="balance-card__row">
|
|
<span>{copy().rentPaymentDestinationAccount}</span>
|
|
<strong>
|
|
<button
|
|
class="copyable-detail"
|
|
classList={{
|
|
'is-copied': copiedValue() === destination.account
|
|
}}
|
|
type="button"
|
|
onClick={() => void handleCopy(destination.account)}
|
|
>
|
|
<span>{destination.account}</span>
|
|
{copiedValue() === destination.account ? (
|
|
<Check size={14} />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
</button>
|
|
</strong>
|
|
</div>
|
|
<Show when={destination.link}>
|
|
{(value) => (
|
|
<div class="balance-card__row">
|
|
<span>{copy().rentPaymentDestinationLink}</span>
|
|
<strong>
|
|
<button
|
|
class="copyable-detail"
|
|
classList={{ 'is-copied': copiedValue() === value() }}
|
|
type="button"
|
|
onClick={() => void handleCopy(value())}
|
|
>
|
|
<span>{value()}</span>
|
|
{copiedValue() === value() ? (
|
|
<Check size={14} />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
</button>
|
|
</strong>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<Show when={destination.note}>
|
|
{(value) => (
|
|
<div class="balance-card__row">
|
|
<span>{copy().rentPaymentDestinationNote}</span>
|
|
<strong>
|
|
<button
|
|
class="copyable-detail"
|
|
classList={{ 'is-copied': copiedValue() === value() }}
|
|
type="button"
|
|
onClick={() => void handleCopy(value())}
|
|
>
|
|
<span>{value()}</span>
|
|
{copiedValue() === value() ? (
|
|
<Check size={14} />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
</button>
|
|
</strong>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</>
|
|
)
|
|
}}
|
|
</Show>
|
|
|
|
{/* Rent FX card */}
|
|
<Show when={data().rentSourceCurrency !== data().currency}>
|
|
<Card muted>
|
|
<div class="fx-card">
|
|
<strong class="fx-card__title">{copy().rentFxTitle}</strong>
|
|
<div class="fx-card__row">
|
|
<span>{copy().sourceAmountLabel}</span>
|
|
<strong>
|
|
{data().rentSourceAmountMajor} {data().rentSourceCurrency}
|
|
</strong>
|
|
</div>
|
|
<div class="fx-card__row">
|
|
<span>{copy().settlementAmountLabel}</span>
|
|
<strong>
|
|
{data().rentDisplayAmountMajor} {data().currency}
|
|
</strong>
|
|
</div>
|
|
<Show when={data().rentFxEffectiveDate}>
|
|
<div class="fx-card__row fx-card__row--muted">
|
|
<span>{copy().fxEffectiveDateLabel}</span>
|
|
<span>{data().rentFxEffectiveDate}</span>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* Latest activity */}
|
|
<Card>
|
|
<div class="activity-card">
|
|
<div class="activity-card__header">
|
|
<Clock size={16} />
|
|
<span>{copy().latestActivityTitle}</span>
|
|
</div>
|
|
<Show
|
|
when={data().ledger.length > 0}
|
|
fallback={<p class="empty-state">{copy().latestActivityEmpty}</p>}
|
|
>
|
|
<div class="activity-card__list">
|
|
<For each={showAllActivity() ? data().ledger : data().ledger.slice(0, 5)}>
|
|
{(entry) => (
|
|
<div class="activity-card__item">
|
|
<span class="activity-card__title">{entry.title}</span>
|
|
<span class="activity-card__amount">{ledgerPrimaryAmount(entry)}</span>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
<Show when={data().ledger.length > 5}>
|
|
<button
|
|
class="activity-card__show-more"
|
|
onClick={() => setShowAllActivity(!showAllActivity())}
|
|
>
|
|
<Show
|
|
when={showAllActivity()}
|
|
fallback={
|
|
<>
|
|
<span>{copy().showMoreAction}</span>
|
|
<ChevronDown size={14} />
|
|
</>
|
|
}
|
|
>
|
|
<span>{copy().showLessAction}</span>
|
|
<ChevronUp size={14} />
|
|
</Show>
|
|
</button>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</Match>
|
|
</Switch>
|
|
|
|
{/* Quick Payment Modal */}
|
|
<Modal
|
|
open={quickPaymentOpen()}
|
|
title={copy().quickPaymentTitle}
|
|
description={(quickPaymentContext() === 'overdue'
|
|
? copy().quickPaymentOverdueBody
|
|
: copy().quickPaymentCurrentBody
|
|
).replace(
|
|
'{type}',
|
|
quickPaymentType() === 'rent' ? copy().shareRent : copy().shareUtilities
|
|
)}
|
|
closeLabel={copy().showLessAction}
|
|
onClose={() => setQuickPaymentOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setQuickPaymentOpen(false)}>
|
|
{copy().showLessAction}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
loading={submittingPayment()}
|
|
disabled={!quickPaymentAmount().trim()}
|
|
onClick={() => void handleQuickPaymentSubmit()}
|
|
>
|
|
{submittingPayment()
|
|
? copy().quickPaymentSubmitting
|
|
: copy().quickPaymentSubmitAction}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: 'grid', gap: '12px' }}>
|
|
<Field label={copy().quickPaymentAmountLabel}>
|
|
<Input
|
|
type="number"
|
|
value={quickPaymentAmount()}
|
|
onInput={(e) => setQuickPaymentAmount(e.currentTarget.value)}
|
|
placeholder="0.00"
|
|
/>
|
|
</Field>
|
|
<Field label={copy().quickPaymentCurrencyLabel}>
|
|
<Input type="text" value={(dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'} disabled />
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Toast Notifications */}
|
|
<Toast
|
|
state={toastState()}
|
|
onClose={() => setToastState({ ...toastState(), visible: false })}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|