323 lines
15 KiB
TypeScript
323 lines
15 KiB
TypeScript
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 flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div className="relative w-full sm:w-80">
|
||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||
<input
|
||
type="text"
|
||
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-x-auto">
|
||
<table className="w-full text-xs text-left table-fixed md:table-auto">
|
||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||
<tr>
|
||
<th className="p-3 font-semibold">ФИО / Контакт</th>
|
||
<th className="p-3 font-semibold hidden lg:table-cell">Компания</th>
|
||
<th className="p-3 font-semibold">Телефон</th>
|
||
<th className="p-3 font-semibold hidden xl:table-cell">Email</th>
|
||
<th className="p-3 font-semibold hidden xl:table-cell">Роли</th>
|
||
<th className="p-3 font-semibold text-right">Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<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 hidden lg:table-cell">{comp?.name || '—'}</td>
|
||
<td className="p-3 font-mono text-krona-secondary break-words">{contact.phone || '—'}</td>
|
||
<td className="p-3 text-krona-secondary hidden xl:table-cell">{contact.email || '—'}</td>
|
||
<td className="p-3 hidden xl:table-cell">
|
||
<div className="flex gap-1">
|
||
{contact.roles?.map((r) => (
|
||
<Badge key={r} variant="gold" size="sm">
|
||
{r}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="p-3 text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onSelectContact(contact);
|
||
}}
|
||
>
|
||
<span className="hidden md:inline">Карточка</span><ChevronRight className="w-3.5 h-3.5 md:ml-1" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'companies') {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div className="relative w-full sm:w-80">
|
||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||
<input
|
||
type="text"
|
||
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-x-auto">
|
||
<table className="w-full text-xs text-left table-fixed md:table-auto">
|
||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||
<tr>
|
||
<th className="p-3 font-semibold">Название компании</th>
|
||
<th className="p-3 font-semibold hidden md:table-cell">ИНН</th>
|
||
<th className="p-3 font-semibold hidden xl:table-cell">Город</th>
|
||
<th className="p-3 font-semibold">Телефон</th>
|
||
<th className="p-3 font-semibold hidden xl:table-cell">Роли</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 hidden md:table-cell">{company.inn || '—'}</td>
|
||
<td className="p-3 text-krona-secondary hidden xl:table-cell">{company.address || '—'}</td>
|
||
<td className="p-3 font-mono text-krona-secondary">{company.phone || '—'}</td>
|
||
<td className="p-3 hidden xl:table-cell">
|
||
<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);
|
||
}}
|
||
>
|
||
<span className="hidden md:inline">Карточка</span><ChevronRight className="w-3.5 h-3.5 md:ml-1" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'leads') {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||
<div className="relative w-full sm:w-80">
|
||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||
<input
|
||
type="text"
|
||
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-x-auto">
|
||
<table className="w-full text-xs text-left table-fixed md:table-auto">
|
||
<thead className="bg-krona-workspace text-krona-secondary border-b border-krona-border">
|
||
<tr>
|
||
<th className="p-3 font-semibold">Название лида</th>
|
||
<th className="p-3 font-semibold hidden md:table-cell">Контактное лицо</th>
|
||
<th className="p-3 font-semibold hidden lg:table-cell">Контакты</th>
|
||
<th className="p-3 font-semibold">Оценка бюджета</th>
|
||
<th className="p-3 font-semibold">Статус</th>
|
||
<th className="p-3 font-semibold text-right">Конвертация</th>
|
||
</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 hidden md:table-cell">{lead.contactName}</td>
|
||
<td className="p-3 text-krona-secondary hidden lg:table-cell">
|
||
<div>{lead.phone}</div>
|
||
<div className="text-[11px]">{lead.email}</div>
|
||
</td>
|
||
<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 md:mr-1" /> <span className="hidden md:inline">В сделку</span>
|
||
</Button>
|
||
) : (
|
||
<span className="text-emerald-600 dark:text-emerald-400 text-xs font-semibold flex items-center justify-end gap-1">
|
||
<Check className="w-3.5 h-3.5" /> Сделка создана
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|