krona-front/src/features/catalog/ModulesCatalog.tsx

624 lines
29 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, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDemoStore } from '../../mock/store';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import {
CATALOG_MODULES,
CATALOG_CATEGORIES,
CatalogModuleItem,
} from './catalogData';
import { ModuleCard } from './ModuleCard';
import { ModuleInfoModal } from './ModuleInfoModal';
import { CatalogFilterDrawer } from './CatalogFilterDrawer';
import {
Search,
X,
Sparkles,
Layers,
Filter,
Building,
RotateCcw,
BarChart3,
Grid,
Zap,
LayoutGrid,
List,
SlidersHorizontal,
Users,
ShoppingBag,
Store,
Package,
CreditCard,
CheckSquare,
} from 'lucide-react';
import { Button } from '../../shared/ui/Button';
import { DynamicDashboard } from '../dashboard/DynamicDashboard';
export const ModulesCatalog: React.FC = () => {
const navigate = useNavigate();
const {
currentProfileId,
catalogProfileFilter,
setCatalogProfileFilter,
catalogCategoryFilter,
setCatalogCategoryFilter,
catalogStatusFilter,
setCatalogStatusFilter,
catalogSearchQuery,
setCatalogSearchQuery,
resetCatalogFilters,
} = useDemoStore();
const [activeViewMode, setActiveViewMode] = useState<'catalog' | 'dashboard'>('catalog');
const [selectedModuleForModal, setSelectedModuleForModal] = useState<CatalogModuleItem | null>(null);
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [displayDensity, setDisplayDensity] = useState<'compact' | 'detailed'>(() => {
if (typeof window !== 'undefined' && window.innerWidth < 768) {
return 'compact';
}
return 'detailed';
});
const activeFiltersCount =
(catalogProfileFilter !== 'all' ? 1 : 0) +
(catalogCategoryFilter !== 'all' ? 1 : 0) +
(catalogStatusFilter !== 'all' ? 1 : 0) +
(catalogSearchQuery.trim() ? 1 : 0);
const activeProfile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
// Search and Category filtering
const filteredModules = useMemo(() => {
return CATALOG_MODULES.filter((m) => {
// Category filter
if (catalogCategoryFilter !== 'all' && m.category !== catalogCategoryFilter) {
return false;
}
// Status filter
if (catalogStatusFilter !== 'all' && m.readiness !== catalogStatusFilter) {
return false;
}
// Search filter
if (catalogSearchQuery.trim()) {
const q = catalogSearchQuery.toLowerCase();
const matchTitle = m.title.toLowerCase().includes(q);
const matchDesc = m.description.toLowerCase().includes(q);
const matchFeatures = m.features.some((f) => f.toLowerCase().includes(q));
if (!matchTitle && !matchDesc && !matchFeatures) {
return false;
}
}
return true;
});
}, [catalogCategoryFilter, catalogStatusFilter, catalogSearchQuery]);
// Split into recommended vs other when a specific profile or "recommended" is active
const isProfileFilterActive = catalogProfileFilter !== 'all';
const effectiveProfileId =
catalogProfileFilter === 'recommended' ? currentProfileId : catalogProfileFilter;
const { recommendedList, otherList } = useMemo(() => {
if (!isProfileFilterActive) {
return { recommendedList: [], otherList: filteredModules };
}
const rec: CatalogModuleItem[] = [];
const oth: CatalogModuleItem[] = [];
for (const m of filteredModules) {
if (m.recommendedProfiles.includes(effectiveProfileId)) {
rec.push(m);
} else {
oth.push(m);
}
}
return { recommendedList: rec, otherList: oth };
}, [isProfileFilterActive, effectiveProfileId, filteredModules]);
const stats = useMemo(() => {
const ready = CATALOG_MODULES.filter((m) => m.readiness === 'ready').length;
const target = CATALOG_MODULES.filter((m) => m.readiness === 'ui_prepared').length;
const planned = CATALOG_MODULES.filter((m) => m.readiness === 'planned').length;
const service = CATALOG_MODULES.filter((m) => m.readiness === 'service_required').length;
return { total: CATALOG_MODULES.length, ready, target, planned, service };
}, []);
return (
<div className="space-y-6 max-w-7xl mx-auto animate-fade-in pb-12" data-testid="modules-catalog-container">
{/* View Mode Switcher Header: Catalog vs KPI Dashboard */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-2xl border border-krona-border shadow-krona-soft">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-krona-gold/15 text-krona-gold border border-krona-gold/30">
<Grid className="w-5 h-5" />
</div>
<div>
<span className="text-[10px] uppercase font-bold tracking-wider text-krona-secondary">
Обзор платформы Krona Business OS
</span>
<div className="text-xs text-krona-primary font-semibold">
Единый каталог всех 18 модулей и функциональных блоков
</div>
</div>
</div>
{/* Tab switch between Catalog and Dashboard */}
<div className="flex items-center bg-krona-workspace p-1 rounded-xl border border-krona-border text-xs">
<button
onClick={() => setActiveViewMode('catalog')}
data-testid="view-tab-catalog"
className={`px-3 py-1.5 rounded-lg font-medium transition-colors flex items-center gap-1.5 ${
activeViewMode === 'catalog'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<Grid className="w-3.5 h-3.5" />
<span>Каталог модулей ({stats.total})</span>
</button>
<button
onClick={() => setActiveViewMode('dashboard')}
data-testid="view-tab-dashboard"
className={`px-3 py-1.5 rounded-lg font-medium transition-colors flex items-center gap-1.5 ${
activeViewMode === 'dashboard'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
<BarChart3 className="w-3.5 h-3.5" />
<span>Операционная сводка / KPI</span>
</button>
</div>
</div>
{/* If Dashboard mode is active, render DynamicDashboard */}
{activeViewMode === 'dashboard' ? (
<DynamicDashboard />
) : (
/* Catalog Mode */
<div className="space-y-6">
{/* Main Hero Header */}
<div className="p-6 md:p-8 rounded-3xl bg-gradient-to-br from-[#252722] via-krona-surface to-[#1F211C] border border-krona-border shadow-krona-card space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2 max-w-2xl">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-500 text-xs font-bold uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5" />
<span>Все возможности бизнес-платформы</span>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-krona-primary">
Единый каталог модулей Krona
</h1>
<p className="text-xs md:text-sm text-krona-secondary leading-relaxed hidden md:block">
Платформа открыта для изучения целиком без предварительного выбора отрасли. Исследуйте Pilot 1 транзакции, интерактивные прототипы и перспективные блоки экосистемы.
</p>
<p className="text-xs text-krona-secondary leading-relaxed md:hidden">
Все 18 модулей единой бизнес-платформы
</p>
</div>
{/* Stats badges */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center text-xs">
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-emerald-500">{stats.ready}</div>
<div className="text-[10px] text-krona-secondary">Работает в демо</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-sky-500">{stats.target}</div>
<div className="text-[10px] text-krona-secondary">Интерфейс готов</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-amber-500">{stats.planned}</div>
<div className="text-[10px] text-krona-secondary">Запланирован</div>
</div>
<div className="p-3 rounded-xl bg-krona-workspace/80 border border-krona-border">
<div className="text-lg font-bold text-purple-500">{stats.service}</div>
<div className="text-[10px] text-krona-secondary">Сервисные</div>
</div>
</div>
</div>
{/* Live Search Input */}
<div className="relative pt-2">
<Search className="w-4 h-4 absolute left-3.5 top-5 text-krona-secondary" />
<input
type="text"
value={catalogSearchQuery}
onChange={(e) => setCatalogSearchQuery(e.target.value)}
placeholder="Быстрый поиск по названию, возможностям или описанию модуля..."
data-testid="catalog-search-input"
className="w-full pl-10 pr-10 py-2.5 text-xs bg-krona-workspace border border-krona-border rounded-xl text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-gold shadow-inner"
/>
{catalogSearchQuery && (
<button
onClick={() => setCatalogSearchQuery('')}
className="absolute right-3.5 top-5 text-krona-secondary hover:text-krona-primary p-0.5"
title="Очистить поиск"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Quick Controls Bar: Mobile Filter Button + Density Toggle */}
<div className="flex items-center justify-between gap-3 bg-krona-surface p-3 rounded-2xl border border-krona-border shadow-krona-soft">
{/* Filter Drawer Trigger Button */}
<Button
variant={activeFiltersCount > 0 ? 'primary' : 'secondary'}
size="sm"
onClick={() => setIsFilterDrawerOpen(true)}
data-testid="mobile-filter-drawer-btn"
className="text-xs flex items-center gap-1.5"
>
<SlidersHorizontal className="w-3.5 h-3.5" />
<span>Фильтры</span>
{activeFiltersCount > 0 && (
<span className="ml-1 px-1.5 py-0.2 rounded-full text-[10px] bg-white/20 text-white font-bold">
{activeFiltersCount}
</span>
)}
</Button>
{/* Density Toggle */}
<div className="flex items-center bg-krona-workspace p-1 rounded-xl border border-krona-border text-xs">
<button
type="button"
onClick={() => setDisplayDensity('compact')}
data-testid="density-toggle-compact"
className={`px-2.5 py-1 rounded-lg font-medium transition-colors flex items-center gap-1 ${
displayDensity === 'compact'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
title="Компактный вид карточек"
>
<LayoutGrid className="w-3.5 h-3.5" />
<span>Компактно</span>
</button>
<button
type="button"
onClick={() => setDisplayDensity('detailed')}
data-testid="density-toggle-detailed"
className={`px-2.5 py-1 rounded-lg font-medium transition-colors flex items-center gap-1 ${
displayDensity === 'detailed'
? 'bg-krona-surface text-krona-primary font-bold shadow-xs border border-krona-border'
: 'text-krona-secondary hover:text-krona-primary'
}`}
title="Подробный вид карточек"
>
<List className="w-3.5 h-3.5" />
<span>Подробно</span>
</button>
</div>
</div>
{/* Quick Access Block */}
<div className="p-3.5 sm:p-4 rounded-2xl bg-krona-surface border border-krona-border shadow-krona-soft space-y-2.5" data-testid="quick-access-block">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-bold text-krona-primary uppercase tracking-wider">
<Zap className="w-3.5 h-3.5 text-krona-gold" />
<span>Быстрый доступ</span>
</div>
<span className="text-[11px] text-krona-secondary">Ключевые модули платформы</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{[
{ id: 'crm', title: 'CRM', path: '/crm', icon: Users, color: 'text-sky-500' },
{ id: 'pos', title: 'Продажи', path: '/sales', icon: ShoppingBag, color: 'text-emerald-500' },
{ id: 'operations', title: 'Касса и операции', path: '/operations', icon: Store, color: 'text-amber-500' },
{ id: 'inventory', title: 'Склад', path: '/inventory', icon: Package, color: 'text-blue-500' },
{ id: 'finance', title: 'Финансы', path: '/finance', icon: CreditCard, color: 'text-purple-500' },
{ id: 'tasks', title: 'Задачи', path: '/tasks', icon: CheckSquare, color: 'text-rose-500' },
].map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => navigate(item.path)}
data-testid={`quick-access-${item.id}`}
className="flex items-center gap-2 p-2 rounded-xl bg-krona-workspace hover:bg-krona-hover border border-krona-border transition-colors text-left group"
>
<div className="p-1.5 rounded-lg bg-krona-surface border border-krona-border/60 group-hover:border-krona-gold/40 shrink-0">
<Icon className={`w-3.5 h-3.5 ${item.color}`} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-krona-primary truncate">{item.title}</div>
<div className="text-[10px] text-krona-secondary truncate">Перейти</div>
</div>
</button>
);
})}
</div>
</div>
{/* Industry Profiles Filter Bar */}
<div className="p-4 rounded-2xl bg-krona-surface border border-krona-border shadow-krona-soft space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-krona-border/60 pb-2.5">
<div className="flex items-center gap-2">
<Building className="w-4 h-4 text-krona-gold" />
<span className="text-xs font-bold text-krona-primary uppercase tracking-wider">
Отраслевой фокус (не скрывает остальные модули):
</span>
</div>
{isProfileFilterActive && (
<button
onClick={() => setCatalogProfileFilter('all')}
className="text-[11px] text-krona-gold hover:underline flex items-center gap-1"
data-testid="reset-profile-filter-btn"
>
<RotateCcw className="w-3 h-3" />
<span>Сбросить в «Все модули»</span>
</button>
)}
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{/* Все модули (По умолчанию) */}
<button
data-testid="profile-filter-all"
onClick={() => setCatalogProfileFilter('all')}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors ${
catalogProfileFilter === 'all'
? 'bg-krona-gold text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
Все модули ({stats.total})
</button>
{/* Рекомендовано для текущей отрасли */}
<button
data-testid="profile-filter-recommended"
onClick={() => setCatalogProfileFilter('recommended')}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
catalogProfileFilter === 'recommended'
? 'bg-[#2F684C] text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
<Sparkles className="w-3 h-3 text-krona-gold" />
<span>Рекомендовано для моей отрасли ({activeProfile.name})</span>
</button>
{/* Specific profiles */}
{Object.values(BUSINESS_PROFILES).map((p) => (
<button
key={p.id}
data-testid={`profile-filter-${p.id}`}
onClick={() => setCatalogProfileFilter(p.id)}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition-colors ${
catalogProfileFilter === p.id
? 'bg-[#2F684C] text-white font-semibold shadow-xs'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
{p.name}
</button>
))}
</div>
</div>
{/* Categories and Status Filters Bar */}
<div className="space-y-3">
{/* Categories horizontal selector */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 text-xs">
{CATALOG_CATEGORIES.map((cat) => {
const isSelected = catalogCategoryFilter === cat.id;
const count =
cat.id === 'all'
? CATALOG_MODULES.length
: CATALOG_MODULES.filter((m) => m.category === cat.id).length;
return (
<button
key={cat.id}
data-testid={`category-filter-${cat.id}`}
onClick={() => setCatalogCategoryFilter(cat.id)}
className={`px-3 py-1.5 rounded-xl font-medium whitespace-nowrap transition-all flex items-center gap-1.5 ${
isSelected
? 'bg-krona-surface text-krona-primary font-bold border-2 border-krona-green shadow-xs'
: 'bg-krona-surface/60 text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
<span>{cat.label}</span>
<span className={`text-[10px] px-1.5 py-0.2 rounded-full ${
isSelected ? 'bg-krona-green/20 text-krona-green' : 'bg-krona-workspace text-krona-secondary'
}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Status & Reset filter row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 text-krona-secondary">
<Filter className="w-3.5 h-3.5" />
<span>Статус готовности:</span>
</div>
<div className="flex items-center gap-1 flex-wrap">
{[
{ id: 'all', label: 'Все статусы' },
{ id: 'ready', label: 'Работает в демо' },
{ id: 'ui_prepared', label: 'Интерфейс подготовлен' },
{ id: 'planned', label: 'Запланирован' },
{ id: 'service_required', label: 'Требует подключения' },
].map((st) => (
<button
key={st.id}
data-testid={`readiness-filter-${st.id}`}
onClick={() => setCatalogStatusFilter(st.id)}
className={`px-2.5 py-1 rounded-lg transition-colors ${
catalogStatusFilter === st.id
? 'bg-krona-surface text-krona-primary font-bold border border-krona-border shadow-xs'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
{st.label}
</button>
))}
</div>
</div>
{(catalogCategoryFilter !== 'all' ||
catalogStatusFilter !== 'all' ||
catalogSearchQuery ||
catalogProfileFilter !== 'all') && (
<Button
variant="secondary"
size="sm"
onClick={resetCatalogFilters}
className="text-xs"
data-testid="reset-all-filters-btn"
>
<RotateCcw className="w-3 h-3 mr-1" />
<span>Сбросить все фильтры</span>
</Button>
)}
</div>
</div>
{/* Module Cards Sections */}
{isProfileFilterActive ? (
/* When an industry profile is selected: 2 sections */
<div className="space-y-8">
{/* Section 1: Recommended */}
<div className="space-y-4" data-testid="recommended-section">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-krona-gold" />
<h2 className="text-base font-bold text-krona-primary">
Рекомендовано для вашей отрасли ({
catalogProfileFilter === 'recommended'
? activeProfile.name
: BUSINESS_PROFILES[catalogProfileFilter]?.name || 'Выбранная отрасль'
})
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-gold/15 text-krona-gold-text font-bold">
{recommendedList.length}
</span>
</div>
</div>
{recommendedList.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendedList.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
isRecommended={true}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-6 text-center text-xs text-krona-secondary italic bg-krona-surface/40 rounded-2xl border border-krona-border">
По текущим фильтрам нет рекомендованных модулей
</div>
)}
</div>
{/* Section 2: Other platform capabilities */}
<div className="space-y-4" data-testid="other-modules-section">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-krona-secondary" />
<h2 className="text-base font-bold text-krona-primary">
Все остальные возможности платформы (доступны для подключения)
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-workspace text-krona-secondary font-bold">
{otherList.length}
</span>
</div>
</div>
{otherList.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{otherList.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
isRecommended={false}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-6 text-center text-xs text-krona-secondary italic bg-krona-surface/40 rounded-2xl border border-krona-border">
По текущим фильтрам нет дополнительных модулей
</div>
)}
</div>
</div>
) : (
/* Unified Grid of All Modules */
<div className="space-y-4" data-testid="all-modules-grid">
<div className="flex items-center justify-between border-b border-krona-border pb-2">
<div className="flex items-center gap-2">
<Grid className="w-4 h-4 text-krona-gold" />
<h2 className="text-base font-bold text-krona-primary">
Все модули платформы Krona
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-krona-gold/15 text-krona-gold-text font-bold">
Найдено: {filteredModules.length}
</span>
</div>
</div>
{filteredModules.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredModules.map((mod) => (
<ModuleCard
key={mod.id}
module={mod}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
</div>
) : (
<div className="p-12 text-center text-xs text-krona-secondary bg-krona-surface/40 rounded-2xl border border-krona-border space-y-3">
<p>По запросу «{catalogSearchQuery}» ничего не найдено.</p>
<Button variant="secondary" size="sm" onClick={resetCatalogFilters}>
Сбросить фильтры поиска
</Button>
</div>
)}
</div>
)}
</div>
)}
{/* Mobile Bottom-Sheet Filter Drawer */}
<CatalogFilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
profileFilter={catalogProfileFilter}
onSelectProfileFilter={(id) => setCatalogProfileFilter(id)}
categoryFilter={catalogCategoryFilter}
onSelectCategoryFilter={(id) => setCatalogCategoryFilter(id as any)}
statusFilter={catalogStatusFilter}
onSelectStatusFilter={(id) => setCatalogStatusFilter(id)}
onReset={() => {
resetCatalogFilters();
}}
resultsCount={isProfileFilterActive ? recommendedList.length + otherList.length : filteredModules.length}
/>
{/* Module Info Modal */}
<ModuleInfoModal
module={selectedModuleForModal}
isOpen={Boolean(selectedModuleForModal)}
onClose={() => setSelectedModuleForModal(null)}
/>
</div>
);
};