krona-front/src/features/tasks/TasksModule.tsx

379 lines
17 KiB
TypeScript
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, 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>
);
};