krona-front/src/features/crm/CrmModule.tsx

1141 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};