379 lines
17 KiB
TypeScript
379 lines
17 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
||
import {
|
||
Clock,
|
||
Plus,
|
||
CheckCircle2,
|
||
Send,
|
||
Link,
|
||
Check,
|
||
} from 'lucide-react';
|
||
|
||
interface SubtaskItem {
|
||
id: string;
|
||
text: string;
|
||
completed: boolean;
|
||
}
|
||
|
||
interface CommentItem {
|
||
id: string;
|
||
author: string;
|
||
text: string;
|
||
time: string;
|
||
}
|
||
|
||
export const TasksModule: React.FC = () => {
|
||
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
||
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
|
||
|
||
// Load persisted tasks on mount
|
||
useEffect(() => {
|
||
async function loadTasks() {
|
||
try {
|
||
const saved = await getStoreItem<{ key: string; tasks: TaskEntity[] }>('settings', 'krona_persisted_tasks');
|
||
if (saved?.tasks && Array.isArray(saved.tasks) && saved.tasks.length > 0) {
|
||
setTasks(saved.tasks);
|
||
}
|
||
} catch {
|
||
// Keep initial
|
||
}
|
||
}
|
||
loadTasks();
|
||
}, []);
|
||
|
||
const persistTasks = useCallback((updated: TaskEntity[]) => {
|
||
setTasks(updated);
|
||
void putStoreItem('settings', { key: 'krona_persisted_tasks', tasks: updated }).catch(() => {});
|
||
}, []);
|
||
|
||
// Selected task detail state
|
||
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
||
{ id: 'st-1', text: 'Сверить расчетную себестоимость и маржинальность', completed: true },
|
||
{ id: 'st-2', text: 'Согласовать тайминг подачи с шеф-поваром', completed: false },
|
||
{ id: 'st-3', text: 'Выставить предварительный счет клиенту', completed: false },
|
||
]);
|
||
const [comments, setComments] = useState<CommentItem[]>([
|
||
{ id: 'c-1', author: 'Ольга Власова', text: 'Клиент подтвердил количество 60 персон. Ждем аванс.', time: 'Вчера, 16:20' },
|
||
{ id: 'c-2', author: 'Артур Ким (Шеф)', text: 'Продукты зарезервированы на основном складе.', time: 'Сегодня, 10:15' },
|
||
]);
|
||
const [newComment, setNewComment] = useState('');
|
||
|
||
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
||
const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: nextStatus } : t));
|
||
persistTasks(updated);
|
||
if (selectedTask && selectedTask.id === taskId) {
|
||
setSelectedTask({ ...selectedTask, status: nextStatus });
|
||
}
|
||
};
|
||
|
||
const handleToggleSubtask = (id: string) => {
|
||
setSubtasks((prev) =>
|
||
prev.map((s) => (s.id === id ? { ...s, completed: !s.completed } : s))
|
||
);
|
||
};
|
||
|
||
const handleAddComment = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newComment.trim()) return;
|
||
setComments((prev) => [
|
||
...prev,
|
||
{
|
||
id: `c-${Date.now()}`,
|
||
author: 'Вы (Текущий пользователь)',
|
||
text: newComment,
|
||
time: 'Только что',
|
||
},
|
||
]);
|
||
setNewComment('');
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4 animate-fade-in">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-bold text-krona-primary">
|
||
Задачи, поручения и проекты команды
|
||
</h2>
|
||
<MaturityBadge level="target" size="sm" />
|
||
</div>
|
||
<p className="text-xs text-krona-secondary mt-0.5">
|
||
Канбан-доска исполнения, дедлайны, привязка к CRM и ответственные
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
|
||
<button
|
||
onClick={() => setActiveView('kanban')}
|
||
className={`px-3 py-1 rounded font-medium transition-colors ${
|
||
activeView === 'kanban' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
|
||
}`}
|
||
>
|
||
Канбан
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveView('list')}
|
||
className={`px-3 py-1 rounded font-medium transition-colors ${
|
||
activeView === 'list' ? 'bg-[#2F684C] text-white shadow-sm' : 'text-krona-secondary'
|
||
}`}
|
||
>
|
||
Список
|
||
</button>
|
||
</div>
|
||
<Button variant="primary" size="sm">
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Создать задачу
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeView === 'kanban' ? (
|
||
/* Kanban Board */
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3.5">
|
||
{(['todo', 'in_progress', 'review', 'done'] as const).map((st) => {
|
||
const colTasks = tasks.filter(t => t.status === st);
|
||
const colTitles: Record<string, string> = {
|
||
todo: 'К выполнению',
|
||
in_progress: 'В работе',
|
||
review: 'На проверке',
|
||
done: 'Выполнено',
|
||
};
|
||
|
||
return (
|
||
<div
|
||
key={st}
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const taskId = e.dataTransfer.getData('text/plain');
|
||
if (taskId) handleStatusChange(taskId, st);
|
||
}}
|
||
className="bg-krona-workspace/80 border border-krona-border rounded-xl p-3 flex flex-col min-h-[420px]"
|
||
>
|
||
<div className="flex items-center justify-between pb-2 mb-2 border-b border-krona-border">
|
||
<span className="text-xs font-bold text-krona-primary">{colTitles[st]}</span>
|
||
<span className="text-xs font-mono font-bold text-krona-secondary">
|
||
{colTasks.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2.5 flex-1 overflow-y-auto">
|
||
{colTasks.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData('text/plain', t.id);
|
||
}}
|
||
onClick={() => setSelectedTask(t)}
|
||
className="p-3 rounded-lg border border-krona-border bg-krona-surface shadow-krona-soft space-y-2 cursor-pointer hover:border-krona-green/70 transition-all group active:scale-[0.99]"
|
||
>
|
||
<div className="flex items-center justify-between text-[10px] text-krona-secondary">
|
||
<span className="font-semibold uppercase">{t.project}</span>
|
||
<Badge
|
||
variant={t.priority === 'high' ? 'danger' : 'neutral'}
|
||
size="sm"
|
||
>
|
||
{t.priority === 'high' ? 'Срочно' : 'Обычный'}
|
||
</Badge>
|
||
</div>
|
||
|
||
<h5 className="text-xs font-bold text-krona-primary leading-snug group-hover:text-krona-green transition-colors">
|
||
{t.title}
|
||
</h5>
|
||
|
||
<div className="flex items-center justify-between pt-1 border-t border-krona-border/60 text-[11px] text-krona-secondary">
|
||
<span>{t.assignee}</span>
|
||
<span className="flex items-center gap-1 font-mono">
|
||
<Clock className="w-3 h-3" />
|
||
{t.dueDate}
|
||
</span>
|
||
</div>
|
||
|
||
{st !== 'done' && (
|
||
<div className="pt-1 flex justify-end">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleStatusChange(t.id, st === 'todo' ? 'in_progress' : st === 'in_progress' ? 'review' : 'done');
|
||
}}
|
||
className="text-[11px] text-krona-green font-medium hover:underline"
|
||
>
|
||
Сдвинуть вперед →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
/* List View */
|
||
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft p-4">
|
||
<table className="w-full text-left text-xs border-collapse">
|
||
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
|
||
<tr>
|
||
<th className="p-3 font-semibold">Название задачи</th>
|
||
<th className="p-3 font-semibold">Проект</th>
|
||
<th className="p-3 font-semibold">Ответственный</th>
|
||
<th className="p-3 font-semibold">Срок</th>
|
||
<th className="p-3 font-semibold">Приоритет</th>
|
||
<th className="p-3 font-semibold">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-krona-border/50">
|
||
{tasks.map((t) => (
|
||
<tr
|
||
key={t.id}
|
||
onClick={() => setSelectedTask(t)}
|
||
className="hover:bg-krona-workspace/50 transition-colors cursor-pointer"
|
||
>
|
||
<td className="p-3 font-medium text-krona-primary">{t.title}</td>
|
||
<td className="p-3 text-krona-secondary">{t.project}</td>
|
||
<td className="p-3 font-medium text-krona-primary">{t.assignee}</td>
|
||
<td className="p-3 text-krona-secondary font-mono">{t.dueDate}</td>
|
||
<td className="p-3">
|
||
<Badge variant={t.priority === 'high' ? 'danger' : 'neutral'} size="sm">
|
||
{t.priority === 'high' ? 'Высокий' : 'Обычный'}
|
||
</Badge>
|
||
</td>
|
||
<td className="p-3">
|
||
<Badge variant="gold" size="sm">{t.status}</Badge>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Task Detail Modal (GAP 5) */}
|
||
{selectedTask && (
|
||
<Modal
|
||
isOpen={Boolean(selectedTask)}
|
||
onClose={() => setSelectedTask(null)}
|
||
title={`Задача: ${selectedTask.title}`}
|
||
subtitle={`Проект: ${selectedTask.project} • Исполнитель: ${selectedTask.assignee}`}
|
||
maxWidth="lg"
|
||
footer={
|
||
<div className="flex items-center justify-between w-full">
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => handleStatusChange(selectedTask.id, 'done')}
|
||
>
|
||
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
|
||
Отметить выполненной
|
||
</Button>
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={() => setSelectedTask(null)}>
|
||
Закрыть
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-4 text-xs">
|
||
{/* Meta attributes */}
|
||
<div className="grid grid-cols-3 gap-3 p-3 rounded-lg bg-krona-workspace border border-krona-border">
|
||
<div>
|
||
<span className="text-krona-secondary block text-[10px]">Ответственный:</span>
|
||
<span className="font-semibold text-krona-primary">{selectedTask.assignee}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-krona-secondary block text-[10px]">Срок завершения:</span>
|
||
<span className="font-semibold text-krona-primary font-mono">{selectedTask.dueDate}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-krona-secondary block text-[10px]">Приоритет:</span>
|
||
<Badge variant={selectedTask.priority === 'high' ? 'danger' : 'neutral'} size="sm">
|
||
{selectedTask.priority === 'high' ? 'Высокий (Срочно)' : 'Обычный'}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Linked CRM Entity */}
|
||
<div className="p-3 rounded-lg border border-krona-border bg-krona-surface flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Link className="w-4 h-4 text-krona-gold shrink-0" />
|
||
<div>
|
||
<span className="font-bold text-krona-primary">Связанный CRM контрагент:</span>
|
||
<div className="text-[11px] text-krona-secondary">ООО "Сибирский Ритейл" (Сделка: Банкет на 60 персон)</div>
|
||
</div>
|
||
</div>
|
||
<Badge variant="gold" size="sm">CRM Link</Badge>
|
||
</div>
|
||
|
||
{/* Checklist of Subtasks */}
|
||
<div className="space-y-2">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-krona-secondary block">
|
||
Чек-лист подзадач ({subtasks.filter(s => s.completed).length} из {subtasks.length}):
|
||
</span>
|
||
<div className="space-y-1.5 p-3 rounded-lg border border-krona-border bg-krona-workspace">
|
||
{subtasks.map((st) => (
|
||
<div
|
||
key={st.id}
|
||
onClick={() => handleToggleSubtask(st.id)}
|
||
className="flex items-center gap-2 cursor-pointer p-1 rounded hover:bg-krona-surface/60 transition-colors select-none"
|
||
>
|
||
<div
|
||
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||
st.completed
|
||
? 'bg-krona-green border-krona-green text-white'
|
||
: 'border-krona-border bg-krona-surface'
|
||
}`}
|
||
>
|
||
{st.completed && <Check className="w-3 h-3 stroke-[3]" />}
|
||
</div>
|
||
<span className={`text-xs ${st.completed ? 'line-through text-krona-secondary' : 'text-krona-primary'}`}>
|
||
{st.text}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Comments Stream & Input */}
|
||
<div className="space-y-2">
|
||
<span className="text-[10px] font-bold uppercase tracking-wider text-krona-secondary block">
|
||
Обсуждение задачи ({comments.length}):
|
||
</span>
|
||
|
||
<div className="space-y-2 max-h-40 overflow-y-auto p-2 rounded-lg bg-krona-workspace border border-krona-border">
|
||
{comments.map((cm) => (
|
||
<div key={cm.id} className="p-2 rounded bg-krona-surface border border-krona-border/60 text-[11px] space-y-0.5">
|
||
<div className="flex items-center justify-between text-krona-secondary">
|
||
<span className="font-semibold text-krona-primary">{cm.author}</span>
|
||
<span className="text-[10px]">{cm.time}</span>
|
||
</div>
|
||
<p className="text-krona-primary">{cm.text}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<form onSubmit={handleAddComment} className="flex gap-2 pt-1">
|
||
<input
|
||
type="text"
|
||
value={newComment}
|
||
onChange={(e) => setNewComment(e.target.value)}
|
||
placeholder="Добавить комментарий к задаче..."
|
||
className="flex-1 bg-krona-workspace border border-krona-border rounded-lg px-3 py-1.5 text-xs text-krona-primary placeholder:text-krona-secondary focus:outline-none focus:border-krona-green"
|
||
/>
|
||
<Button type="submit" variant="primary" size="sm">
|
||
<Send className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|