226 lines
13 KiB
TypeScript
Executable File
226 lines
13 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||
import { DevMarketingActivity } from '../../types';
|
||
import { Megaphone, Users, MessageCircle, Phone, Calendar, ClipboardList, CheckCircle2, MapPin, Plus, Edit, Search, Filter } from 'lucide-react';
|
||
import { backendApi } from '../../services/apiClient';
|
||
import { MarketingActivityModal } from './MarketingActivityModal';
|
||
|
||
export const MarketingCampaigns: React.FC = () => {
|
||
const [activities, setActivities] = useState<DevMarketingActivity[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingActivity, setEditingActivity] = useState<DevMarketingActivity | null>(null);
|
||
const [search, setSearch] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||
|
||
const fetchActivities = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const params = statusFilter ? { status: statusFilter } : undefined;
|
||
const data = await backendApi.getDevelopmentMarketing(params);
|
||
setActivities(data);
|
||
} catch (error) {
|
||
console.error('Error fetching marketing activities:', error);
|
||
setActivities([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [statusFilter]);
|
||
|
||
useEffect(() => {
|
||
fetchActivities();
|
||
}, [fetchActivities]);
|
||
|
||
const filteredActivities = useMemo(() => {
|
||
if (!search.trim()) return activities;
|
||
const q = search.trim().toLowerCase();
|
||
return activities.filter(a => (a.address || '').toLowerCase().includes(q) || (a.competitor || '').toLowerCase().includes(q));
|
||
}, [activities, search]);
|
||
|
||
const handleCreate = () => {
|
||
setEditingActivity(null);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleEdit = (activity: DevMarketingActivity) => {
|
||
setEditingActivity(activity);
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleContacts = () => {
|
||
alert('В разработке: список контактов по объекту (активисты, председатели).');
|
||
};
|
||
|
||
const handleMeetingsPlan = () => {
|
||
alert('В разработке: календарь и список запланированных встреч по объекту.');
|
||
};
|
||
|
||
const totals = useMemo(() => {
|
||
const sum = (arr: DevMarketingActivity[], key: keyof DevMarketingActivity) =>
|
||
arr.reduce((acc, a) => acc + (Number(a[key]) || 0), 0);
|
||
return {
|
||
meetings: sum(activities, 'meetingsHeld'),
|
||
ads: sum(activities, 'adsDistributed'),
|
||
activists: sum(activities, 'activistsCount'),
|
||
withMetrics: activities.filter(a => (a.meetingsHeld || 0) > 0 || (a.adsDistributed || 0) > 0 || (a.activistsCount || 0) > 0).length,
|
||
};
|
||
}, [activities]);
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
<div className="flex flex-wrap justify-between items-center gap-3 px-1">
|
||
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Работа с активом</h3>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по адресу, УК..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="pl-9 pr-3 py-2 rounded-xl border border-slate-200 text-sm w-48 focus:ring-2 focus:ring-primary-500 outline-none"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Filter className="w-4 h-4 text-slate-400" />
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="text-[10px] font-bold text-slate-600 bg-white border border-slate-200 rounded-lg py-2 px-2.5 focus:ring-2 focus:ring-primary-500 outline-none"
|
||
>
|
||
<option value="">Все статусы</option>
|
||
<option value="voting">Идёт голосование</option>
|
||
<option value="my_house">Наш дом</option>
|
||
<option value="competitor_house">Дом конкурента</option>
|
||
</select>
|
||
</div>
|
||
<button
|
||
onClick={handleCreate}
|
||
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить активность
|
||
</button>
|
||
<button className="p-2 bg-white border border-slate-200 rounded-xl text-slate-400 hover:text-primary-600 transition-colors" title="Отчёты"><ClipboardList className="w-5 h-5"/></button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="text-center py-20 text-slate-400">
|
||
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
|
||
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
|
||
</div>
|
||
) : activities.length === 0 ? (
|
||
<div className="text-center py-20 text-slate-300">
|
||
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
|
||
<p className="text-[10px] font-bold uppercase tracking-widest">Нет маркетинговых активностей</p>
|
||
<p className="text-xs text-slate-400 mt-2 max-w-sm mx-auto">Агитация и маркетинг по объектам из воронки и существующим домам.</p>
|
||
</div>
|
||
) : filteredActivities.length === 0 ? (
|
||
<div className="text-center py-12 text-slate-400">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest">Нет активностей по фильтру</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{filteredActivities.map(m => (
|
||
<div key={m.id} className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm relative overflow-hidden group hover:shadow-lg transition-all">
|
||
{/* Status Vertical Line */}
|
||
<div className={`absolute left-0 top-1/4 bottom-1/4 w-1.5 rounded-r-full ${m.status === 'voting' ? 'bg-amber-500' : 'bg-emerald-500'}`}/>
|
||
|
||
<div className="pl-4">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">{m.address}</h4>
|
||
<div className="flex items-center gap-1.5 mt-1">
|
||
<MapPin className="w-3 h-3 text-slate-400"/>
|
||
<p className="text-[10px] text-slate-400 font-bold uppercase">Тек. УК: {m.competitor}</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => handleEdit(m)}
|
||
className="p-1.5 text-slate-300 hover:text-primary-600 transition-colors"
|
||
title="Редактировать"
|
||
>
|
||
<Edit className="w-5 h-5"/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||
<MarketingMetric icon={Users} value={m.activistsCount} label="Активиста" color="text-primary-600" bg="bg-primary-50" />
|
||
<MarketingMetric icon={MessageCircle} value={m.meetingsHeld} label="Встречи" color="text-indigo-600" bg="bg-indigo-50" />
|
||
<MarketingMetric icon={Megaphone} value={m.adsDistributed} label="Листовки" color="text-amber-600" bg="bg-amber-50" />
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-4 border-t border-slate-100">
|
||
<button
|
||
onClick={handleContacts}
|
||
className="flex-1 py-2.5 bg-slate-50 text-slate-700 rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-100 transition-all border border-slate-100"
|
||
>
|
||
<Phone className="w-3.5 h-3.5"/> Контакты
|
||
</button>
|
||
<button
|
||
onClick={handleMeetingsPlan}
|
||
className="flex-1 py-2.5 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
|
||
>
|
||
<Calendar className="w-3.5 h-3.5"/> План встреч
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Итоги по активностям (реальные данные) */}
|
||
<div className="bg-slate-900 rounded-[2rem] p-6 text-white shadow-xl">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||
<h3 className="font-bold text-sm uppercase tracking-widest">Итоги по активностям</h3>
|
||
</div>
|
||
{activities.length === 0 ? (
|
||
<p className="text-[11px] text-slate-400">Данные по итогам месяца будут доступны после добавления активностей.</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white/10 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Встреч проведено</p>
|
||
<p className="text-2xl font-black text-white">{totals.meetings}</p>
|
||
</div>
|
||
<div className="bg-white/10 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Листовок роздано</p>
|
||
<p className="text-2xl font-black text-white">{totals.ads}</p>
|
||
</div>
|
||
<div className="bg-white/10 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Активистов</p>
|
||
<p className="text-2xl font-black text-white">{totals.activists}</p>
|
||
</div>
|
||
<div className="bg-white/10 rounded-xl p-4">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Объектов с метриками</p>
|
||
<p className="text-2xl font-black text-white">{totals.withMetrics} / {activities.length}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Modal */}
|
||
<MarketingActivityModal
|
||
isOpen={isModalOpen}
|
||
onClose={() => {
|
||
setIsModalOpen(false);
|
||
setEditingActivity(null);
|
||
}}
|
||
onSuccess={() => {
|
||
fetchActivities();
|
||
}}
|
||
activity={editingActivity}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MarketingMetric = ({ icon: Icon, value, label, color, bg }: any) => (
|
||
<div className={`${bg} p-3 rounded-2xl flex flex-col items-center justify-center border border-white/50`}>
|
||
<Icon className={`w-4 h-4 ${color} mb-1`} />
|
||
<span className="text-sm font-black text-slate-800 leading-none">{value}</span>
|
||
<span className="text-[8px] font-black text-slate-400 uppercase mt-1">{label}</span>
|
||
</div>
|
||
);
|