Files
mkd/components/office/RepairRequests.tsx

1157 lines
60 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { Wrench, Plus, Search, Clock, CheckCircle2, XCircle, AlertCircle, X, Edit, Receipt, DollarSign, FileText, Calendar, AlertTriangle, Truck, Package, UserCog, Settings } from 'lucide-react';
import { OfficeRepairRequest, OfficeEquipment, RepairRequestStatus } from '../../types';
import { CURRENT_USER_MOCK } from '../../constants';
import { PaymentInvoiceForm } from '../finance/PaymentInvoiceForm';
import { apiClient, authFetch } from '../../services/apiClient';
import type { PaymentInvoice } from '../../types';
const isOverdue = (req: OfficeRepairRequest): boolean => {
if (req.status === 'completed' || req.status === 'canceled') return false;
if (!req.expectedReturnDate) return false;
const expected = new Date(req.expectedReturnDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
expected.setHours(0, 0, 0, 0);
return expected < today;
};
export const RepairRequests: React.FC = () => {
const [requests, setRequests] = useState<OfficeRepairRequest[]>([]);
const [equipment, setEquipment] = useState<OfficeEquipment[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [filters, setFilters] = useState({
status: '' as RepairRequestStatus | '',
equipmentId: '',
search: ''
});
const [formData, setFormData] = useState({
equipmentId: '',
description: '',
priority: 'medium' as 'low' | 'medium' | 'high' | 'urgent',
expectedReturnDate: ''
});
const [showCardModal, setShowCardModal] = useState(false);
const [showEditRepairModal, setShowEditRepairModal] = useState(false);
const [selectedRepair, setSelectedRepair] = useState<OfficeRepairRequest | null>(null);
const [repairFormData, setRepairFormData] = useState({
status: '' as RepairRequestStatus | '',
assignedTo: '',
solution: '',
expectedReturnDate: '',
isPaid: false,
cost: 0,
costEstimated: false
});
useEffect(() => {
fetchEquipment();
}, []);
useEffect(() => {
fetchRequests();
}, [filters.status, filters.equipmentId]);
const fetchRequests = async () => {
try {
const params = new URLSearchParams();
if (filters.status) params.append('status', filters.status);
if (filters.equipmentId) params.append('equipmentId', filters.equipmentId);
const response = await authFetch(`/api/office/repair-requests?${params}`);
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((req: any) => ({
id: req.id,
equipmentId: req.equipment_id || req.equipmentId,
equipment: req.equipment_name ? {
id: req.equipment_id,
name: req.equipment_name,
type: req.equipment_type
} : req.equipment,
requesterName: req.requester_name || req.requesterName || '',
description: req.description || '',
priority: req.priority || 'medium',
status: req.status || 'new',
assignedTo: req.assigned_to || req.assignedTo,
solution: req.solution,
expectedReturnDate: req.expected_return_date || req.expectedReturnDate,
waitingDeliveryDeadline: req.waiting_delivery_deadline || req.waitingDeliveryDeadline,
waitingDeliveryContacts: req.waiting_delivery_contacts || req.waitingDeliveryContacts,
takenForRepairDeadline: req.taken_for_repair_deadline || req.takenForRepairDeadline,
takenForRepairContacts: req.taken_for_repair_contacts || req.takenForRepairContacts,
agreedContractorPrice: req.agreed_contractor_price != null ? req.agreed_contractor_price : req.agreedContractorPrice,
isPaid: req.is_paid || req.isPaid || false,
cost: req.cost || 0,
costEstimated: req.cost_estimated || req.costEstimated || false,
invoiceId: req.invoice_id || req.invoiceId,
invoiceUrl: req.invoice_url || req.invoiceUrl,
createdAt: req.created_at || req.createdAt,
startedAt: req.started_at || req.startedAt,
completedAt: req.completed_at || req.completedAt,
comments: Array.isArray(req.comments) ? req.comments.map((c: any) => ({
author: c.author || c.author_name || '',
text: c.text || c.comment || '',
createdAt: c.created_at || c.createdAt || ''
})) : []
}));
setRequests(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки заявок:', error);
} finally {
setLoading(false);
}
};
const fetchEquipment = async () => {
try {
const response = await authFetch('/api/office/equipment');
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((eq: any) => ({
id: eq.id,
name: eq.name || '',
type: eq.type || 'other',
brand: eq.brand,
model: eq.model,
serialNumber: eq.serial_number || eq.serialNumber || '',
assignedTo: eq.assigned_to || eq.assignedTo,
purchaseDate: eq.purchase_date || eq.purchaseDate,
warrantyUntil: eq.warranty_until || eq.warrantyUntil,
nextMaintenanceDate: eq.next_maintenance_date || eq.nextMaintenanceDate,
condition: eq.condition || 'good',
notes: eq.notes,
createdAt: eq.created_at,
updatedAt: eq.updated_at
}));
setEquipment(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки оборудования:', error);
}
};
const handleCreateRequest = async () => {
try {
if (!formData.equipmentId) {
alert('Пожалуйста, выберите оборудование');
return;
}
if (!formData.description || !formData.description.trim()) {
alert('Пожалуйста, укажите описание проблемы');
return;
}
const response = await authFetch('/api/office/repair-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
equipmentId: parseInt(formData.equipmentId),
requesterName: CURRENT_USER_MOCK.name,
description: formData.description,
priority: formData.priority || 'medium',
expectedReturnDate: formData.expectedReturnDate || null
})
});
if (response.ok) {
const newRequest = await response.json();
// Нормализуем данные
const normalizedRequest = {
id: newRequest.id,
equipmentId: newRequest.equipment_id,
equipment: equipment.find(eq => eq.id === parseInt(formData.equipmentId)),
requesterName: newRequest.requester_name || CURRENT_USER_MOCK.name,
description: newRequest.description || formData.description,
priority: newRequest.priority || formData.priority,
status: newRequest.status || 'new',
assignedTo: newRequest.assigned_to,
solution: newRequest.solution,
createdAt: newRequest.created_at,
startedAt: newRequest.started_at,
completedAt: newRequest.completed_at
};
setRequests([normalizedRequest, ...requests]);
setShowCreateModal(false);
setFormData({
equipmentId: '',
description: '',
priority: 'medium',
expectedReturnDate: ''
});
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания заявки: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания заявки:', error);
alert(`Ошибка создания заявки: ${error.message || 'Неизвестная ошибка'}`);
}
};
const REPAIR_STATUSES: { value: RepairRequestStatus; label: string; icon: React.ReactNode; bg: string }[] = [
{ value: 'new', label: 'Новая', icon: <AlertCircle className="w-4 h-4" />, bg: 'text-blue-600' },
{ value: 'search_contractor', label: 'Поиск подрядчика', icon: <UserCog className="w-4 h-4" />, bg: 'text-violet-600' },
{ value: 'agreed_with_contractor', label: 'Договорились с подрядчиком', icon: <CheckCircle2 className="w-4 h-4" />, bg: 'text-indigo-600' },
{ value: 'waiting_delivery', label: 'Ожидание поставки', icon: <Package className="w-4 h-4" />, bg: 'text-cyan-600' },
{ value: 'taken_for_repair', label: 'Увезли на ремонт', icon: <Truck className="w-4 h-4" />, bg: 'text-orange-600' },
{ value: 'self_repair', label: 'Ремонт самостоятельно', icon: <Settings className="w-4 h-4" />, bg: 'text-slate-600' },
{ value: 'in_progress', label: 'В работе', icon: <Clock className="w-4 h-4" />, bg: 'text-amber-600' },
{ value: 'completed', label: 'Выполнена', icon: <CheckCircle2 className="w-4 h-4" />, bg: 'text-emerald-600' },
{ value: 'canceled', label: 'Отменена', icon: <XCircle className="w-4 h-4" />, bg: 'text-red-600' }
];
const STATUSES_NEED_EXTRA = ['waiting_delivery', 'taken_for_repair', 'agreed_with_contractor'] as const;
const [statusExtraModal, setStatusExtraModal] = useState<{ open: boolean; request: OfficeRepairRequest | null; newStatus: RepairRequestStatus | null }>({ open: false, request: null, newStatus: null });
const [statusExtraForm, setStatusExtraForm] = useState<{ deadline: string; contacts: string; price: string }>({ deadline: '', contacts: '', price: '' });
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
const [invoiceModalRepair, setInvoiceModalRepair] = useState<OfficeRepairRequest | null>(null);
const [cardCommentText, setCardCommentText] = useState('');
const getStatusIcon = (status: RepairRequestStatus) => {
const s = REPAIR_STATUSES.find((x) => x.value === status);
return s ? <span className={s.bg}>{s.icon}</span> : <AlertCircle className="w-4 h-4 text-slate-400" />;
};
const getStatusLabel = (status: RepairRequestStatus) => {
const s = REPAIR_STATUSES.find((x) => x.value === status);
return s ? s.label : status;
};
const setStatusQuick = async (request: OfficeRepairRequest, newStatus: RepairRequestStatus, e?: React.MouseEvent, extra?: Record<string, unknown>) => {
e?.stopPropagation();
try {
const body: Record<string, unknown> = { status: newStatus, ...extra };
const res = await authFetch(`/api/office/repair-requests/${request.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
fetchRequests();
if (selectedRepair?.id === request.id) {
setSelectedRepair({ ...request, status: newStatus, ...extra } as OfficeRepairRequest);
}
} else {
const data = await res.json().catch(() => ({}));
alert(data?.error || `Ошибка смены статуса (${res.status})`);
}
} catch (err) {
console.error(err);
alert('Ошибка смены статуса');
}
};
const handleQuickStatusChange = (request: OfficeRepairRequest, newStatus: RepairRequestStatus, e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation();
if (STATUSES_NEED_EXTRA.includes(newStatus as any)) {
setStatusExtraModal({ open: true, request, newStatus });
setStatusExtraForm({
deadline: newStatus === 'waiting_delivery' ? (request.waitingDeliveryDeadline || '') : (request.takenForRepairDeadline || ''),
contacts: newStatus === 'waiting_delivery' ? (request.waitingDeliveryContacts || '') : (request.takenForRepairContacts || ''),
price: request.agreedContractorPrice != null ? String(request.agreedContractorPrice) : ''
});
e.currentTarget.value = '';
return;
}
setStatusQuick(request, newStatus, e as unknown as React.MouseEvent);
e.currentTarget.value = '';
};
const submitStatusExtra = async () => {
if (!statusExtraModal.request || !statusExtraModal.newStatus) return;
const { request, newStatus } = statusExtraModal;
const extra: Record<string, unknown> = {};
if (newStatus === 'waiting_delivery') {
extra.waitingDeliveryDeadline = statusExtraForm.deadline || null;
extra.waitingDeliveryContacts = statusExtraForm.contacts || null;
} else if (newStatus === 'taken_for_repair') {
extra.takenForRepairDeadline = statusExtraForm.deadline || null;
extra.takenForRepairContacts = statusExtraForm.contacts || null;
} else if (newStatus === 'agreed_with_contractor') {
extra.agreedContractorPrice = statusExtraForm.price ? parseFloat(statusExtraForm.price) : null;
}
await setStatusQuick(request, newStatus, undefined, extra);
setStatusExtraModal({ open: false, request: null, newStatus: null });
setStatusExtraForm({ deadline: '', contacts: '', price: '' });
};
const filteredRequests = requests.filter(req => {
if (filters.search) {
const searchLower = filters.search.toLowerCase();
const description = (req.description || '').toLowerCase();
const requesterName = (req.requesterName || '').toLowerCase();
const equipmentName = (req.equipment?.name || '').toLowerCase();
return (
description.includes(searchLower) ||
requesterName.includes(searchLower) ||
equipmentName.includes(searchLower)
);
}
return true;
});
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-slate-800">Заявки на ремонт техники</h3>
<button
onClick={() => setShowCreateModal(true)}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Новая заявка
</button>
</div>
{/* Фильтры */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Статус</label>
<select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value as RepairRequestStatus | '' }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Все</option>
{REPAIR_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Оборудование</label>
<select
value={filters.equipmentId}
onChange={(e) => setFilters({ ...filters, equipmentId: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Все</option>
{equipment.map((eq) => (
<option key={eq.id} value={eq.id.toString()}>
{eq.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Поиск</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
placeholder="Поиск..."
className="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
</div>
{/* Список заявок */}
{loading ? (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
</div>
) : filteredRequests.length > 0 ? (
<div className="space-y-3">
{filteredRequests.map((request) => (
<div
key={request.id}
className={`bg-white rounded-xl border shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer ${
isOverdue(request) ? 'border-red-300 ring-1 ring-red-200' : 'border-slate-200'
}`}
onClick={() => {
setSelectedRepair(request);
setShowCardModal(true);
}}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2 flex-wrap">
{getStatusIcon(request.status)}
<span className="text-sm font-bold text-slate-800">
{request.equipment?.name || 'Оборудование не указано'}
</span>
<span className="text-xs text-slate-500">
{request.createdAt ? new Date(request.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'}
</span>
{request.expectedReturnDate && (
<span className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Вернуть до: {new Date(request.expectedReturnDate).toLocaleDateString('ru-RU')}
</span>
)}
{isOverdue(request) && (
<span className="px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-700 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Просрочено
</span>
)}
</div>
<p className="text-sm text-slate-600 mb-2">{request.description || 'Описание не указано'}</p>
<div className="flex items-center gap-4 text-xs text-slate-500 flex-wrap">
<span>Заявитель: {request.requesterName || 'Не указан'}</span>
{request.assignedTo && <span>Исполнитель: {request.assignedTo}</span>}
{request.isPaid && request.cost && request.cost > 0 && (
<span className="flex items-center gap-1 text-primary-600 font-bold">
<DollarSign className="w-3 h-3" />
{request.cost} {request.costEstimated ? ' (предв.)' : ''}
</span>
)}
<span className={`px-2 py-1 rounded ${
request.priority === 'urgent' ? 'bg-red-100 text-red-700' :
request.priority === 'high' ? 'bg-orange-100 text-orange-700' :
request.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
{request.priority === 'urgent' ? 'Срочно' :
request.priority === 'high' ? 'Высокий' :
request.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
</div>
</div>
<div className="text-right flex flex-col items-end gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs font-medium text-slate-600">
{getStatusLabel(request.status)}
</span>
{request.status === 'waiting_delivery' && (request.waitingDeliveryDeadline || request.waitingDeliveryContacts) && (
<span className="text-[10px] text-slate-500 max-w-[180px] text-right">
{request.waitingDeliveryDeadline && <span>Срок: {request.waitingDeliveryDeadline}</span>}
{request.waitingDeliveryDeadline && request.waitingDeliveryContacts && ' · '}
{request.waitingDeliveryContacts && <span>Контакты: {request.waitingDeliveryContacts}</span>}
</span>
)}
{request.status === 'taken_for_repair' && (request.takenForRepairDeadline || request.takenForRepairContacts) && (
<span className="text-[10px] text-slate-500 max-w-[180px] text-right">
{request.takenForRepairDeadline && <span>Срок: {request.takenForRepairDeadline}</span>}
{request.takenForRepairDeadline && request.takenForRepairContacts && ' · '}
{request.takenForRepairContacts && <span>Контакты: {request.takenForRepairContacts}</span>}
</span>
)}
{request.status === 'agreed_with_contractor' && request.agreedContractorPrice != null && request.agreedContractorPrice > 0 && (
<span className="text-[10px] text-slate-500">Цена: {request.agreedContractorPrice} </span>
)}
<div className="flex items-center gap-2">
<label className="text-[10px] font-medium text-slate-500 whitespace-nowrap">Быстрая смена:</label>
<select
defaultValue=""
onChange={(e) => {
const v = e.target.value as RepairRequestStatus;
if (v) handleQuickStatusChange(request, v, e);
}}
onClick={(e) => e.stopPropagation()}
className="px-2 py-1.5 rounded-lg text-xs font-medium border border-slate-300 bg-white min-w-[160px]"
title="Сменить статус"
>
<option value="">Быстрая смена...</option>
{REPAIR_STATUSES.map((s) => (
<option key={s.value} value={s.value} disabled={s.value === request.status}>
{s.value === request.status ? `${s.label} (текущий)` : s.label}
</option>
))}
</select>
</div>
<button
onClick={() => {
setSelectedRepair(request);
setShowCardModal(true);
}}
className="px-3 py-1.5 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold hover:bg-blue-100 transition-colors flex items-center gap-1"
title="Карточка заявки"
>
<FileText className="w-3 h-3" />
Карточка
</button>
<button
onClick={() => {
setSelectedRepair(request);
setRepairFormData({
status: request.status,
assignedTo: request.assignedTo || '',
solution: request.solution || '',
expectedReturnDate: request.expectedReturnDate || '',
isPaid: request.isPaid || false,
cost: request.cost || 0,
costEstimated: request.costEstimated || false
});
setShowEditRepairModal(true);
setShowCardModal(false);
}}
className="px-3 py-1.5 bg-amber-50 text-amber-600 rounded-lg text-xs font-bold hover:bg-amber-100 transition-colors flex items-center gap-1"
title="Редактировать"
>
<Edit className="w-3 h-3" />
Редактировать
</button>
{!request.invoiceId && (['waiting_delivery', 'taken_for_repair', 'agreed_with_contractor', 'in_progress'].includes(request.status) || (request.isPaid && (request.cost || 0) > 0)) && (
<button
onClick={(e) => {
e.stopPropagation();
setInvoiceModalRepair(request);
setShowInvoiceModal(true);
}}
className="px-3 py-1.5 bg-violet-50 text-violet-600 rounded-lg text-xs font-bold hover:bg-violet-100 transition-colors flex items-center gap-1"
title="Создать счёт (окно как в закупках)"
>
<Receipt className="w-3 h-3" />
Создать счёт
</button>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<Wrench className="w-12 h-12 mx-auto text-slate-300 mb-4" />
<p className="text-slate-500">Нет заявок на ремонт</p>
</div>
)}
{/* Карточка ремонта */}
{showCardModal && selectedRepair && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => { setShowCardModal(false); setCardCommentText(''); }}>
<div className="bg-white rounded-2xl p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<FileText className="w-5 h-5 text-primary-600" />
Карточка заявки на ремонт
</h3>
<button onClick={() => setShowCardModal(false)} className="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
{getStatusIcon(selectedRepair.status)}
<span className="font-bold text-slate-800">{getStatusLabel(selectedRepair.status)}</span>
{isOverdue(selectedRepair) && (
<span className="px-2 py-1 rounded text-xs font-bold bg-red-100 text-red-700 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Просрочено
</span>
)}
<span className={`px-2 py-1 rounded text-xs font-bold ${
selectedRepair.priority === 'urgent' ? 'bg-red-100 text-red-700' :
selectedRepair.priority === 'high' ? 'bg-orange-100 text-orange-700' :
selectedRepair.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-700'
}`}>
{selectedRepair.priority === 'urgent' ? 'Срочно' : selectedRepair.priority === 'high' ? 'Высокий' : selectedRepair.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Быстрый статус</p>
<select
value=""
onChange={(e) => {
const v = e.target.value as RepairRequestStatus;
if (v) handleQuickStatusChange(selectedRepair, v, e);
e.currentTarget.value = '';
}}
className="w-full px-3 py-2 rounded-lg text-sm font-medium border border-slate-300 bg-white"
title="Сменить статус"
>
<option value="">Выберите статус...</option>
{REPAIR_STATUSES.map((s) => (
<option key={s.value} value={s.value} disabled={s.value === selectedRepair.status}>
{s.value === selectedRepair.status ? `${s.label} (текущий)` : s.label}
</option>
))}
</select>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Оборудование</p>
<p className="text-sm font-bold text-slate-800">{selectedRepair.equipment?.name || '—'}</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Описание проблемы</p>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{selectedRepair.description || '—'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Заявитель</p>
<p className="text-sm text-slate-800">{selectedRepair.requesterName || '—'}</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Исполнитель</p>
<p className="text-sm text-slate-800">{selectedRepair.assignedTo || '—'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Создана</p>
<p className="text-sm text-slate-800">
{selectedRepair.createdAt ? new Date(selectedRepair.createdAt).toLocaleDateString('ru-RU') : '—'}
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Ожидаемая дата возврата</p>
<p className="text-sm text-slate-800 flex items-center gap-1">
{selectedRepair.expectedReturnDate
? new Date(selectedRepair.expectedReturnDate).toLocaleDateString('ru-RU')
: '—'}
{selectedRepair.expectedReturnDate && isOverdue(selectedRepair) && (
<span className="text-red-600 text-xs">(просрочено)</span>
)}
</p>
</div>
</div>
{/* Данные по статусам: ожидание поставки, увезли на ремонт, договорились с подрядчиком */}
{(selectedRepair.status === 'waiting_delivery' || selectedRepair.status === 'taken_for_repair' || selectedRepair.status === 'agreed_with_contractor') && (
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Данные по текущему статусу</p>
{selectedRepair.status === 'waiting_delivery' && (
<>
{selectedRepair.waitingDeliveryDeadline && (
<p className="text-sm text-slate-800"><span className="text-slate-500">Примерный срок:</span> {selectedRepair.waitingDeliveryDeadline}</p>
)}
{selectedRepair.waitingDeliveryContacts && (
<p className="text-sm text-slate-800"><span className="text-slate-500">Контакты для уточнения поставки:</span> {selectedRepair.waitingDeliveryContacts}</p>
)}
</>
)}
{selectedRepair.status === 'taken_for_repair' && (
<>
{selectedRepair.takenForRepairDeadline && (
<p className="text-sm text-slate-800"><span className="text-slate-500">Срок:</span> {selectedRepair.takenForRepairDeadline}</p>
)}
{selectedRepair.takenForRepairContacts && (
<p className="text-sm text-slate-800"><span className="text-slate-500">Контакты:</span> {selectedRepair.takenForRepairContacts}</p>
)}
</>
)}
{selectedRepair.status === 'agreed_with_contractor' && selectedRepair.agreedContractorPrice != null && selectedRepair.agreedContractorPrice > 0 && (
<p className="text-sm text-slate-800"><span className="text-slate-500">Цена (если известна):</span> {selectedRepair.agreedContractorPrice} </p>
)}
</div>
)}
{selectedRepair.startedAt && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Взята в работу</p>
<p className="text-sm text-slate-800">{new Date(selectedRepair.startedAt).toLocaleDateString('ru-RU')}</p>
</div>
)}
{selectedRepair.completedAt && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Выполнена</p>
<p className="text-sm text-slate-800">{new Date(selectedRepair.completedAt).toLocaleDateString('ru-RU')}</p>
</div>
)}
{selectedRepair.solution && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Решение / что сделано</p>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{selectedRepair.solution}</p>
</div>
)}
{(selectedRepair.isPaid && (selectedRepair.cost || 0) > 0) && (
<div className="bg-slate-50 rounded-lg p-3">
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Платный ремонт</p>
<p className="text-sm font-bold text-primary-600">
{selectedRepair.cost} {selectedRepair.costEstimated ? ' (предварительно)' : ''}
</p>
{selectedRepair.invoiceId && (
<a href={`#/finance/invoices/${selectedRepair.invoiceId}`} className="text-xs text-primary-600 hover:underline">Счёт #{selectedRepair.invoiceId}</a>
)}
</div>
)}
<div className="border-t border-slate-200 pt-4">
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Комментарии</p>
{(selectedRepair.comments && selectedRepair.comments.length > 0) ? (
<ul className="space-y-2 mb-3 max-h-40 overflow-y-auto">
{selectedRepair.comments.map((c, i) => (
<li key={i} className="bg-slate-50 rounded-lg p-2 text-sm">
<span className="font-medium text-slate-700">{c.author}</span>
{c.createdAt && (
<span className="text-slate-400 text-xs ml-2">
{new Date(c.createdAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })}
</span>
)}
<p className="text-slate-700 mt-1 whitespace-pre-wrap">{c.text}</p>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500 mb-3">Пока нет комментариев</p>
)}
<div className="flex gap-2">
<textarea
value={cardCommentText}
onChange={(e) => setCardCommentText(e.target.value)}
placeholder="Добавить комментарий..."
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm resize-none"
rows={2}
/>
<button
type="button"
onClick={async () => {
const text = cardCommentText.trim();
if (!text) return;
const newComments = [...(selectedRepair.comments || []), { author: CURRENT_USER_MOCK.name, text, createdAt: new Date().toISOString() }];
try {
const res = await authFetch(`/api/office/repair-requests/${selectedRepair.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comments: newComments })
});
if (res.ok) {
setSelectedRepair({ ...selectedRepair, comments: newComments });
fetchRequests();
setCardCommentText('');
} else {
const data = await res.json().catch(() => ({}));
alert(data?.error || 'Не удалось добавить комментарий');
}
} catch (err) {
console.error(err);
alert('Ошибка при добавлении комментария');
}
}}
disabled={!cardCommentText.trim()}
className="px-4 py-2 rounded-lg text-sm font-bold bg-primary-600 text-white hover:bg-primary-700 disabled:bg-slate-300 disabled:cursor-not-allowed shrink-0 self-end"
>
Добавить
</button>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-slate-200">
{!selectedRepair.invoiceId && ['waiting_delivery', 'taken_for_repair', 'agreed_with_contractor', 'in_progress'].includes(selectedRepair.status) && (
<button
onClick={() => {
setInvoiceModalRepair(selectedRepair);
setShowInvoiceModal(true);
setShowCardModal(false);
}}
className="px-4 py-2 rounded-lg text-sm font-bold bg-violet-600 text-white hover:bg-violet-700 transition-colors flex items-center justify-center gap-2"
>
<Receipt className="w-4 h-4" />
Создать счёт
</button>
)}
<button
onClick={() => {
setRepairFormData({
status: selectedRepair.status,
assignedTo: selectedRepair.assignedTo || '',
solution: selectedRepair.solution || '',
expectedReturnDate: selectedRepair.expectedReturnDate || '',
isPaid: selectedRepair.isPaid || false,
cost: selectedRepair.cost || 0,
costEstimated: selectedRepair.costEstimated || false
});
setShowEditRepairModal(true);
setShowCardModal(false);
}}
className="flex-1 px-4 py-2 rounded-lg text-sm font-bold bg-primary-600 text-white hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Edit className="w-4 h-4" />
Редактировать
</button>
<button onClick={() => { setShowCardModal(false); setCardCommentText(''); }} className="px-4 py-2 rounded-lg text-sm font-bold bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors">
Закрыть
</button>
</div>
</div>
</div>
</div>
)}
{/* Модалка доп. данных при смене статуса */}
{statusExtraModal.open && statusExtraModal.request && statusExtraModal.newStatus && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setStatusExtraModal({ open: false, request: null, newStatus: null })}>
<div className="bg-white rounded-2xl p-6 max-w-sm w-full shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-2">
{statusExtraModal.newStatus === 'waiting_delivery' && 'Ожидание поставки'}
{statusExtraModal.newStatus === 'taken_for_repair' && 'Увезли на ремонт'}
{statusExtraModal.newStatus === 'agreed_with_contractor' && 'Договорились с подрядчиком'}
</h3>
<p className="text-sm text-slate-500 mb-4">{statusExtraModal.request.equipment?.name}</p>
{(statusExtraModal.newStatus === 'waiting_delivery' || statusExtraModal.newStatus === 'taken_for_repair') && (
<>
<div className="mb-3">
<label className="block text-sm font-medium text-slate-700 mb-1">
{statusExtraModal.newStatus === 'waiting_delivery' ? 'Примерный срок' : 'Срок'}
</label>
<input
type="text"
value={statusExtraForm.deadline}
onChange={(e) => setStatusExtraForm((f) => ({ ...f, deadline: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Например: 2 недели, до 15.02.2026"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-1">
{statusExtraModal.newStatus === 'waiting_delivery' ? 'Контакты для уточнения поставки' : 'Контакты'}
</label>
<input
type="text"
value={statusExtraForm.contacts}
onChange={(e) => setStatusExtraForm((f) => ({ ...f, contacts: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Телефон, email, контактное лицо"
/>
</div>
</>
)}
{statusExtraModal.newStatus === 'agreed_with_contractor' && (
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-1">Цена (если знаете), </label>
<input
type="number"
min={0}
step={0.01}
value={statusExtraForm.price}
onChange={(e) => setStatusExtraForm((f) => ({ ...f, price: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="0"
/>
</div>
)}
<div className="flex gap-2">
<button onClick={submitStatusExtra} className="flex-1 px-4 py-2 rounded-lg text-sm font-bold bg-primary-600 text-white hover:bg-primary-700">
Сохранить
</button>
<button onClick={() => setStatusExtraModal({ open: false, request: null, newStatus: null })} className="px-4 py-2 rounded-lg text-sm font-bold bg-slate-100 text-slate-600 hover:bg-slate-200">
Отмена
</button>
</div>
</div>
</div>
)}
{/* Окно создания счёта — та же форма, что в закупках (PaymentInvoiceForm с префиллом) */}
{showInvoiceModal && invoiceModalRepair && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto" onClick={() => { setShowInvoiceModal(false); setInvoiceModalRepair(null); }}>
<div className="bg-white rounded-2xl border border-slate-200 shadow-xl w-full max-w-3xl my-8 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-slate-200 flex justify-between items-center sticky top-0 bg-white rounded-t-2xl">
<h3 className="text-lg font-bold text-slate-800">Создать счёт на ремонт</h3>
<button type="button" onClick={() => { setShowInvoiceModal(false); setInvoiceModalRepair(null); }} className="p-2 hover:bg-slate-100 rounded-lg">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6">
<PaymentInvoiceForm
initialPrefill={{
purposeType: 'office',
purposeDescription: invoiceModalRepair.status === 'waiting_delivery'
? `Деталь/запчасть: ${invoiceModalRepair.equipment?.name || 'оборудование'}`
: `Ремонт: ${invoiceModalRepair.equipment?.name || 'оборудование'}${invoiceModalRepair.costEstimated ? ' (предварительная стоимость)' : ''}`,
contractorName: '',
totalAmount: invoiceModalRepair.cost || 0,
itemType: invoiceModalRepair.status === 'waiting_delivery' ? 'materials' : 'service',
serviceItems: invoiceModalRepair.status !== 'waiting_delivery' ? [{ name: `Ремонт: ${invoiceModalRepair.equipment?.name || 'оборудование'}${invoiceModalRepair.costEstimated ? ' (предварительная стоимость)' : ''}`, amount: invoiceModalRepair.cost || 0 }] : undefined,
materialItems: invoiceModalRepair.status === 'waiting_delivery' ? [{ name: `Деталь/запчасть: ${invoiceModalRepair.equipment?.name || 'оборудование'}`, quantity: 1, unit: 'шт', pricePerUnit: invoiceModalRepair.cost || 0, amount: invoiceModalRepair.cost || 0 }] : undefined,
notes: invoiceModalRepair.description || ''
}}
currentUserId={CURRENT_USER_MOCK.id || CURRENT_USER_MOCK.name || 'user-1'}
onSave={async (payload) => {
const invoice = await apiClient.post<PaymentInvoice>('/finance/payment-invoices', payload);
await authFetch(`/api/office/repair-requests/${invoiceModalRepair.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invoiceId: invoice.id,
invoiceUrl: `/api/finance/payment-invoices/${invoice.id}`
})
});
setShowInvoiceModal(false);
setInvoiceModalRepair(null);
fetchRequests();
alert('Счёт создан и привязан к заявке');
}}
onCancel={() => { setShowInvoiceModal(false); setInvoiceModalRepair(null); }}
/>
</div>
</div>
</div>
)}
{/* Create Request Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Создать заявку на ремонт</h3>
<button
onClick={() => setShowCreateModal(false)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Оборудование *</label>
<select
value={formData.equipmentId}
onChange={(e) => setFormData({ ...formData, equipmentId: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
required
>
<option value="">Выберите оборудование</option>
{equipment.map((eq) => (
<option key={eq.id} value={eq.id.toString()}>
{eq.name} {eq.serialNumber ? `(S/N: ${eq.serialNumber})` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Описание проблемы *</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={4}
placeholder="Опишите проблему с оборудованием..."
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Приоритет</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg 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-sm font-medium text-slate-700 mb-1">Ожидаемая дата возврата</label>
<input
type="date"
value={formData.expectedReturnDate}
onChange={(e) => setFormData({ ...formData, expectedReturnDate: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleCreateRequest}
disabled={!formData.equipmentId || !formData.description}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Repair Modal */}
{showEditRepairModal && selectedRepair && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Редактировать заявку на ремонт</h3>
<button
onClick={() => {
setShowEditRepairModal(false);
setSelectedRepair(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Оборудование</label>
<input
type="text"
value={selectedRepair.equipment?.name || ''}
disabled
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm bg-slate-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Статус</label>
<select
value={repairFormData.status}
onChange={(e) => setRepairFormData({ ...repairFormData, status: e.target.value as RepairRequestStatus })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{REPAIR_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Исполнитель</label>
<input
type="text"
value={repairFormData.assignedTo}
onChange={(e) => setRepairFormData({ ...repairFormData, assignedTo: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="ФИО исполнителя"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Ожидаемая дата возврата</label>
<input
type="date"
value={repairFormData.expectedReturnDate}
onChange={(e) => setRepairFormData({ ...repairFormData, expectedReturnDate: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Решение / что сделано</label>
<textarea
value={repairFormData.solution}
onChange={(e) => setRepairFormData({ ...repairFormData, solution: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Опишите выполненную работу..."
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPaid"
checked={repairFormData.isPaid}
onChange={(e) => setRepairFormData({ ...repairFormData, isPaid: e.target.checked })}
className="w-4 h-4 text-primary-600 border-slate-300 rounded focus:ring-primary-500"
/>
<label htmlFor="isPaid" className="text-sm font-medium text-slate-700 cursor-pointer">
Платный ремонт
</label>
</div>
{repairFormData.isPaid && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Стоимость ремонта</label>
<input
type="number"
value={repairFormData.cost}
onChange={(e) => setRepairFormData({ ...repairFormData, cost: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
min="0"
step="0.01"
placeholder="0"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="costEstimated"
checked={repairFormData.costEstimated}
onChange={(e) => setRepairFormData({ ...repairFormData, costEstimated: e.target.checked })}
className="w-4 h-4 text-primary-600 border-slate-300 rounded focus:ring-primary-500"
/>
<label htmlFor="costEstimated" className="text-sm font-medium text-slate-700 cursor-pointer">
Предварительная стоимость (требуется диагностика)
</label>
</div>
</>
)}
<div className="flex gap-3 pt-4">
<button
onClick={async () => {
try {
const response = await authFetch(`/api/office/repair-requests/${selectedRepair.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: repairFormData.status,
assignedTo: repairFormData.assignedTo || null,
solution: repairFormData.solution || null,
expectedReturnDate: repairFormData.expectedReturnDate || null,
isPaid: repairFormData.isPaid,
cost: repairFormData.isPaid ? (repairFormData.cost || 0) : 0,
costEstimated: repairFormData.isPaid ? repairFormData.costEstimated : false
})
});
if (response.ok) {
const updated = await response.json();
setSelectedRepair({
...selectedRepair,
status: updated.status || repairFormData.status,
assignedTo: updated.assigned_to || repairFormData.assignedTo,
solution: updated.solution || repairFormData.solution,
expectedReturnDate: updated.expected_return_date || repairFormData.expectedReturnDate,
isPaid: updated.is_paid ?? repairFormData.isPaid,
cost: updated.cost ?? repairFormData.cost,
costEstimated: updated.cost_estimated ?? repairFormData.costEstimated
});
fetchRequests();
setShowEditRepairModal(false);
setSelectedRepair(null);
alert('Заявка обновлена');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка обновления: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка обновления заявки:', error);
alert(`Ошибка обновления: ${error.message || 'Неизвестная ошибка'}`);
}
}}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors"
>
Сохранить
</button>
<button
onClick={() => {
setShowEditRepairModal(false);
setSelectedRepair(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};