MDM/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx

583 lines
20 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 {
memo,
type ChangeEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Popover from '@radix-ui/react-popover'
import { Building2, ChevronDown, Search, ShieldCheck, X } from 'lucide-react'
import { useQuery } from '@apollo/client/react'
import {
GET_ORGANISATION_QUERY,
GET_ORGANISATIONS_QUERY,
} from '../../../../entities/employee/api/employee.graphql'
import type {
EmployeeRole,
GetOrganisationData,
GetOrganisationVariables,
GetOrganisationsData,
GetOrganisationsVariables,
} from '../../../../entities/employee/model/types'
import './EmployeesToolbar.scss'
export type EmployeesSortOption = {
value: string
label: string
sortField: 'ID' | 'Name' | 'Date'
sortDirection: 'ASC' | 'DESC'
}
export const usersSortOptions: EmployeesSortOption[] = [
{
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
sortField: 'ID',
sortDirection: 'ASC',
},
{
value: 'id-desc',
label: 'ID по убыванию',
sortField: 'ID',
sortDirection: 'DESC',
},
{
value: 'name-asc',
label: 'ФИО А–Я',
sortField: 'Name',
sortDirection: 'ASC',
},
{
value: 'name-desc',
label: 'ФИО Я–А',
sortField: 'Name',
sortDirection: 'DESC',
},
{
value: 'date-asc',
label: 'Сначала старые',
sortField: 'Date',
sortDirection: 'ASC',
},
]
export const organisationsSortOptions: EmployeesSortOption[] = [
{
value: 'date-desc',
label: 'Сначала новые',
sortField: 'Date',
sortDirection: 'DESC',
},
{
value: 'id-asc',
label: 'ID по возрастанию',
sortField: 'ID',
sortDirection: 'ASC',
},
{
value: 'id-desc',
label: 'ID по убыванию',
sortField: 'ID',
sortDirection: 'DESC',
},
{
value: 'name-asc',
label: 'Название А–Я',
sortField: 'Name',
sortDirection: 'ASC',
},
{
value: 'name-desc',
label: 'Название Я–А',
sortField: 'Name',
sortDirection: 'DESC',
},
{
value: 'date-asc',
label: 'Сначала старые',
sortField: 'Date',
sortDirection: 'ASC',
},
]
export type EmployeesRoleFilterOption = {
value: 'Admin' | 'User'
label: string
}
const roleFilterOptions: EmployeesRoleFilterOption[] = [
{
value: 'Admin',
label: 'Администратор',
},
{
value: 'User',
label: 'Пользователь',
},
]
type EmployeesToolbarProps = {
addButtonText: string
onAdd: () => void
searchValue?: string
onSearchChange?: (value: string) => void
sortOptions: EmployeesSortOption[]
selectedSort: EmployeesSortOption
onSortChange: (option: EmployeesSortOption) => void
showSort?: boolean
roleFilter?: EmployeeRole | null
onRoleFilterChange?: (role: EmployeeRole | null) => void
organisationFilterId?: string | null
onOrganisationFilterChange?: (
organisation: { id: string; name: string } | null,
) => void
showUserFilters?: boolean
}
export const EmployeesToolbar = memo(function EmployeesToolbar({
addButtonText,
onAdd,
searchValue = '',
onSearchChange,
sortOptions,
selectedSort,
onSortChange,
showSort = true,
roleFilter = null,
onRoleFilterChange,
organisationFilterId = null,
onOrganisationFilterChange,
showUserFilters = false,
}: EmployeesToolbarProps) {
const [isOrganisationOpen, setIsOrganisationOpen] = useState(false)
const [organisationSearch, setOrganisationSearch] = useState('')
const [debouncedOrganisationSearch, setDebouncedOrganisationSearch] =
useState('')
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedOrganisationSearch(organisationSearch.trim())
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [organisationSearch])
const selectedRoleOption = useMemo(
() =>
roleFilterOptions.find((option) => option.value === roleFilter) ?? null,
[roleFilter],
)
const selectedOrganisationVariables = useMemo<GetOrganisationVariables>(
() => ({
id: organisationFilterId ?? '',
}),
[organisationFilterId],
)
const organisationsVariables = useMemo<GetOrganisationsVariables>(
() => ({
page: 0,
query: debouncedOrganisationSearch || undefined,
sortDirection: 'ASC',
sortField: 'Name',
}),
[debouncedOrganisationSearch],
)
const {
data: selectedOrganisationData,
} = useQuery<GetOrganisationData, GetOrganisationVariables>(
GET_ORGANISATION_QUERY,
{
variables: selectedOrganisationVariables,
skip: !showUserFilters || !organisationFilterId,
fetchPolicy: 'cache-first',
},
)
const {
data: organisationsData,
previousData: previousOrganisationsData,
loading: organisationsLoading,
error: organisationsError,
} = useQuery<GetOrganisationsData, GetOrganisationsVariables>(
GET_ORGANISATIONS_QUERY,
{
variables: organisationsVariables,
skip: !showUserFilters || !isOrganisationOpen,
fetchPolicy: 'network-only',
notifyOnNetworkStatusChange: true,
},
)
const organisationsResponse =
organisationsData?.getOrganisations ??
previousOrganisationsData?.getOrganisations
const organisations = useMemo(
() => organisationsResponse?.page ?? [],
[organisationsResponse?.page],
)
const selectedOrganisationName =
selectedOrganisationData?.getOrganisation?.name ??
(organisationFilterId ? `Организация #${organisationFilterId}` : null)
const organisationOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string }>()
if (organisationFilterId && selectedOrganisationName) {
map.set(organisationFilterId, {
id: organisationFilterId,
name: selectedOrganisationName,
})
}
organisations.forEach((organisation) => {
map.set(String(organisation.id), {
id: String(organisation.id),
name: organisation.name,
})
})
return Array.from(map.values())
}, [organisationFilterId, selectedOrganisationName, organisations])
const handleSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
onSearchChange?.(event.target.value)
}, [onSearchChange])
const handleOrganisationSearchChange = useCallback((
event: ChangeEvent<HTMLInputElement>,
) => {
setOrganisationSearch(event.target.value)
}, [])
const handleResetClick = useCallback((
event: MouseEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
}, [])
const handleRoleResetPointerDown = useCallback((
event: PointerEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
onRoleFilterChange?.(null)
}, [onRoleFilterChange])
const handleOrganisationResetPointerDown = useCallback((
event: PointerEvent<HTMLSpanElement>,
) => {
event.preventDefault()
event.stopPropagation()
onOrganisationFilterChange?.(null)
}, [onOrganisationFilterChange])
const handleRoleResetKeyDown = useCallback((
event: KeyboardEvent<HTMLSpanElement>,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
onRoleFilterChange?.(null)
}, [onRoleFilterChange])
const handleOrganisationResetKeyDown = useCallback((
event: KeyboardEvent<HTMLSpanElement>,
) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
event.stopPropagation()
onOrganisationFilterChange?.(null)
}, [onOrganisationFilterChange])
const handleOrganisationSelect = useCallback((
organisation: { id: string; name: string },
) => {
onOrganisationFilterChange?.(organisation)
setIsOrganisationOpen(false)
setOrganisationSearch('')
setDebouncedOrganisationSearch('')
}, [onOrganisationFilterChange])
return (
<div className="devices-toolbar">
<label className="devices-search">
<Search size={16} />
<input
type="text"
placeholder="Поиск"
value={searchValue}
onChange={handleSearchChange}
/>
</label>
<div className="devices-toolbar__right">
{showUserFilters && (
<>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className={`employees-filter-button ${
selectedRoleOption ? 'is-active' : ''
}`}
type="button"
>
<ShieldCheck size={17} />
<span>{selectedRoleOption?.label ?? 'Роль'}</span>
{selectedRoleOption ? (
<span
className="employees-filter-button__reset"
role="button"
tabIndex={0}
onPointerDown={handleRoleResetPointerDown}
onClick={handleResetClick}
onKeyDown={handleRoleResetKeyDown}
aria-label="Сбросить фильтр роли"
>
<X size={14} />
</span>
) : (
<ChevronDown size={16} />
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{roleFilterOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onRoleFilterChange?.(option.value)}
>
<span>{option.label}</span>
{roleFilter === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<Popover.Root
open={isOrganisationOpen}
onOpenChange={setIsOrganisationOpen}
>
<Popover.Trigger asChild>
<button
className={`employees-filter-button employees-filter-button--organisation ${
organisationFilterId ? 'is-active' : ''
}`}
type="button"
>
<Building2 size={17} />
<span>{selectedOrganisationName ?? 'Организация'}</span>
{organisationFilterId ? (
<span
className="employees-filter-button__reset"
role="button"
tabIndex={0}
onPointerDown={handleOrganisationResetPointerDown}
onClick={handleResetClick}
onKeyDown={handleOrganisationResetKeyDown}
aria-label="Сбросить фильтр организации"
>
<X size={14} />
</span>
) : (
<ChevronDown size={16} />
)}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="employees-organisation-filter"
align="end"
sideOffset={8}
collisionPadding={12}
>
<label className="employees-organisation-filter__search">
<Search size={16} />
<input
value={organisationSearch}
onChange={handleOrganisationSearchChange}
placeholder="Название организации"
autoFocus
/>
</label>
<div className="employees-organisation-filter__list">
{organisationsLoading && organisationOptions.length === 0 && (
<div className="employees-organisation-filter__state">
Загрузка организаций...
</div>
)}
{organisationsError && (
<div className="employees-organisation-filter__state is-error">
Не удалось загрузить организации
</div>
)}
{!organisationsLoading &&
!organisationsError &&
organisationOptions.length === 0 && (
<div className="employees-organisation-filter__state">
Организации не найдены
</div>
)}
{organisationOptions.map((organisation) => {
const isSelected =
organisation.id === organisationFilterId
return (
<button
className={`employees-organisation-filter__option ${
isSelected ? 'is-selected' : ''
}`}
type="button"
key={organisation.id}
onClick={() => handleOrganisationSelect(organisation)}
>
<span>{organisation.name}</span>
<small>ID: {organisation.id}</small>
</button>
)
})}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</>
)}
{showSort && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="employees-sort" type="button">
{selectedSort.label}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M11 10h7m-7 4h5m-5 4h3M11 6h10M7 18.813C6.607 19.255 5.56 21 5 21m-2-2.187C3.393 19.255 4.44 21 5 21m0 0v-6M3 5.188C3.393 4.745 4.44 3 5 3m2 2.188C6.607 4.745 5.56 3 5 3m0 0v6" />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="employees-sort-menu"
align="end"
sideOffset={8}
>
{sortOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="employees-sort-menu__item"
onSelect={() => onSortChange?.(option)}
>
<span>{option.label}</span>
{selectedSort.value === option.value && (
<span className="employees-sort-menu__check" />
)}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<button
className="add-device add-employees"
type="button"
onClick={onAdd}
>
{addButtonText === 'Добавить организацию' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<defs>
<mask id="SVGRejWFe9v">
<g fill="none" stroke="#fff" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path strokeDasharray="64" d="M9 7h11c0.55 0 1 0.45 1 1v11c0 0.55 -0.45 1 -1 1h-16c-0.55 0 -1 -0.45 -1 -1v-11c0 -0.55 0.45 -1 1 -1Z">
<animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0" />
</path>
<path strokeDasharray="16" strokeDashoffset="16" d="M9 7v-3c0 -0.55 0.45 -1 1 -1h4c0.55 0 1 0.45 1 1v3">
<animate fill="freeze" attributeName="stroke-dashoffset" begin="0.6s" dur="0.3s" to="0" />
</path>
</g>
<path d="M19 13c3.31 0 6 2.69 6 6c0 3.31 -2.69 6 -6 6c-3.31 0 -6 -2.69 -6 -6c0 -3.31 2.69 -6 6 -6Z" opacity="0">
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
</path>
</mask>
</defs>
<path fill="currentColor" d="M0 0h24v24H0z" mask="url(#SVGRejWFe9v)" />
<g fill="none" stroke="currentColor" strokeDasharray="8" strokeDashoffset="8" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M16 19h6">
<animate fill="freeze" attributeName="stroke-dashoffset" begin="1s" dur="0.2s" to="0" />
</path>
<path d="M19 16v6">
<animate fill="freeze" attributeName="stroke-dashoffset" begin="1.2s" dur="0.2s" to="0" />
</path>
</g>
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.00004 4.66683C4.00004 3.78277 4.35123 2.93493 4.97635 2.30981C5.60147 1.68469 6.44932 1.3335 7.33337 1.3335C8.21743 1.3335 9.06527 1.68469 9.6904 2.30981C10.3155 2.93493 10.6667 3.78277 10.6667 4.66683C10.6667 5.55088 10.3155 6.39873 9.6904 7.02385C9.06527 7.64897 8.21743 8.00016 7.33337 8.00016C6.44932 8.00016 5.60147 7.64897 4.97635 7.02385C4.35123 6.39873 4.00004 5.55088 4.00004 4.66683ZM3.21471 9.7815C4.28337 9.1295 5.73671 8.66683 7.33337 8.66683C7.6316 8.66683 7.92404 8.68238 8.21071 8.7135C8.32522 8.72578 8.4346 8.76752 8.52818 8.83465C8.62177 8.90178 8.69636 8.99201 8.7447 9.09654C8.79304 9.20108 8.81348 9.31635 8.80401 9.43114C8.79455 9.54592 8.75551 9.65629 8.69071 9.7515C8.23917 10.4143 7.99844 11.1981 8.00004 12.0002C8.00004 12.6135 8.13804 13.1935 8.38337 13.7115C8.43118 13.8124 8.45286 13.9237 8.44645 14.0352C8.44004 14.1467 8.40572 14.2547 8.34666 14.3495C8.2876 14.4443 8.20568 14.5227 8.10843 14.5775C8.01117 14.6324 7.90169 14.662 7.79004 14.6635L7.33337 14.6668C5.84737 14.6668 4.44337 14.5735 3.39137 14.2948C2.86804 14.1562 2.37537 13.9575 2.00204 13.6575C1.60671 13.3402 1.33337 12.8968 1.33337 12.3335C1.33337 11.8088 1.57204 11.3182 1.89604 10.9075C2.22537 10.4908 2.68071 10.1075 3.21471 9.78083V9.7815ZM12 9.3335C12.1769 9.3335 12.3464 9.40373 12.4714 9.52876C12.5965 9.65378 12.6667 9.82335 12.6667 10.0002V11.3335H14C14.1769 11.3335 14.3464 11.4037 14.4714 11.5288C14.5965 11.6538 14.6667 11.8234 14.6667 12.0002C14.6667 12.177 14.5965 12.3465 14.4714 12.4716C14.3464 12.5966 14.1769 12.6668 14 12.6668H12.6667V14.0002C12.6667 14.177 12.5965 14.3465 12.4714 14.4716C12.3464 14.5966 12.1769 14.6668 12 14.6668C11.8232 14.6668 11.6537 14.5966 11.5286 14.4716C11.4036 14.3465 11.3334 14.177 11.3334 14.0002V12.6668H10C9.82323 12.6668 9.65366 12.5966 9.52864 12.4716C9.40361 12.3465 9.33337 12.177 9.33337 12.0002C9.33337 11.8234 9.40361 11.6538 9.52864 11.5288C9.65366 11.4037 9.82323 11.3335 10 11.3335H11.3334V10.0002C11.3334 9.82335 11.4036 9.65378 11.5286 9.52876C11.6537 9.40373 11.8232 9.3335 12 9.3335Z"
fill="white"
/>
</svg>
)}
{addButtonText}
</button>
</div>
</div>
)
})