feat: refine CRM workspace and responsive hierarchy

This commit is contained in:
ochenstarik 2026-09-22 00:20:37 +07:00
parent 24ef652275
commit c4e8add07b
4 changed files with 87 additions and 103 deletions

View file

@ -103,7 +103,9 @@ export const AppShell: React.FC = () => {
</div> </div>
{/* Right Command Panel (Only on full Desktop, not on communications which has its own full right card) */} {/* Right Command Panel (Only on full Desktop, not on communications which has its own full right card) */}
{!isEffectiveMobile && !isEffectiveTablet && !isCommunicationsPage && <RightCommandPanel />} {!isEffectiveMobile && !isEffectiveTablet && width >= 1536 && !isCommunicationsPage && (
<RightCommandPanel />
)}
</div> </div>
{/* Mobile Bottom Navigation (Always present on mobile) */} {/* Mobile Bottom Navigation (Always present on mobile) */}

View file

@ -255,16 +255,12 @@ describe('Configuration Engine & CRM Advanced Interactions', () => {
expect(deal.id).toBeDefined(); expect(deal.id).toBeDefined();
render( render(
<MemoryRouter initialEntries={['/crm']}> <MemoryRouter initialEntries={['/crm/deals']}>
<CrmModule /> <CrmModule />
</MemoryRouter> </MemoryRouter>
); );
// 2. Switch to Deals Kanban tab // 2. Verify stage columns are rendered from the route-owned workspace.
const dealsTab = screen.getByRole('button', { name: /Воронка сделок/i });
fireEvent.click(dealsTab);
// 3. Verify stage columns are rendered
expect(screen.getByText('Квалификация')).toBeInTheDocument(); expect(screen.getByText('Квалификация')).toBeInTheDocument();
expect(screen.getByText('Выиграна')).toBeInTheDocument(); expect(screen.getByText('Выиграна')).toBeInTheDocument();

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ulid } from 'ulidx'; import { ulid } from 'ulidx';
import { useCrm } from '../../services/hooks/useCrm'; import { useCrm } from '../../services/hooks/useCrm';
import { useOrders } from '../../services/hooks/useOrders'; import { useOrders } from '../../services/hooks/useOrders';
@ -10,7 +10,7 @@ import { Deal, DealStage } from '../../entities/crm/deal';
import { Lead } from '../../entities/crm/lead'; import { Lead } from '../../entities/crm/lead';
import { OrderItem } from '../../entities/commerce/order'; import { OrderItem } from '../../entities/commerce/order';
import { MoneyAmount } from '../../entities/types'; import { MoneyAmount } from '../../entities/types';
import { MaturityBadge } from '../../shared/ui/MaturityBadge'; import { Button } from '../../shared/ui/Button';
import { useToast } from '../../shared/ui/useToast'; import { useToast } from '../../shared/ui/useToast';
import { CsvImportWizard } from './CsvImportWizard'; import { CsvImportWizard } from './CsvImportWizard';
import { DEAL_STAGES } from './constants'; import { DEAL_STAGES } from './constants';
@ -26,6 +26,7 @@ import { UploadCloud } from 'lucide-react';
export const CrmModule: React.FC = () => { export const CrmModule: React.FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const { showToast } = useToast(); const { showToast } = useToast();
const { const {
@ -50,14 +51,12 @@ export const CrmModule: React.FC = () => {
const dealMeta = useMetadata('deal'); const dealMeta = useMetadata('deal');
const leadMeta = useMetadata('lead'); const leadMeta = useMetadata('lead');
const [activeTab, setActiveTab] = useState<'deals' | 'contacts' | 'companies' | 'leads' | 'import'>(() => { const activeTab: 'deals' | 'contacts' | 'companies' | 'leads' | 'import' =
const p = location.pathname; location.pathname.includes('/crm/deals') ? 'deals'
if (p.includes('/crm/deals')) return 'deals'; : location.pathname.includes('/crm/leads') ? 'leads'
if (p.includes('/crm/leads')) return 'leads'; : location.pathname.includes('/crm/companies') ? 'companies'
if (p.includes('/crm/companies')) return 'companies'; : location.pathname.includes('/crm/import') ? 'import'
if (p.includes('/crm/import')) return 'import'; : 'contacts';
return 'contacts';
});
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
// Modals state // Modals state
@ -76,16 +75,6 @@ export const CrmModule: React.FC = () => {
const [selectedContact, setSelectedContact] = useState<Contact | null>(null); const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null); const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
// Synchronize active tab with URL path if navigated externally
const p = location.pathname;
if (p !== '/crm' && p !== '/crm/') {
if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals');
else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads');
else if (p.includes('/crm/companies') && activeTab !== 'companies') setActiveTab('companies');
else if (p.includes('/crm/import') && activeTab !== 'import') setActiveTab('import');
else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts');
}
// Deal stage transition handler // Deal stage transition handler
const handleStageChange = async (dealId: string, nextStage: DealStage) => { const handleStageChange = async (dealId: string, nextStage: DealStage) => {
const deal = deals.find((d) => d.id === dealId); const deal = deals.find((d) => d.id === dealId);
@ -210,54 +199,51 @@ export const CrmModule: React.FC = () => {
} }
}; };
const navTabs = [ const pageMeta = {
{ id: 'deals', label: `Воронка сделок (${deals.length})` }, deals: {
{ id: 'contacts', label: `Контакты (${contacts.length})` }, title: 'Воронка сделок',
{ id: 'companies', label: `Компании (${companies.length})` }, description: `${deals.length} сделок — от первого контакта до заказа`,
{ id: 'leads', label: `Лиды (${leads.length})` }, },
] as const; contacts: {
title: 'Клиенты и контакты',
description: `${contacts.length} контактов в единой клиентской базе`,
},
companies: {
title: 'Компании',
description: `${companies.length} организаций, поставщиков и партнёров`,
},
leads: {
title: 'Лиды',
description: `${leads.length} обращений ожидают обработки`,
},
import: {
title: 'Импорт данных',
description: 'Загрузка клиентской базы из CSV с предварительной проверкой',
},
}[activeTab];
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-5 animate-fade-in max-w-[1600px] mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft"> <header className="flex items-start sm:items-center justify-between gap-4 border-b border-krona-border pb-4">
<div> <div className="min-w-0">
<div className="flex items-center gap-2"> <h1 className="text-xl sm:text-2xl font-semibold tracking-[-0.02em] text-krona-primary">
<h1 className="text-sm font-bold text-krona-primary"> {pageMeta.title}
CRM: Клиенты, Воронка продаж & Сделки
</h1> </h1>
<MaturityBadge level="pilot1" size="sm" /> <p className="text-sm text-krona-secondary mt-1">{pageMeta.description}</p>
</div> </div>
<p className="text-xs text-krona-secondary mt-0.5"> {activeTab !== 'import' && (
Сквозной процесс: Лид → Контакт/Компания → Сделка (Kanban) → Выиграна → Заказ в Коммерции <Button
</p> variant="secondary"
</div> size="sm"
aria-label="Импорт CSV"
<div className="flex items-center gap-1.5 overflow-x-auto text-xs"> onClick={() => navigate('/crm/import')}
{navTabs.map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`px-3 py-1.5 rounded-lg font-medium transition-all ${
activeTab === t.id
? 'bg-krona-green text-white shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
> >
{t.label} <UploadCloud className="w-4 h-4 mr-1.5" />
</button> <span className="hidden sm:inline">Импорт CSV</span>
))} <span className="sm:hidden">Импорт</span>
<button </Button>
onClick={() => setActiveTab('import')} )}
className={`px-3 py-1.5 rounded-lg font-medium transition-all flex items-center gap-1 ${ </header>
activeTab === 'import'
? 'bg-krona-green text-white shadow-sm'
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
}`}
>
<UploadCloud className="w-3.5 h-3.5" /> Импорт CSV
</button>
</div>
</div>
{activeTab === 'deals' && ( {activeTab === 'deals' && (
<CrmKanbanView <CrmKanbanView

View file

@ -39,8 +39,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
if (activeTab === 'contacts') { if (activeTab === 'contacts') {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="relative w-64"> <div className="relative w-full sm:w-80">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" /> <Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input <input
type="text" type="text"
@ -55,15 +55,15 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
</Button> </Button>
</div> </div>
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden"> <div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-x-auto">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left table-fixed md:table-auto">
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border"> <thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
<tr> <tr>
<th className="p-3 font-semibold">ФИО / Контакт</th> <th className="p-3 font-semibold">ФИО / Контакт</th>
<th className="p-3 font-semibold">Компания</th> <th className="p-3 font-semibold hidden lg:table-cell">Компания</th>
<th className="p-3 font-semibold">Телефон</th> <th className="p-3 font-semibold">Телефон</th>
<th className="p-3 font-semibold">Email</th> <th className="p-3 font-semibold hidden xl:table-cell">Email</th>
<th className="p-3 font-semibold">Роли</th> <th className="p-3 font-semibold hidden xl:table-cell">Роли</th>
<th className="p-3 font-semibold text-right">Действие</th> <th className="p-3 font-semibold text-right">Действие</th>
</tr> </tr>
</thead> </thead>
@ -97,10 +97,10 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
</div> </div>
</div> </div>
</td> </td>
<td className="p-3 text-krona-secondary">{comp?.name || '—'}</td> <td className="p-3 text-krona-secondary hidden lg:table-cell">{comp?.name || '—'}</td>
<td className="p-3 font-mono text-krona-secondary">{contact.phone || '—'}</td> <td className="p-3 font-mono text-krona-secondary break-words">{contact.phone || '—'}</td>
<td className="p-3 text-krona-secondary">{contact.email || '—'}</td> <td className="p-3 text-krona-secondary hidden xl:table-cell">{contact.email || '—'}</td>
<td className="p-3"> <td className="p-3 hidden xl:table-cell">
<div className="flex gap-1"> <div className="flex gap-1">
{contact.roles?.map((r) => ( {contact.roles?.map((r) => (
<Badge key={r} variant="gold" size="sm"> <Badge key={r} variant="gold" size="sm">
@ -118,7 +118,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
onSelectContact(contact); onSelectContact(contact);
}} }}
> >
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" /> <span className="hidden md:inline">Карточка</span><ChevronRight className="w-3.5 h-3.5 md:ml-1" />
</Button> </Button>
</td> </td>
</tr> </tr>
@ -134,8 +134,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
if (activeTab === 'companies') { if (activeTab === 'companies') {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="relative w-64"> <div className="relative w-full sm:w-80">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" /> <Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input <input
type="text" type="text"
@ -150,15 +150,15 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
</Button> </Button>
</div> </div>
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden"> <div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-x-auto">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left table-fixed md:table-auto">
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border"> <thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
<tr> <tr>
<th className="p-3 font-semibold">Название компании</th> <th className="p-3 font-semibold">Название компании</th>
<th className="p-3 font-semibold">ИНН</th> <th className="p-3 font-semibold hidden md:table-cell">ИНН</th>
<th className="p-3 font-semibold">Город</th> <th className="p-3 font-semibold hidden xl:table-cell">Город</th>
<th className="p-3 font-semibold">Телефон</th> <th className="p-3 font-semibold">Телефон</th>
<th className="p-3 font-semibold">Роли</th> <th className="p-3 font-semibold hidden xl:table-cell">Роли</th>
<th className="p-3 font-semibold text-right">Действие</th> <th className="p-3 font-semibold text-right">Действие</th>
</tr> </tr>
</thead> </thead>
@ -187,10 +187,10 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
</div> </div>
</div> </div>
</td> </td>
<td className="p-3 font-mono text-krona-secondary">{company.inn || '—'}</td> <td className="p-3 font-mono text-krona-secondary hidden md:table-cell">{company.inn || '—'}</td>
<td className="p-3 text-krona-secondary">{company.address || '—'}</td> <td className="p-3 text-krona-secondary hidden xl:table-cell">{company.address || '—'}</td>
<td className="p-3 font-mono text-krona-secondary">{company.phone || '—'}</td> <td className="p-3 font-mono text-krona-secondary">{company.phone || '—'}</td>
<td className="p-3"> <td className="p-3 hidden xl:table-cell">
<div className="flex gap-1"> <div className="flex gap-1">
{company.roles?.map((r) => ( {company.roles?.map((r) => (
<Badge key={r} variant="gold" size="sm"> <Badge key={r} variant="gold" size="sm">
@ -208,7 +208,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
onSelectCompany(company); onSelectCompany(company);
}} }}
> >
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" /> <span className="hidden md:inline">Карточка</span><ChevronRight className="w-3.5 h-3.5 md:ml-1" />
</Button> </Button>
</td> </td>
</tr> </tr>
@ -223,8 +223,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
if (activeTab === 'leads') { if (activeTab === 'leads') {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="relative w-64"> <div className="relative w-full sm:w-80">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" /> <Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
<input <input
type="text" type="text"
@ -244,13 +244,13 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
</Button> </Button>
</div> </div>
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden"> <div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-x-auto">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left table-fixed md:table-auto">
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border"> <thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
<tr> <tr>
<th className="p-3 font-semibold">Название лида</th> <th className="p-3 font-semibold">Название лида</th>
<th className="p-3 font-semibold">Контактное лицо</th> <th className="p-3 font-semibold hidden md:table-cell">Контактное лицо</th>
<th className="p-3 font-semibold">Контакты</th> <th className="p-3 font-semibold hidden lg:table-cell">Контакты</th>
<th className="p-3 font-semibold">Оценка бюджета</th> <th className="p-3 font-semibold">Оценка бюджета</th>
<th className="p-3 font-semibold">Статус</th> <th className="p-3 font-semibold">Статус</th>
<th className="p-3 font-semibold text-right">Конвертация</th> <th className="p-3 font-semibold text-right">Конвертация</th>
@ -268,8 +268,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
.map((lead) => ( .map((lead) => (
<tr key={lead.id} className="hover:bg-krona-workspace/50 transition-colors"> <tr key={lead.id} className="hover:bg-krona-workspace/50 transition-colors">
<td className="p-3 font-bold text-krona-primary">{lead.title}</td> <td className="p-3 font-bold text-krona-primary">{lead.title}</td>
<td className="p-3 font-medium text-krona-primary">{lead.contactName}</td> <td className="p-3 font-medium text-krona-primary hidden md:table-cell">{lead.contactName}</td>
<td className="p-3 text-krona-secondary"> <td className="p-3 text-krona-secondary hidden lg:table-cell">
<div>{lead.phone}</div> <div>{lead.phone}</div>
<div className="text-[11px]">{lead.email}</div> <div className="text-[11px]">{lead.email}</div>
</td> </td>
@ -302,7 +302,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
data-testid="convert-lead-btn" data-testid="convert-lead-btn"
onClick={() => onConvertLead(lead)} onClick={() => onConvertLead(lead)}
> >
<Sparkles className="w-3.5 h-3.5 mr-1" /> Конвертировать в сделку <Sparkles className="w-3.5 h-3.5 md:mr-1" /> <span className="hidden md:inline">В сделку</span>
</Button> </Button>
) : ( ) : (
<span className="text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center justify-end gap-1"> <span className="text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center justify-end gap-1">