Files
optovia/webapp/app/pages/clientarea/billing/index.vue
Ruslan Bakiev 2aa6d11db3 Add Billing API with M2M/Team schema separation and frontend
Backend changes:
- Create separate schemas: m2m_schema.py (internal) and team_schema.py (teams:member scope)
- Add teamBalance query (TigerBeetle lookup) and teamTransactions query
- Add TeamGraphQLView with BillingJWTMiddleware
- Add /graphql/team/ endpoint, remove old schema.py

Frontend changes:
- Add Billing to Sidebar navigation
- Create billing page with balance display and transactions list
- Add GraphQL operations for billing
- Add i18n keys for billing (ru/en)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 13:20:48 +07:00

191 lines
5.9 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.reason') }}</th>
<th>{{ t('billing.transactions.amount') }}</th>
<th>{{ t('billing.transactions.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.uuid">
<td>{{ formatDate(tx.createdAt) }}</td>
<td>{{ tx.reasonName || '—' }}</td>
<td :class="tx.direction === 'credit' ? 'text-success' : 'text-error'">
{{ tx.direction === 'credit' ? '+' : '-' }}{{ formatCurrency(tx.amount) }}
</td>
<td>
<Pill
:variant="tx.state === 'COMPLETED' ? 'primary' : 'outline'"
:tone="getStateTone(tx.state)"
>
{{ tx.state }}
</Pill>
</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) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2
}).format(amount)
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
return date.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getStateTone = (state: string) => {
switch (state) {
case 'COMPLETED': return 'success'
case 'PENDING': return 'warning'
case 'FAILED': return 'error'
default: return 'neutral'
}
}
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, offset: 0 }, '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>