feat(catalog): compact mobile modules catalog, filter drawer, view switcher, and quick access

This commit is contained in:
ochenstarik 2026-09-11 11:33:08 +07:00
parent 6681e0ef56
commit 3dfeac7734
5 changed files with 383 additions and 23 deletions

View file

@ -158,4 +158,33 @@ test.describe('Scenario 11: Universal Modules Catalog & Profiles', () => {
await catalogTab.click();
await expect(page.locator('[data-testid="modules-catalog-container"]')).toBeVisible();
});
test('8. Mobile filter drawer, density switcher, and quick access block function smoothly', async ({ page }) => {
// Quick access block
const quickAccess = page.locator('[data-testid="quick-access-block"]');
await expect(quickAccess).toBeVisible();
await expect(page.locator('[data-testid="quick-access-crm"]')).toBeVisible();
// Density toggle
const compactToggle = page.locator('[data-testid="density-toggle-compact"]');
const detailedToggle = page.locator('[data-testid="density-toggle-detailed"]');
await expect(compactToggle).toBeVisible();
await expect(detailedToggle).toBeVisible();
await compactToggle.click();
await expect(page.locator('text=Ключевые возможности:').first()).not.toBeVisible();
await detailedToggle.click();
await expect(page.locator('text=Ключевые возможности:').first()).toBeVisible();
// Filter drawer
const filterDrawerBtn = page.locator('[data-testid="mobile-filter-drawer-btn"]');
await expect(filterDrawerBtn).toBeVisible();
await filterDrawerBtn.click();
const applyDrawerBtn = page.locator('[data-testid="apply-filters-drawer-btn"]');
await expect(applyDrawerBtn).toBeVisible();
await applyDrawerBtn.click();
await expect(applyDrawerBtn).not.toBeVisible();
});
});

View file

@ -0,0 +1,184 @@
import React from 'react';
import { Drawer } from '../../shared/ui/Drawer';
import { Button } from '../../shared/ui/Button';
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
import { CATALOG_CATEGORIES, READINESS_CONFIG, ReadinessStatus } from './catalogData';
import { Building, Layers, ShieldCheck, RotateCcw, Check } from 'lucide-react';
export interface CatalogFilterDrawerProps {
isOpen: boolean;
onClose: () => void;
profileFilter: string;
onSelectProfileFilter: (profileId: string) => void;
categoryFilter: string;
onSelectCategoryFilter: (categoryId: string) => void;
statusFilter: string;
onSelectStatusFilter: (status: string) => void;
onReset: () => void;
resultsCount: number;
}
export const CatalogFilterDrawer: React.FC<CatalogFilterDrawerProps> = ({
isOpen,
onClose,
profileFilter,
onSelectProfileFilter,
categoryFilter,
onSelectCategoryFilter,
statusFilter,
onSelectStatusFilter,
onReset,
resultsCount,
}) => {
return (
<Drawer
isOpen={isOpen}
onClose={onClose}
title="Фильтры каталога"
subtitle={`Найдено модулей: ${resultsCount}`}
width="md"
footer={
<div className="flex items-center gap-2 w-full">
<Button
variant="secondary"
size="md"
className="flex-1"
onClick={() => {
onReset();
}}
data-testid="reset-filters-drawer-btn"
>
<RotateCcw className="w-4 h-4 mr-1.5" />
<span>Сбросить</span>
</Button>
<Button
variant="primary"
size="md"
className="flex-1"
onClick={onClose}
data-testid="apply-filters-drawer-btn"
>
<span>Показать ({resultsCount})</span>
</Button>
</div>
}
>
<div className="space-y-6 text-xs text-krona-primary py-1">
{/* Section 1: Industry / Profile */}
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
<Building className="w-3.5 h-3.5 text-krona-gold" />
<span>Отраслевой фокус</span>
</div>
<div className="grid grid-cols-1 gap-1.5">
<button
onClick={() => onSelectProfileFilter('all')}
className={`p-3 rounded-xl border text-left flex items-center justify-between transition-colors ${
profileFilter === 'all'
? 'bg-krona-gold/15 border-krona-gold font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span>Все модули (без отраслевого фокуса)</span>
{profileFilter === 'all' && <Check className="w-4 h-4 text-krona-gold" />}
</button>
<button
onClick={() => onSelectProfileFilter('recommended')}
className={`p-3 rounded-xl border text-left flex items-center justify-between transition-colors ${
profileFilter === 'recommended'
? 'bg-emerald-500/15 border-emerald-500 font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span>Рекомендовано для активного профиля</span>
{profileFilter === 'recommended' && <Check className="w-4 h-4 text-emerald-500" />}
</button>
{Object.values(BUSINESS_PROFILES).map((p) => (
<button
key={p.id}
onClick={() => onSelectProfileFilter(p.id)}
className={`p-3 rounded-xl border text-left flex items-center justify-between transition-colors ${
profileFilter === p.id
? 'bg-krona-gold/15 border-krona-gold font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span>{p.name}</span>
{profileFilter === p.id && <Check className="w-4 h-4 text-krona-gold" />}
</button>
))}
</div>
</div>
{/* Section 2: Categories */}
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
<Layers className="w-3.5 h-3.5 text-sky-500" />
<span>Категория</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{CATALOG_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => onSelectCategoryFilter(cat.id)}
className={`p-2.5 rounded-xl border text-left flex items-center justify-between text-xs transition-colors ${
categoryFilter === cat.id
? 'bg-sky-500/15 border-sky-500 font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span className="truncate">{cat.label}</span>
{categoryFilter === cat.id && <Check className="w-3.5 h-3.5 text-sky-500 shrink-0 ml-1" />}
</button>
))}
</div>
</div>
{/* Section 3: Status / Readiness */}
<div className="space-y-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-krona-secondary">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-500" />
<span>Статус реализации</span>
</div>
<div className="grid grid-cols-1 gap-1.5">
<button
onClick={() => onSelectStatusFilter('all')}
className={`p-2.5 rounded-xl border text-left flex items-center justify-between text-xs transition-colors ${
statusFilter === 'all'
? 'bg-emerald-500/15 border-emerald-500 font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span>Все статусы реализации</span>
{statusFilter === 'all' && <Check className="w-4 h-4 text-emerald-500" />}
</button>
{(Object.keys(READINESS_CONFIG) as ReadinessStatus[]).map((st) => {
const info = READINESS_CONFIG[st];
return (
<button
key={st}
onClick={() => onSelectStatusFilter(st)}
className={`p-2.5 rounded-xl border text-left flex items-center justify-between text-xs transition-colors ${
statusFilter === st
? 'bg-emerald-500/15 border-emerald-500 font-bold text-krona-primary'
: 'bg-krona-workspace border-krona-border text-krona-secondary hover:text-krona-primary'
}`}
>
<span>{info.label}</span>
{statusFilter === st && <Check className="w-4 h-4 text-emerald-500" />}
</button>
);
})}
</div>
</div>
</div>
</Drawer>
);
};

View file

@ -51,12 +51,14 @@ export interface ModuleCardProps {
module: CatalogModuleItem;
onOpenDetails: (module: CatalogModuleItem) => void;
isRecommended?: boolean;
isCompact?: boolean;
}
export const ModuleCard: React.FC<ModuleCardProps> = ({
module,
onOpenDetails,
isRecommended = false,
isCompact = false,
}) => {
const navigate = useNavigate();
const readiness = READINESS_CONFIG[module.readiness];
@ -69,17 +71,19 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
return (
<div
data-testid={`module-card-${module.id}`}
className={`bg-krona-surface rounded-2xl border transition-all duration-200 flex flex-col justify-between hover:shadow-krona-card hover:border-krona-gold/60 p-5 ${
className={`bg-krona-surface rounded-2xl border transition-all duration-200 flex flex-col justify-between hover:shadow-krona-card hover:border-krona-gold/60 ${
isCompact ? 'p-3.5 space-y-3' : 'p-5 space-y-4'
} ${
isRecommended
? 'border-krona-gold/50 bg-gradient-to-b from-krona-gold/5 to-transparent'
: 'border-krona-border'
}`}
>
<div className="space-y-3.5">
<div className={isCompact ? 'space-y-2' : 'space-y-3.5'}>
{/* Top meta bar: category + badges */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-krona-workspace border border-krona-border/60 shadow-xs">
<div className={`${isCompact ? 'p-1.5' : 'p-2'} rounded-xl bg-krona-workspace border border-krona-border/60 shadow-xs`}>
{ICON_COMPONENTS[module.iconName] || <Grid className="w-5 h-5 text-krona-secondary" />}
</div>
{isRecommended && (
@ -91,23 +95,26 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
<div className="flex items-center gap-1.5 flex-wrap">
<MaturityBadge level={readiness.maturity} size="sm" />
{!isCompact && (
<Badge variant={availability.variant} size="sm">
{availability.label}
</Badge>
)}
</div>
</div>
{/* Title and description */}
<div>
<h3 className="text-base font-bold text-krona-primary leading-tight">
<h3 className={`${isCompact ? 'text-sm' : 'text-base'} font-bold text-krona-primary leading-tight`}>
{module.title}
</h3>
<p className="text-xs text-krona-secondary mt-1.5 line-clamp-2 leading-relaxed">
<p className="text-xs text-krona-secondary mt-1 line-clamp-2 leading-relaxed">
{module.description}
</p>
</div>
{/* Features list */}
{/* Features list (Only in detailed mode) */}
{!isCompact && (
<div className="space-y-1.5 pt-1 border-t border-krona-border/50">
<span className="text-[10px] uppercase font-semibold tracking-wider text-krona-secondary block mb-1">
Ключевые возможности:
@ -119,10 +126,11 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
</div>
))}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-4 mt-4 border-t border-krona-border/60">
<div className={`flex items-center gap-2 ${isCompact ? 'pt-2.5 mt-2' : 'pt-4 mt-4'} border-t border-krona-border/60`}>
<Button
variant="secondary"
size="sm"

View file

@ -1,4 +1,5 @@
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 {
@ -8,6 +9,7 @@ import {
} from './catalogData';
import { ModuleCard } from './ModuleCard';
import { ModuleInfoModal } from './ModuleInfoModal';
import { CatalogFilterDrawer } from './CatalogFilterDrawer';
import {
Search,
X,
@ -18,11 +20,22 @@ import {
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,
@ -38,6 +51,19 @@ export const ModulesCatalog: React.FC = () => {
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;
@ -165,9 +191,12 @@ export const ModulesCatalog: React.FC = () => {
<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">
<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 */}
@ -214,6 +243,97 @@ export const ModulesCatalog: React.FC = () => {
</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">
@ -393,6 +513,7 @@ export const ModulesCatalog: React.FC = () => {
key={mod.id}
module={mod}
isRecommended={true}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
@ -425,6 +546,7 @@ export const ModulesCatalog: React.FC = () => {
key={mod.id}
module={mod}
isRecommended={false}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
@ -457,6 +579,7 @@ export const ModulesCatalog: React.FC = () => {
<ModuleCard
key={mod.id}
module={mod}
isCompact={displayDensity === 'compact'}
onOpenDetails={setSelectedModuleForModal}
/>
))}
@ -474,6 +597,22 @@ export const ModulesCatalog: React.FC = () => {
</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}

File diff suppressed because one or more lines are too long