1141 lines
51 KiB
TypeScript
1141 lines
51 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
||
import { useLocation } from 'react-router-dom';
|
||
import { useCrm } from '../../services/hooks/useCrm';
|
||
import { useOrders } from '../../services/hooks/useOrders';
|
||
import { useMetadata } from '../../services/hooks/useMetadata';
|
||
import { Contact } from '../../entities/crm/contact';
|
||
import { Company } from '../../entities/crm/company';
|
||
import { Deal, DealStage } from '../../entities/crm/deal';
|
||
import { Lead } from '../../entities/crm/lead';
|
||
import { TimelineItem, Activity } from '../../services/contracts/crm';
|
||
import { Button } from '../../shared/ui/Button';
|
||
import { Badge } from '../../shared/ui/Badge';
|
||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||
import { Modal } from '../../shared/ui/Modal';
|
||
import { Drawer } from '../../shared/ui/Drawer';
|
||
import { useToast } from '../../shared/ui/useToast';
|
||
import { formatCurrencyMinor } from '../../shared/lib/currency';
|
||
import { formatDateTime } from '../../shared/lib/date';
|
||
import { DynamicFormRenderer } from '../metadata/DynamicFormRenderer';
|
||
import { CsvImportWizard } from './CsvImportWizard';
|
||
import {
|
||
Search,
|
||
Plus,
|
||
ArrowRight,
|
||
ShoppingBag,
|
||
Building,
|
||
Check,
|
||
UploadCloud,
|
||
ChevronRight,
|
||
Sparkles,
|
||
} from 'lucide-react';
|
||
|
||
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' },
|
||
];
|
||
|
||
export const CrmModule: React.FC = () => {
|
||
const location = useLocation();
|
||
const { showToast } = useToast();
|
||
|
||
const {
|
||
contacts,
|
||
companies,
|
||
leads,
|
||
deals,
|
||
createContact,
|
||
createCompany,
|
||
createDeal,
|
||
updateDeal,
|
||
convertLead,
|
||
getTimeline,
|
||
createActivity,
|
||
} = useCrm();
|
||
|
||
const { orders, createOrder } = useOrders();
|
||
|
||
// Active Entity metadata for dynamic forms
|
||
const contactMeta = useMetadata('contact');
|
||
const companyMeta = useMetadata('company');
|
||
const dealMeta = useMetadata('deal');
|
||
|
||
const [activeTab, setActiveTab] = useState<'deals' | 'contacts' | 'companies' | 'leads' | 'import'>('deals');
|
||
const [search, setSearch] = useState('');
|
||
|
||
// Modals state
|
||
const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false);
|
||
const [isAddCompanyModalOpen, setIsAddCompanyModalOpen] = useState(false);
|
||
const [isAddDealModalOpen, setIsAddDealModalOpen] = useState(false);
|
||
const [isConvertLeadModalOpen, setIsConvertLeadModalOpen] = useState(false);
|
||
const [selectedLeadToConvert, setSelectedLeadToConvert] = useState<Lead | null>(null);
|
||
const [convertDealTitle, setConvertDealTitle] = useState('');
|
||
|
||
// Deal Won -> Order flow
|
||
const [isDealWonModalOpen, setIsDealWonModalOpen] = useState(false);
|
||
const [wonDeal, setWonDeal] = useState<Deal | null>(null);
|
||
const [orderTitle, setOrderTitle] = useState('');
|
||
const [orderWarehouse, setOrderWarehouse] = useState('wh_main');
|
||
|
||
// Customer 360 Detail View
|
||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
||
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');
|
||
|
||
// Drag and Drop state
|
||
const [draggedDealId, setDraggedDealId] = useState<string | null>(null);
|
||
|
||
// Sync tab with URL
|
||
React.useEffect(() => {
|
||
const path = location.pathname;
|
||
if (path.includes('/crm/deals')) setActiveTab('deals');
|
||
else if (path.includes('/crm/leads')) setActiveTab('leads');
|
||
else if (path.includes('/crm/companies')) setActiveTab('companies');
|
||
else if (path.includes('/crm/import')) setActiveTab('import');
|
||
else if (path.includes('/crm/contacts') || path === '/crm') setActiveTab('contacts');
|
||
}, [location.pathname]);
|
||
|
||
// Load timeline when selecting contact/company
|
||
React.useEffect(() => {
|
||
if (selectedContact) {
|
||
getTimeline('contact', selectedContact.id).then(setTimelineItems).catch(() => {});
|
||
} else if (selectedCompany) {
|
||
getTimeline('company', selectedCompany.id).then(setTimelineItems).catch(() => {});
|
||
} else {
|
||
setTimelineItems([]);
|
||
}
|
||
}, [selectedContact, selectedCompany, getTimeline]);
|
||
|
||
// Deal stage transition handler
|
||
const handleStageChange = async (dealId: string, nextStage: DealStage) => {
|
||
const deal = deals.find((d) => d.id === dealId);
|
||
if (!deal) return;
|
||
|
||
await updateDeal(dealId, { stage: nextStage });
|
||
showToast(`Сделка «${deal.title}» переведена на этап: ${DEAL_STAGES.find((s) => s.key === nextStage)?.label}`, 'info');
|
||
|
||
// Trigger Deal Won Flow
|
||
if (nextStage === 'won') {
|
||
setWonDeal(deal);
|
||
setOrderTitle(`Заказ по сделке: ${deal.title}`);
|
||
setIsDealWonModalOpen(true);
|
||
}
|
||
};
|
||
|
||
// Drag & Drop Handlers
|
||
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 handleStageChange(dealId, targetStage);
|
||
}
|
||
setDraggedDealId(null);
|
||
};
|
||
|
||
// Confirm Deal Won -> Order creation
|
||
const handleConfirmOrderCreation = async () => {
|
||
if (!wonDeal) return;
|
||
try {
|
||
const customer = contacts.find((c) => c.id === wonDeal.contactId);
|
||
const company = companies.find((c) => c.id === wonDeal.companyId);
|
||
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : (company?.name || 'Клиент');
|
||
const customerId = wonDeal.contactId || wonDeal.companyId || 'cnt_default';
|
||
|
||
const orderNumber = `ORD-${Date.now().toString().slice(-6)}`;
|
||
await createOrder({
|
||
orderNumber,
|
||
customerType: wonDeal.contactId ? 'contact' : 'company',
|
||
customerId,
|
||
customerName,
|
||
status: 'draft',
|
||
paymentStatus: 'pending',
|
||
fulfillmentStatus: 'unfulfilled',
|
||
items: [
|
||
{
|
||
id: `item_${Date.now()}`,
|
||
productId: 'prd_coffee_beans',
|
||
sku: 'COF-100',
|
||
name: wonDeal.title,
|
||
quantity: 1,
|
||
unitPrice: wonDeal.amount,
|
||
discountMinor: 0,
|
||
totalPrice: wonDeal.amount,
|
||
},
|
||
],
|
||
subtotal: wonDeal.amount,
|
||
discountMinor: 0,
|
||
taxMinor: Math.round(wonDeal.amount.minor * 0.2),
|
||
shippingMinor: 0,
|
||
total: wonDeal.amount,
|
||
channel: 'b2b',
|
||
});
|
||
|
||
showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции!`, 'success');
|
||
setIsDealWonModalOpen(false);
|
||
setWonDeal(null);
|
||
} catch (err) {
|
||
showToast(`Ошибка создания заказа: ${err instanceof Error ? err.message : String(err)}`, 'error');
|
||
}
|
||
};
|
||
|
||
// Convert Lead
|
||
const handleConvertLead = async () => {
|
||
if (!selectedLeadToConvert) return;
|
||
try {
|
||
const res = await convertLead(selectedLeadToConvert.id, convertDealTitle);
|
||
showToast(`Лид конвертирован! Создана сделка «${res.deal.title}»`, 'success');
|
||
setIsConvertLeadModalOpen(false);
|
||
setSelectedLeadToConvert(null);
|
||
} catch (err) {
|
||
showToast(`Ошибка конвертации: ${err instanceof Error ? err.message : String(err)}`, 'error');
|
||
}
|
||
};
|
||
|
||
// 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(() => {});
|
||
};
|
||
|
||
// 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]
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4 animate-fade-in">
|
||
{/* Module Header */}
|
||
<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>
|
||
|
||
{/* Navigation Tabs */}
|
||
<div className="flex items-center gap-1.5 overflow-x-auto text-xs">
|
||
<button
|
||
onClick={() => setActiveTab('deals')}
|
||
className={`px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||
activeTab === 'deals'
|
||
? 'bg-krona-green text-white shadow-sm'
|
||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
|
||
}`}
|
||
>
|
||
Воронка сделок ({deals.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('contacts')}
|
||
className={`px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||
activeTab === 'contacts'
|
||
? 'bg-krona-green text-white shadow-sm'
|
||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
|
||
}`}
|
||
>
|
||
Контакты ({contacts.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('companies')}
|
||
className={`px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||
activeTab === 'companies'
|
||
? 'bg-krona-green text-white shadow-sm'
|
||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
|
||
}`}
|
||
>
|
||
Компании ({companies.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('leads')}
|
||
className={`px-3 py-1.5 rounded-lg font-medium transition-all ${
|
||
activeTab === 'leads'
|
||
? 'bg-krona-green text-white shadow-sm'
|
||
: 'bg-krona-workspace text-krona-secondary hover:text-krona-primary border border-krona-border'
|
||
}`}
|
||
>
|
||
Лиды ({leads.length})
|
||
</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'
|
||
}`}
|
||
>
|
||
<UploadCloud className="w-3.5 h-3.5" /> Импорт CSV
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* 1. DEALS KANBAN BOARD */}
|
||
{/* ---------------------------------------------------- */}
|
||
{activeTab === 'deals' && (
|
||
<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) => setSearch(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={() => setIsAddDealModalOpen(true)}>
|
||
<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}
|
||
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}
|
||
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={() => handleStageChange(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>
|
||
)}
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* 2. CONTACTS VIEW */}
|
||
{/* ---------------------------------------------------- */}
|
||
{activeTab === 'contacts' && (
|
||
<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) => setSearch(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={() => setIsAddContactModalOpen(true)}>
|
||
<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={() => {
|
||
setSelectedContact(contact);
|
||
setSelectedCompany(null);
|
||
}}
|
||
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">
|
||
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* 3. COMPANIES VIEW */}
|
||
{/* ---------------------------------------------------- */}
|
||
{activeTab === 'companies' && (
|
||
<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) => setSearch(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={() => setIsAddCompanyModalOpen(true)}>
|
||
<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={() => {
|
||
setSelectedCompany(company);
|
||
setSelectedContact(null);
|
||
}}
|
||
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">
|
||
Карточка 360 <ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* 4. LEADS VIEW */}
|
||
{/* ---------------------------------------------------- */}
|
||
{activeTab === 'leads' && (
|
||
<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) => setSearch(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>
|
||
</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.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"
|
||
onClick={() => {
|
||
setSelectedLeadToConvert(lead);
|
||
setConvertDealTitle(`Сделка: ${lead.title}`);
|
||
setIsConvertLeadModalOpen(true);
|
||
}}
|
||
>
|
||
<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>
|
||
)}
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* 5. CSV IMPORT WIZARD */}
|
||
{/* ---------------------------------------------------- */}
|
||
{activeTab === 'import' && <CsvImportWizard initialEntityType="contact" />}
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* CUSTOMER 360 DETAIL DRAWER */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Drawer
|
||
isOpen={Boolean(selectedContact || selectedCompany)}
|
||
onClose={() => {
|
||
setSelectedContact(null);
|
||
setSelectedCompany(null);
|
||
}}
|
||
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>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* DEAL WON -> CREATE ORDER MODAL */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Modal
|
||
isOpen={isDealWonModalOpen}
|
||
onClose={() => setIsDealWonModalOpen(false)}
|
||
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"
|
||
/>
|
||
</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"
|
||
>
|
||
<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={() => setIsDealWonModalOpen(false)}>
|
||
Не создавать сейчас
|
||
</Button>
|
||
<Button variant="primary" size="sm" onClick={handleConfirmOrderCreation}>
|
||
<ShoppingBag className="w-3.5 h-3.5 mr-1" /> Создать заказ в Коммерции
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* CONVERT LEAD MODAL */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Modal
|
||
isOpen={isConvertLeadModalOpen}
|
||
onClose={() => setIsConvertLeadModalOpen(false)}
|
||
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={convertDealTitle}
|
||
onChange={(e) => setConvertDealTitle(e.target.value)}
|
||
className="w-full px-3 py-1.5 bg-krona-workspace border border-krona-border rounded-lg text-krona-primary"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 pt-2 border-t border-krona-border">
|
||
<Button variant="secondary" size="sm" onClick={() => setIsConvertLeadModalOpen(false)}>
|
||
Отмена
|
||
</Button>
|
||
<Button variant="primary" size="sm" onClick={handleConvertLead}>
|
||
Подтвердить конвертацию
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* NEW CONTACT MODAL WITH DYNAMIC METADATA FORM */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Modal
|
||
isOpen={isAddContactModalOpen}
|
||
onClose={() => setIsAddContactModalOpen(false)}
|
||
title="Создание нового контакта"
|
||
size="lg"
|
||
>
|
||
{contactMeta.currentEntity ? (
|
||
<DynamicFormRenderer
|
||
entity={contactMeta.currentEntity}
|
||
layout={contactMeta.layouts?.[0] || null}
|
||
mode="create"
|
||
onSave={async (values) => {
|
||
await createContact({
|
||
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,
|
||
});
|
||
setIsAddContactModalOpen(false);
|
||
showToast('Контакт успешно создан!', 'success');
|
||
}}
|
||
onCancel={() => setIsAddContactModalOpen(false)}
|
||
/>
|
||
) : (
|
||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* NEW COMPANY MODAL WITH DYNAMIC METADATA FORM */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Modal
|
||
isOpen={isAddCompanyModalOpen}
|
||
onClose={() => setIsAddCompanyModalOpen(false)}
|
||
title="Создание новой компании"
|
||
size="lg"
|
||
>
|
||
{companyMeta.currentEntity ? (
|
||
<DynamicFormRenderer
|
||
entity={companyMeta.currentEntity}
|
||
layout={companyMeta.layouts?.[0] || null}
|
||
mode="create"
|
||
onSave={async (values) => {
|
||
await createCompany({
|
||
name: String(values.name || 'Новая компания'),
|
||
inn: String(values.inn || ''),
|
||
address: String(values.city || values.address || 'г. Москва'),
|
||
phone: String(values.phone || ''),
|
||
roles: ['client'],
|
||
status: 'active',
|
||
customFields: values,
|
||
});
|
||
setIsAddCompanyModalOpen(false);
|
||
showToast('Компания успешно создана!', 'success');
|
||
}}
|
||
onCancel={() => setIsAddCompanyModalOpen(false)}
|
||
/>
|
||
) : (
|
||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* ---------------------------------------------------- */}
|
||
{/* NEW DEAL MODAL */}
|
||
{/* ---------------------------------------------------- */}
|
||
<Modal
|
||
isOpen={isAddDealModalOpen}
|
||
onClose={() => setIsAddDealModalOpen(false)}
|
||
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 createDeal({
|
||
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,
|
||
});
|
||
setIsAddDealModalOpen(false);
|
||
showToast('Сделка успешно создана в воронке!', 'success');
|
||
}}
|
||
onCancel={() => setIsAddDealModalOpen(false)}
|
||
/>
|
||
) : (
|
||
<div className="p-4 text-center text-krona-secondary">Загрузка схемы...</div>
|
||
)}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|