krona-front/src/features/control-center/pages/BillingPage.tsx

315 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import {
CreditCard,
RefreshCw,
SlidersHorizontal,
Calendar,
CheckCircle2,
Clock,
ShieldAlert,
} from 'lucide-react';
import { useKccStore, useOperatorRole } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
import { MoneyDisplay } from '../components/MoneyDisplay';
export const BillingPage: React.FC = () => {
const subscriptions = useKccStore((state) => state.subscriptions);
const modules = useKccStore((state) => state.modules);
const renewSubscription = useKccStore((state) => state.renewSubscription);
const grantGracePeriod = useKccStore((state) => state.grantGracePeriod);
const openConfirmModal = useKccStore((state) => state.openConfirmModal);
const openInspector = useKccStore((state) => state.openInspector);
const { permissions } = useOperatorRole();
const [filter, setFilter] = useState<'all' | 'expiring_7d' | 'expiring_30d' | 'overdue' | 'grace'>('all');
const [selectedSubId, setSelectedSubId] = useState<string | null>(subscriptions[0]?.subscriptionId || null);
const selectedSub = subscriptions.find((s) => s.subscriptionId === selectedSubId) || subscriptions[0];
// Form state for renewal calculator
const [newPeriod, setNewPeriod] = useState<'month' | 'year'>('year');
const [customModules, setCustomModules] = useState<string[] | null>(null);
const activeModulesToDisplay = customModules ?? (selectedSub ? selectedSub.activeModules : []);
const filteredSubs = subscriptions.filter((sub) => {
if (filter === 'all') return true;
return sub.status === filter;
});
// Calculate live preview amount
const baseRatePerMonth = 1500000; // 15 000,00 ₽
const moduleRatePerMonth = 400000; // 4 000,00 ₽
const monthlyTotal = baseRatePerMonth + activeModulesToDisplay.length * moduleRatePerMonth;
const calculatedTotal = newPeriod === 'year' ? monthlyTotal * 10 : monthlyTotal;
const handleRenew = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Продление подписки: ${selectedSub.organizationName}`,
actionName: 'grant_grace_period',
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей: ${activeModulesToDisplay.join(', ')}. Расчётная сумма: ${calculatedTotal / 100} ₽.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.organizationName,
onConfirm: async (reason) => {
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
},
});
};
const handleGrantGrace = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Предоставление льготного периода: ${selectedSub.organizationName}`,
actionName: 'grant_grace_period',
impactSummary: `Организации будет предоставлен льготный период (Grace Period) на 14 дней с сохранением доступа ко всем модулям.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.organizationName,
onConfirm: async (reason) => {
await grantGracePeriod(selectedSub.subscriptionId, 14, reason);
},
});
};
const toggleModuleSelection = (moduleCode: string) => {
if (activeModulesToDisplay.includes(moduleCode)) {
setCustomModules(activeModulesToDisplay.filter((m) => m !== moduleCode));
} else {
setCustomModules([...activeModulesToDisplay, moduleCode]);
}
};
return (
<div className="space-y-4 sm:space-y-6" data-testid="billing-page">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 border-b border-krona-border pb-4">
<div>
<h1 className="text-lg sm:text-xl font-bold text-krona-primary flex items-center gap-2">
<CreditCard className="w-5 h-5 text-krona-gold" />
<span>Подписки и биллинг платформы</span>
</h1>
<p className="text-xs text-krona-secondary mt-0.5">
Контроль сроков действия лицензий, предоставление льготных периодов и продление тарифов
</p>
</div>
<div className="text-xs text-krona-secondary">
Всего подписок: <span className="font-bold text-krona-primary">{subscriptions.length}</span> (найдено:{' '}
<span className="font-bold text-krona-gold">{filteredSubs.length}</span>)
</div>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все подписки' },
{ id: 'expiring_7d', label: 'Истекают (< 7 дн.)' },
{ id: 'expiring_30d', label: 'Истекают (< 30 дн.)' },
{ id: 'grace', label: 'Льготный период' },
{ id: 'overdue', label: 'Просроченные' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setFilter(tab.id as typeof filter)}
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
filter === tab.id
? 'bg-krona-gold/15 text-krona-gold border border-krona-gold/30 font-bold'
: 'bg-krona-surface text-krona-secondary border border-krona-border hover:text-krona-primary hover:bg-krona-hover'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Two Column Layout: List on Left, Calculator on Right */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
{/* Left Column: Subscriptions List */}
<div className="lg:col-span-7 space-y-3">
{filteredSubs.map((sub) => {
const isSelected = sub.subscriptionId === selectedSub?.subscriptionId;
const validUntilDate = new Date(sub.validUntil);
return (
<div
key={sub.subscriptionId}
onClick={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
}}
className={`p-4 rounded-xl border transition-all cursor-pointer space-y-3 ${
isSelected
? 'border-krona-gold bg-krona-surface shadow-md ring-1 ring-krona-gold/30'
: 'border-krona-border bg-krona-surface/60 hover:bg-krona-surface hover:border-krona-border'
}`}
data-testid={`subscription-card-${sub.subscriptionId}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-bold text-sm text-krona-primary">{sub.organizationName}</h3>
<div className="text-[11px] text-krona-tertiary mt-0.5">
Тарифный план: <span className="font-semibold text-krona-primary">{sub.planName}</span>
</div>
</div>
<StatusBadge status={sub.status} size="sm" />
</div>
<div className="grid grid-cols-2 gap-2 text-xs py-2 border-y border-krona-border">
<div>
<div className="text-[10px] text-krona-tertiary">Стоимость в месяц</div>
<MoneyDisplay minorUnits={sub.priceMinorUnits} currency={sub.currency} className="font-bold text-krona-gold text-xs" />
</div>
<div>
<div className="text-[10px] text-krona-tertiary">Действует до</div>
<div className="font-bold text-krona-primary flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-krona-gold" />
<span>{validUntilDate.toLocaleDateString('ru-RU')}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div className="text-[11px] text-krona-secondary">
Подключено модулей: <span className="font-semibold text-krona-primary">{sub.activeModules.length}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector({ type: 'subscription', id: sub.subscriptionId, title: sub.organizationName, data: sub });
}}
className="min-h-[44px] px-3 text-xs font-semibold text-krona-gold hover:underline flex items-center gap-1"
>
<span>360° Инспектор</span>
</button>
</div>
</div>
);
})}
{filteredSubs.length === 0 && (
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Подписки не найдены по выбранным критериям
</div>
)}
</div>
{/* Right Column: Calculator & Renewal Actions */}
<div className="lg:col-span-5">
{selectedSub ? (
<div className="p-5 rounded-xl border border-krona-border bg-krona-surface space-y-4 sticky top-20 shadow-sm">
<div className="border-b border-krona-border pb-3">
<div className="text-[11px] font-semibold text-krona-gold uppercase tracking-wider">
Управление подпиской
</div>
<h2 className="text-base font-bold text-krona-primary mt-0.5">{selectedSub.organizationName}</h2>
<div className="text-xs text-krona-secondary mt-0.5">
Текущий статус: <StatusBadge status={selectedSub.status} size="sm" />
</div>
</div>
{/* Period Switcher */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-krona-secondary">
Период продления лицензии
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setNewPeriod('month')}
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
newPeriod === 'month'
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
1 месяц (Базовый)
</button>
<button
type="button"
onClick={() => setNewPeriod('year')}
className={`min-h-[44px] py-2 px-3 rounded-lg text-xs font-semibold border transition-colors ${
newPeriod === 'year'
? 'border-krona-gold bg-krona-gold/15 text-krona-gold'
: 'border-krona-border bg-krona-workspace text-krona-secondary hover:text-krona-primary'
}`}
>
1 год (Скидка 16%)
</button>
</div>
</div>
{/* Included Modules Checklist */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-krona-secondary">
Состав модулей ({activeModulesToDisplay.length})
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-48 overflow-y-auto p-1">
{modules.map((mod) => {
const isChecked = activeModulesToDisplay.includes(mod.code);
return (
<button
key={mod.code}
type="button"
onClick={() => toggleModuleSelection(mod.code)}
className={`min-h-[40px] px-2.5 py-1.5 rounded-lg text-xs border text-left flex items-center justify-between transition-colors ${
isChecked
? 'border-krona-gold/40 bg-krona-gold/10 text-krona-primary'
: 'border-krona-border bg-krona-workspace text-krona-tertiary hover:text-krona-secondary'
}`}
>
<span className="truncate">{mod.name}</span>
{isChecked && <CheckCircle2 className="w-3.5 h-3.5 text-krona-gold shrink-0 ml-1" />}
</button>
);
})}
</div>
</div>
{/* Total Calculation */}
<div className="p-3.5 rounded-xl bg-krona-workspace border border-krona-border flex items-center justify-between">
<div>
<div className="text-[11px] text-krona-tertiary">Расчётная стоимость продления</div>
<MoneyDisplay minorUnits={calculatedTotal} currency={selectedSub.currency} className="text-lg font-bold text-krona-gold" />
</div>
<div className="text-right text-[11px] text-krona-secondary">
{newPeriod === 'year' ? 'За 12 месяцев' : 'За 1 месяц'}
</div>
</div>
{/* Action Buttons */}
<div className="space-y-2 pt-2">
<button
type="button"
onClick={handleRenew}
className="w-full min-h-[44px] px-4 py-2.5 rounded-lg bg-krona-gold hover:bg-krona-gold-hover text-zinc-950 font-bold text-xs flex items-center justify-center gap-2 transition-colors shadow-sm"
data-testid="renew-subscription-btn"
>
<RefreshCw className="w-4 h-4" />
<span>Продлить подписку (Выставить счёт)</span>
</button>
<button
type="button"
onClick={handleGrantGrace}
className="w-full min-h-[44px] px-4 py-2 rounded-lg border border-status-warning-border bg-status-warning-soft text-status-warning-text font-semibold text-xs flex items-center justify-center gap-2 transition-colors"
data-testid="grant-grace-btn"
>
<Clock className="w-4 h-4" />
<span>Предоставить льготный период (14 дн.)</span>
</button>
</div>
</div>
) : (
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Выберите организацию из списка для продления или изменения состава модулей
</div>
)}
</div>
</div>
</div>
);
};