Initial commit from monorepo
This commit is contained in:
181
app/pages/clientarea/billing/index.vue
Normal file
181
app/pages/clientarea/billing/index.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<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({
|
||||
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>
|
||||
Reference in New Issue
Block a user