Files
mkd/components/office/RepairRequests.tsx
2026-02-04 00:17:04 +05:00

1157 lines
60 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 { 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>
);
};