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

245 lines
12 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 {
Building2,
Search,
ChevronRight,
} from 'lucide-react';
import { useKccStore } from '../store/kccStore';
import { StatusBadge } from '../components/StatusBadge';
import { MoneyDisplay } from '../components/MoneyDisplay';
import { PlatformStatus } from '../contracts';
export const OrganizationsPage: React.FC = () => {
const organizations = useKccStore((state) => state.organizations);
const openInspector = useKccStore((state) => state.openInspector);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | PlatformStatus>('all');
const filtered = organizations.filter((org) => {
const matchSearch =
!search ||
org.displayName.toLowerCase().includes(search.toLowerCase()) ||
org.inn.includes(search) ||
org.domain.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === 'all' || org.platformStatus === statusFilter;
return matchSearch && matchStatus;
});
return (
<div className="space-y-4 sm:space-y-6" data-testid="organizations-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">
<Building2 className="w-5 h-5 text-krona-gold" />
<span>Организации платформы</span>
</h1>
<p className="text-xs text-krona-secondary mt-0.5">
Реестр организаций, управление статусами, тарифами и 360°-анализ тенантов
</p>
</div>
<div className="text-xs text-krona-secondary">
Всего: <span className="font-bold text-krona-primary">{organizations.length}</span> (найдено:{' '}
<span className="font-bold text-krona-gold">{filtered.length}</span>)
</div>
</div>
{/* Filter and Search Bar */}
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center justify-between">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-krona-tertiary absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по названию, ИНН или домену..."
className="w-full bg-krona-surface border border-krona-border rounded-lg pl-9 pr-3 py-2.5 min-h-[44px] text-xs text-krona-primary placeholder-krona-tertiary focus:outline-hidden focus:border-krona-gold transition-colors"
/>
</div>
{/* Status Filters */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{[
{ id: 'all', label: 'Все' },
{ id: 'active', label: 'Активные' },
{ id: 'trial', label: 'Пробные' },
{ id: 'grace', label: 'Льготные' },
{ id: 'suspended', label: 'Приостановленные' },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatusFilter(tab.id as typeof statusFilter)}
className={`min-h-[44px] px-3.5 py-2 rounded-lg font-medium transition-colors shrink-0 ${
statusFilter === 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>
</div>
{/* ========================================================================= */}
{/* 1. MOBILE CARD VIEW (< lg) */}
{/* ========================================================================= */}
<div className="lg:hidden space-y-3" data-testid="orgs-mobile-cards">
{filtered.map((org) => (
<div
key={org.tenantId}
onClick={() => openInspector('organization', org.tenantId)}
className="rounded-xl border border-krona-border bg-krona-surface p-4 space-y-3 shadow-sm hover:border-krona-gold/40 transition-colors cursor-pointer"
>
{/* Header: Title + Badge */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-bold text-sm text-krona-primary truncate">{org.displayName}</h3>
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
ИНН {org.inn} • {org.domain}
</div>
</div>
<StatusBadge status={org.platformStatus} size="sm" />
</div>
{/* Metrics Row */}
<div className="grid grid-cols-3 gap-2 py-2 border-y border-krona-border text-center text-xs">
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">Пользователи</div>
<div className="font-bold text-krona-primary">{org.userCount} <span className="text-[10px] font-normal text-krona-tertiary">/ {org.branchCount} т.</span></div>
</div>
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">Модули</div>
<div className="font-bold text-krona-primary">{org.installedModulesCount}</div>
</div>
<div className="p-1 rounded-md bg-krona-workspace/50">
<div className="text-[10px] text-krona-tertiary">MRR</div>
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="font-bold text-krona-gold text-xs" />
</div>
</div>
{/* Bottom: Tariff + Inspector Action */}
<div className="flex items-center justify-between pt-1">
<div className="text-xs text-krona-secondary">
Тариф: <span className="font-semibold text-krona-primary">{org.tariffPlan}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector('organization', org.tenantId);
}}
className="min-h-[44px] min-w-[44px] px-3 flex items-center gap-1 text-xs font-semibold text-krona-gold bg-krona-gold/10 hover:bg-krona-gold/20 rounded-lg border border-krona-gold/30 transition-colors"
data-testid={`inspect-org-${org.tenantId}`}
>
<span>360° Карточка</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="p-8 text-center text-krona-tertiary rounded-xl border border-krona-border bg-krona-surface text-xs">
Организации не найдены по заданным критериям фильтрации
</div>
)}
</div>
{/* ========================================================================= */}
{/* 2. DESKTOP & TABLET TABLE VIEW (hidden on mobile, visible >= lg) */}
{/* ========================================================================= */}
<div className="hidden lg:block rounded-xl border border-krona-border bg-krona-surface overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-krona-border bg-krona-workspace text-krona-tertiary 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-center">Польз. / Точки</th>
<th className="py-3 px-3 text-center">Модули</th>
<th className="py-3 px-3 text-right">MRR Доход</th>
<th className="py-3 px-3 text-right">Задолженность</th>
<th className="py-3 px-3">Активность</th>
<th className="py-3 px-4 text-right">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border">
{filtered.map((org) => (
<tr
key={org.tenantId}
onClick={() => openInspector('organization', org.tenantId)}
className="hover:bg-krona-hover cursor-pointer transition-colors group"
>
<td className="py-3 px-4">
<div className="font-bold text-krona-primary group-hover:text-krona-gold transition-colors">
{org.displayName}
</div>
<div className="text-[11px] text-krona-tertiary font-mono mt-0.5">
ИНН {org.inn} • {org.domain}
</div>
</td>
<td className="py-3 px-3">
<StatusBadge status={org.platformStatus} size="sm" />
</td>
<td className="py-3 px-3 font-medium text-krona-primary">{org.tariffPlan}</td>
<td className="py-3 px-3 text-center tabular-nums">
<span className="font-bold text-krona-primary">{org.userCount}</span>
<span className="text-krona-tertiary text-[10px]"> / {org.branchCount}</span>
</td>
<td className="py-3 px-3 text-center font-bold text-krona-primary tabular-nums">
{org.installedModulesCount}
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay minorUnits={org.mrrMinorUnits} className="text-krona-gold font-bold" />
</td>
<td className="py-3 px-3 text-right">
<MoneyDisplay
minorUnits={org.outstandingDebtMinorUnits}
className={org.outstandingDebtMinorUnits > 0 ? 'text-status-danger-text font-bold' : 'text-krona-tertiary'}
/>
</td>
<td className="py-3 px-3 text-krona-tertiary text-[11px]">
{new Date(org.lastActiveAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="py-3 px-4 text-right">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openInspector('organization', org.tenantId);
}}
className="inline-flex items-center gap-1 px-3 py-2 min-h-[38px] rounded-lg bg-krona-workspace border border-krona-border text-krona-primary hover:text-krona-gold hover:border-krona-gold/40 text-[11px] font-medium transition-colors"
data-testid={`inspect-org-btn-${org.tenantId}`}
>
<span>360° Карточка</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={9} className="py-8 text-center text-krona-tertiary">
Организации не найдены по заданным критериям фильтрации
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};