236 lines
9.2 KiB
TypeScript
236 lines
9.2 KiB
TypeScript
import { useState } from 'react';
|
||
import { useLocation, Link } from 'wouter';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import Layout from '@/components/Layout';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||
import { Plus, ChevronRight, FileText, CheckSquare, ArrowRight } from 'lucide-react';
|
||
import { ListSkeleton, Skeleton } from '@/components/ui/skeleton';
|
||
import { StaggerList, StaggerItem } from '@/components/transitions/StaggerList';
|
||
|
||
interface TaskItem {
|
||
id: number;
|
||
formId: number;
|
||
title: string;
|
||
currentStatusId: number;
|
||
assignedTo?: number;
|
||
updatedAt: string;
|
||
createdAt: string;
|
||
status?: string;
|
||
}
|
||
|
||
interface FormItem {
|
||
id: number;
|
||
name: string;
|
||
description?: string;
|
||
isActive: boolean;
|
||
}
|
||
|
||
function timeAgo(dateStr: string): string {
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff / 60000);
|
||
if (mins < 1) return 'только что';
|
||
if (mins < 60) return `${mins} мин назад`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs} ч назад`;
|
||
const days = Math.floor(hrs / 24);
|
||
if (days < 30) return `${days} д назад`;
|
||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
const Tasks = () => {
|
||
const [, setLocation] = useLocation();
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
|
||
const { data: tasksData, isLoading: tasksLoading } = useQuery<{ success: boolean; tasks: TaskItem[] }>({
|
||
queryKey: ['/api/tasks?minimal=true&limit=100'],
|
||
});
|
||
|
||
const { data: formsData, isLoading: formsLoading } = useQuery<{ success: boolean; forms: FormItem[] }>({
|
||
queryKey: ['/api/forms'],
|
||
});
|
||
|
||
const tasks = tasksData?.tasks || [];
|
||
const forms = formsData?.forms || [];
|
||
const activeForms = forms.filter(f => f.isActive);
|
||
const formMap = new Map(forms.map(f => [f.id, f]));
|
||
|
||
const isLoading = tasksLoading || formsLoading;
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="p-3 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-sm font-medium text-foreground">Задачи</h1>
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{isLoading ? <Skeleton className="h-3 w-24" /> : `${tasks.length} задач в системе`}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
className="h-7 px-2.5 text-xs"
|
||
onClick={() => setCreateDialogOpen(true)}
|
||
disabled={formsLoading || activeForms.length === 0}
|
||
data-testid="button-create-task"
|
||
>
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Создать
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<ListSkeleton rows={8} />
|
||
) : tasks.length === 0 ? (
|
||
<EmptyState forms={activeForms} onCreateClick={() => setCreateDialogOpen(true)} />
|
||
) : (
|
||
<StaggerList className="border border-border rounded divide-y divide-border overflow-hidden">
|
||
{tasks.map(task => {
|
||
const form = formMap.get(task.formId);
|
||
return (
|
||
<StaggerItem
|
||
key={task.id}
|
||
className="flex items-center gap-2 p-2.5 hover:bg-muted/40 cursor-pointer transition-colors"
|
||
onClick={() => setLocation(`/forms/${task.formId}/tasks/${task.id}`)}
|
||
data-testid={`task-row-${task.id}`}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
|
||
{form && (
|
||
<span className="text-[10px] text-muted-foreground font-medium shrink-0">
|
||
{form.name}
|
||
</span>
|
||
)}
|
||
{task.status && (
|
||
<Badge variant="outline" className="text-[10px] h-4 px-1 py-0 font-normal shrink-0">
|
||
{task.status}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<p className="text-xs truncate">{task.title}</p>
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground shrink-0 hidden sm:block">
|
||
{timeAgo(task.updatedAt)}
|
||
</div>
|
||
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
|
||
</StaggerItem>
|
||
);
|
||
})}
|
||
</StaggerList>
|
||
)}
|
||
</div>
|
||
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">Выберите форму</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
Задача будет создана в выбранной форме
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||
{activeForms.length === 0 ? (
|
||
<div className="text-center py-4 space-y-2">
|
||
<p className="text-xs text-muted-foreground">Нет доступных форм</p>
|
||
<Link href="/forms">
|
||
<Button variant="outline" className="h-7 px-2.5 text-xs">
|
||
Создать форму
|
||
<ArrowRight className="w-3 h-3 ml-1" />
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
activeForms.map(form => (
|
||
<button
|
||
key={form.id}
|
||
className="w-full flex items-center gap-2 p-2 rounded text-left hover:bg-muted transition-colors"
|
||
onClick={() => {
|
||
setCreateDialogOpen(false);
|
||
setLocation(`/forms/${form.id}/tasks`);
|
||
}}
|
||
data-testid={`form-option-${form.id}`}
|
||
>
|
||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-xs">{form.name}</p>
|
||
{form.description && (
|
||
<p className="text-[10px] text-muted-foreground truncate">{form.description}</p>
|
||
)}
|
||
</div>
|
||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
function EmptyState({ forms, onCreateClick }: { forms: FormItem[]; onCreateClick: () => void }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="border border-border rounded p-6 flex flex-col items-center gap-2 text-center">
|
||
<CheckSquare className="w-8 h-8 text-muted-foreground/40" />
|
||
{forms.length === 0 ? (
|
||
<>
|
||
<div>
|
||
<p className="text-xs font-medium">Пока нет ни одной формы</p>
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||
Задачи создаются внутри форм — начните с создания первой формы
|
||
</p>
|
||
</div>
|
||
<Link href="/forms">
|
||
<Button className="h-7 px-2.5 text-xs mt-1">
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Создать форму
|
||
<ArrowRight className="w-3 h-3 ml-1" />
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div>
|
||
<p className="text-xs font-medium">Задач пока нет</p>
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||
Выберите форму и создайте первую задачу
|
||
</p>
|
||
</div>
|
||
<Button className="h-7 px-2.5 text-xs mt-1" onClick={onCreateClick}>
|
||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||
Создать задачу
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{forms.length > 0 && (
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground font-medium mb-1.5 px-0.5">
|
||
Доступные формы
|
||
</p>
|
||
<div className="border border-border rounded divide-y divide-border overflow-hidden">
|
||
{forms.map(form => (
|
||
<Link key={form.id} href={`/forms/${form.id}/tasks`}>
|
||
<div className="flex items-center gap-2 p-2.5 hover:bg-muted/40 cursor-pointer transition-colors">
|
||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-xs font-medium">{form.name}</p>
|
||
{form.description && (
|
||
<p className="text-[10px] text-muted-foreground truncate">{form.description}</p>
|
||
)}
|
||
</div>
|
||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Tasks;
|