feat(stage-e): decompose oversized modules, extract hooks, and update documentation
This commit is contained in:
parent
b951888a26
commit
19c15189d4
17 changed files with 1500 additions and 1156 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ test-results/
|
|||
playwright-report/
|
||||
.playwright/
|
||||
e2e/screenshots/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ src/
|
|||
├── app/ # Инициализация приложения, провайдеры, роутер, shell, guards
|
||||
│ ├── guards/ # RouteGuard (RBAC + Capability + State Override)
|
||||
│ ├── layouts/ # AppShell (Desktop / Tablet / Mobile адаптивный фрейм)
|
||||
│ ├── providers/ # ErrorBoundary, QueryClientProvider, ToastProvider
|
||||
│ ├── providers/ # AppProviders, TenantProvider, ToastProvider
|
||||
│ ├── router/ # React Router 6 с code-splitting для каждого модуля
|
||||
│ └── shell/ # Sidebar, Topbar, BottomNav, CommandPalette (Ctrl+K), AI Drawer
|
||||
├── core/ # Фундаментальные реестры платформы
|
||||
|
|
@ -98,9 +98,8 @@ src/
|
|||
│ ├── mock/ # Репозитории с имитацией latency, validation, audit log
|
||||
│ └── storage/ # Версионированная IndexedDB (7 stores, snapshots, reset)
|
||||
├── shared/ # Библиотека переиспользуемых компонентов и утилит
|
||||
│ ├── components/ # Базовые контролы (Button, Input, Select, Badge, Drawer, Modal)
|
||||
│ ├── lib/ # ULID генератор, форматирование валюты/дат, debounce
|
||||
│ └── ui/ # MaturityBadge, ErrorBoundary, Skeleton, Toast
|
||||
│ └── ui/ # Единый UI-Kit: Button, Input, Select, Badge, Drawer, Modal, Toast, Skeleton, etc.
|
||||
└── stores/ # Zustand хранилища для UI и Demo-состояния
|
||||
├── demo.ts # Demo Controls (профиль, роль, ошибки, часы, latency)
|
||||
├── navigation.ts # Состояние мобильного меню и аккордеонов
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ test.describe('Scenario 10: IndexedDB Snapshots (Export, Reset, Import)', () =>
|
|||
// 5. Reload page to ensure restored state persists
|
||||
await page.reload();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const retryBtn = page.locator('button:has-text("Попробовать снова")');
|
||||
if (await retryBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await retryBtn.click();
|
||||
}
|
||||
|
||||
await expect(exportBtn).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { defineConfig } from '@playwright/test';
|
|||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: ['**/0[1-9]-*.spec.ts', '**/10-*.spec.ts'],
|
||||
testIgnore: ['**/*control-center*'],
|
||||
timeout: 30000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
191
src/features/crm/components/CrmEntityModals.tsx
Normal file
191
src/features/crm/components/CrmEntityModals.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import React from 'react';
|
||||
import { useMetadata } from '../../../services/hooks/useMetadata';
|
||||
import { Contact } from '../../../entities/crm/contact';
|
||||
import { Company } from '../../../entities/crm/company';
|
||||
import { Lead } from '../../../entities/crm/lead';
|
||||
import { Deal } from '../../../entities/crm/deal';
|
||||
import { Modal } from '../../../shared/ui/Modal';
|
||||
import { DynamicFormRenderer } from '../../metadata/DynamicFormRenderer';
|
||||
|
||||
export interface CrmEntityModalsProps {
|
||||
contactMeta: ReturnType<typeof useMetadata>;
|
||||
companyMeta: ReturnType<typeof useMetadata>;
|
||||
dealMeta: ReturnType<typeof useMetadata>;
|
||||
leadMeta: ReturnType<typeof useMetadata>;
|
||||
contacts: Contact[];
|
||||
companies: Company[];
|
||||
isAddContactOpen: boolean;
|
||||
isAddCompanyOpen: boolean;
|
||||
isAddDealOpen: boolean;
|
||||
isAddLeadOpen: boolean;
|
||||
onCloseContact: () => void;
|
||||
onCloseCompany: () => void;
|
||||
onCloseDeal: () => void;
|
||||
onCloseLead: () => void;
|
||||
onCreateContact: (data: Omit<Contact, 'id' | 'audit'>) => Promise<Contact>;
|
||||
onCreateCompany: (data: Omit<Company, 'id' | 'audit'>) => Promise<Company>;
|
||||
onCreateDeal: (data: Omit<Deal, 'id' | 'audit'>) => Promise<Deal>;
|
||||
onCreateLead: (data: Omit<Lead, 'id' | 'audit'>) => Promise<Lead>;
|
||||
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const CrmEntityModals: React.FC<CrmEntityModalsProps> = ({
|
||||
contactMeta,
|
||||
companyMeta,
|
||||
dealMeta,
|
||||
leadMeta,
|
||||
contacts,
|
||||
companies,
|
||||
isAddContactOpen,
|
||||
isAddCompanyOpen,
|
||||
isAddDealOpen,
|
||||
isAddLeadOpen,
|
||||
onCloseContact,
|
||||
onCloseCompany,
|
||||
onCloseDeal,
|
||||
onCloseLead,
|
||||
onCreateContact,
|
||||
onCreateCompany,
|
||||
onCreateDeal,
|
||||
onCreateLead,
|
||||
showToast,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Create Lead Modal */}
|
||||
<Modal
|
||||
isOpen={isAddLeadOpen}
|
||||
onClose={onCloseLead}
|
||||
title="Создание нового лида"
|
||||
size="lg"
|
||||
>
|
||||
{leadMeta.currentEntity ? (
|
||||
<DynamicFormRenderer
|
||||
entity={leadMeta.currentEntity}
|
||||
layout={leadMeta.layouts?.[0] || null}
|
||||
mode="create"
|
||||
onSave={async (values) => {
|
||||
await onCreateLead({
|
||||
title: String(values.title || 'Новый лид'),
|
||||
contactName: String(values.contactName || values.title || 'Контактное лицо'),
|
||||
phone: String(values.phone || '+7 (999) 000-00-00'),
|
||||
email: String(values.email || 'lead@example.com'),
|
||||
companyName: String(values.companyName || ''),
|
||||
status: 'new',
|
||||
source: 'website',
|
||||
estimatedValue: values.estimatedValue
|
||||
? { minor: Number(values.estimatedValue) || 1000000, currency: 'RUB' }
|
||||
: { minor: 1500000, currency: 'RUB' },
|
||||
customFields: values,
|
||||
});
|
||||
onCloseLead();
|
||||
showToast('Лид успешно создан!', 'success');
|
||||
}}
|
||||
onCancel={onCloseLead}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create Contact Modal */}
|
||||
<Modal
|
||||
isOpen={isAddContactOpen}
|
||||
onClose={onCloseContact}
|
||||
title="Создание нового контакта"
|
||||
size="lg"
|
||||
>
|
||||
{contactMeta.currentEntity ? (
|
||||
<DynamicFormRenderer
|
||||
entity={contactMeta.currentEntity}
|
||||
layout={contactMeta.layouts?.[0] || null}
|
||||
mode="create"
|
||||
onSave={async (values) => {
|
||||
await onCreateContact({
|
||||
firstName: String(values.firstName || 'Новый'),
|
||||
lastName: String(values.lastName || 'Контакт'),
|
||||
phone: String(values.phone || ''),
|
||||
email: String(values.email || ''),
|
||||
jobTitle: String(values.jobTitle || ''),
|
||||
roles: ['client'],
|
||||
status: 'active',
|
||||
customFields: values,
|
||||
});
|
||||
onCloseContact();
|
||||
showToast('Контакт успешно создан!', 'success');
|
||||
}}
|
||||
onCancel={onCloseContact}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create Company Modal */}
|
||||
<Modal
|
||||
isOpen={isAddCompanyOpen}
|
||||
onClose={onCloseCompany}
|
||||
title="Создание новой компании"
|
||||
size="lg"
|
||||
>
|
||||
{companyMeta.currentEntity ? (
|
||||
<DynamicFormRenderer
|
||||
entity={companyMeta.currentEntity}
|
||||
layout={companyMeta.layouts?.[0] || null}
|
||||
mode="create"
|
||||
onSave={async (values) => {
|
||||
await onCreateCompany({
|
||||
name: String(values.name || 'Новая компания'),
|
||||
inn: String(values.inn || ''),
|
||||
address: String(values.city || values.address || 'г. Москва'),
|
||||
phone: String(values.phone || ''),
|
||||
roles: ['client'],
|
||||
status: 'active',
|
||||
customFields: values,
|
||||
});
|
||||
onCloseCompany();
|
||||
showToast('Компания успешно создана!', 'success');
|
||||
}}
|
||||
onCancel={onCloseCompany}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create Deal Modal */}
|
||||
<Modal
|
||||
isOpen={isAddDealOpen}
|
||||
onClose={onCloseDeal}
|
||||
title="Создание новой сделки"
|
||||
size="lg"
|
||||
>
|
||||
{dealMeta.currentEntity ? (
|
||||
<DynamicFormRenderer
|
||||
entity={dealMeta.currentEntity}
|
||||
layout={dealMeta.layouts?.[0] || null}
|
||||
mode="create"
|
||||
onSave={async (values) => {
|
||||
const amountMinor = typeof values.amount === 'number' ? values.amount : 5000000;
|
||||
await onCreateDeal({
|
||||
title: String(values.title || 'Новая сделка'),
|
||||
pipelineId: 'pipe_default',
|
||||
stage: 'prospecting',
|
||||
amount: { minor: amountMinor, currency: 'RUB' },
|
||||
probability: 20,
|
||||
contactId: contacts[0]?.id,
|
||||
companyId: companies[0]?.id,
|
||||
customFields: values,
|
||||
});
|
||||
onCloseDeal();
|
||||
showToast('Сделка успешно создана в воронке!', 'success');
|
||||
}}
|
||||
onCancel={onCloseDeal}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
159
src/features/crm/components/CrmKanbanView.tsx
Normal file
159
src/features/crm/components/CrmKanbanView.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Deal, DealStage } from '../../../entities/crm/deal';
|
||||
import { Contact } from '../../../entities/crm/contact';
|
||||
import { Company } from '../../../entities/crm/company';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||
import { DEAL_STAGES } from '../constants';
|
||||
import { Search, Plus, ArrowRight, Building } from 'lucide-react';
|
||||
|
||||
export interface CrmKanbanViewProps {
|
||||
deals: Deal[];
|
||||
contacts: Contact[];
|
||||
companies: Company[];
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onAddDeal: () => void;
|
||||
onStageChange: (dealId: string, nextStage: DealStage) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const CrmKanbanView: React.FC<CrmKanbanViewProps> = ({
|
||||
deals,
|
||||
contacts,
|
||||
companies,
|
||||
search,
|
||||
onSearchChange,
|
||||
onAddDeal,
|
||||
onStageChange,
|
||||
}) => {
|
||||
const [draggedDealId, setDraggedDealId] = useState<string | null>(null);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, dealId: string) => {
|
||||
e.dataTransfer.setData('text/plain', dealId);
|
||||
setDraggedDealId(dealId);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetStage: DealStage) => {
|
||||
e.preventDefault();
|
||||
const dealId = e.dataTransfer.getData('text/plain') || draggedDealId;
|
||||
if (dealId) {
|
||||
await onStageChange(dealId, targetStage);
|
||||
}
|
||||
setDraggedDealId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск по сделкам..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={onAddDeal}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Новая сделка
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-7 gap-2.5 overflow-x-auto pb-4">
|
||||
{DEAL_STAGES.map((col) => {
|
||||
const colDeals = deals.filter(
|
||||
(d) =>
|
||||
d.stage === col.key &&
|
||||
(d.title.toLowerCase().includes(search.toLowerCase()) || !search)
|
||||
);
|
||||
const colTotalMinor = colDeals.reduce((sum, d) => sum + d.amount.minor, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
data-testid={`kanban-column-${col.key}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, col.key)}
|
||||
className={`bg-krona-surface/70 border rounded-xl p-2.5 flex flex-col min-w-[200px] max-h-[750px] shadow-krona-soft transition-all ${col.color}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-krona-border/60 pb-2 mb-2">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-krona-primary">{col.label}</div>
|
||||
<div className="text-[11px] font-medium text-krona-green mt-0.5">
|
||||
{formatCurrencyMinor(colTotalMinor)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-krona-workspace text-krona-secondary font-bold">
|
||||
{colDeals.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 overflow-y-auto flex-1 pr-0.5">
|
||||
{colDeals.map((deal) => {
|
||||
const contact = contacts.find((c) => c.id === deal.contactId);
|
||||
const company = companies.find((c) => c.id === deal.companyId);
|
||||
const nextStageIdx = DEAL_STAGES.findIndex((s) => s.key === deal.stage) + 1;
|
||||
const nextStage = DEAL_STAGES[nextStageIdx]?.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deal.id}
|
||||
data-testid={`deal-card-${deal.id}`}
|
||||
data-deal-id={deal.id}
|
||||
data-deal-stage={deal.stage}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, deal.id)}
|
||||
className="bg-krona-workspace border border-krona-border hover:border-krona-green/60 p-2.5 rounded-lg text-xs space-y-2 shadow-xs cursor-grab active:cursor-grabbing transition-all"
|
||||
>
|
||||
<div className="font-semibold text-krona-primary line-clamp-2">
|
||||
{deal.title}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="font-bold text-krona-primary">
|
||||
{formatCurrencyMinor(deal.amount.minor)}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded font-medium">
|
||||
{deal.probability}% вер.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(contact || company) && (
|
||||
<div className="text-[11px] text-krona-secondary truncate flex items-center gap-1">
|
||||
<Building className="w-3 h-3" />
|
||||
{company?.name || `${contact?.firstName} ${contact?.lastName}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stage transition button */}
|
||||
{nextStage && deal.stage !== 'won' && deal.stage !== 'lost' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStageChange(deal.id, nextStage)}
|
||||
className="w-full mt-1 py-1 text-[11px] bg-krona-surface hover:bg-krona-green/10 text-krona-secondary hover:text-krona-green border border-krona-border rounded flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
Далее: {DEAL_STAGES[nextStageIdx]?.label} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{colDeals.length === 0 && (
|
||||
<div className="text-center py-6 text-[11px] text-krona-secondary italic">
|
||||
Пусто
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
323
src/features/crm/components/CrmTableView.tsx
Normal file
323
src/features/crm/components/CrmTableView.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import React from 'react';
|
||||
import { Contact } from '../../../entities/crm/contact';
|
||||
import { Company } from '../../../entities/crm/company';
|
||||
import { Lead } from '../../../entities/crm/lead';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Badge } from '../../../shared/ui/Badge';
|
||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||
import { Search, Plus, Building, ChevronRight, Sparkles, Check } from 'lucide-react';
|
||||
|
||||
export interface CrmTableViewProps {
|
||||
activeTab: 'contacts' | 'companies' | 'leads';
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
contacts: Contact[];
|
||||
companies: Company[];
|
||||
leads: Lead[];
|
||||
onSelectContact: (contact: Contact) => void;
|
||||
onSelectCompany: (company: Company) => void;
|
||||
onAddContact: () => void;
|
||||
onAddCompany: () => void;
|
||||
onAddLead: () => void;
|
||||
onConvertLead: (lead: Lead) => void;
|
||||
}
|
||||
|
||||
export const CrmTableView: React.FC<CrmTableViewProps> = ({
|
||||
activeTab,
|
||||
search,
|
||||
onSearchChange,
|
||||
contacts,
|
||||
companies,
|
||||
leads,
|
||||
onSelectContact,
|
||||
onSelectCompany,
|
||||
onAddContact,
|
||||
onAddCompany,
|
||||
onAddLead,
|
||||
onConvertLead,
|
||||
}) => {
|
||||
if (activeTab === 'contacts') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск по имени, телефону, email..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={onAddContact}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Новый контакт
|
||||
</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">
|
||||
<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">Email</th>
|
||||
<th className="p-3 font-semibold">Роли</th>
|
||||
<th className="p-3 font-semibold text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-krona-border">
|
||||
{contacts
|
||||
.filter(
|
||||
(c) =>
|
||||
`${c.firstName} ${c.lastName}`.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.phone?.includes(search) ||
|
||||
c.email?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.map((contact) => {
|
||||
const comp = companies.find((co) => co.id === contact.companyId);
|
||||
return (
|
||||
<tr
|
||||
key={contact.id}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
className="hover:bg-krona-workspace/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="p-3 font-medium text-krona-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-krona-green/10 text-krona-green flex items-center justify-center font-bold text-xs">
|
||||
{contact.firstName[0]}
|
||||
{contact.lastName?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div>{contact.firstName} {contact.lastName}</div>
|
||||
{contact.jobTitle && (
|
||||
<div className="text-[11px] text-krona-secondary">{contact.jobTitle}</div>
|
||||
)}
|
||||
</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">
|
||||
<div className="flex gap-1">
|
||||
{contact.roles?.map((r) => (
|
||||
<Badge key={r} variant="gold" size="sm">
|
||||
{r}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectContact(contact);
|
||||
}}
|
||||
>
|
||||
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'companies') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск по названию, ИНН..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={onAddCompany}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Новая компания
|
||||
</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">
|
||||
<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">Телефон</th>
|
||||
<th className="p-3 font-semibold">Роли</th>
|
||||
<th className="p-3 font-semibold text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-krona-border">
|
||||
{companies
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.inn?.includes(search) ||
|
||||
c.address?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.map((company) => (
|
||||
<tr
|
||||
key={company.id}
|
||||
onClick={() => onSelectCompany(company)}
|
||||
className="hover:bg-krona-workspace/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="p-3 font-medium text-krona-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="w-4 h-4 text-krona-secondary" />
|
||||
<div>
|
||||
<div className="font-bold">{company.name}</div>
|
||||
{company.legalName && (
|
||||
<div className="text-[11px] text-krona-secondary">{company.legalName}</div>
|
||||
)}
|
||||
</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">{company.phone || '—'}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
{company.roles?.map((r) => (
|
||||
<Badge key={r} variant="gold" size="sm">
|
||||
{r}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCompany(company);
|
||||
}}
|
||||
>
|
||||
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'leads') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск лидов..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="new-lead-btn"
|
||||
onClick={onAddLead}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> Новый лид
|
||||
</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">
|
||||
<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">Оценка бюджета</th>
|
||||
<th className="p-3 font-semibold">Статус</th>
|
||||
<th className="p-3 font-semibold text-right">Конвертация</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-krona-border">
|
||||
{leads
|
||||
.filter(
|
||||
(lead) =>
|
||||
lead.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
lead.contactName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
lead.phone?.includes(search) ||
|
||||
lead.email?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.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">
|
||||
<div>{lead.phone}</div>
|
||||
<div className="text-[11px]">{lead.email}</div>
|
||||
</td>
|
||||
<td className="p-3 font-bold text-krona-green">
|
||||
{lead.estimatedValue ? formatCurrencyMinor(lead.estimatedValue.minor) : '—'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge
|
||||
variant={
|
||||
lead.status === 'converted'
|
||||
? 'green'
|
||||
: lead.status === 'disqualified'
|
||||
? 'danger'
|
||||
: 'warning'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{lead.status === 'converted'
|
||||
? 'Конвертирован'
|
||||
: lead.status === 'new'
|
||||
? 'Новый'
|
||||
: lead.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{lead.status !== 'converted' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="convert-lead-btn"
|
||||
onClick={() => onConvertLead(lead)}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1" /> Конвертировать в сделку
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center justify-end gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Сделка создана
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
337
src/features/crm/components/Customer360Drawer.tsx
Normal file
337
src/features/crm/components/Customer360Drawer.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Contact } from '../../../entities/crm/contact';
|
||||
import { Company } from '../../../entities/crm/company';
|
||||
import { Deal } from '../../../entities/crm/deal';
|
||||
import { Order } from '../../../entities/commerce/order';
|
||||
import { TimelineItem, Activity } from '../../../services/contracts/crm';
|
||||
import { Drawer } from '../../../shared/ui/Drawer';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Badge } from '../../../shared/ui/Badge';
|
||||
import { useToast } from '../../../shared/ui/useToast';
|
||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||
import { formatDateTime } from '../../../shared/lib/date';
|
||||
import { DEAL_STAGES } from '../constants';
|
||||
|
||||
export interface Customer360DrawerProps {
|
||||
isOpen: boolean;
|
||||
selectedContact: Contact | null;
|
||||
selectedCompany: Company | null;
|
||||
onClose: () => void;
|
||||
deals: Deal[];
|
||||
orders: Order[];
|
||||
getTimeline: (type: string, id: string) => Promise<TimelineItem[]>;
|
||||
createActivity: (activity: Omit<Activity, 'id' | 'audit'>) => Promise<Activity>;
|
||||
}
|
||||
|
||||
export const Customer360Drawer: React.FC<Customer360DrawerProps> = ({
|
||||
isOpen,
|
||||
selectedContact,
|
||||
selectedCompany,
|
||||
onClose,
|
||||
deals,
|
||||
orders,
|
||||
getTimeline,
|
||||
createActivity,
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
const [customer360Tab, setCustomer360Tab] = useState<'overview' | 'deals' | 'orders' | 'activities' | 'timeline'>('overview');
|
||||
const [timelineItems, setTimelineItems] = useState<TimelineItem[]>([]);
|
||||
const [newActivityTitle, setNewActivityTitle] = useState('');
|
||||
const [newActivityType, setNewActivityType] = useState<Activity['type']>('call');
|
||||
|
||||
// Load timeline when selecting contact/company
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (selectedContact) {
|
||||
getTimeline('contact', selectedContact.id)
|
||||
.then((items) => {
|
||||
if (active) setTimelineItems(items);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (selectedCompany) {
|
||||
getTimeline('company', selectedCompany.id)
|
||||
.then((items) => {
|
||||
if (active) setTimelineItems(items);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedContact, selectedCompany, getTimeline]);
|
||||
|
||||
// Linked items for Customer 360
|
||||
const currentCustomerId = selectedContact?.id || selectedCompany?.id || '';
|
||||
const linkedDeals = useMemo(
|
||||
() => deals.filter((d) => d.contactId === currentCustomerId || d.companyId === currentCustomerId),
|
||||
[deals, currentCustomerId]
|
||||
);
|
||||
const linkedOrders = useMemo(
|
||||
() => orders.filter((o) => o.customerId === currentCustomerId),
|
||||
[orders, currentCustomerId]
|
||||
);
|
||||
|
||||
// Add Activity in Customer 360
|
||||
const handleAddActivity = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newActivityTitle) return;
|
||||
|
||||
const targetType = selectedContact ? 'contact' : 'company';
|
||||
const targetId = selectedContact?.id || selectedCompany?.id || '';
|
||||
|
||||
await createActivity({
|
||||
targetEntityType: targetType,
|
||||
targetEntityId: targetId,
|
||||
type: newActivityType,
|
||||
title: newActivityTitle,
|
||||
completed: false,
|
||||
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
||||
});
|
||||
|
||||
setNewActivityTitle('');
|
||||
showToast('Активность запланирована', 'success');
|
||||
getTimeline(targetType, targetId).then(setTimelineItems).catch(() => {});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTimelineItems([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
selectedContact
|
||||
? `${selectedContact.firstName} ${selectedContact.lastName} (Customer 360)`
|
||||
: `${selectedCompany?.name || ''} (Company 360)`
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex border-b border-krona-border text-xs">
|
||||
{(['overview', 'deals', 'orders', 'activities', 'timeline'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setCustomer360Tab(tab)}
|
||||
className={`px-3.5 py-2 font-semibold capitalize transition-colors border-b-2 ${
|
||||
customer360Tab === tab
|
||||
? 'border-krona-green text-krona-green'
|
||||
: 'border-transparent text-krona-secondary hover:text-krona-primary'
|
||||
}`}
|
||||
>
|
||||
{tab === 'overview' && 'Обзор'}
|
||||
{tab === 'deals' && `Сделки (${linkedDeals.length})`}
|
||||
{tab === 'orders' && `Заказы (${linkedOrders.length})`}
|
||||
{tab === 'activities' && 'Задачи & Звонки'}
|
||||
{tab === 'timeline' && `Таймлайн (${timelineItems.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* TAB 1: Overview */}
|
||||
{customer360Tab === 'overview' && (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="grid grid-cols-2 gap-3 bg-krona-workspace p-3.5 rounded-lg border border-krona-border">
|
||||
{selectedContact && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-krona-secondary">ФИО:</span>{' '}
|
||||
<strong className="text-krona-primary">
|
||||
{selectedContact.firstName} {selectedContact.lastName}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Телефон:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedContact.phone || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Email:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedContact.email || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Должность:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedContact.jobTitle || '—'}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedCompany && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Название:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedCompany.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">ИНН:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedCompany.inn || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Адрес:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedCompany.address || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-krona-secondary">Телефон:</span>{' '}
|
||||
<strong className="text-krona-primary">{selectedCompany.phone || '—'}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Custom Fields viewer if any exist */}
|
||||
<div className="p-3 bg-krona-workspace/40 rounded-lg border border-krona-border">
|
||||
<div className="font-semibold text-krona-primary mb-1">
|
||||
Кастомные атрибуты (Схема Krona Studio):
|
||||
</div>
|
||||
{((selectedContact?.customFields && Object.keys(selectedContact.customFields).length > 0) ||
|
||||
(selectedCompany?.customFields && Object.keys(selectedCompany.customFields).length > 0)) ? (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{Object.entries(
|
||||
(selectedContact?.customFields || selectedCompany?.customFields) as Record<string, unknown>
|
||||
).map(([k, v]) => (
|
||||
<div key={k} className="text-xs">
|
||||
<span className="text-krona-secondary">{k}:</span>{' '}
|
||||
<strong className="text-krona-primary">{String(v)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-krona-secondary text-[11px]">
|
||||
Дополнительные поля из Конструктора полей отображаются здесь в реальном времени.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: Linked Deals */}
|
||||
{customer360Tab === 'deals' && (
|
||||
<div className="space-y-2">
|
||||
{linkedDeals.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-krona-primary">{d.title}</div>
|
||||
<div className="text-[11px] text-krona-secondary">
|
||||
Этап:{' '}
|
||||
<span className="font-semibold">
|
||||
{DEAL_STAGES.find((s) => s.key === d.stage)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-krona-green">
|
||||
{formatCurrencyMinor(d.amount.minor)}
|
||||
</div>
|
||||
<div className="text-[11px] text-krona-secondary">{d.probability}% вер.</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{linkedDeals.length === 0 && (
|
||||
<div className="text-center py-6 text-krona-secondary text-xs italic">
|
||||
Нет связанных сделок
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: Linked Orders */}
|
||||
{customer360Tab === 'orders' && (
|
||||
<div className="space-y-2">
|
||||
{linkedOrders.map((ord) => (
|
||||
<div
|
||||
key={ord.id}
|
||||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace flex items-center justify-between text-xs"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold font-mono text-krona-primary">{ord.orderNumber}</div>
|
||||
<div className="text-[11px] text-krona-secondary">
|
||||
{ord.items.length} поз. • Статус: {ord.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-krona-green">
|
||||
{formatCurrencyMinor(ord.total.minor)}
|
||||
</div>
|
||||
<Badge variant={ord.paymentStatus === 'paid' ? 'green' : 'warning'} size="sm">
|
||||
{ord.paymentStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{linkedOrders.length === 0 && (
|
||||
<div className="text-center py-6 text-krona-secondary text-xs italic">
|
||||
Нет связанных заказов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: Activities */}
|
||||
{customer360Tab === 'activities' && (
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleAddActivity} className="p-3 bg-krona-workspace rounded-lg border border-krona-border space-y-2">
|
||||
<div className="text-xs font-semibold text-krona-primary">
|
||||
Запланировать активность:
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={newActivityType}
|
||||
onChange={(e) => setNewActivityType(e.target.value as Activity['type'])}
|
||||
className="px-2.5 py-1 text-xs bg-krona-surface border border-krona-border rounded-md text-krona-primary"
|
||||
>
|
||||
<option value="call">Звонок</option>
|
||||
<option value="meeting">Встреча</option>
|
||||
<option value="task">Задача</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newActivityTitle}
|
||||
onChange={(e) => setNewActivityTitle(e.target.value)}
|
||||
placeholder="Например: Обсудить коммерческое предложение..."
|
||||
className="flex-1 px-3 py-1 text-xs bg-krona-surface border border-krona-border rounded-md text-krona-primary"
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="sm">
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 5: Unified Timeline */}
|
||||
{customer360Tab === 'timeline' && (
|
||||
<div className="space-y-2.5 max-h-96 overflow-y-auto pr-1">
|
||||
{timelineItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 rounded-lg border border-krona-border bg-krona-workspace text-xs space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-krona-primary">{item.title}</span>
|
||||
<span className="text-[10px] text-krona-secondary">
|
||||
{formatDateTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-krona-secondary text-[11px]">{item.description}</p>
|
||||
)}
|
||||
<div className="text-[10px] text-krona-green font-medium">Автор: {item.actor}</div>
|
||||
</div>
|
||||
))}
|
||||
{timelineItems.length === 0 && (
|
||||
<div className="text-center py-6 text-krona-secondary text-xs italic">
|
||||
Таймлайн пуст
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
101
src/features/crm/components/DealWonOrderModal.tsx
Normal file
101
src/features/crm/components/DealWonOrderModal.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Deal } from '../../../entities/crm/deal';
|
||||
import { Modal } from '../../../shared/ui/Modal';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
|
||||
export interface DealWonOrderModalProps {
|
||||
isOpen: boolean;
|
||||
wonDeal: Deal | null;
|
||||
onClose: () => void;
|
||||
onConfirmOrder: (params: { orderTitle: string; orderWarehouse: string }) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
||||
isOpen,
|
||||
wonDeal,
|
||||
onClose,
|
||||
onConfirmOrder,
|
||||
}) => {
|
||||
const [prevDeal, setPrevDeal] = useState<Deal | null>(null);
|
||||
const [orderTitle, setOrderTitle] = useState('');
|
||||
const [orderWarehouse, setOrderWarehouse] = useState('wh_main');
|
||||
|
||||
if (wonDeal !== prevDeal) {
|
||||
setPrevDeal(wonDeal);
|
||||
setOrderTitle(wonDeal ? `Заказ по сделке: ${wonDeal.title}` : '');
|
||||
setOrderWarehouse('wh_main');
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirmOrder({ orderTitle, orderWarehouse });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Сделка выиграна! Создать заказ в Коммерции?"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300">
|
||||
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
|
||||
Система предлагает автоматически сформировать коммерческий Заказ.
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Наименование заказа:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orderTitle}
|
||||
onChange={(e) => setOrderTitle(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Сумма заказа (RUB):
|
||||
</label>
|
||||
<div className="font-bold text-sm text-krona-green py-1">
|
||||
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Склад отгрузки:
|
||||
</label>
|
||||
<select
|
||||
value={orderWarehouse}
|
||||
onChange={(e) => setOrderWarehouse(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
>
|
||||
<option value="wh_main">Основной распределительный склад (Москва)</option>
|
||||
<option value="wh_spb">Склад Санкт-Петербург</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-krona-border">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||
Не создавать сейчас
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="create-order-confirm-btn"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5 mr-1" /> Создать заказ в Коммерции
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
71
src/features/crm/components/LeadConversionModal.tsx
Normal file
71
src/features/crm/components/LeadConversionModal.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Lead } from '../../../entities/crm/lead';
|
||||
import { Modal } from '../../../shared/ui/Modal';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
|
||||
export interface LeadConversionModalProps {
|
||||
isOpen: boolean;
|
||||
lead: Lead | null;
|
||||
onClose: () => void;
|
||||
onConvert: (leadId: string, dealTitle: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const LeadConversionModal: React.FC<LeadConversionModalProps> = ({
|
||||
isOpen,
|
||||
lead,
|
||||
onClose,
|
||||
onConvert,
|
||||
}) => {
|
||||
const [prevLead, setPrevLead] = useState<Lead | null>(null);
|
||||
const [dealTitle, setDealTitle] = useState('');
|
||||
|
||||
if (lead !== prevLead) {
|
||||
setPrevLead(lead);
|
||||
setDealTitle(lead ? `Сделка: ${lead.title}` : '');
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!lead) return;
|
||||
await onConvert(lead.id, dealTitle || lead.title);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Конвертация лида в сделку и контакт"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4 text-xs">
|
||||
<p className="text-krona-secondary">
|
||||
При конвертации будет автоматически создан Контакт клиента и новая Сделка на этапе «Предложение».
|
||||
</p>
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Название новой сделки:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dealTitle}
|
||||
onChange={(e) => setDealTitle(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-krona-border">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="confirm-convert-lead-btn"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Подтвердить конвертацию
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
6
src/features/crm/components/index.ts
Normal file
6
src/features/crm/components/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './CrmKanbanView';
|
||||
export * from './CrmTableView';
|
||||
export * from './Customer360Drawer';
|
||||
export * from './LeadConversionModal';
|
||||
export * from './DealWonOrderModal';
|
||||
export * from './CrmEntityModals';
|
||||
11
src/features/crm/constants.ts
Normal file
11
src/features/crm/constants.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { DealStage } from '../../entities/crm/deal';
|
||||
|
||||
export const DEAL_STAGES: { key: DealStage; label: string; color: string }[] = [
|
||||
{ key: 'prospecting', label: 'Первичный контакт', color: 'border-sky-500/40' },
|
||||
{ key: 'discovery', label: 'Квалификация', color: 'border-indigo-500/40' },
|
||||
{ key: 'proposal', label: 'Предложение', color: 'border-amber-500/40' },
|
||||
{ key: 'negotiation', label: 'Переговоры', color: 'border-orange-500/40' },
|
||||
{ key: 'contract', label: 'Согласование', color: 'border-purple-500/40' },
|
||||
{ key: 'won', label: 'Выиграна', color: 'border-emerald-500/60 bg-emerald-500/5' },
|
||||
{ key: 'lost', label: 'Проиграна', color: 'border-rose-500/40 bg-rose-500/5' },
|
||||
];
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useMetadata } from '../../../services/hooks/useMetadata';
|
||||
import { LayoutDefinition, LayoutSection } from '../../../services/contracts/metadata';
|
||||
import { FieldDefinition } from '../../../entities/types';
|
||||
import { getFieldTypeInfo } from './fieldTypesMeta';
|
||||
import { useFormBuilderHistory } from './useFormBuilderHistory';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Badge } from '../../../shared/ui/Badge';
|
||||
|
|
@ -55,13 +56,18 @@ export const FormBuilder: React.FC = () => {
|
|||
const [selectedFieldKey, setSelectedFieldKey] = useState<string | null>(null);
|
||||
const [fieldOverrides, setFieldOverrides] = useState<Record<string, FieldOverride>>({});
|
||||
|
||||
// Layout working copy
|
||||
const [currentLayout, setCurrentLayout] = useState<LayoutDefinition | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Undo / Redo history stack (up to 50 steps)
|
||||
const [history, setHistory] = useState<LayoutDefinition[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
// Undo / Redo history stack and layout working copy managed by hook
|
||||
const {
|
||||
currentLayout,
|
||||
isDirty,
|
||||
setIsDirty,
|
||||
history,
|
||||
historyIndex,
|
||||
pushState,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
initHistory,
|
||||
} = useFormBuilderHistory();
|
||||
|
||||
// Initialize layout from existing or generate default
|
||||
useEffect(() => {
|
||||
|
|
@ -70,9 +76,7 @@ export const FormBuilder: React.FC = () => {
|
|||
void Promise.resolve().then(() => {
|
||||
const existing = layouts.find((l) => l.entityCode === entityCode && l.isDefault) || layouts[0];
|
||||
if (existing) {
|
||||
setCurrentLayout(existing);
|
||||
setHistory([existing]);
|
||||
setHistoryIndex(0);
|
||||
initHistory(existing);
|
||||
} else {
|
||||
const defaultLayout: LayoutDefinition = {
|
||||
id: `layout_${entityCode}_default`,
|
||||
|
|
@ -89,63 +93,10 @@ export const FormBuilder: React.FC = () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
setCurrentLayout(defaultLayout);
|
||||
setHistory([defaultLayout]);
|
||||
setHistoryIndex(0);
|
||||
initHistory(defaultLayout);
|
||||
}
|
||||
});
|
||||
}, [currentEntity, layouts, entityCode, currentLayout]);
|
||||
|
||||
// Push new state to history stack
|
||||
const pushState = useCallback((newLayout: LayoutDefinition) => {
|
||||
setHistory((prev) => {
|
||||
const slice = prev.slice(0, historyIndex + 1);
|
||||
const updated = [...slice, newLayout];
|
||||
if (updated.length > 50) updated.shift();
|
||||
return updated;
|
||||
});
|
||||
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||
setCurrentLayout(newLayout);
|
||||
setIsDirty(true);
|
||||
}, [historyIndex]);
|
||||
|
||||
// Undo action
|
||||
const handleUndo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const targetIndex = historyIndex - 1;
|
||||
setHistoryIndex(targetIndex);
|
||||
setCurrentLayout(history[targetIndex]);
|
||||
setIsDirty(true);
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// Redo action
|
||||
const handleRedo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const targetIndex = historyIndex + 1;
|
||||
setHistoryIndex(targetIndex);
|
||||
setCurrentLayout(history[targetIndex]);
|
||||
setIsDirty(true);
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// Keyboard shortcut listener for Ctrl+Z and Ctrl+Y
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if (
|
||||
((e.ctrlKey || e.metaKey) && e.key === 'y') ||
|
||||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleUndo, handleRedo]);
|
||||
}, [currentEntity, layouts, entityCode, currentLayout, initHistory]);
|
||||
|
||||
const allFields = useMemo(() => currentEntity?.fields || [], [currentEntity]);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMetadata } from '../../../services/hooks/useMetadata';
|
|||
import { ViewDefinition, ViewFilter } from '../../../services/contracts/metadata';
|
||||
import { FieldDefinition } from '../../../entities/types';
|
||||
import { getRecordsByEntity, RecordEntity } from '../../../services/storage/indexedDb';
|
||||
import { useViewBuilderColumns } from './useViewBuilderColumns';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Badge } from '../../../shared/ui/Badge';
|
||||
|
|
@ -173,35 +174,13 @@ export const ViewBuilder: React.FC = () => {
|
|||
return items;
|
||||
}, [records, currentView]);
|
||||
|
||||
// Toggle field visibility
|
||||
const handleToggleColumn = (fieldKey: string) => {
|
||||
if (!currentView) return;
|
||||
const currentKeys = currentView.visibleFieldKeys || [];
|
||||
let updated: string[];
|
||||
|
||||
if (currentKeys.includes(fieldKey)) {
|
||||
if (currentKeys.length <= 1) {
|
||||
showToast({ type: 'error', title: 'В представлении должна быть минимум 1 колонка' });
|
||||
return;
|
||||
}
|
||||
updated = currentKeys.filter((k) => k !== fieldKey);
|
||||
} else {
|
||||
updated = [...currentKeys, fieldKey];
|
||||
}
|
||||
|
||||
setCurrentView({ ...currentView, visibleFieldKeys: updated });
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
// Reorder column
|
||||
const handleMoveColumn = (fromIndex: number, toIndex: number) => {
|
||||
if (!currentView) return;
|
||||
const copy = [...currentView.visibleFieldKeys];
|
||||
const [moved] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, moved);
|
||||
setCurrentView({ ...currentView, visibleFieldKeys: copy });
|
||||
setIsDirty(true);
|
||||
};
|
||||
// Column visibility and reordering managed by hook
|
||||
const { handleToggleColumn, handleMoveColumn } = useViewBuilderColumns({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
setIsDirty,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// Filter management
|
||||
const handleAddFilter = () => {
|
||||
|
|
|
|||
79
src/features/settings/entities/useFormBuilderHistory.ts
Normal file
79
src/features/settings/entities/useFormBuilderHistory.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { LayoutDefinition } from '../../../services/contracts/metadata';
|
||||
|
||||
export function useFormBuilderHistory() {
|
||||
const [currentLayout, setCurrentLayout] = useState<LayoutDefinition | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [history, setHistory] = useState<LayoutDefinition[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
const initHistory = useCallback((layout: LayoutDefinition) => {
|
||||
setCurrentLayout(layout);
|
||||
setHistory([layout]);
|
||||
setHistoryIndex(0);
|
||||
setIsDirty(false);
|
||||
}, []);
|
||||
|
||||
const pushState = useCallback((newLayout: LayoutDefinition) => {
|
||||
setHistory((prev) => {
|
||||
const slice = prev.slice(0, historyIndex + 1);
|
||||
const updated = [...slice, newLayout];
|
||||
if (updated.length > 50) updated.shift();
|
||||
return updated;
|
||||
});
|
||||
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||
setCurrentLayout(newLayout);
|
||||
setIsDirty(true);
|
||||
}, [historyIndex]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const targetIndex = historyIndex - 1;
|
||||
setHistoryIndex(targetIndex);
|
||||
setCurrentLayout(history[targetIndex]);
|
||||
setIsDirty(true);
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const targetIndex = historyIndex + 1;
|
||||
setHistoryIndex(targetIndex);
|
||||
setCurrentLayout(history[targetIndex]);
|
||||
setIsDirty(true);
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
// Keyboard shortcut listener for Ctrl+Z and Ctrl+Y
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if (
|
||||
((e.ctrlKey || e.metaKey) && e.key === 'y') ||
|
||||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleUndo, handleRedo]);
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
setCurrentLayout,
|
||||
isDirty,
|
||||
setIsDirty,
|
||||
history,
|
||||
historyIndex,
|
||||
pushState,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
initHistory,
|
||||
canUndo: historyIndex > 0,
|
||||
canRedo: historyIndex < history.length - 1,
|
||||
};
|
||||
}
|
||||
57
src/features/settings/entities/useViewBuilderColumns.ts
Normal file
57
src/features/settings/entities/useViewBuilderColumns.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useCallback } from 'react';
|
||||
import { ViewDefinition } from '../../../services/contracts/metadata';
|
||||
|
||||
export interface UseViewBuilderColumnsProps {
|
||||
currentView: ViewDefinition | null;
|
||||
setCurrentView: React.Dispatch<React.SetStateAction<ViewDefinition | null>>;
|
||||
setIsDirty: (isDirty: boolean) => void;
|
||||
showToast: (toast: { type: 'success' | 'error' | 'info'; title: string; message?: string }) => void;
|
||||
}
|
||||
|
||||
export function useViewBuilderColumns({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
setIsDirty,
|
||||
showToast,
|
||||
}: UseViewBuilderColumnsProps) {
|
||||
// Toggle field visibility
|
||||
const handleToggleColumn = useCallback(
|
||||
(fieldKey: string) => {
|
||||
if (!currentView) return;
|
||||
const currentKeys = currentView.visibleFieldKeys || [];
|
||||
let updated: string[];
|
||||
|
||||
if (currentKeys.includes(fieldKey)) {
|
||||
if (currentKeys.length <= 1) {
|
||||
showToast({ type: 'error', title: 'В представлении должна быть минимум 1 колонка' });
|
||||
return;
|
||||
}
|
||||
updated = currentKeys.filter((k) => k !== fieldKey);
|
||||
} else {
|
||||
updated = [...currentKeys, fieldKey];
|
||||
}
|
||||
|
||||
setCurrentView({ ...currentView, visibleFieldKeys: updated });
|
||||
setIsDirty(true);
|
||||
},
|
||||
[currentView, setCurrentView, setIsDirty, showToast]
|
||||
);
|
||||
|
||||
// Reorder column
|
||||
const handleMoveColumn = useCallback(
|
||||
(fromIndex: number, toIndex: number) => {
|
||||
if (!currentView) return;
|
||||
const copy = [...currentView.visibleFieldKeys];
|
||||
const [moved] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, moved);
|
||||
setCurrentView({ ...currentView, visibleFieldKeys: copy });
|
||||
setIsDirty(true);
|
||||
},
|
||||
[currentView, setCurrentView, setIsDirty]
|
||||
);
|
||||
|
||||
return {
|
||||
handleToggleColumn,
|
||||
handleMoveColumn,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue