Files
Ruslan Bakiev 5d1ce88927
Some checks failed
Build Docker Image / build (push) Failing after 1m20s
Migrate pages to topnav layout
2026-01-08 01:08:25 +07:00

183 lines
5.6 KiB
Vue

<template>
<Section variant="plain" paddingY="md">
<Stack gap="6">
<PageHeader :title="t('billing.header.title')" />
<!-- Loading state -->
<Card v-if="isLoading" padding="lg">
<Stack align="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('billing.states.loading') }}</Text>
</Stack>
</Card>
<!-- Error state -->
<Alert v-else-if="error" variant="error">
<Stack gap="2">
<Heading :level="4" weight="semibold">{{ t('billing.errors.title') }}</Heading>
<Text tone="muted">{{ error }}</Text>
<Button @click="loadBalance">{{ t('billing.errors.retry') }}</Button>
</Stack>
</Alert>
<!-- Balance card -->
<template v-else>
<Card padding="lg">
<Stack gap="4">
<Stack direction="row" gap="4" align="center" justify="between">
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.label') }}</Text>
<Heading :level="2" weight="bold">
{{ formatCurrency(balance.balance) }}
</Heading>
</Stack>
<IconCircle tone="primary" size="lg">
<Icon name="lucide:wallet" size="24" />
</IconCircle>
</Stack>
<div class="divider my-0"></div>
<Grid :cols="2" :gap="4">
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.credits') }}</Text>
<Text weight="semibold" class="text-success">
+{{ formatCurrency(balance.creditsPosted) }}
</Text>
</Stack>
<Stack gap="1">
<Text tone="muted" size="sm">{{ t('billing.balance.debits') }}</Text>
<Text weight="semibold" class="text-error">
-{{ formatCurrency(balance.debitsPosted) }}
</Text>
</Stack>
</Grid>
</Stack>
</Card>
<!-- Transactions section -->
<Stack gap="3">
<Heading :level="3">{{ t('billing.transactions.title') }}</Heading>
<Card v-if="transactions.length === 0" padding="lg" tone="muted">
<Stack align="center" gap="2">
<Icon name="lucide:receipt" size="32" class="opacity-50" />
<Text tone="muted">{{ t('billing.transactions.empty') }}</Text>
</Stack>
</Card>
<Card v-else padding="none">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{{ t('billing.transactions.date') }}</th>
<th>{{ t('billing.transactions.code') }}</th>
<th>{{ t('billing.transactions.amount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id">
<td>{{ formatTimestamp(tx.timestamp) }}</td>
<td>{{ tx.codeName || tx.code || '—' }}</td>
<td :class="tx.direction === 'credit' ? 'text-success' : 'text-error'">
{{ tx.direction === 'credit' ? '+' : '-' }}{{ formatAmount(tx.amount) }}
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</Stack>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'topnav',
middleware: ['auth-oidc']
})
const { t } = useI18n()
const isLoading = ref(true)
const error = ref<string | null>(null)
const balance = ref({
balance: 0,
creditsPosted: 0,
debitsPosted: 0,
exists: false
})
const transactions = ref<any[]>([])
const formatCurrency = (amount: number) => {
// Amount is in kopecks, convert to base units
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount / 100)
}
const formatAmount = (amount: number) => {
// Amount is in kopecks, convert to rubles
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount / 100)
}
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '—'
// TigerBeetle timestamp is in nanoseconds since epoch
const date = new Date(timestamp / 1000000)
return date.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadBalance = async () => {
isLoading.value = true
error.value = null
try {
// Import will work after codegen runs
const { GetTeamBalanceDocument } = await import('~/composables/graphql/team/billing-generated')
const { data, error: balanceError } = await useServerQuery('team-balance', GetTeamBalanceDocument, {}, 'team', 'billing')
if (balanceError.value) throw balanceError.value
if (data.value?.teamBalance) {
balance.value = data.value.teamBalance
}
} catch (e: any) {
error.value = e.message || t('billing.errors.load_failed')
} finally {
isLoading.value = false
}
}
const loadTransactions = async () => {
try {
const { GetTeamTransactionsDocument } = await import('~/composables/graphql/team/billing-generated')
const { data, error: txError } = await useServerQuery('team-transactions', GetTeamTransactionsDocument, { limit: 50 }, 'team', 'billing')
if (txError.value) throw txError.value
transactions.value = data.value?.teamTransactions || []
} catch (e) {
console.error('Failed to load transactions', e)
}
}
await loadBalance()
await loadTransactions()
</script>