krona-front/src/features/crm/components/CrmTableView.tsx

323 lines
15 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 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;
};