feat: refine CRM workspace and responsive hierarchy
This commit is contained in:
parent
24ef652275
commit
c4e8add07b
4 changed files with 87 additions and 103 deletions
|
|
@ -103,7 +103,9 @@ export const AppShell: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Mobile Bottom Navigation (Always present on mobile) */}
|
||||
|
|
|
|||
|
|
@ -255,16 +255,12 @@ describe('Configuration Engine & CRM Advanced Interactions', () => {
|
|||
expect(deal.id).toBeDefined();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crm']}>
|
||||
<MemoryRouter initialEntries={['/crm/deals']}>
|
||||
<CrmModule />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 2. Switch to Deals Kanban tab
|
||||
const dealsTab = screen.getByRole('button', { name: /Воронка сделок/i });
|
||||
fireEvent.click(dealsTab);
|
||||
|
||||
// 3. Verify stage columns are rendered
|
||||
// 2. Verify stage columns are rendered from the route-owned workspace.
|
||||
expect(screen.getByText('Квалификация')).toBeInTheDocument();
|
||||
expect(screen.getByText('Выиграна')).toBeInTheDocument();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ulid } from 'ulidx';
|
||||
import { useCrm } from '../../services/hooks/useCrm';
|
||||
import { useOrders } from '../../services/hooks/useOrders';
|
||||
|
|
@ -10,7 +10,7 @@ import { Deal, DealStage } from '../../entities/crm/deal';
|
|||
import { Lead } from '../../entities/crm/lead';
|
||||
import { OrderItem } from '../../entities/commerce/order';
|
||||
import { MoneyAmount } from '../../entities/types';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { CsvImportWizard } from './CsvImportWizard';
|
||||
import { DEAL_STAGES } from './constants';
|
||||
|
|
@ -26,6 +26,7 @@ import { UploadCloud } from 'lucide-react';
|
|||
|
||||
export const CrmModule: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const {
|
||||
|
|
@ -50,14 +51,12 @@ export const CrmModule: React.FC = () => {
|
|||
const dealMeta = useMetadata('deal');
|
||||
const leadMeta = useMetadata('lead');
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'deals' | 'contacts' | 'companies' | 'leads' | 'import'>(() => {
|
||||
const p = location.pathname;
|
||||
if (p.includes('/crm/deals')) return 'deals';
|
||||
if (p.includes('/crm/leads')) return 'leads';
|
||||
if (p.includes('/crm/companies')) return 'companies';
|
||||
if (p.includes('/crm/import')) return 'import';
|
||||
return 'contacts';
|
||||
});
|
||||
const activeTab: 'deals' | 'contacts' | 'companies' | 'leads' | 'import' =
|
||||
location.pathname.includes('/crm/deals') ? 'deals'
|
||||
: location.pathname.includes('/crm/leads') ? 'leads'
|
||||
: location.pathname.includes('/crm/companies') ? 'companies'
|
||||
: location.pathname.includes('/crm/import') ? 'import'
|
||||
: 'contacts';
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Modals state
|
||||
|
|
@ -76,16 +75,6 @@ export const CrmModule: React.FC = () => {
|
|||
const [selectedContact, setSelectedContact] = useState<Contact | 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
|
||||
const handleStageChange = async (dealId: string, nextStage: DealStage) => {
|
||||
const deal = deals.find((d) => d.id === dealId);
|
||||
|
|
@ -210,54 +199,51 @@ export const CrmModule: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const navTabs = [
|
||||
{ id: 'deals', label: `Воронка сделок (${deals.length})` },
|
||||
{ id: 'contacts', label: `Контакты (${contacts.length})` },
|
||||
{ id: 'companies', label: `Компании (${companies.length})` },
|
||||
{ id: 'leads', label: `Лиды (${leads.length})` },
|
||||
] as const;
|
||||
const pageMeta = {
|
||||
deals: {
|
||||
title: 'Воронка сделок',
|
||||
description: `${deals.length} сделок — от первого контакта до заказа`,
|
||||
},
|
||||
contacts: {
|
||||
title: 'Клиенты и контакты',
|
||||
description: `${contacts.length} контактов в единой клиентской базе`,
|
||||
},
|
||||
companies: {
|
||||
title: 'Компании',
|
||||
description: `${companies.length} организаций, поставщиков и партнёров`,
|
||||
},
|
||||
leads: {
|
||||
title: 'Лиды',
|
||||
description: `${leads.length} обращений ожидают обработки`,
|
||||
},
|
||||
import: {
|
||||
title: 'Импорт данных',
|
||||
description: 'Загрузка клиентской базы из CSV с предварительной проверкой',
|
||||
},
|
||||
}[activeTab];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<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">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-krona-primary">
|
||||
CRM: Клиенты, Воронка продаж & Сделки
|
||||
</h1>
|
||||
<MaturityBadge level="pilot1" size="sm" />
|
||||
</div>
|
||||
<p className="text-xs text-krona-secondary mt-0.5">
|
||||
Сквозной процесс: Лид → Контакт/Компания → Сделка (Kanban) → Выиграна → Заказ в Коммерции
|
||||
</p>
|
||||
<div className="space-y-5 animate-fade-in max-w-[1600px] mx-auto">
|
||||
<header className="flex items-start sm:items-center justify-between gap-4 border-b border-krona-border pb-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-[-0.02em] text-krona-primary">
|
||||
{pageMeta.title}
|
||||
</h1>
|
||||
<p className="text-sm text-krona-secondary mt-1">{pageMeta.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto text-xs">
|
||||
{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}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`px-3 py-1.5 rounded-lg font-medium transition-all flex items-center gap-1 ${
|
||||
activeTab === 'import'
|
||||
? 'bg-krona-green text-white shadow-sm'
|
||||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
|
||||
}`}
|
||||
{activeTab !== 'import' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label="Импорт CSV"
|
||||
onClick={() => navigate('/crm/import')}
|
||||
>
|
||||
<UploadCloud className="w-3.5 h-3.5" /> Импорт CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UploadCloud className="w-4 h-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">Импорт CSV</span>
|
||||
<span className="sm:hidden">Импорт</span>
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{activeTab === 'deals' && (
|
||||
<CrmKanbanView
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
if (activeTab === 'contacts') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -55,15 +55,15 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
||||
<table className="w-full text-xs text-left">
|
||||
<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-fixed md:table-auto">
|
||||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||||
<tr>
|
||||
<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">Email</th>
|
||||
<th className="p-3 font-semibold">Роли</th>
|
||||
<th className="p-3 font-semibold hidden xl:table-cell">Email</th>
|
||||
<th className="p-3 font-semibold hidden xl:table-cell">Роли</th>
|
||||
<th className="p-3 font-semibold text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -97,10 +97,10 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-krona-secondary">{comp?.name || '—'}</td>
|
||||
<td className="p-3 font-mono text-krona-secondary">{contact.phone || '—'}</td>
|
||||
<td className="p-3 text-krona-secondary">{contact.email || '—'}</td>
|
||||
<td className="p-3">
|
||||
<td className="p-3 text-krona-secondary hidden lg:table-cell">{comp?.name || '—'}</td>
|
||||
<td className="p-3 font-mono text-krona-secondary break-words">{contact.phone || '—'}</td>
|
||||
<td className="p-3 text-krona-secondary hidden xl:table-cell">{contact.email || '—'}</td>
|
||||
<td className="p-3 hidden xl:table-cell">
|
||||
<div className="flex gap-1">
|
||||
{contact.roles?.map((r) => (
|
||||
<Badge key={r} variant="gold" size="sm">
|
||||
|
|
@ -118,7 +118,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -134,8 +134,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
if (activeTab === 'companies') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -150,15 +150,15 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
||||
<table className="w-full text-xs text-left">
|
||||
<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-fixed md:table-auto">
|
||||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||||
<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 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 hidden xl:table-cell">Роли</th>
|
||||
<th className="p-3 font-semibold text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -187,10 +187,10 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-krona-secondary">{company.inn || '—'}</td>
|
||||
<td className="p-3 text-krona-secondary">{company.address || '—'}</td>
|
||||
<td className="p-3 font-mono text-krona-secondary hidden md:table-cell">{company.inn || '—'}</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">
|
||||
<td className="p-3 hidden xl:table-cell">
|
||||
<div className="flex gap-1">
|
||||
{company.roles?.map((r) => (
|
||||
<Badge key={r} variant="gold" size="sm">
|
||||
|
|
@ -208,7 +208,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -223,8 +223,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
if (activeTab === 'leads') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -244,13 +244,13 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
|
||||
<table className="w-full text-xs text-left">
|
||||
<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-fixed md:table-auto">
|
||||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||||
<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 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 text-right">Конвертация</th>
|
||||
|
|
@ -268,8 +268,8 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
.map((lead) => (
|
||||
<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-medium text-krona-primary">{lead.contactName}</td>
|
||||
<td className="p-3 text-krona-secondary">
|
||||
<td className="p-3 font-medium text-krona-primary hidden md:table-cell">{lead.contactName}</td>
|
||||
<td className="p-3 text-krona-secondary hidden lg:table-cell">
|
||||
<div>{lead.phone}</div>
|
||||
<div className="text-[11px]">{lead.email}</div>
|
||||
</td>
|
||||
|
|
@ -302,7 +302,7 @@ export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
|||
data-testid="convert-lead-btn"
|
||||
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>
|
||||
) : (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center justify-end gap-1">
|
||||
|
|
|
|||
Loading…
Reference in a new issue