245 lines
12 KiB
TypeScript
245 lines
12 KiB
TypeScript
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>
|
||
);
|
||
};
|