Initial commit MKD fixes
This commit is contained in:
716
components/building/TaskModal.tsx
Executable file
716
components/building/TaskModal.tsx
Executable file
@@ -0,0 +1,716 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BuildingTask, Employee, TaskComment, WorkPhoto } from '../../types';
|
||||
import { X, User, Calendar, Flag, Tag, Clock, MessageSquare, ImageIcon, Camera } from 'lucide-react';
|
||||
import { backendApi } from '../../services/apiClient';
|
||||
import { apiClient } from '../../services/apiClient';
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (task: BuildingTask) => void;
|
||||
task?: BuildingTask | null;
|
||||
buildingId: string;
|
||||
employees?: Employee[];
|
||||
}
|
||||
|
||||
export const TaskModal: React.FC<TaskModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
task,
|
||||
buildingId,
|
||||
employees = []
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Partial<BuildingTask>>({
|
||||
title: '',
|
||||
description: '',
|
||||
deadline: '',
|
||||
status: 'new',
|
||||
priority: 'medium',
|
||||
assignedTo: '',
|
||||
assignedToName: '',
|
||||
createdBy: '',
|
||||
createdByName: '',
|
||||
category: '',
|
||||
tags: [],
|
||||
estimatedHours: undefined,
|
||||
comments: [],
|
||||
requirePhotoReport: true,
|
||||
photoReportId: undefined,
|
||||
});
|
||||
|
||||
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [photoReport, setPhotoReport] = useState<WorkPhoto | null>(null);
|
||||
const [photoReportLoading, setPhotoReportLoading] = useState(false);
|
||||
const [showPhotoForm, setShowPhotoForm] = useState(false);
|
||||
const [draftTaskId, setDraftTaskId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (task) {
|
||||
setFormData({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
deadline: task.deadline || '',
|
||||
status: task.status || 'new',
|
||||
priority: task.priority || 'medium',
|
||||
assignedTo: task.assignedTo || '',
|
||||
assignedToName: task.assignedToName || '',
|
||||
createdBy: task.createdBy || '',
|
||||
createdByName: task.createdByName || '',
|
||||
category: task.category || '',
|
||||
tags: task.tags || [],
|
||||
estimatedHours: task.estimatedHours,
|
||||
comments: task.comments || [],
|
||||
requirePhotoReport: task.requirePhotoReport !== false,
|
||||
photoReportId: task.photoReportId,
|
||||
});
|
||||
setDraftTaskId(task.id);
|
||||
setPhotoReport(null);
|
||||
if (task.photoReportId) {
|
||||
setPhotoReportLoading(true);
|
||||
apiClient.get<WorkPhoto>(`/pr/work-photos/${task.photoReportId}`)
|
||||
.then((data: WorkPhoto) => setPhotoReport(data))
|
||||
.catch(() => setPhotoReport(null))
|
||||
.finally(() => setPhotoReportLoading(false));
|
||||
} else {
|
||||
setPhotoReportLoading(false);
|
||||
}
|
||||
} else {
|
||||
const newId = `task-${Date.now()}`;
|
||||
setDraftTaskId(newId);
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
deadline: '',
|
||||
status: 'new',
|
||||
priority: 'medium',
|
||||
assignedTo: '',
|
||||
assignedToName: '',
|
||||
createdBy: '',
|
||||
createdByName: '',
|
||||
category: '',
|
||||
tags: [],
|
||||
estimatedHours: undefined,
|
||||
comments: [],
|
||||
requirePhotoReport: true,
|
||||
photoReportId: undefined,
|
||||
});
|
||||
setPhotoReport(null);
|
||||
}
|
||||
setShowPhotoForm(false);
|
||||
setCommentText('');
|
||||
fetchEmployees();
|
||||
}
|
||||
}, [isOpen, task, buildingId]);
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
const fetchedEmployees = await backendApi.getEmployees();
|
||||
setAvailableEmployees(fetchedEmployees);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employees:', error);
|
||||
setAvailableEmployees(employees);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.title?.trim()) return;
|
||||
|
||||
const selectedEmployee = availableEmployees.find(emp => emp.id === formData.assignedTo);
|
||||
const authorEmployee = availableEmployees.find(emp => emp.id === formData.createdBy);
|
||||
const taskId = task?.id || draftTaskId || `task-${Date.now()}`;
|
||||
|
||||
const taskData: BuildingTask = {
|
||||
id: taskId,
|
||||
title: formData.title.trim(),
|
||||
description: formData.description?.trim(),
|
||||
deadline: formData.deadline || new Date().toISOString(),
|
||||
status: formData.status || 'new',
|
||||
priority: formData.priority || 'medium',
|
||||
assignedTo: formData.assignedTo || undefined,
|
||||
assignedToName: selectedEmployee?.name || formData.assignedToName || undefined,
|
||||
createdBy: formData.createdBy || undefined,
|
||||
createdByName: authorEmployee?.name || formData.createdByName || undefined,
|
||||
category: formData.category || undefined,
|
||||
tags: formData.tags || [],
|
||||
estimatedHours: formData.estimatedHours,
|
||||
createdAt: task?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
buildingId: buildingId,
|
||||
comments: formData.comments || [],
|
||||
requirePhotoReport: formData.requirePhotoReport !== false,
|
||||
photoReportId: formData.photoReportId,
|
||||
};
|
||||
|
||||
onSave(taskData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAddComment = () => {
|
||||
if (!commentText.trim()) return;
|
||||
const author = availableEmployees.find(emp => emp.id === formData.createdBy)?.name || formData.createdByName || 'Не указан';
|
||||
const newComment: TaskComment = {
|
||||
id: `comment-${Date.now()}`,
|
||||
authorId: formData.createdBy || undefined,
|
||||
authorName: author,
|
||||
text: commentText.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setFormData({
|
||||
...formData,
|
||||
comments: [...(formData.comments || []), newComment],
|
||||
});
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const handlePhotoReportCreated = (workPhoto: WorkPhoto) => {
|
||||
const updatedFormData = { ...formData, photoReportId: workPhoto.id };
|
||||
setFormData(updatedFormData);
|
||||
setPhotoReport(workPhoto);
|
||||
setShowPhotoForm(false);
|
||||
// Сразу сохраняем задачу с привязкой к фото, чтобы photoReportId не терялся
|
||||
const taskId = task?.id || draftTaskId || `task-${Date.now()}`;
|
||||
const authorEmployee = availableEmployees.find(emp => emp.id === formData.createdBy);
|
||||
const selectedEmployee = availableEmployees.find(emp => emp.id === formData.assignedTo);
|
||||
const taskData: BuildingTask = {
|
||||
id: taskId,
|
||||
title: (formData.title || '').trim() || 'Задача',
|
||||
description: formData.description?.trim(),
|
||||
deadline: formData.deadline || new Date().toISOString(),
|
||||
status: formData.status || 'new',
|
||||
priority: formData.priority || 'medium',
|
||||
assignedTo: formData.assignedTo || undefined,
|
||||
assignedToName: selectedEmployee?.name || formData.assignedToName || undefined,
|
||||
createdBy: formData.createdBy || undefined,
|
||||
createdByName: authorEmployee?.name || formData.createdByName || undefined,
|
||||
category: formData.category || undefined,
|
||||
tags: formData.tags || [],
|
||||
estimatedHours: formData.estimatedHours,
|
||||
createdAt: task?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
buildingId: buildingId,
|
||||
comments: formData.comments || [],
|
||||
requirePhotoReport: formData.requirePhotoReport !== false,
|
||||
photoReportId: workPhoto.id,
|
||||
};
|
||||
onSave(taskData);
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...(formData.tags || []), tagInput.trim()]
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: formData.tags?.filter(t => t !== tag) || []
|
||||
});
|
||||
};
|
||||
|
||||
const priorityColors = {
|
||||
low: 'bg-slate-100 text-slate-600',
|
||||
medium: 'bg-blue-50 text-blue-600',
|
||||
high: 'bg-amber-50 text-amber-600',
|
||||
urgent: 'bg-red-50 text-red-600',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
new: 'bg-blue-50 text-blue-600',
|
||||
in_progress: 'bg-amber-50 text-amber-600',
|
||||
done: 'bg-emerald-50 text-emerald-600',
|
||||
cancelled: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
const uploadsBase = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000';
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-slate-200 p-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{task ? 'Редактировать задачу' : 'Новая задача'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Название */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">
|
||||
Название задачи *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Описание */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Срок */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" /> Срок выполнения *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.deadline ? new Date(formData.deadline).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => setFormData({ ...formData, deadline: new Date(e.target.value).toISOString() })}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Приоритет */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Flag className="w-4 h-4" /> Приоритет
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value as BuildingTask['priority'] })}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="medium">Средний</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="urgent">Срочный</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Ответственный */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<User className="w-4 h-4" /> Ответственный
|
||||
</label>
|
||||
<select
|
||||
value={formData.assignedTo}
|
||||
onChange={(e) => {
|
||||
const selected = availableEmployees.find(emp => emp.id === e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
assignedTo: e.target.value,
|
||||
assignedToName: selected?.name || ''
|
||||
});
|
||||
}}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
>
|
||||
<option value="">Не назначен</option>
|
||||
{availableEmployees.filter(emp => emp.status === 'active').map(emp => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.name} - {emp.position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Постановщик */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<User className="w-4 h-4" /> Постановщик
|
||||
</label>
|
||||
<select
|
||||
value={formData.createdBy}
|
||||
onChange={(e) => {
|
||||
const selected = availableEmployees.find(emp => emp.id === e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
createdBy: e.target.value,
|
||||
createdByName: selected?.name || ''
|
||||
});
|
||||
}}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
>
|
||||
<option value="">Не указан</option>
|
||||
{availableEmployees.filter(emp => emp.status === 'active').map(emp => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.name} - {emp.position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">
|
||||
Статус
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as BuildingTask['status'] })}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
>
|
||||
<option value="new">Новая</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="done">Выполнена</option>
|
||||
<option value="cancelled">Отменена</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Категория */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" /> Категория
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
placeholder="Например: Ремонт, Обслуживание"
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Оценка времени */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" /> Оценка времени (часы)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={formData.estimatedHours || ''}
|
||||
onChange={(e) => setFormData({ ...formData, estimatedHours: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Комментарии */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" /> Комментарии
|
||||
</label>
|
||||
{(formData.comments && formData.comments.length > 0) && (
|
||||
<ul className="space-y-2 mb-3 max-h-32 overflow-y-auto rounded-xl border border-slate-100 p-3 bg-slate-50">
|
||||
{formData.comments.map(c => (
|
||||
<li key={c.id} className="text-sm text-slate-700">
|
||||
<span className="font-bold text-slate-500">{c.authorName}:</span> {c.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddComment())}
|
||||
placeholder="Добавить комментарий..."
|
||||
className="flex-1 border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddComment}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors text-sm"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фото отчёт до/после */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 bg-slate-50/50">
|
||||
<label className="flex items-center gap-2 cursor-pointer mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.requirePhotoReport !== false}
|
||||
onChange={(e) => setFormData({ ...formData, requirePhotoReport: e.target.checked })}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<span className="text-sm font-bold text-slate-700">Требуется фото отчёт по работе (до/после)</span>
|
||||
</label>
|
||||
{formData.requirePhotoReport && (
|
||||
<>
|
||||
{photoReport || formData.photoReportId ? (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase mb-1">До</p>
|
||||
{photoReportLoading ? (
|
||||
<div className="w-full h-24 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400 text-xs">Загрузка...</div>
|
||||
) : (photoReport?.photoBeforeUrl || (photoReport as any)?.photo_before_url) ? (
|
||||
<img
|
||||
src={uploadsBase + (photoReport?.photoBeforeUrl || (photoReport as any)?.photo_before_url)}
|
||||
alt="До"
|
||||
className="w-full h-24 object-cover rounded-lg border border-slate-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 rounded-lg border border-dashed border-slate-200 flex items-center justify-center text-slate-400 text-xs">Нет фото</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-500 uppercase mb-1">После</p>
|
||||
{photoReportLoading ? (
|
||||
<div className="w-full h-24 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400 text-xs">Загрузка...</div>
|
||||
) : (photoReport?.photoAfterUrl || (photoReport as any)?.photo_after_url) ? (
|
||||
<img
|
||||
src={uploadsBase + (photoReport?.photoAfterUrl || (photoReport as any)?.photo_after_url)}
|
||||
alt="После"
|
||||
className="w-full h-24 object-cover rounded-lg border border-slate-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 rounded-lg border border-dashed border-slate-200 flex items-center justify-center text-slate-400 text-xs">Нет фото</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhotoForm(true)}
|
||||
className="text-sm font-bold text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<Camera className="w-4 h-4" /> Изменить фото отчёт
|
||||
</button>
|
||||
</div>
|
||||
) : showPhotoForm ? (
|
||||
<TaskPhotoReportForm
|
||||
buildingId={buildingId}
|
||||
taskId={draftTaskId || undefined}
|
||||
workName={formData.title || 'Фото отчёт'}
|
||||
workDescription={formData.description || ''}
|
||||
onSuccess={handlePhotoReportCreated}
|
||||
onCancel={() => setShowPhotoForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhotoForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-100 text-primary-700 rounded-xl font-bold hover:bg-primary-200 transition-colors text-sm"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" /> Добавить фото отчёт (до/после)
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Теги */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-700 mb-2">
|
||||
Теги
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
placeholder="Добавить тег"
|
||||
className="flex-1 border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-primary-50 text-primary-700 rounded-lg text-xs font-bold"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:text-primary-900"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div className="flex gap-3 pt-4 border-t border-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl font-bold text-slate-700 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
{task ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Форма добавления фото отчёта до/после для задачи (название и описание из задачи → попадают в справочник PR)
|
||||
interface TaskPhotoReportFormProps {
|
||||
buildingId: string;
|
||||
taskId?: string | null;
|
||||
workName: string;
|
||||
workDescription?: string;
|
||||
onSuccess: (photo: WorkPhoto) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TaskPhotoReportForm: React.FC<TaskPhotoReportFormProps> = ({
|
||||
buildingId,
|
||||
taskId,
|
||||
workName,
|
||||
workDescription = '',
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [photoBefore, setPhotoBefore] = useState<File | null>(null);
|
||||
const [photoAfter, setPhotoAfter] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState(workDescription);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// При открытии формы подставляем описание задачи
|
||||
useEffect(() => {
|
||||
if (workDescription) setDescription(workDescription);
|
||||
}, [workDescription]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!photoBefore || !photoAfter) {
|
||||
alert('Загрузите оба фото: «До» и «После»');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('building_id', buildingId);
|
||||
if (taskId) formDataToSend.append('task_id', taskId);
|
||||
formDataToSend.append('work_name', workName || 'Фото отчёт по задаче');
|
||||
formDataToSend.append('work_date', new Date().toISOString().split('T')[0]);
|
||||
if (description?.trim()) formDataToSend.append('description', description.trim());
|
||||
formDataToSend.append('photo_before', photoBefore);
|
||||
formDataToSend.append('photo_after', photoAfter);
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
|
||||
const res = await fetch(`${API_BASE}/pr/work-photos`, {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) ? data.error : `Ошибка ${res.status}: ${res.statusText}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
onSuccess(data as WorkPhoto);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const msg = err?.message || 'Не удалось загрузить фото отчёт. Проверьте, что сервер запущен и доступен.';
|
||||
alert(msg);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 p-4 bg-white space-y-3">
|
||||
<p className="text-sm font-bold text-slate-700">Фото отчёт в формате «До» / «После»</p>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Фото «До» *</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setPhotoBefore(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Фото «После» *</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setPhotoAfter(e.target.files?.[0] || null)}
|
||||
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-600 mb-1">Описание (необязательно)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
|
||||
placeholder="Кратко, что сделано"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => { e.preventDefault(); handleSubmit(e as any); }}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Загрузка...' : 'Сохранить фото отчёт'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user