Files
PnvPanel/frontend/src/features/billing/PaymentRequestPanel.tsx
T
Leonid Pershin 5ff5224935
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
Refactor payment request handling to support role change top-ups
- Updated the `PaymentRequest` model to include a new `Kind` property, distinguishing between `Subscription` and `RoleChangeTopUp` requests.
- Modified the `TelegramNotifier` to accommodate the new request type, ensuring accurate notifications for role change top-ups.
- Enhanced the `ConfirmPaymentRequestCommandHandler` to handle role change top-ups without extending the billing period, reflecting the new payment logic.
- Updated various application components and tests to support the new payment request structure and ensure proper functionality.
- Revised API documentation to clarify the behavior of role change top-ups and their impact on billing.
2026-07-19 16:45:17 +03:00

158 lines
5.8 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import type { BillingStatusDto, PaymentPeriod } from '@/shared/api/types'
import { cancelPaymentRequest, createPaymentRequest, markPaymentSent, sendRequisitesToTelegram } from './api'
const PERIODS: PaymentPeriod[] = ['Quarter', 'HalfYear', 'Year']
export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [period, setPeriod] = useState<PaymentPeriod>('Quarter')
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
const createMutation = useMutation({
mutationFn: () => createPaymentRequest(period),
onSuccess: async () => {
toast.success(t('billing.requestCreated'))
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const cancelMutation = useMutation({
mutationFn: (id: string) => cancelPaymentRequest(id),
onSuccess: async () => {
toast.success(t('billing.requestCancelled'))
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const markPaidMutation = useMutation({
mutationFn: (id: string) => markPaymentSent(id),
onSuccess: async () => {
toast.success(t('billing.markedPaid'))
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const sendToTelegramMutation = useMutation({
mutationFn: (id: string) => sendRequisitesToTelegram(id),
onSuccess: () => toast.success(t('billing.requisitesSent')),
onError: (error) => {
const message =
error instanceof HttpError && error.status === 409 ? t('billing.telegramNotLinked') : t('auth.genericError')
toast.error(message)
},
})
const request = status.activeRequest
if (!request) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('billing.newRequest')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>{t('billing.period')}</Label>
<Select value={period} onValueChange={(v) => setPeriod(v as PaymentPeriod)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERIODS.map((p) => (
<SelectItem key={p} value={p}>
{t(`billing.periods.${p}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{t('billing.createRequest')}
</Button>
</div>
</CardContent>
</Card>
)
}
const isAwaitingConfirmation = request.status === 'AwaitingConfirmation'
return (
<Card>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">{t('billing.activeRequest')}</CardTitle>
<Badge variant={isAwaitingConfirmation ? 'warning' : 'outline'}>
{t(`billing.requestStatus.${request.status}`)}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm">
{request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} {' '}
<span className="font-medium">{request.amountSnapshot} </span>
</p>
{request.kind === 'RoleChangeTopUp' && (
<p className="text-xs text-muted-foreground">{t('billing.roleChangeTopUpHint')}</p>
)}
{!isAwaitingConfirmation && (
<div className="flex flex-col gap-1.5">
<Label>{t('billing.requisites')}</Label>
<p className="whitespace-pre-wrap rounded-md border border-border bg-muted px-3 py-2 text-sm">
{status.requisitesText || t('billing.requisitesMissing')}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => sendToTelegramMutation.mutate(request.id)}
disabled={sendToTelegramMutation.isPending}
>
{t('billing.sendToTelegram')}
</Button>
{!isAwaitingConfirmation && (
<>
<Button
size="sm"
onClick={() => markPaidMutation.mutate(request.id)}
disabled={markPaidMutation.isPending}
>
{t('billing.iPaid')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
if (confirm(t('billing.confirmCancel'))) cancelMutation.mutate(request.id)
}}
disabled={cancelMutation.isPending}
>
{t('billing.cancelRequest')}
</Button>
</>
)}
</div>
{isAwaitingConfirmation && <p className="text-xs text-muted-foreground">{t('billing.awaitingAdminHint')}</p>}
</CardContent>
</Card>
)
}