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

320 lines
16 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,
} 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 { 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
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.tenantName}`,
actionName: `subscription.renew (${newPeriod})`,
impactSummary: `Подписка будет продлена на ${newPeriod === 'year' ? '1 год' : '1 месяц'}. Состав модулей (${activeModulesToDisplay.length}): ${activeModulesToDisplay.join(', ')}. Расчетная сумма: ${calculatedTotal / 100} ₽.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.tenantName,
onConfirm: async (reason) => {
await renewSubscription(selectedSub.subscriptionId, newPeriod, activeModulesToDisplay, reason);
},
});
};
const handleGrantGrace = () => {
if (!selectedSub) return;
openConfirmModal({
title: `Предоставление Grace Period: ${selectedSub.tenantName}`,
actionName: 'subscription.grace_period_granted',
impactSummary: `Организации будет предоставлена отсрочка блокировки на 10 дней без остановки сервисов.`,
targetType: 'subscription',
targetId: selectedSub.subscriptionId,
targetName: selectedSub.tenantName,
onConfirm: async (reason) => {
await grantGracePeriod(selectedSub.subscriptionId, 10, reason);
},
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 border-b border-zinc-800 pb-4">
<div>
<h1 className="text-xl font-bold text-zinc-100 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-amber-400" />
<span>Подписки, лицензии и Entitlements</span>
</h1>
<p className="text-xs text-zinc-400 mt-0.5">
Управление правами доступа, сроками действия тарифов и составом включенных модулей
</p>
</div>
{/* Filters */}
<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: 'Grace Period' },
{ id: 'suspended', label: 'Просрочено' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setFilter(tab.id as typeof filter)}
className={`px-3 py-1.5 rounded-lg font-medium transition-colors ${
filter === tab.id
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 font-semibold'
: 'bg-zinc-900 text-zinc-400 border border-zinc-800 hover:text-zinc-200 hover:bg-zinc-800'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Main Grid: Subscriptions Table + Simulator Panel */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left 2 Columns: Subscriptions List */}
<div className="xl:col-span-2 rounded-xl border border-zinc-800 bg-[#091a13] overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-950/70 text-zinc-400 font-semibold uppercase tracking-wider text-[11px]">
<th className="py-3 px-4">Организация / Счёт</th>
<th className="py-3 px-3">Статус</th>
<th className="py-3 px-3">Период</th>
<th className="py-3 px-3 text-right">Сумма тарифа</th>
<th className="py-3 px-3">Срок действия</th>
<th className="py-3 px-3">Grace</th>
<th className="py-3 px-4 text-center">Выбор</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{filteredSubs.map((sub) => {
const isSelected = sub.subscriptionId === (selectedSub?.subscriptionId ?? '');
return (
<tr
key={sub.subscriptionId}
onClick={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
setNewPeriod(sub.period);
}}
className={`cursor-pointer transition-colors ${
isSelected ? 'bg-amber-500/10 border-l-2 border-amber-400' : 'hover:bg-zinc-800/40'
}`}
>
<td className="py-3 px-4">
<div className="font-bold text-zinc-100">{sub.tenantName}</div>
<div className="text-[11px] text-zinc-400 font-mono mt-0.5">{sub.billingAccountRef}</div>
</td>
<td className="py-3 px-3">
<StatusBadge status={sub.status} size="sm" />
</td>
<td className="py-3 px-3 text-zinc-300 font-medium">
{sub.period === 'year' ? 'Годовой' : 'Месячный'}
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay minorUnits={sub.amountMinorUnits} className="text-amber-400 font-bold" />
</td>
<td className="py-3 px-3 text-zinc-300 font-mono text-[11px]">
до {new Date(sub.validUntil).toLocaleDateString('ru-RU')}
</td>
<td className="py-3 px-3 text-zinc-400 text-[11px]">
{sub.graceUntil ? new Date(sub.graceUntil).toLocaleDateString('ru-RU') : '—'}
</td>
<td className="py-3 px-4 text-center">
<input
type="radio"
name="selected_sub"
checked={isSelected}
onChange={() => {
setSelectedSubId(sub.subscriptionId);
setCustomModules(null);
setNewPeriod(sub.period);
}}
className="accent-amber-400 cursor-pointer"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Right Column: Live Renewal & Module Composition Simulator */}
<div className="space-y-6">
{selectedSub ? (
<div className="rounded-xl border border-zinc-800 bg-[#0a1c15] p-5 space-y-4 shadow-xl">
<div className="flex items-center gap-2 text-sm font-bold text-zinc-100">
<SlidersHorizontal className="w-4 h-4 text-amber-400" />
<span>Продление и состав модулей (Preview)</span>
</div>
<div className="p-3 rounded-lg bg-zinc-950/70 border border-zinc-800 text-xs space-y-1">
<div className="text-zinc-400">Организация:</div>
<div className="font-bold text-zinc-200 text-sm">{selectedSub.tenantName}</div>
<div className="text-[11px] text-zinc-500 font-mono">{selectedSub.billingAccountRef}</div>
</div>
{/* Period selection */}
<div className="space-y-1.5 text-xs">
<label className="font-semibold text-zinc-300">Период действия тарифа:</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setNewPeriod('month')}
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
newPeriod === 'month'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
}`}
>
1 месяц
</button>
<button
type="button"
onClick={() => setNewPeriod('year')}
className={`py-2 px-3 rounded-lg font-medium border text-xs transition-colors ${
newPeriod === 'year'
? 'bg-amber-500/20 text-amber-300 border-amber-500/50 font-bold'
: 'bg-zinc-900 text-zinc-400 border-zinc-800 hover:bg-zinc-800'
}`}
>
1 год (скидка 17%)
</button>
</div>
</div>
{/* Modules selection */}
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center font-semibold text-zinc-300">
<span>Включенные модули:</span>
<span className="text-amber-400 font-mono">{activeModulesToDisplay.length} выбрано</span>
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
{modules.map((m) => {
const isChecked = activeModulesToDisplay.includes(m.moduleId);
const isCore = m.moduleId === 'krona-core';
return (
<label
key={m.moduleId}
className={`flex items-center justify-between p-2 rounded-lg border text-xs cursor-pointer transition-colors ${
isChecked ? 'bg-zinc-900/90 border-zinc-700 text-zinc-100' : 'bg-zinc-950/40 border-zinc-800/60 text-zinc-500'
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isChecked}
disabled={isCore}
onChange={(e) => {
if (e.target.checked) setCustomModules([...activeModulesToDisplay, m.moduleId]);
else setCustomModules(activeModulesToDisplay.filter((id) => id !== m.moduleId));
}}
className="accent-amber-400"
/>
<span className="font-medium">{m.name}</span>
</div>
<span className="text-[10px] text-zinc-400 font-mono">v{m.latestVersion}</span>
</label>
);
})}
</div>
</div>
{/* Price calculation preview */}
<div className="p-3.5 rounded-xl bg-zinc-950 border border-amber-500/30 text-xs space-y-2">
<div className="flex justify-between text-zinc-400">
<span>Расчетная сумма (Preview):</span>
<MoneyDisplay minorUnits={calculatedTotal} className="text-sm font-bold text-amber-400" />
</div>
<div className="text-[10px] text-zinc-500">
* Демо-расчет стоимости лицензии (minor units RUB). Реальные транзакции не производятся.
</div>
</div>
{/* Action Buttons */}
<div className="space-y-2 pt-2">
<button
type="button"
disabled={!permissions.canEditEntitlements}
onClick={handleRenew}
className="w-full py-2 px-4 rounded-lg bg-amber-500 hover:bg-amber-400 text-zinc-950 font-bold text-xs transition-colors flex items-center justify-center gap-1.5 shadow-md shadow-amber-900/30 disabled:opacity-40"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Продлить тариф с новым составом</span>
</button>
<button
type="button"
disabled={!permissions.canGrantTrialGrace}
onClick={handleGrantGrace}
className="w-full py-2 px-4 rounded-lg bg-zinc-900 border border-zinc-700 hover:bg-zinc-800 text-zinc-300 font-medium text-xs transition-colors disabled:opacity-40"
>
Предоставить льготу (Grace 10 дней)
</button>
</div>
{/* Subscription Timeline History */}
<div className="pt-3 border-t border-zinc-800 space-y-2 text-xs">
<div className="font-semibold text-zinc-300">История изменений подписки:</div>
<div className="space-y-1.5 text-[11px]">
{selectedSub.history.map((h, i) => (
<div key={i} className="p-2 rounded-md bg-zinc-950/60 border border-zinc-850">
<div className="flex justify-between text-zinc-400 text-[10px]">
<span className="font-bold text-zinc-300">{h.actor}</span>
<span>{new Date(h.eventAt).toLocaleDateString('ru-RU')}</span>
</div>
<div className="text-zinc-200 mt-0.5">{h.description}</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="p-8 rounded-xl border border-zinc-800 bg-[#0a1c15] text-center text-xs text-zinc-500">
Выберите подписку в таблице слева для управления
</div>
)}
</div>
</div>
</div>
);
};