searchTasksByKeyword теперь возвращает isCompleted и через leftJoin formStatuses — statusName/statusColor/statusIsFinal. В HomeGlobalSearch закрытые задачи (isCompleted || statusIsFinal) зачёркнуты и приглушены, у каждой задачи цветной бейдж текущего статуса.
157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
import { useState, useMemo, useRef, useEffect } from 'react';
|
||
import { useLocation } from 'wouter';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Search, FileText, CheckSquare } from 'lucide-react';
|
||
import { Input } from '@/components/ui/input';
|
||
import { apiRequest } from '@/lib/queryClient';
|
||
import { useDebounce } from '@/hooks/useDebounce';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface FormItem {
|
||
id: number;
|
||
name: string;
|
||
}
|
||
|
||
interface TaskItem {
|
||
id: number;
|
||
title: string;
|
||
formId: number;
|
||
isCompleted?: boolean;
|
||
statusName?: string | null;
|
||
statusColor?: string | null;
|
||
statusIsFinal?: boolean | null;
|
||
}
|
||
|
||
interface HomeGlobalSearchProps {
|
||
className?: string;
|
||
}
|
||
|
||
export function HomeGlobalSearch({ className }: HomeGlobalSearchProps) {
|
||
const [rawQuery, setRawQuery] = useState('');
|
||
const [open, setOpen] = useState(false);
|
||
const query = useDebounce(rawQuery, 250);
|
||
const [_, setLocation] = useLocation();
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const trimmed = query.trim().toLowerCase();
|
||
|
||
const { data: formsData } = useQuery<{ success: boolean; forms: FormItem[] }>({
|
||
queryKey: ['/api/forms'],
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
const { data: tasksData, isFetching: isTasksLoading } = useQuery<{ success: boolean; tasks: TaskItem[] }>({
|
||
queryKey: ['/api/tasks', 'search', trimmed],
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', `/api/tasks?search=${encodeURIComponent(trimmed)}&limit=50`);
|
||
return res.json();
|
||
},
|
||
enabled: trimmed.length > 0,
|
||
});
|
||
|
||
const forms = formsData?.forms || [];
|
||
const tasks = tasksData?.tasks || [];
|
||
|
||
const filteredForms = useMemo(() => {
|
||
if (!trimmed) return [];
|
||
return forms.filter((f) => f.name.toLowerCase().includes(trimmed)).slice(0, 10);
|
||
}, [forms, trimmed]);
|
||
|
||
const handleSelectForm = (formId: number) => {
|
||
setOpen(false);
|
||
setRawQuery('');
|
||
setLocation(`/forms/${formId}/tasks`);
|
||
};
|
||
|
||
const handleSelectTask = (task: TaskItem) => {
|
||
setOpen(false);
|
||
setRawQuery('');
|
||
setLocation(`/forms/${task.formId}/tasks/${task.id}`);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||
setOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [open]);
|
||
|
||
const hasResults = filteredForms.length > 0 || tasks.length > 0;
|
||
const showDropdown = open && trimmed;
|
||
|
||
return (
|
||
<div ref={containerRef} className={cn('relative', className)}>
|
||
<div className="relative">
|
||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Поиск форм и задач..."
|
||
className="pl-7 h-8 text-xs w-full"
|
||
value={rawQuery}
|
||
onChange={(e) => {
|
||
setRawQuery(e.target.value);
|
||
setOpen(true);
|
||
}}
|
||
onFocus={() => setOpen(true)}
|
||
/>
|
||
</div>
|
||
{showDropdown && (
|
||
<div className="absolute z-50 mt-1 right-0 w-72 max-w-[90vw] bg-popover border border-border rounded-md shadow-md p-1">
|
||
{isTasksLoading && !hasResults ? (
|
||
<div className="px-2 py-3 text-xs text-muted-foreground text-center">Поиск...</div>
|
||
) : !hasResults ? (
|
||
<div className="px-2 py-3 text-xs text-muted-foreground text-center">Ничего не найдено</div>
|
||
) : (
|
||
<div className="max-h-72 overflow-auto">
|
||
{filteredForms.length > 0 && (
|
||
<div className="mb-1">
|
||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase">Формы</div>
|
||
{filteredForms.map((f) => (
|
||
<button
|
||
key={`form-${f.id}`}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent rounded text-left"
|
||
onClick={() => handleSelectForm(f.id)}
|
||
>
|
||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||
<span className="truncate">{f.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{tasks.length > 0 && (
|
||
<div>
|
||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase">Задачи</div>
|
||
{tasks.map((t) => {
|
||
const isClosed = !!t.isCompleted || !!t.statusIsFinal;
|
||
return (
|
||
<button
|
||
key={`task-${t.id}`}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-accent rounded text-left"
|
||
onClick={() => handleSelectTask(t)}
|
||
>
|
||
<CheckSquare className={cn('w-3.5 h-3.5 shrink-0', isClosed ? 'text-muted-foreground/60' : 'text-muted-foreground')} />
|
||
<span className={cn('truncate flex-1 min-w-0', isClosed && 'line-through text-muted-foreground')}>{t.title}</span>
|
||
{t.statusName && (
|
||
<span
|
||
className="shrink-0 px-1.5 py-0.5 rounded text-[10px] leading-none"
|
||
style={{ backgroundColor: (t.statusColor || '#6b7280') + '20', color: t.statusColor || '#6b7280' }}
|
||
>
|
||
{t.statusName}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|