1157 lines
60 KiB
TypeScript
Executable File
1157 lines
60 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|