320 lines
16 KiB
TypeScript
320 lines
16 KiB
TypeScript
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>
|
||
);
|
||
};
|