583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
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>
|
||
)
|
||
})
|