315 lines
15 KiB
TypeScript
315 lines
15 KiB
TypeScript
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>
|
||
);
|
||
};
|