Files
mkd/components/pr/NegativeResolution.tsx

498 lines
23 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { Incident, Building } from '../../types';
import { apiClient } from '../../services/apiClient';
import { backendApi } from '../../services/apiClient';
import {
ShieldAlert,
AlertCircle,
MessageCircle,
CheckCircle2,
Phone,
Search,
Filter,
Trash2,
ChevronRight,
Plus,
Loader2,
X
} from 'lucide-react';
export const NegativeResolution: React.FC = () => {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterType, setFilterType] = useState<string>('');
const [search, setSearch] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
loadIncidents();
}, [filterStatus, filterType]);
const loadIncidents = async () => {
try {
setIsLoading(true);
const params = new URLSearchParams();
if (filterStatus) params.append('status', filterStatus);
if (filterType) params.append('type', filterType);
const queryString = params.toString();
const path = `/pr/incidents${queryString ? `?${queryString}` : ''}`;
const data = await apiClient.get<Incident[]>(path);
setIncidents(data);
} catch (err) {
console.error('Error loading incidents:', err);
setIncidents([]);
} finally {
setIsLoading(false);
}
};
const handleResolve = async (id: number, resolutionNotes?: string) => {
try {
await apiClient.put(`/pr/incidents/${id}/resolve`, { resolution_notes: resolutionNotes });
await loadIncidents();
} catch (err) {
console.error('Error resolving incident:', err);
alert('Ошибка разрешения инцидента');
}
};
const handleStatusChange = async (id: number, status: Incident['status']) => {
try {
await apiClient.put(`/pr/incidents/${id}`, { status });
await loadIncidents();
} catch (err) {
console.error('Error updating incident status:', err);
alert('Ошибка обновления статуса');
}
};
const filteredIncidents = incidents.filter(incident => {
const matchesSearch = !search ||
incident.title.toLowerCase().includes(search.toLowerCase()) ||
incident.description.toLowerCase().includes(search.toLowerCase()) ||
(incident.address && incident.address.toLowerCase().includes(search.toLowerCase()));
return matchesSearch;
});
const newIncidents = filteredIncidents.filter(i => i.status === 'new');
const inProgressIncidents = filteredIncidents.filter(i => i.status === 'in_progress');
return (
<div className="space-y-6 animate-fade-in">
{/* Header Alert */}
<div className="bg-red-600 rounded-[2rem] p-6 text-white shadow-xl shadow-red-500/20 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-2xl animate-pulse">
<ShieldAlert className="w-8 h-8 text-white"/>
</div>
<div>
<h3 className="text-xl font-black leading-none">Работа с инцидентами</h3>
<p className="text-red-100 text-xs mt-1 font-medium opacity-80">
{newIncidents.length} новых инцидентов {inProgressIncidents.length} в работе
</p>
</div>
</div>
<button
onClick={() => setShowCreateForm(true)}
className="bg-white text-red-600 px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg active:scale-95 transition-all"
>
<Plus className="w-4 h-4 inline mr-2" />
Создать инцидент
</button>
</div>
{/* Filters */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 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="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="new">Новые</option>
<option value="in_progress">В работе</option>
<option value="resolved">Разрешены</option>
<option value="closed">Закрыты</option>
</select>
<select
value={filterType}
onChange={e => setFilterType(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все типы</option>
<option value="property_damage">Повреждение имущества</option>
<option value="debtor_complaint">Жалоба должника</option>
<option value="service_quality">Качество услуг</option>
<option value="other">Другое</option>
</select>
</div>
{/* Create Form Modal */}
{showCreateForm && (
<IncidentCreateForm
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadIncidents();
}}
/>
)}
{/* Incidents List */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : filteredIncidents.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p>Инциденты не найдены</p>
</div>
) : (
<div className="space-y-4">
{filteredIncidents.map(incident => (
<IncidentCard
key={incident.id}
incident={incident}
onResolve={handleResolve}
onStatusChange={handleStatusChange}
/>
))}
</div>
)}
</div>
);
};
interface IncidentCardProps {
incident: Incident;
onResolve: (id: number, notes?: string) => void;
onStatusChange: (id: number, status: Incident['status']) => void;
}
const IncidentCard: React.FC<IncidentCardProps> = ({ incident, onResolve, onStatusChange }) => {
const [showResolveForm, setShowResolveForm] = useState(false);
const [resolutionNotes, setResolutionNotes] = useState('');
const typeLabels = {
property_damage: 'Повреждение имущества',
debtor_complaint: 'Жалоба должника',
service_quality: 'Качество услуг',
other: 'Другое'
};
const statusColors = {
new: 'bg-red-50 text-red-600 border-red-100',
in_progress: 'bg-amber-50 text-amber-600 border-amber-100',
resolved: 'bg-emerald-50 text-emerald-600 border-emerald-100',
closed: 'bg-slate-50 text-slate-600 border-slate-100'
};
const priorityColors = {
low: 'bg-slate-100 text-slate-600',
medium: 'bg-blue-100 text-blue-600',
high: 'bg-orange-100 text-orange-600',
urgent: 'bg-red-100 text-red-600'
};
return (
<div className="bg-white p-6 rounded-[2.5rem] border-2 border-red-50 shadow-sm relative overflow-hidden group hover:border-red-200 transition-all">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500">
<AlertCircle className="w-7 h-7"/>
</div>
<div>
<h4 className="font-black text-slate-800 text-base leading-tight">{incident.title}</h4>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter border ${statusColors[incident.status]}`}>
{incident.status === 'new' ? 'Новый' :
incident.status === 'in_progress' ? 'В работе' :
incident.status === 'resolved' ? 'Разрешен' : 'Закрыт'}
</span>
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter ${priorityColors[incident.priority]}`}>
{incident.priority === 'urgent' ? 'Срочно' :
incident.priority === 'high' ? 'Высокий' :
incident.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">
{typeLabels[incident.type]}
</span>
{incident.address && (
<span className="text-[10px] text-slate-400 font-bold">
{incident.address}
</span>
)}
</div>
</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100 mb-6 text-sm text-slate-600 leading-relaxed">
{incident.description}
</div>
{incident.review && (
<div className="bg-amber-50 p-3 rounded-xl border border-amber-100 mb-4 text-xs text-amber-800 italic">
<strong>Связанный отзыв:</strong> «{incident.review.text}» (Рейтинг: {incident.review.rating}/10)
</div>
)}
{/* Actions */}
{!showResolveForm ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{incident.status === 'new' && (
<button
onClick={() => onStatusChange(incident.id, 'in_progress')}
className="py-3 bg-amber-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all"
>
<AlertCircle className="w-4 h-4"/> Взять в работу
</button>
)}
{incident.status === 'in_progress' && (
<button
onClick={() => setShowResolveForm(true)}
className="py-3 bg-emerald-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20 active:scale-95 transition-all"
>
<CheckCircle2 className="w-4 h-4"/> Разрешить инцидент
</button>
)}
{incident.status === 'resolved' && (
<button
onClick={() => onStatusChange(incident.id, 'closed')}
className="py-3 bg-slate-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all"
>
<CheckCircle2 className="w-4 h-4"/> Закрыть
</button>
)}
</div>
) : (
<div className="space-y-3">
<textarea
value={resolutionNotes}
onChange={e => setResolutionNotes(e.target.value)}
placeholder="Опишите, как был разрешен инцидент..."
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={3}
/>
<div className="flex gap-2">
<button
onClick={() => {
onResolve(incident.id, resolutionNotes);
setShowResolveForm(false);
setResolutionNotes('');
}}
className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Сохранить
</button>
<button
onClick={() => {
setShowResolveForm(false);
setResolutionNotes('');
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase"
>
Отмена
</button>
</div>
</div>
)}
{incident.assignedTo && (
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center gap-2 text-[9px] font-black text-slate-400 uppercase tracking-widest">
<CheckCircle2 className="w-3 h-3 text-slate-300"/> Ответственный: {incident.assignedTo}
</div>
)}
</div>
);
};
interface IncidentCreateFormProps {
onClose: () => void;
onSuccess: () => void;
}
const IncidentCreateForm: React.FC<IncidentCreateFormProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState({
building_id: '',
type: 'service_quality' as Incident['type'],
title: '',
description: '',
priority: 'medium' as Incident['priority'],
assigned_to: ''
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoadingBuildings, setIsLoadingBuildings] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadBuildings();
}, []);
const loadBuildings = async () => {
try {
setIsLoadingBuildings(true);
const data = await backendApi.getBuildings();
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
setBuildings([]);
} finally {
setIsLoadingBuildings(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSubmitting(true);
await apiClient.post('/pr/incidents', {
...formData,
created_by: 'Current User' // TODO: получить из контекста
});
onSuccess();
} catch (err) {
console.error('Error creating incident:', err);
alert('Ошибка создания инцидента');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Создать инцидент</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом
</label>
{isLoadingBuildings ? (
<div className="w-full p-3 border border-slate-200 rounded-xl text-sm text-slate-400">
Загрузка домов...
</div>
) : (
<select
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Тип инцидента
</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value as Incident['type'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
>
<option value="property_damage">Повреждение имущества</option>
<option value="debtor_complaint">Жалоба должника</option>
<option value="service_quality">Качество услуг</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Заголовок
</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={4}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Приоритет
</label>
<select
value={formData.priority}
onChange={e => setFormData({ ...formData, priority: e.target.value as Incident['priority'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="urgent">Срочно</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ответственный (опционально)
</label>
<input
type="text"
value={formData.assigned_to}
onChange={e => setFormData({ ...formData, assigned_to: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting ? 'Создание...' : 'Создать инцидент'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};