Files
mkd/components/building/TaskModal.tsx
2026-02-04 00:17:04 +05:00

717 lines
30 KiB
TypeScript
Executable File
Raw 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 { 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>
);
};