Files
iistwin/client/src/components/HomeGlobalSearch.tsx
Ильяс Султанов 1bb6a53226 feat: закрытые задачи в глобальном поиске зачёркнуты + бейдж статуса
searchTasksByKeyword теперь возвращает isCompleted и через leftJoin
formStatuses — statusName/statusColor/statusIsFinal. В HomeGlobalSearch
закрытые задачи (isCompleted || statusIsFinal) зачёркнуты и приглушены,
у каждой задачи цветной бейдж текущего статуса.
2026-08-03 13:29:36 +03:00

157 lines
5.8 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 { 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>
);
}