diff --git a/components/command-menu.tsx b/components/command-menu.tsx index 7efc976..77ca369 100644 --- a/components/command-menu.tsx +++ b/components/command-menu.tsx @@ -10,11 +10,11 @@ import { CommandList, CommandSeparator } from '@/components/ui/command' -import { searchEmployees, type StrapiResponse } from '@/lib/employees/api' +import { type StrapiResponse } from '@/lib/employees/api' import { MapObjectType } from '@/lib/map/MapObject' import { useMapStore } from '@/lib/stores/mapStore' import { cn } from '@/lib/utils' -import { type DialogProps } from '@radix-ui/react-dialog' +import { DialogProps } from '@radix-ui/react-dialog' import { CircleIcon, LaptopIcon, @@ -23,69 +23,66 @@ import { } from '@radix-ui/react-icons' import { Search } from 'lucide-react' import { useTheme } from 'next-themes' -import Image from 'next/image' -import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useQuery } from 'react-query' +import React from 'react' import { toast } from 'sonner' import { DialogTitle } from './ui/dialog' -export function CommandMenu({ ...props }: DialogProps) { - const router = useRouter() +export const CommandMenu = React.memo(function CommandMenu({ + ...props +}: DialogProps) { const [open, setOpen] = React.useState(false) const { setTheme } = useTheme() const [query, setQuery] = React.useState('') const { mapData, setSelectedFromSearchRoom } = useMapStore() - const { data: employeeData, isLoading: employeeIsLoading } = - useQuery( - ['searchEmployees', query], - async () => { - const employees = await searchEmployees(query) - const employeesByPositions = [] - for (const employee of employees.data) { - for (const position of employee.attributes.positions) { - employeesByPositions.push({ - id: employee.id, - attributes: { - ...employee.attributes, - positions: [position] - } - }) - } - } - return { data: employeesByPositions } - }, - { enabled: query !== '' && query.length > 3 } - ) + const calculateMatchPercentage = (name: string, query: string): number => { + const nameLower = name.toLowerCase() + const queryLower = query.toLowerCase() + let matchCount = 0 + + for (let i = 0; i < queryLower.length; i++) { + if (nameLower.includes(queryLower[i]!)) { + matchCount++ + } + } - const [results, setResults] = React.useState[]>([]) + return (matchCount / queryLower.length) * 100 + } - React.useEffect(() => { - if (query.length < 2) return + const results = React.useMemo(() => { + if (query.length < 2) return [] const searchResults = mapData?.searchObjectsByName(query, [MapObjectType.ROOM]) ?? [] - const newRes = [] - const visitedFloors = new Set() - for (const res of searchResults) { - if (!visitedFloors.has(res.floor)) { - const elementsForThisFloor = searchResults.filter( - result => result.floor === res.floor - ) - visitedFloors.add(res.floor) - newRes.push({ [res.floor]: elementsForThisFloor }) - } - } - if (newRes !== results) { - setResults(newRes) - } + const rankedResults = searchResults + .map(res => ({ + ...res, + matchPercentage: calculateMatchPercentage(res.mapObject.name, query) + })) + .sort((a, b) => b.matchPercentage - a.matchPercentage) + .slice(0, 30) // Обрезаем до 30 результатов + + const groupedResults = rankedResults.reduce( + (acc, res) => { + if (!acc[res.floor]) acc[res.floor] = [] + if (acc[res.floor]) { + acc[res.floor]!.push(res) + } + return acc + }, + {} as Record + ) + + return Object.entries(groupedResults).map(([floor, objects]) => ({ + floor, + objects + })) }, [query, mapData]) React.useEffect(() => { - const down = (e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { if ( (e.target instanceof HTMLElement && e.target.isContentEditable) || @@ -97,12 +94,12 @@ export function CommandMenu({ ...props }: DialogProps) { } e.preventDefault() - setOpen(open => !open) + setOpen(prev => !prev) } } - document.addEventListener('keydown', down) - return () => document.removeEventListener('keydown', down) + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) }, []) const runCommand = React.useCallback((command: () => unknown) => { @@ -110,28 +107,31 @@ export function CommandMenu({ ...props }: DialogProps) { command() }, []) - const onEmployeeClick = (employee: StrapiResponse['data'][0]) => { - const employeeRooms = employee?.attributes?.positions - .map(position => - position?.contacts.map(contact => contact?.room?.data.attributes) - ) - .flat() - - if (employeeRooms.length === 1) { - if (employeeRooms[0]?.name && employeeRooms[0]?.campus) { - const room = { - name: employeeRooms[0]?.name, - campus: employeeRooms[0]?.campus, - mapObject: null + const onEmployeeClick = React.useCallback( + (employee: StrapiResponse['data'][0]) => { + const employeeRooms = employee?.attributes?.positions + .flatMap(position => + position?.contacts.map(contact => contact?.room?.data.attributes) + ) + .filter(Boolean) + + if (employeeRooms.length === 1) { + const room = employeeRooms[0] + if (room?.name && room?.campus) { + setSelectedFromSearchRoom({ + name: room.name, + campus: room.campus, + mapObject: null + }) + setOpen(false) + return } - setSelectedFromSearchRoom(room) - setOpen(false) - return } - } - toast.error('Не удалось определить аудиторию сотрудника') - } + toast.error('Не удалось определить аудиторию сотрудника') + }, + [setSelectedFromSearchRoom] + ) return ( <> @@ -155,11 +155,12 @@ export function CommandMenu({ ...props }: DialogProps) { + Поиск аудиторий или сотрудников... @@ -168,83 +169,43 @@ export function CommandMenu({ ...props }: DialogProps) { placeholder="Введите или выберите команду..." onValueChange={setQuery} /> + {query === '' && ( Введите запрос для поиска сотрудников или аудиторий )} - {employeeData?.data && employeeData?.data.length > 0 && ( - - {employeeData.data.map(employee => ( - onEmployeeClick(employee)} - > - {employee.attributes.photo && ( - {`${employee.attributes.firstName} - )} -
- - {employee.attributes.lastName}{' '} - {employee.attributes.firstName} - {employee.attributes.patronymic && - ` ${employee.attributes.patronymic}`} - - - {employee.attributes.positions.map(position => ( - - {position.post}, {position.department} - - ))} - -
-
- ))} -
- )} + {results.length > 0 && ( - {results.map(object => - Object.entries(object).map(([floor, objects]) => ( -
-

{`Этаж ${floor}`}

- {objects.map((obj: any) => ( - { - setSelectedFromSearchRoom({ - name: obj.mapObject.name, - campus: '', - mapObject: obj.mapObject - }) - setOpen(false) - }} - value={obj.mapObject.name} - > -
- -
- {obj.mapObject.name} -
- ))} -
- )) - )} + {results.map(({ floor, objects }) => ( + +

{`Этаж ${floor}`}

+ {objects.map(obj => ( + { + setSelectedFromSearchRoom({ + name: obj.mapObject.name, + campus: '', + mapObject: obj.mapObject + }) + setOpen(false) + }} + value={obj.mapObject.name} + > +
+ +
+ {obj.mapObject.name} +
+ ))} +
+ ))}
)} - {query !== '' && - employeeData && - employeeData.data.length === 0 && - results.length === 0 && ( - Результатов не найдено - )} + runCommand(() => setTheme('light'))}> @@ -264,4 +225,4 @@ export function CommandMenu({ ...props }: DialogProps) {
) -} +}) diff --git a/lib/map/MapData.ts b/lib/map/MapData.ts index 4a77c49..58492ff 100644 --- a/lib/map/MapData.ts +++ b/lib/map/MapData.ts @@ -64,7 +64,7 @@ export class MapData { if ( !current || dist.get(vertex.id)! + this.heuristic(vertex, end) < - dist.get(current.id)! + this.heuristic(current, end) + dist.get(current.id)! + this.heuristic(current, end) ) { current = vertex } @@ -148,7 +148,7 @@ export class MapData { const prevVert = path[i - 1] as Vertex if (!vert.mapObjectId && !prevVert.mapObjectId) { - ;(pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) + ; (pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) continue } @@ -167,7 +167,7 @@ export class MapData { prevMapObj.type !== MapObjectType.STAIRS ) { // Если текущий объект лестница, а предыдущий нет, то добавить текущий объект в последний сегмент пути - ;(pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) + ; (pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) } else if ( mapObj.type !== MapObjectType.STAIRS && prevMapObj.type === MapObjectType.STAIRS @@ -179,10 +179,10 @@ export class MapData { prevMapObj.type !== MapObjectType.STAIRS ) { // Если оба объекта не лестницы, то добавить текущий объект в последний сегмент пути - ;(pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) + ; (pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) } } else { - ;(pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) + ; (pathsByStairs[pathsByStairs.length - 1] as Vertex[]).push(vert) } } @@ -317,67 +317,63 @@ export class MapData { const roomNumberPattern = /(?[А-Яа-я]+)?-? ?(?\d+)(?[А-Яа-я])?([-.]?(?[А-Яа-я0-9]+))?/ - return searchObjects - .filter(o => { - if ( - o.mapObject.type === MapObjectType.ROOM && - mapTypesToSearch.includes(MapObjectType.ROOM) - ) { - const matchObject = o.mapObject.name.match(roomNumberPattern) - const matchName = name.match(roomNumberPattern) + const normalizedQuery = name.toLowerCase().replace('-', '').replace(' ', '') - if (matchObject && matchName) { - const buildingObject = matchObject.groups?.building - const numberObject = matchObject.groups?.number - const letterObject = matchObject.groups?.letter - const postfixObject = matchObject.groups?.postfix + const objectsByRoomName = new Map() - const buildingName = matchName.groups?.building - const numberName = matchName.groups?.number - const letterName = matchName.groups?.letter - const postfixName = matchName.groups?.postfix + searchObjects.forEach(o => { + const roomName = o.mapObject.name + .toLowerCase() + .replace('-', '') + .replace(' ', '') + if (objectsByRoomName.has(roomName)) { + objectsByRoomName.get(roomName)?.push(o) + } else { + objectsByRoomName.set(roomName, [o]) + } + }) - if (numberName) { - if ( - !numberObject?.toLowerCase().includes(numberName.toLowerCase()) - ) { - return false - } - } + const results: SearchableObject[] = [] + + for (const [roomName, objects] of objectsByRoomName) { + if (roomName.includes(normalizedQuery)) { + for (const o of objects) { + if ( + mapTypesToSearch.includes(o.mapObject.type) && + (!roomName || roomName.includes(normalizedQuery)) + ) { + const matchObject = o.mapObject.name.match(roomNumberPattern) + const matchName = name.match(roomNumberPattern) + + if (matchObject && matchName) { + const buildingObject = matchObject.groups?.building?.toLowerCase() + const numberObject = matchObject.groups?.number?.toLowerCase() + const letterObject = matchObject.groups?.letter?.toLowerCase() + const postfixObject = matchObject.groups?.postfix?.toLowerCase() + + const buildingName = matchName.groups?.building?.toLowerCase() + const numberName = matchName.groups?.number?.toLowerCase() + const letterName = matchName.groups?.letter?.toLowerCase() + const postfixName = matchName.groups?.postfix?.toLowerCase() - if (buildingName) { if ( - !buildingObject - ?.toLowerCase() - .includes(buildingName.toLowerCase()) + (numberName && numberObject?.includes(numberName)) || + (buildingName && buildingObject?.includes(buildingName)) || + (letterName && letterObject === letterName) || + (postfixName && postfixObject === postfixName) ) { - return false - } - } - - if (letterName) { - if (letterObject?.toLowerCase() !== letterName.toLowerCase()) { - return false + results.push(o) + continue } } - if (postfixName) { - if (postfixObject?.toLowerCase() !== postfixName.toLowerCase()) { - return false - } - } + results.push(o) } } + } + } - return ( - o.mapObject.name - .toLowerCase() - .replace('-', '') - .replace(' ', '') - .includes(name.toLowerCase().replace('-', '').replace(' ', '')) && - mapTypesToSearch.includes(o.mapObject.type) - ) - }) + return results .map(o => ({ floor: this.getObjectFloorByMapObjectId(o.mapObject.id)?.toString() || '', @@ -389,7 +385,7 @@ export class MapData { if (b.mapObject.name === name) return 1 if (a.mapObject.name < b.mapObject.name) return -1 - if (a.mapObject.name > b.mapObject.name) return 1 + if (b.mapObject.name > a.mapObject.name) return 1 return 0 })