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

339 lines
15 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, User, Employee, Building, TaskComment } from '../types';
import { X, User as UserIcon, Calendar, Flag, MessageSquare } from 'lucide-react';
import { backendApi } from '../services/apiClient';
import { storageService } from '../services/storageService';
import { MOCK_EMPLOYEES } from '../constants';
interface DashboardTaskModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (task: BuildingTask) => void;
currentUser: User;
task?: BuildingTask | null;
}
/** Модалка задачи на сводке — без фото отчёта, ответственные из списка сотрудников */
export const DashboardTaskModal: React.FC<DashboardTaskModalProps> = ({
isOpen,
onClose,
onSave,
currentUser,
task,
}) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [deadline, setDeadline] = useState('');
const [priority, setPriority] = useState<BuildingTask['priority']>('medium');
const [status, setStatus] = useState<BuildingTask['status']>('new');
const [assignedTo, setAssignedTo] = useState('');
const [createdBy, setCreatedBy] = useState('');
const [buildingId, setBuildingId] = useState('');
const [employees, setEmployees] = useState<Employee[]>([]);
const [employeesLoading, setEmployeesLoading] = useState(false);
const [buildings, setBuildings] = useState<Building[]>([]);
const [comments, setComments] = useState<TaskComment[]>([]);
const [commentText, setCommentText] = useState('');
const [draftTaskId, setDraftTaskId] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const load = async () => {
setEmployeesLoading(true);
try {
const [empList, bldList] = await Promise.all([
backendApi.getEmployees().then((l) => (Array.isArray(l) ? l : [])).catch(() => MOCK_EMPLOYEES),
backendApi.getBuildings().catch(() => storageService.getAllBuildings()),
]);
setEmployees(empList);
setBuildings(Array.isArray(bldList) ? bldList : storageService.getAllBuildings());
} catch {
setEmployees(MOCK_EMPLOYEES);
setBuildings(storageService.getAllBuildings());
} finally {
setEmployeesLoading(false);
}
};
load();
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
if (task) {
setTitle(task.title || '');
setDescription(task.description || '');
setDeadline(task.deadline ? new Date(task.deadline).toISOString().slice(0, 10) : '');
setPriority(task.priority || 'medium');
setStatus(task.status || 'new');
setAssignedTo(task.assignedTo || '');
setCreatedBy(task.createdBy ?? currentUser.id);
setBuildingId(task.buildingId || '');
setComments(task.comments ?? []);
setDraftTaskId(null);
} else {
setTitle('');
setDescription('');
const d = new Date();
d.setDate(d.getDate() + 7);
setDeadline(d.toISOString().slice(0, 10));
setPriority('medium');
setStatus('new');
setAssignedTo('');
setCreatedBy(currentUser.id);
setBuildingId('');
setComments([]);
setDraftTaskId(`dt-${Date.now()}`);
}
setCommentText('');
}, [isOpen, task, currentUser.id]);
const assigneeList: { id: string; name: string; position: string }[] = [
{ id: currentUser.id, name: currentUser.name, position: '' },
...employees.filter((e) => e.id !== currentUser.id).map((e) => ({ id: e.id, name: e.name, position: e.position })),
];
if (task?.createdBy && !assigneeList.some((p) => p.id === task.createdBy)) {
assigneeList.push({ id: task.createdBy, name: task.createdByName ?? task.createdBy, position: '' });
}
const executorList = employees.filter((e) => e.status === 'active');
if (task?.assignedTo && !executorList.some((e) => e.id === task.assignedTo)) {
executorList.push({ id: task.assignedTo, name: task.assignedToName ?? task.assignedTo, position: '' } as Employee);
}
const selectedBuilding = buildings.find((b) => b.id === buildingId);
const buildingAddress = selectedBuilding?.passport?.address ?? '';
const buildTaskData = (commentsOverride?: TaskComment[]) => {
const now = new Date().toISOString();
const exec = executorList.find((e) => e.id === assignedTo);
const author = assigneeList.find((p) => p.id === createdBy);
const taskId = task?.id ?? draftTaskId ?? `dt-${Date.now()}`;
return {
id: taskId,
title: title.trim() || 'Задача',
description: description.trim() || undefined,
deadline: deadline ? new Date(deadline).toISOString() : now,
status,
priority,
assignedTo: assignedTo || undefined,
assignedToName: exec?.name ?? (assignedTo && task?.assignedTo === assignedTo ? task?.assignedToName : undefined),
createdBy: createdBy || undefined,
createdByName: author?.name ?? (createdBy && task?.createdBy === createdBy ? task?.createdByName : undefined),
createdAt: task?.createdAt || now,
updatedAt: now,
buildingId: buildingId || '',
buildingAddress: buildingId ? buildingAddress : undefined,
requirePhotoReport: false,
coAssignees: [],
coAssigneesNames: [],
observers: [],
observersNames: [],
comments: commentsOverride ?? comments,
} as BuildingTask;
};
const handleAddComment = () => {
if (!commentText.trim()) return;
const author = assigneeList.find((p) => p.id === createdBy)?.name ?? currentUser.name;
const newComment: TaskComment = {
id: `comment-${Date.now()}`,
authorId: createdBy || undefined,
authorName: author,
text: commentText.trim(),
createdAt: new Date().toISOString(),
};
const nextComments = [...comments, newComment];
setComments(nextComments);
setCommentText('');
onSave(buildTaskData(nextComments));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSave(buildTaskData());
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 p-6 flex items-center justify-between rounded-t-2xl z-10">
<h2 className="text-xl font-bold text-slate-800">{task ? 'Редактировать задачу' : 'Новая задача'}</h2>
<button type="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={title}
onChange={(e) => setTitle(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={description}
onChange={(e) => setDescription(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={deadline}
onChange={(e) => setDeadline(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 flex items-center gap-2">
<Flag className="w-4 h-4" /> Приоритет
</label>
<select
value={priority}
onChange={(e) => setPriority(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">
<UserIcon className="w-4 h-4" /> Исполнитель
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(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"
disabled={employeesLoading}
>
<option value="">Не назначен</option>
{executorList.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.name} {emp.position ? `${emp.position}` : ''}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Постановщик</label>
<select
value={createdBy}
onChange={(e) => setCreatedBy(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"
disabled={employeesLoading}
>
{assigneeList.map((p) => (
<option key={p.id} value={p.id}>
{p.id === currentUser.id ? `Я (${p.name})` : p.name + (p.position ? `${p.position}` : '')}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Дом / объект</label>
<select
value={buildingId}
onChange={(e) => setBuildingId(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"
>
<option value="">Общая задача</option>
{buildings.map((b) => (
<option key={b.id} value={b.id}>
{b.passport?.address ?? b.id}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">Статус</label>
<select
value={status}
onChange={(e) => setStatus(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>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Комментарии
</label>
{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">
{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 shrink-0"
>
Добавить
</button>
</div>
</div>
<p className="text-[10px] text-slate-400">
Фото отчёт для задач на сводке не требуется. Для задач с фото отчётом создайте задачу в карточке дома.
</p>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 border border-slate-200 rounded-xl font-bold text-slate-600 hover:bg-slate-50"
>
Отмена
</button>
<button type="submit" className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700">
{task ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};