183 lines
5.6 KiB
Vue
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>
|