Files
mkd/components/pr/NegativeResolution.tsx
2026-02-04 00:17:04 +05:00

498 lines
23 KiB
TypeScript
Executable File
Raw Permalink 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, { 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>
);
};