diff --git a/admin-ui/src/common/util.test.ts b/admin-ui/src/common/util.test.ts index 206ac0065c..f5e8394b01 100644 --- a/admin-ui/src/common/util.test.ts +++ b/admin-ui/src/common/util.test.ts @@ -50,4 +50,16 @@ test("filterData", () => { [{ title: "", key: "name", value: "bar" }] ) ).toEqual([{ name: "bar" }]); + expect( + filterData( + [ + { name: "bar", size: 4 }, + { name: "bar", size: 5 }, + ], + [ + { title: "", key: "name", value: "bar" }, + { title: "", key: "size", value: 5 }, + ] + ) + ).toEqual([{ name: "bar", size: 5 }]); }); diff --git a/admin-ui/src/common/util.ts b/admin-ui/src/common/util.ts index 2d7d135f2c..b86cb3f176 100644 --- a/admin-ui/src/common/util.ts +++ b/admin-ui/src/common/util.ts @@ -3,7 +3,7 @@ import i18next from "i18next"; import trim from "lodash/trim"; import upperFirst from "lodash/upperFirst"; import get from "lodash/get"; -import { uniqBy } from "lodash"; +import { groupBy } from "lodash"; import { AllocationResult, ApplicationEventSchedule, @@ -336,15 +336,23 @@ export const parseAddressLine2 = ( ); }; +/** Filtering logic "OR within the group, AND between groups" */ export const filterData = (data: T[], filters: DataFilterOption[]): T[] => { - const len = uniqBy(filters, "key").length; - return data.filter( - (row) => - filters.filter((filter) => { + const groups = groupBy(filters, "key"); + const groupCount = Object.keys(groups).length; + + return data.filter((row) => { + const groupsNames = Object.keys(groups); + const groupsMatched = groupsNames.filter((name) => { + const found = groups[name].find((filter) => { if (filter.function) { return filter.function(row); } return get(row, filter.key as string) === filter.value; - }).length === len - ); + }); + return Boolean(found); + }); + + return groupsMatched.length === groupCount; + }); }; diff --git a/admin-ui/src/component/recurring-reservations/Review.tsx b/admin-ui/src/component/recurring-reservations/Review.tsx index b7b468cc90..fe7a1da136 100644 --- a/admin-ui/src/component/recurring-reservations/Review.tsx +++ b/admin-ui/src/component/recurring-reservations/Review.tsx @@ -1,5 +1,5 @@ -import { IconSliders, Table, Tabs } from "hds-react"; -import { uniq, uniqBy } from "lodash"; +import { Button, IconCross, Select, Table, Tabs, Tag } from "hds-react"; +import { memoize, sortBy, uniq, uniqBy } from "lodash"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -7,17 +7,16 @@ import styled from "styled-components"; import { getApplications } from "../../common/api"; import { ApplicationRound as ApplicationRoundType, - DataFilterConfig, DataFilterOption, + Unit, } from "../../common/types"; import { applicationDetailsUrl, applicationRoundUrl } from "../../common/urls"; import { filterData } from "../../common/util"; import { useNotification } from "../../context/NotificationContext"; import { IngressContainer } from "../../styles/layout"; import { H2 } from "../../styles/new-typography"; +import { breakpoints } from "../../styles/util"; import StatusRecommendation from "../applications/StatusRecommendation"; -import { FilterBtn } from "../FilterContainer"; -import FilterControls from "../FilterControls"; import Loader from "../Loader"; import withMainMenu from "../withMainMenu"; import { NaviItem } from "./ApplicationRoundNavi"; @@ -51,6 +50,9 @@ const StyledH2 = styled(H2)` `; const TabContent = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-m); margin-top: var(--spacing-s); line-height: 1; `; @@ -61,6 +63,11 @@ const ApplicationRoundName = styled.div` line-height: var(--lineheight-m); `; +const RoundTag = styled(Tag)` + border-radius: 30px; + padding: 0 1em; +`; + const StyledLink = styled(Link)` color: black; `; @@ -69,18 +76,31 @@ const StyledApplicationRoundStatusBlock = styled(ApplicationRoundStatusBlock)` margin: 0; `; +const NoDataMessage = styled.span` + line-height: 4; +`; + const TableWrapper = styled.div` - width: 100%; + div { + overflow-x: auto; + @media (min-width: ${breakpoints.xl}) { + overflow-x: unset !important; + } + } caption { text-align: end; } table { - min-width: 1000px; - overflow: scroll; + max-width: var(--container-width-l); + th { font-family: var(--font-bold); padding: var(--spacing-xs); + background: white; + position: sticky; + top: 0; + box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4); } td { white-space: nowrap; @@ -89,60 +109,99 @@ const TableWrapper = styled.div` } `; -const FilterContainer = styled.div` - background-color: white; +const FiltersContainer = styled.div` + z-index: 10000; + max-width: var(--container-width-l); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2em; + top: 0; +`; + +const ApplicationCount = styled.div` + line-height: 1.5; +`; + +const FilterTags = styled.div` display: flex; align-items: center; - height: 56px; - position: sticky; - top: 0; - z-index: var(--tilavaraus-admin-sticky-header); - justify-content: space-between; - :nth-child(1) { - text-align: right; - } + gap: 1em; + flex-wrap: wrap; + border-bottom: 1px solid var(--color-black-20); + padding-bottom: 1rem; `; -const getFilterConfig = ( - applications: ApplicationView[] -): DataFilterConfig[] => { - const applicantTypes = uniq(applications.map((app) => app.type)); - const statuses = uniq(applications.map((app) => app.status)); - const units = uniqBy( - applications.flatMap((app) => app.units), - "id" - ); +const ClearTagsButton = styled(Button)` + height: 0; + border: 0; +`; + +type FilterOption = { + label: string; + value: DataFilterOption; +}; + +const getUnitOptions = memoize( + (applications: ApplicationView[]): FilterOption[] => { + const units = uniqBy( + applications.flatMap((app) => app.units), + "id" + ); + + units.sort((u1: Unit, u2: Unit) => + u1.name.fi.toLowerCase().localeCompare(u2.name.fi.toLowerCase()) + ); - return [ - { - title: "Application.headings.applicantType", - filters: applicantTypes - .filter((n) => n) - .map((value) => ({ - title: value, - key: "type", - value: value || "", - })), - }, - { - title: "Application.headings.applicationStatus", - filters: statuses.map((status) => ({ - title: `Application.statuses.${status}`, - key: "status", - value: status, - })), - }, - { - title: "Application.headings.unit", - filters: units.map((unit) => ({ + return units.map((unit) => ({ + label: unit.name.fi, + value: { title: unit.name.fi, key: "unit", function: (application: ApplicationView) => Boolean(application.units.find((u) => u.id === unit.id)), - })), - }, - ]; -}; + }, + })); + } +); + +const getApplicantTypeOptions = memoize( + (applications: ApplicationView[]): FilterOption[] => { + const applicantTypes = uniq(applications.map((app) => app.type)); + + return applicantTypes.map((value) => ({ + label: value, + value: { + title: value, + key: "type", + value, + }, + })); + } +); + +function DataOrMessage({ + data, + filteredData, + children, + noData, + noFilteredData, +}: { + data: ApplicationView[]; + filteredData: ApplicationView[]; + children: JSX.Element; + noData: string; + noFilteredData: string; +}) { + if (filteredData.length) { + return children; + } + + if (data.length === 0) { + return {noData}; + } + + return {noFilteredData}; +} function Review({ applicationRound }: IProps): JSX.Element | null { const [isLoading, setIsLoading] = useState(true); @@ -150,22 +209,22 @@ function Review({ applicationRound }: IProps): JSX.Element | null { const [applicationEvents, setApplicationEvents] = useState( [] ); - const [filterConfig, setFilterConfig] = useState( - null - ); + + const [unitFilters, setUnitFilters] = useState([]); + const [typeFilters, setTypeFilters] = useState([]); const { notifyError } = useNotification(); - const [filters, setFilters] = useState([]); - const [filtersAreVisible, toggleFilterVisibility] = useState(false); const { t } = useTranslation(); - const filteredApplications = useMemo( - () => ({ + const filteredApplications = useMemo(() => { + const filters = unitFilters + .map((f) => f.value) + .concat(typeFilters.map((f) => f.value)); + return { applications: filterData(applications, filters), applicationEvents: filterData(applicationEvents, filters), - }), - [applications, applicationEvents, filters] - ); + }; + }, [applications, applicationEvents, typeFilters, unitFilters]); useEffect(() => { const fetchApplications = async (ar: ApplicationRoundType) => { @@ -175,7 +234,6 @@ function Review({ applicationRound }: IProps): JSX.Element | null { status: "in_review,review_done,declined", }); const mapped = result.map((app) => appMapper(ar, app, t)); - setFilterConfig(getFilterConfig(mapped)); setApplications(mapped); setApplicationEvents( result @@ -203,12 +261,89 @@ function Review({ applicationRound }: IProps): JSX.Element | null { return ; } - const ready = applicationRound && filterConfig; + const ready = applicationRound; if (!ready) { return null; } + function deleteFilterTag(tag: FilterOption) { + const reducedUnitFilters = unitFilters.filter( + (uf) => uf.label !== tag.label + ); + + if (reducedUnitFilters.length !== unitFilters.length) { + setUnitFilters(reducedUnitFilters); + } + + const reducedTypeFilters = typeFilters.filter( + (uf) => uf.label !== tag.label + ); + + if (reducedTypeFilters.length !== typeFilters.length) { + setTypeFilters(reducedTypeFilters); + } + } + + const clearFilterTags = () => { + setUnitFilters([]); + setTypeFilters([]); + }; + + const tags = memoize((units, types) => + sortBy(units, "label").concat(sortBy(types, "label")) + )(unitFilters, typeFilters); + + const hasApplications = applications.length > 0; + + const filterControls = hasApplications && ( + <> + + + + + {tags.map((tag) => ( + deleteFilterTag(tag)}> + {tag.label} + + ))}{" "} + {tags.length > 0 ? ( + } + variant="supplementary" + type="button" + theme="black" + onClick={clearFilterTags} + size="small" + > + {t("common.clear")} + + ) : null} + + + ); + return ( <> @@ -250,208 +385,183 @@ function Review({ applicationRound }: IProps): JSX.Element | null { - - <> - } - onClick={(): void => - toggleFilterVisibility(!filtersAreVisible) - } - className={ - filtersAreVisible ? "filterControlsAreOpen" : "" - } - $filterControlsAreOpen={filtersAreVisible} - $filtersActive={filterConfig.length > 0} - title={t( - `${ - filters.length > 0 - ? "common.filtered" - : "common.filter" - }` - )} - > - {t( - `${ - filters.length > 0 - ? "common.filtered" - : "common.filter" - }` - )} - - - - {t("Application.unhandledApplications", { - count: filteredApplications.applications.length, - of: filteredApplications.applications.length, - })} - + {filterControls} + {filteredApplications.applications.length > 0 && ( + + {t("Application.unhandledApplications", { + count: filteredApplications.applications.length, + })} + + )} - ( - - {truncate(applicant, 20)} - - ), - }, - { - headerName: t("Application.headings.applicantType"), - isSortable: true, - key: "type", - }, - { - headerName: t("Application.headings.unit"), - isSortable: true, - key: "unitsSort", - transform: ({ units }: ApplicationView) => - truncate( - units - .filter((u, i) => i < 2) + +
( + + + {truncate(applicant, 20)} + + + ), + }, + { + headerName: t("Application.headings.applicantType"), + isSortable: true, + key: "type", + }, + { + headerName: t("Application.headings.unit"), + isSortable: true, + key: "unitsSort", + transform: ({ units }: ApplicationView) => { + const allUnits = units .map((u) => u.name.fi) - .join(", "), - 23 + .join(", "); + + return ( + + {truncate( + units + .filter((u, i) => i < 2) + .map((u) => u.name.fi) + .join(", "), + 23 + )} + + ); + }, + }, + { + headerName: t( + "Application.headings.applicationCount" ), - }, - { - headerName: t("Application.headings.applicationCount"), - isSortable: true, - key: "applicationCountSort", - sortIconType: "other", - transform: ({ applicationCount }: ApplicationView) => - applicationCount, - }, - { - headerName: t("Application.headings.phase"), - key: "status", - transform: ({ statusView }: ApplicationView) => - statusView, - }, - ]} - indexKey="id" - rows={filteredApplications.applications} - variant="light" - /> + isSortable: true, + key: "applicationCountSort", + sortIconType: "other", + transform: ({ applicationCount }: ApplicationView) => + applicationCount, + }, + { + headerName: t("Application.headings.phase"), + key: "status", + transform: ({ statusView }: ApplicationView) => + statusView, + }, + ]} + indexKey="id" + rows={filteredApplications.applications} + variant="light" + /> + - - <> - } - onClick={(): void => - toggleFilterVisibility(!filtersAreVisible) - } - className={ - filtersAreVisible ? "filterControlsAreOpen" : "" - } - $filterControlsAreOpen={filtersAreVisible} - $filtersActive={filterConfig.length > 0} - title={t( - `${ - filters.length > 0 - ? "common.filtered" - : "common.filter" - }` - )} - > - {t( - `${ - filters.length > 0 - ? "common.filtered" - : "common.filter" - }` - )} - - - - {t("Application.unhandledApplications", { - count: filteredApplications.applications.length, - of: filteredApplications.applications.length, - })} - + {filterControls} + {filteredApplications.applicationEvents.length > 0 && ( + + {t("Application.unhandledApplicationEvents", { + count: filteredApplications.applicationEvents.length, + })} + + )} -
( - - {truncate(applicant, 20)} - - ), - }, - { - headerName: t("Application.headings.name"), - isSortable: true, - transform: ({ name }) => truncate(name, 20), - key: "nameSort", - }, - { - headerName: t("Application.headings.unit"), - isSortable: true, - key: "unitsSort", - transform: ({ units }: ApplicationView) => - truncate( - units - .filter((u, i) => i < 2) + +
( + + + {truncate(applicant, 20)} + + + ), + }, + { + headerName: t("Application.headings.name"), + isSortable: true, + transform: ({ name }) => truncate(name, 20), + key: "nameSort", + }, + { + headerName: t("Application.headings.unit"), + isSortable: true, + key: "unitsSort", + transform: ({ units }: ApplicationView) => { + const allUnits = units .map((u) => u.name.fi) - .join(", "), - 23 + .join(", "); + + return ( + + {truncate( + units + .filter((u, i) => i < 2) + .map((u) => u.name.fi) + .join(", "), + 23 + )} + + ); + }, + }, + { + headerName: t( + "Application.headings.applicationCount" ), - }, - { - headerName: t("Application.headings.applicationCount"), - isSortable: true, - key: "applicationCountSort", - sortIconType: "other", - transform: ({ applicationCount }: ApplicationView) => - applicationCount, - }, - { - headerName: t("Application.headings.phase"), - key: "status", - transform: ({ statusView }: ApplicationView) => - statusView, - }, - ]} - indexKey="key" - rows={filteredApplications.applicationEvents} - variant="light" - /> + isSortable: true, + key: "applicationCountSort", + sortIconType: "other", + transform: ({ applicationCount }: ApplicationView) => + applicationCount, + }, + { + headerName: t("Application.headings.phase"), + key: "status", + transform: ({ statusView }: ApplicationView) => + statusView, + }, + ]} + indexKey="key" + rows={filteredApplications.applicationEvents} + variant="light" + /> + diff --git a/admin-ui/src/i18n/messages.ts b/admin-ui/src/i18n/messages.ts index 1191124308..7da71977fc 100644 --- a/admin-ui/src/i18n/messages.ts +++ b/admin-ui/src/i18n/messages.ts @@ -56,6 +56,7 @@ const translations: ITranslations = { }, common: { clearAllSelections: ["Tyhjennä valinnat"], + clear: ["Tyhjennä"], removeValue: ["Poista arvo"], toggleMenu: ["Vaihda valikon tila"], hoursLabel: ["Tunnit"], @@ -234,7 +235,8 @@ const translations: ITranslations = { numTurns: ["Vuorojen määrä"], basket: ["Kori"], authenticatedUser: ["Tunnistautunut käyttäjä"], - unhandledApplications: ["Käsittelemättömät hakemukset {{count}}/{{of}}"], + unhandledApplications: ["{{count}} hakemusta"], + unhandledApplicationEvents: ["{{count}} haettua vuoroa"], headings: { customer: ["Hakija"], unit: ["Toimipiste"], @@ -490,6 +492,14 @@ const translations: ITranslations = { notificationResolutionDoneBody: [ "Voit hallita asiakkaille lähetettäviä päätöksiä täältä.", ], + noApplications: ["Hakukierroksella ei ole vielä hakemuksia."], + noApplicationEvents: ["Hakukierroksella ei ole vielä haettuja vuoroja."], + noFilteredApplications: [ + "Valituilla suodattimilla ei löytynyt yhtään hakemusta. Valitse suodattimia uudelleen tai tyhjennä kaikki suodattimet.", + ], + noFilteredApplicationEvents: [ + "Valituilla suodattimilla ei löytynyt yhtään haettua vuoroa. Valitse suodattimia uudelleen tai tyhjennä kaikki suodattimet.", + ], }, Basket: { purpose: ["Tuettava toiminta"],