159 lines
6.7 KiB
TypeScript
159 lines
6.7 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Deal, DealStage } from '../../../entities/crm/deal';
|
||
import { Contact } from '../../../entities/crm/contact';
|
||
import { Company } from '../../../entities/crm/company';
|
||
import { Button } from '../../../shared/ui/Button';
|
||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||
import { DEAL_STAGES } from '../constants';
|
||
import { Search, Plus, ArrowRight, Building } from 'lucide-react';
|
||
|
||
export interface CrmKanbanViewProps {
|
||
deals: Deal[];
|
||
contacts: Contact[];
|
||
companies: Company[];
|
||
search: string;
|
||
onSearchChange: (value: string) => void;
|
||
onAddDeal: () => void;
|
||
onStageChange: (dealId: string, nextStage: DealStage) => Promise<void> | void;
|
||
}
|
||
|
||
export const CrmKanbanView: React.FC<CrmKanbanViewProps> = ({
|
||
deals,
|
||
contacts,
|
||
companies,
|
||
search,
|
||
onSearchChange,
|
||
onAddDeal,
|
||
onStageChange,
|
||
}) => {
|
||
const [draggedDealId, setDraggedDealId] = useState<string | null>(null);
|
||
|
||
const handleDragStart = (e: React.DragEvent, dealId: string) => {
|
||
e.dataTransfer.setData('text/plain', dealId);
|
||
setDraggedDealId(dealId);
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
};
|
||
|
||
const handleDrop = async (e: React.DragEvent, targetStage: DealStage) => {
|
||
e.preventDefault();
|
||
const dealId = e.dataTransfer.getData('text/plain') || draggedDealId;
|
||
if (dealId) {
|
||
await onStageChange(dealId, targetStage);
|
||
}
|
||
setDraggedDealId(null);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="relative w-64">
|
||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-2.5 text-krona-secondary" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
placeholder="Поиск по сделкам..."
|
||
className="w-full pl-8 pr-3 py-1.5 text-xs bg-krona-surface border border-krona-border rounded-lg text-krona-primary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
</div>
|
||
<Button variant="primary" size="sm" onClick={onAddDeal}>
|
||
<Plus className="w-3.5 h-3.5 mr-1" /> Новая сделка
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-7 gap-2.5 overflow-x-auto pb-4">
|
||
{DEAL_STAGES.map((col) => {
|
||
const colDeals = deals.filter(
|
||
(d) =>
|
||
d.stage === col.key &&
|
||
(d.title.toLowerCase().includes(search.toLowerCase()) || !search)
|
||
);
|
||
const colTotalMinor = colDeals.reduce((sum, d) => sum + d.amount.minor, 0);
|
||
|
||
return (
|
||
<div
|
||
key={col.key}
|
||
data-testid={`kanban-column-${col.key}`}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(e) => handleDrop(e, col.key)}
|
||
className={`bg-krona-surface/70 border rounded-xl p-2.5 flex flex-col min-w-[200px] max-h-[750px] shadow-krona-soft transition-all ${col.color}`}
|
||
>
|
||
<div className="flex items-center justify-between border-b border-krona-border/60 pb-2 mb-2">
|
||
<div>
|
||
<div className="text-xs font-bold text-krona-primary">{col.label}</div>
|
||
<div className="text-[11px] font-medium text-krona-green mt-0.5">
|
||
{formatCurrencyMinor(colTotalMinor)}
|
||
</div>
|
||
</div>
|
||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-krona-workspace text-krona-secondary font-bold">
|
||
{colDeals.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2 overflow-y-auto flex-1 pr-0.5">
|
||
{colDeals.map((deal) => {
|
||
const contact = contacts.find((c) => c.id === deal.contactId);
|
||
const company = companies.find((c) => c.id === deal.companyId);
|
||
const nextStageIdx = DEAL_STAGES.findIndex((s) => s.key === deal.stage) + 1;
|
||
const nextStage = DEAL_STAGES[nextStageIdx]?.key;
|
||
|
||
return (
|
||
<div
|
||
key={deal.id}
|
||
data-testid={`deal-card-${deal.id}`}
|
||
data-deal-id={deal.id}
|
||
data-deal-stage={deal.stage}
|
||
draggable
|
||
onDragStart={(e) => handleDragStart(e, deal.id)}
|
||
className="bg-krona-workspace border border-krona-border hover:border-krona-green/60 p-2.5 rounded-lg text-xs space-y-2 shadow-xs cursor-grab active:cursor-grabbing transition-all"
|
||
>
|
||
<div className="font-semibold text-krona-primary line-clamp-2">
|
||
{deal.title}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-[11px]">
|
||
<span className="font-bold text-krona-primary">
|
||
{formatCurrencyMinor(deal.amount.minor)}
|
||
</span>
|
||
<span className="px-1.5 py-0.2 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded font-medium">
|
||
{deal.probability}% вер.
|
||
</span>
|
||
</div>
|
||
|
||
{(contact || company) && (
|
||
<div className="text-[11px] text-krona-secondary truncate flex items-center gap-1">
|
||
<Building className="w-3 h-3" />
|
||
{company?.name || `${contact?.firstName} ${contact?.lastName}`}
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick stage transition button */}
|
||
{nextStage && deal.stage !== 'won' && deal.stage !== 'lost' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onStageChange(deal.id, nextStage)}
|
||
className="w-full mt-1 py-1 text-[11px] bg-krona-surface hover:bg-krona-green/10 text-krona-secondary hover:text-krona-green border border-krona-border rounded flex items-center justify-center gap-1 transition-colors"
|
||
>
|
||
Далее: {DEAL_STAGES[nextStageIdx]?.label} <ArrowRight className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{colDeals.length === 0 && (
|
||
<div className="text-center py-6 text-[11px] text-krona-secondary italic">
|
||
Пусто
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|