diff --git a/front/src/views/Distributions/components/AddItemsToPackingList/AddItemsToPackingList.tsx b/front/src/views/Distributions/components/AddItemsToPackingList/AddItemsToPackingList.tsx index 1ae013370d..55892e3e4c 100644 --- a/front/src/views/Distributions/components/AddItemsToPackingList/AddItemsToPackingList.tsx +++ b/front/src/views/Distributions/components/AddItemsToPackingList/AddItemsToPackingList.tsx @@ -15,6 +15,7 @@ import { } from "@chakra-ui/react"; import _ from "lodash"; import { useContext, useState } from "react"; +import type React from "react"; import { DistroEventDetailsForPlanningStateContext } from "views/Distributions/DistroEventView/components/State1Planning/DistroEventDetailsForPlanningStateContainer"; import { IPackingListEntry } from "views/Distributions/types"; import { ProductGender } from "../../../../../../graphql/types"; diff --git a/graphql/types.ts b/graphql/types.ts index 3fd0e57d1a..c09a288248 100644 --- a/graphql/types.ts +++ b/graphql/types.ts @@ -11,7 +11,7 @@ import { } from "./fragments"; import { CREATED_BOXES_QUERY } from "../shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesDataContainer"; import { MOVED_BOXES_QUERY } from "../shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesDataContainer"; -import { STOCK_QUERY } from "../shared-components/statviz/components/visualizations/stock/StockDataContainer"; +import { STOCK_QUERY } from "../shared-components/statviz/queries/queries"; import { DEMOGRAPHIC_QUERY } from "../shared-components/statviz/components/visualizations/demographic/DemographicDataContainer"; /** @todo Make a fragment to infer this type. */ diff --git a/shared-components/statviz/components/LinkSharingSection.tsx b/shared-components/statviz/components/LinkSharingSection.tsx index 932f741e3e..7505a492ad 100644 --- a/shared-components/statviz/components/LinkSharingSection.tsx +++ b/shared-components/statviz/components/LinkSharingSection.tsx @@ -22,8 +22,6 @@ export default function LinkSharingSection({ view }: { view?: "StockOverview" }) isLinkSharingEnabled, copyLinkToClipboard, handleShareLinkClick, - includedTags, - excludedTags, boi, expirationDate, } = useShareableLink({ view }); @@ -48,8 +46,6 @@ export default function LinkSharingSection({ view }: { view?: "StockOverview" }) diff --git a/shared-components/statviz/components/ShareableLinkAlert.tsx b/shared-components/statviz/components/ShareableLinkAlert.tsx index eb691561fc..ff52751c79 100644 --- a/shared-components/statviz/components/ShareableLinkAlert.tsx +++ b/shared-components/statviz/components/ShareableLinkAlert.tsx @@ -1,39 +1,23 @@ import React from "react"; import { Alert, AlertIcon, Box } from "@chakra-ui/react"; import { IBoxesOrItemsFilter } from "./filter/BoxesOrItemsSelect"; -import { ITagFilterValue } from "../state/filter"; import { IFilterValue } from "./filter/ValueFilter"; interface ShareableLinkAlertProps { alertType?: "info" | "warning"; boi?: IFilterValue & IBoxesOrItemsFilter; - includedTags?: (IFilterValue & ITagFilterValue)[]; - excludedTags?: (IFilterValue & ITagFilterValue)[]; expirationDate?: string; } export const ShareableLinkAlert: React.FC = ({ alertType, boi, - includedTags = [], - excludedTags = [], expirationDate, }) => { if (!alertType) return ; const boiText = boi?.label; - const tagText = (() => { - const parts: string[] = []; - if (includedTags.length > 0) { - parts.push(`including: ${includedTags.map(({ label }) => label).join(", ")}`); - } - if (excludedTags.length > 0) { - parts.push(`excluding: ${excludedTags.map(({ label }) => label).join(", ")}`); - } - return parts.length > 0 ? `, filtered by tags (${parts.join("; ")})` : ""; - })(); - const expirationText = expirationDate ? `Link will expire on ${expirationDate}.` : ""; return ( @@ -43,8 +27,7 @@ export const ShareableLinkAlert: React.FC = ({

Shareable Link Created
- This link will show your inventory in {boiText} - {tagText}. + This link will show your inventory in {boiText}.
{expirationText}

diff --git a/shared-components/statviz/components/filter/DemographicsFilters.tsx b/shared-components/statviz/components/filter/DemographicsFilters.tsx new file mode 100644 index 0000000000..16aedc84f4 --- /dev/null +++ b/shared-components/statviz/components/filter/DemographicsFilters.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from "react"; +import { + VStack, + Button, + SimpleGrid, + Box, + CheckboxGroup, + Checkbox, + HStack, + FormLabel, +} from "@chakra-ui/react"; +import TabbedTagDropdown from "./TabbedTagDropdown"; +import type { ITagOption, DemographicsAppliedFilters } from "../../utils/dashboardFilters"; +import { AGE_RANGES } from "../../utils/dashboardFilters"; + +const HUMAN_GENDERS = ["Male", "Female", "Diverse"] as const; + +interface DemographicsFiltersProps { + isOpen: boolean; + onClose: () => void; + appliedFilters: DemographicsAppliedFilters; + tags: ITagOption[]; + onApply: (filters: DemographicsAppliedFilters) => void; +} + +export function DemographicsFilters({ + isOpen, + onClose, + appliedFilters, + tags, + onApply, +}: DemographicsFiltersProps) { + const [staged, setStaged] = useState(appliedFilters); + + useEffect(() => { + if (isOpen) { + setStaged(appliedFilters); + } + }, [isOpen, appliedFilters]); + + const handleApply = useCallback(() => { + onApply(staged); + onClose(); + }, [staged, onApply, onClose]); + + const handleClear = useCallback(() => { + setStaged({ ageRanges: [], genders: [], includedTags: [], excludedTags: [] }); + }, []); + + return ( + + + + Age + setStaged((prev) => ({ ...prev, ageRanges: values as string[] }))} + > + + {AGE_RANGES.map((range) => ( + + {range.label} + + ))} + + + + + Gender + setStaged((prev) => ({ ...prev, genders: values as string[] }))} + > + + {HUMAN_GENDERS.map((gender) => ( + + {gender} + + ))} + + + + + Tags + + setStaged((prev) => ({ ...prev, includedTags: newTags })) + } + onExcludedChange={(newTags) => + setStaged((prev) => ({ ...prev, excludedTags: newTags })) + } + onClearAll={() => + setStaged((prev) => ({ ...prev, includedTags: [], excludedTags: [] })) + } + placeholder="Select tags" + /> + + + + + + + + + + ); +} diff --git a/shared-components/statviz/components/filter/GenderProductFilter.test.tsx b/shared-components/statviz/components/filter/GenderProductFilter.test.tsx deleted file mode 100644 index 15af5425b8..0000000000 --- a/shared-components/statviz/components/filter/GenderProductFilter.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; -import { ChakraProvider } from "@chakra-ui/react"; -import { MockedProvider } from "@apollo/client/testing"; -import GenderProductFilter, { - categoryFilterId, - categoryToFilterValue, -} from "./GenderProductFilter"; - -const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe("GenderProductFilter", () => { - it("renders the category filter dropdown", () => { - render( - - - , - ); - - // Check that the category filter is rendered - expect(screen.getByLabelText("product category")).toBeInTheDocument(); - }); - - it("has correct categoryFilterId", () => { - expect(categoryFilterId).toBe("cf"); - }); - - it("categoryToFilterValue converts category correctly", () => { - const mockCategory = { - id: 1, - name: "Clothing", - }; - - const result = categoryToFilterValue(mockCategory); - - expect(result).toEqual({ - id: 1, - value: "1", - name: "Clothing", - label: "Clothing", - urlId: "1", - }); - }); - - it("categoryToFilterValue handles non-null fields", () => { - const mockCategory = { - id: 2, - name: "Footwear", - }; - - const result = categoryToFilterValue(mockCategory); - - expect(result).toEqual({ - id: 2, - value: "2", - name: "Footwear", - label: "Footwear", - urlId: "2", - }); - }); -}); diff --git a/shared-components/statviz/components/filter/GenderProductFilter.tsx b/shared-components/statviz/components/filter/GenderProductFilter.tsx deleted file mode 100644 index 464b0f0c03..0000000000 --- a/shared-components/statviz/components/filter/GenderProductFilter.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useReactiveVar } from "@apollo/client"; -import { Wrap, WrapItem } from "@chakra-ui/react"; -import MultiSelectFilter from "./MultiSelectFilter"; -import { IFilterValue } from "./ValueFilter"; -import useMultiSelectFilter from "../../hooks/useMultiSelectFilter"; -import { - IProductFilterValue, - productFilterValuesVar, - ICategoryFilterValue, - categoryFilterValuesVar, -} from "../../state/filter"; -import { Product } from "../../../../graphql/types"; - -export const genders: IFilterValue[] = [ - { - value: "Boy", - label: "Boy", - urlId: "boy", - }, - { - value: "Girl", - label: "Girl", - urlId: "girl", - }, - { - value: "Men", - label: "Men", - urlId: "men", - }, - { - value: "None", - label: "None", - urlId: "none", - }, - { - value: "Teen Boy", - label: "Teen Boy", - urlId: "tb", - }, - { - value: "Teen Girl", - label: "Teen Girl", - urlId: "tg", - }, - { - value: "UnisexAdult", - label: "UnisexAdult", - urlId: "ua", - }, - { - value: "Unisex Baby", - label: "Unisex Baby", - urlId: "ub", - }, - { - value: "Unisex Kid", - label: "Unisex Kid", - urlId: "uk", - }, - { - value: "Women", - label: "Women", - urlId: "women", - }, -]; - -export const genderFilterId = "gf"; -export const productFilterId = "pf"; -export const categoryFilterId = "cf"; - -export const productToFilterValue = (product: Product): IProductFilterValue => ({ - id: product.id!, - value: product.id!.toString(), - name: product.name!, - label: `${product.name!} (${product.gender!})`, - urlId: product.id!.toString(), - gender: product.gender!, -}); - -export const categoryToFilterValue = (category: { - id: number | null; - name: string | null; -}): ICategoryFilterValue => ({ - id: category.id!, - value: category.id!.toString(), - name: category.name!, - label: category.name!, - urlId: category.id!.toString(), -}); - -export default function GenderProductFilter() { - const productFilterValues = useReactiveVar(productFilterValuesVar); - const categoryFilterValues = useReactiveVar(categoryFilterValuesVar); - - const { onFilterChange: onProductFilterChange, filterValue: productFilterValue } = - useMultiSelectFilter(productFilterValues, productFilterId); - - const { onFilterChange: onGenderFilterChange, filterValue: genderFilterValue } = - useMultiSelectFilter(genders, genderFilterId); - - const { onFilterChange: onCategoryFilterChange, filterValue: categoryFilterValue } = - useMultiSelectFilter(categoryFilterValues, categoryFilterId); - - return ( - - - - - - - - - - - - ); -} diff --git a/shared-components/statviz/components/filter/LocationFilter.tsx b/shared-components/statviz/components/filter/LocationFilter.tsx deleted file mode 100644 index 4ae4234057..0000000000 --- a/shared-components/statviz/components/filter/LocationFilter.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useReactiveVar } from "@apollo/client"; -import { ResultOf } from "gql.tada"; -import useMultiSelectFilter from "../../hooks/useMultiSelectFilter"; -import { ITargetFilterValue, targetFilterValuesVar } from "../../state/filter"; -import MultiSelectFilter from "./MultiSelectFilter"; -import { TARGET_DIMENSION_INFO_FRAGMENT } from "../../queries/fragments"; - -export const targetFilterId = "loc"; - -export const targetToFilterValue = ( - target: ResultOf, -): ITargetFilterValue => ({ - id: target.id!, - value: target.name!, - label: target.name!, - urlId: target.id!, - type: target.type!, -}); - -export default function Targetfilter() { - const targetFilterValues = useReactiveVar(targetFilterValuesVar); - - const { onFilterChange, filterValue } = useMultiSelectFilter(targetFilterValues, targetFilterId); - - return ( - - ); -} diff --git a/shared-components/statviz/components/filter/MovementFilters.tsx b/shared-components/statviz/components/filter/MovementFilters.tsx new file mode 100644 index 0000000000..9a53088a1a --- /dev/null +++ b/shared-components/statviz/components/filter/MovementFilters.tsx @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useState } from "react"; +import { VStack, Button, SimpleGrid, Box, Input, FormLabel, HStack } from "@chakra-ui/react"; +import MultiSelectFilter from "./MultiSelectFilter"; +import TabbedTagDropdown from "./TabbedTagDropdown"; +import type { + IProductOption, + ICategoryOption, + ITagOption, + MovementAppliedFilters, +} from "../../utils/dashboardFilters"; +import { genders } from "./constants"; +import { toFilterValues, toProductFilterValues } from "../../utils/dashboardFilters"; + +interface MovementFiltersProps { + isOpen: boolean; + onClose: () => void; + appliedFilters: MovementAppliedFilters; + products: IProductOption[]; + categories: ICategoryOption[]; + tags: ITagOption[]; + onApply: (filters: MovementAppliedFilters) => void; +} + +export function MovementFilters({ + isOpen, + onClose, + appliedFilters, + products, + categories, + tags, + onApply, +}: MovementFiltersProps) { + const [staged, setStaged] = useState(appliedFilters); + + useEffect(() => { + if (isOpen) { + setStaged(appliedFilters); + } + }, [isOpen, appliedFilters]); + + const productOptions = toProductFilterValues(products); + const categoryOptions = toFilterValues(categories); + + const handleApply = useCallback(() => { + onApply(staged); + onClose(); + }, [staged, onApply, onClose]); + + const handleClear = useCallback(() => { + setStaged((prev) => ({ + ...prev, + products: [], + genders: [], + categories: [], + includedTags: [], + excludedTags: [], + })); + }, []); + + const selectedProductValues = productOptions.filter((o) => + staged.products.some((p) => String(p.id) === o.value), + ); + const selectedCategoryValues = categoryOptions.filter((o) => + staged.categories.some((c) => String(c.id) === o.value), + ); + const selectedGenderValues = genders.filter((g) => staged.genders.includes(g.value)); + + return ( + + + + Move date + + + From + setStaged((prev) => ({ ...prev, dateFrom: e.target.value }))} + border="2px" + borderRadius="0" + borderColor="gray.300" + _hover={{ borderColor: "gray.300" }} + _focus={{ borderColor: "gray.300", boxShadow: "none" }} + /> + + + To + setStaged((prev) => ({ ...prev, dateTo: e.target.value }))} + border="2px" + borderRadius="0" + borderColor="gray.300" + _hover={{ borderColor: "gray.300" }} + _focus={{ borderColor: "gray.300", boxShadow: "none" }} + /> + + + + + setStaged((prev) => ({ + ...prev, + genders: selected.map((s) => s.value), + })) + } + placeholder="All" + /> + { + const selectedIds = selected.map((s) => Number(s.value)); + setStaged((prev) => ({ + ...prev, + products: products.filter((p) => selectedIds.includes(p.id)), + })); + }} + placeholder="All" + /> + { + const selectedIds = selected.map((s) => Number(s.value)); + setStaged((prev) => ({ + ...prev, + categories: categories.filter((c) => selectedIds.includes(c.id)), + })); + }} + placeholder="All" + /> + + Tags + + setStaged((prev) => ({ ...prev, includedTags: newTags })) + } + onExcludedChange={(newTags) => + setStaged((prev) => ({ ...prev, excludedTags: newTags })) + } + onClearAll={() => + setStaged((prev) => ({ ...prev, includedTags: [], excludedTags: [] })) + } + placeholder="Select tags" + /> + + + + + + + + + + ); +} diff --git a/shared-components/statviz/components/filter/StockFilters.tsx b/shared-components/statviz/components/filter/StockFilters.tsx new file mode 100644 index 0000000000..063392a17c --- /dev/null +++ b/shared-components/statviz/components/filter/StockFilters.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from "react"; +import { VStack, Button, SimpleGrid, Box, FormLabel } from "@chakra-ui/react"; +import MultiSelectFilter from "./MultiSelectFilter"; +import TabbedTagDropdown from "./TabbedTagDropdown"; +import type { + IProductOption, + ICategoryOption, + ILocationOption, + ITagOption, + StockAppliedFilters, +} from "../../utils/dashboardFilters"; +import { genders } from "./constants"; +import { toFilterValues, toProductFilterValues } from "../../utils/dashboardFilters"; + +interface StockFiltersProps { + isOpen: boolean; + onClose: () => void; + appliedFilters: StockAppliedFilters; + products: IProductOption[]; + categories: ICategoryOption[]; + locations: ILocationOption[]; + tags: ITagOption[]; + onApply: (filters: StockAppliedFilters) => void; +} + +export function StockFilters({ + isOpen, + onClose, + appliedFilters, + products, + categories, + locations, + tags, + onApply, +}: StockFiltersProps) { + const [staged, setStaged] = useState(appliedFilters); + + useEffect(() => { + if (isOpen) { + setStaged(appliedFilters); + } + }, [isOpen, appliedFilters]); + + const productOptions = toProductFilterValues(products); + const categoryOptions = toFilterValues(categories); + const locationOptions = toFilterValues(locations); + + const handleApply = useCallback(() => { + onApply(staged); + onClose(); + }, [staged, onApply, onClose]); + + const handleClear = useCallback(() => { + setStaged({ + products: [], + genders: [], + categories: [], + locations: [], + includedTags: [], + excludedTags: [], + }); + }, []); + + const selectedProductValues = productOptions.filter((o) => + staged.products.some((p) => String(p.id) === o.value), + ); + const selectedCategoryValues = categoryOptions.filter((o) => + staged.categories.some((c) => String(c.id) === o.value), + ); + const selectedLocationValues = locationOptions.filter((o) => + staged.locations.some((l) => String(l.id) === o.value), + ); + const selectedGenderValues = genders.filter((g) => staged.genders.includes(g.value)); + + return ( + + + + setStaged((prev) => ({ + ...prev, + genders: selected.map((s) => s.value), + })) + } + placeholder="All" + /> + { + const selectedIds = selected.map((s) => Number(s.value)); + setStaged((prev) => ({ + ...prev, + products: products.filter((p) => selectedIds.includes(p.id)), + })); + }} + placeholder="All" + /> + { + const selectedIds = selected.map((s) => Number(s.value)); + setStaged((prev) => ({ + ...prev, + categories: categories.filter((c) => selectedIds.includes(c.id)), + })); + }} + placeholder="All" + /> + { + const selectedIds = selected.map((s) => Number(s.value)); + setStaged((prev) => ({ + ...prev, + locations: locations.filter((l) => selectedIds.includes(l.id)), + })); + }} + placeholder="All" + /> + + Tags + + setStaged((prev) => ({ ...prev, includedTags: newTags })) + } + onExcludedChange={(newTags) => + setStaged((prev) => ({ ...prev, excludedTags: newTags })) + } + onClearAll={() => + setStaged((prev) => ({ ...prev, includedTags: [], excludedTags: [] })) + } + placeholder="Select tags" + /> + + + + + + + + + + ); +} diff --git a/shared-components/statviz/components/filter/TabbedTagFilter.test.tsx b/shared-components/statviz/components/filter/TabbedTagFilter.test.tsx deleted file mode 100644 index e2d1bfd355..0000000000 --- a/shared-components/statviz/components/filter/TabbedTagFilter.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; -import { ChakraProvider } from "@chakra-ui/react"; -import { MockedProvider } from "@apollo/client/testing"; -import TabbedTagFilter, { tagFilterIncludedId, tagFilterExcludedId } from "./TabbedTagFilter"; -import { tagToFilterValue } from "./TagFilter"; - -const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe("TabbedTagFilter", () => { - it("renders the tag filter component", () => { - render( - - - , - ); - - // Check that the tags label is rendered - expect(screen.getByText("tags")).toBeInTheDocument(); - }); - - it("has correct filter IDs", () => { - expect(tagFilterIncludedId).toBe("tags"); - expect(tagFilterExcludedId).toBe("notags"); - }); - - it("tagToFilterValue converts tag correctly", () => { - const mockTag = { - id: 1, - name: "Urgent", - color: "#ff0000", - }; - - const result = tagToFilterValue(mockTag); - - expect(result).toEqual({ - id: 1, - value: "1", - label: "Urgent", - color: "#ff0000", - urlId: "1", - }); - }); - - it("tagToFilterValue handles different tag data", () => { - const mockTag = { - id: 42, - name: "Priority", - color: "#00ff00", - }; - - const result = tagToFilterValue(mockTag); - - expect(result).toEqual({ - id: 42, - value: "42", - label: "Priority", - color: "#00ff00", - urlId: "42", - }); - }); -}); diff --git a/shared-components/statviz/components/filter/TabbedTagFilter.tsx b/shared-components/statviz/components/filter/TabbedTagFilter.tsx deleted file mode 100644 index da38753e96..0000000000 --- a/shared-components/statviz/components/filter/TabbedTagFilter.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, FormLabel } from "@chakra-ui/react"; -import { useReactiveVar } from "@apollo/client"; -import { - tagFilterIncludedValuesVar, - tagFilterExcludedValuesVar, - ITagFilterValue, -} from "../../state/filter"; -import useMultiSelectFilter from "../../hooks/useMultiSelectFilter"; -import TabbedTagDropdown from "./TabbedTagDropdown"; -import { tagFilterId } from "./TagFilter"; - -export const tagFilterIncludedId = tagFilterId; -export const tagFilterExcludedId = "notags"; - -export default function TabbedTagFilter() { - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - - const { - includedFilterValue: includedTags, - excludedFilterValue: excludedTags, - onIncludedFilterChange, - onExcludedFilterChange, - onClearAll, - } = useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); - - return ( - - tags - - - ); -} diff --git a/shared-components/statviz/components/filter/TagFilter.tsx b/shared-components/statviz/components/filter/TagFilter.tsx index d3785c1080..874d998d2e 100644 --- a/shared-components/statviz/components/filter/TagFilter.tsx +++ b/shared-components/statviz/components/filter/TagFilter.tsx @@ -1,12 +1,17 @@ import { Box } from "@chakra-ui/react"; -import { useReactiveVar } from "@apollo/client"; +import { makeVar, useReactiveVar } from "@apollo/client"; import { ResultOf } from "gql.tada"; import MultiSelectFilter from "./MultiSelectFilter"; import useMultiSelectFilter from "../../hooks/useMultiSelectFilter"; -import { ITagFilterValue, tagFilterIncludedValuesVar } from "../../state/filter"; +import { ITagFilterValue } from "../../state/filter"; import { TAG_DIMENSION_INFO_FRAGMENT } from "../../queries/fragments"; +export const tagFilterIncludedValuesVar = makeVar([]); +export const tagFilterExcludedValuesVar = makeVar([]); + export const tagFilterId = "tags"; +export const tagFilterIncludedId = tagFilterId; +export const tagFilterExcludedId = "notags"; export const tagToFilterValue = ( tag: ResultOf, diff --git a/shared-components/statviz/components/filter/constants.ts b/shared-components/statviz/components/filter/constants.ts new file mode 100644 index 0000000000..c5c3235c3f --- /dev/null +++ b/shared-components/statviz/components/filter/constants.ts @@ -0,0 +1,54 @@ +import { IFilterValue } from "./ValueFilter"; + +export const genders: IFilterValue[] = [ + { + value: "Boy", + label: "Boy", + urlId: "boy", + }, + { + value: "Girl", + label: "Girl", + urlId: "girl", + }, + { + value: "Men", + label: "Men", + urlId: "men", + }, + { + value: "None", + label: "None", + urlId: "none", + }, + { + value: "Teen Boy", + label: "Teen Boy", + urlId: "tb", + }, + { + value: "Teen Girl", + label: "Teen Girl", + urlId: "tg", + }, + { + value: "UnisexAdult", + label: "UnisexAdult", + urlId: "ua", + }, + { + value: "Unisex Baby", + label: "Unisex Baby", + urlId: "ub", + }, + { + value: "Unisex Kid", + label: "Unisex Kid", + urlId: "uk", + }, + { + value: "Women", + label: "Women", + urlId: "women", + }, +]; diff --git a/shared-components/statviz/components/nivo/PieChart.tsx b/shared-components/statviz/components/nivo/PieChart.tsx index 90bd616242..aba5690b33 100644 --- a/shared-components/statviz/components/nivo/PieChart.tsx +++ b/shared-components/statviz/components/nivo/PieChart.tsx @@ -71,7 +71,7 @@ export default function PieChart(chart: IPieChart) { layers.push(() => {chart.timestamp}); } if (chart.centerData) { - const y = (height - margin.top - margin.bottom) / 2; + const y = (height - margin.top - margin.bottom) / 1.8; const x = (width - margin.right - margin.left) / 2; const textMaxWidth = baseFontSize * 7; // same as 7em const fontSizeGroupingText = baseFontSize * 0.8; // 0.8em @@ -101,13 +101,14 @@ export default function PieChart(chart: IPieChart) { margin={margin} borderWidth={0} arcLabel="value" - innerRadius={0.4} + innerRadius={0.6} layers={layers} padAngle={0.7} cornerRadius={3} animate={chart.animate === true || chart.animate === null} onClick={chart.onClick} theme={theme} + // colors={{ scheme: 'tableau10' }} isInteractive activeOuterRadiusOffset={2} arcLinkLabelsSkipAngle={10} @@ -116,7 +117,7 @@ export default function PieChart(chart: IPieChart) { arcLinkLabelsColor={{ from: "color" }} arcLabelsSkipAngle={10} arcLinkLabel={arcLabel} - arcLinkLabelsDiagonalLength={10} + arcLinkLabelsDiagonalLength={20} arcLinkLabelsStraightLength={16} /> diff --git a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesCharts.tsx b/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesCharts.tsx deleted file mode 100644 index 58f21d058f..0000000000 --- a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesCharts.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Wrap, WrapItem, Box } from "@chakra-ui/react"; -import CreatedBoxes from "./CreatedBoxes"; -import TopCreatedProducts from "./TopCreatedProducts"; -import { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; -import { CreatedBoxes as CreatedBoxesType } from "../../../../../graphql/types"; - -export default function CreatedBoxesCharts(props: { - data: Partial; - boxesOrItems: BoxesOrItems; -}) { - return ( - - - - - - - - - - - - - ); -} diff --git a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesDataContainer.tsx b/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesDataContainer.tsx index a3dbf7c7d2..ab62c9d81d 100644 --- a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesDataContainer.tsx +++ b/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesDataContainer.tsx @@ -4,6 +4,8 @@ import { useParams } from "react-router-dom"; import ErrorCard, { predefinedErrors } from "../../ErrorCard"; import CreatedBoxesFilterContainer from "./CreatedBoxesFilterContainer"; import { graphql } from "../../../../../graphql/graphql"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; +import type { StockAppliedFilters } from "../../../utils/dashboardFilters"; export const CREATED_BOXES_QUERY = graphql(` query createdBoxes($baseId: Int!) { @@ -37,7 +39,15 @@ export const CREATED_BOXES_QUERY = graphql(` } `); -export default function CreatedBoxesDataContainer() { +interface CreatedBoxesDataContainerProps { + appliedFilters: StockAppliedFilters; + boxesOrItems: BoxesOrItems; +} + +export default function CreatedBoxesDataContainer({ + appliedFilters, + boxesOrItems, +}: CreatedBoxesDataContainerProps) { const { baseId } = useParams(); const { data, loading, error } = useQuery(CREATED_BOXES_QUERY, { variables: { baseId: parseInt(baseId!, 10) }, @@ -52,5 +62,11 @@ export default function CreatedBoxesDataContainer() { if (data === undefined) { return ; } - return ; + return ( + + ); } diff --git a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesFilterContainer.tsx b/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesFilterContainer.tsx index 405e9af0a4..db8be604ba 100644 --- a/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesFilterContainer.tsx +++ b/shared-components/statviz/components/visualizations/createdBoxes/CreatedBoxesFilterContainer.tsx @@ -1,115 +1,39 @@ -import { useEffect, useMemo } from "react"; -import { TidyFn, distinct, filter, tidy } from "@tidyjs/tidy"; -import { useReactiveVar } from "@apollo/client"; -import CreatedBoxesCharts from "./CreatedBoxesCharts"; +import { Box } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { TidyFn, filter, tidy } from "@tidyjs/tidy"; +import CreatedBoxes from "./CreatedBoxes"; import { filterListByInterval } from "../../../../utils/helpers"; -import useTimerange from "../../../hooks/useTimerange"; -import useValueFilter from "../../../hooks/useValueFilter"; -import { - IBoxesOrItemsFilter, - boxesOrItemsFilterValues, - boxesOrItemsUrlId, - defaultBoxesOrItems, -} from "../../filter/BoxesOrItemsSelect"; -import { - genderFilterId, - genders, - productFilterId, - categoryFilterId, - productToFilterValue, - categoryToFilterValue, -} from "../../filter/GenderProductFilter"; -import useMultiSelectFilter from "../../../hooks/useMultiSelectFilter"; -import { tagToFilterValue } from "../../filter/TagFilter"; -import { tagFilterIncludedId, tagFilterExcludedId } from "../../filter/TabbedTagFilter"; -import { - productFilterValuesVar, - tagFilterIncludedValuesVar, - tagFilterExcludedValuesVar, - categoryFilterValuesVar, -} from "../../../state/filter"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; +import type { StockAppliedFilters } from "../../../utils/dashboardFilters"; import { filterByTags } from "../../../utils/filterByTags"; -import { CreatedBoxes, CreatedBoxesResult } from "../../../../../graphql/types"; +import { CreatedBoxes as CreatedBoxesType, CreatedBoxesResult } from "../../../../../graphql/types"; interface ICreatedBoxesFilterContainerProps { - createdBoxes: CreatedBoxes; + createdBoxes: CreatedBoxesType; + appliedFilters: StockAppliedFilters; + boxesOrItems: BoxesOrItems; + /** Optional time interval for filtering by creation date */ + interval?: { start: Date; end: Date }; } export default function CreatedBoxesFilterContainer({ createdBoxes, + appliedFilters, + boxesOrItems, + interval, }: ICreatedBoxesFilterContainerProps) { - const { interval } = useTimerange(); - - const { filterValue } = useValueFilter( - boxesOrItemsFilterValues, - defaultBoxesOrItems, - boxesOrItemsUrlId, - ); - const productFilterValues = useReactiveVar(productFilterValuesVar); - const categoryFilterValues = useReactiveVar(categoryFilterValuesVar); - - const { filterValue: filterProductGenders } = useMultiSelectFilter(genders, genderFilterId); - const { filterValue: filterProducts } = useMultiSelectFilter( - productFilterValues, - productFilterId, - ); - const { filterValue: filterCategories } = useMultiSelectFilter( - categoryFilterValues, - categoryFilterId, - ); - - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - const { includedFilterValue: includedTags, excludedFilterValue: excludedTags } = - useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); - - // use products from the createdBoxes query to feed the global products and Tags for Boxes filter - // Beneficiary and All Tags are merged inside the DemographicFilterContainer - // and filter the product filter by filtered product genders - useEffect(() => { - const p = createdBoxes?.dimensions!.product!.map((e) => productToFilterValue(e!)); - if (filterProductGenders.length > 0 && p?.length) { - productFilterValuesVar([ - ...filterProducts, - ...p.filter( - (product) => filterProductGenders.findIndex((fPG) => fPG.value === product.gender) !== -1, - ), - ]); - } else { - productFilterValuesVar(p); - } - - const c = createdBoxes?.dimensions!.category!.map((e) => categoryToFilterValue(e!)); - categoryFilterValuesVar(c); - - const boxTags = createdBoxes?.dimensions!.tag!.map((e) => tagToFilterValue(e!)); - if (boxTags?.length) { - const distinctTagFilterValues = tidy( - [...includedTagFilterValues, ...boxTags], - distinct(["id"]), - ); - - // Populate the tag filter values for both included and excluded - tagFilterIncludedValuesVar(distinctTagFilterValues); - tagFilterExcludedValuesVar(distinctTagFilterValues); - } - // we only need to update products if the product gender selection is updated - // including filterProducts would cause unnecessary rerenders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [createdBoxes?.dimensions, filterProductGenders]); + const { products, genders, categories, includedTags, excludedTags } = appliedFilters; const createdBoxesFacts = useMemo(() => { try { - return filterListByInterval( - (createdBoxes?.facts as CreatedBoxesResult[]) ?? [], - "createdOn", - interval, - ); + if (interval) { + return filterListByInterval( + (createdBoxes?.facts as CreatedBoxesResult[]) ?? [], + "createdOn", + interval, + ); + } + return (createdBoxes?.facts as CreatedBoxesResult[]) ?? []; } catch { // TODO useError } @@ -118,29 +42,16 @@ export default function CreatedBoxesFilterContainer({ const filteredFacts = useMemo(() => { const filters: TidyFn[] = []; - if (filterProductGenders.length > 0) { - filters.push( - filter( - (fact: CreatedBoxesResult) => - filterProductGenders.find((fPG) => fPG.value === fact.gender!) !== undefined, - ), - ); + if (genders.length > 0) { + filters.push(filter((fact: CreatedBoxesResult) => genders.includes(fact.gender ?? ""))); } - if (filterProducts.length > 0) { - filters.push( - filter( - (fact: CreatedBoxesResult) => - filterProducts.find((fBP) => fBP?.id === fact.productId!) !== undefined, - ), - ); + if (products.length > 0) { + const productIds = new Set(products.map((p) => p.id)); + filters.push(filter((fact: CreatedBoxesResult) => productIds.has(fact.productId!))); } - if (filterCategories.length > 0) { - filters.push( - filter( - (fact: CreatedBoxesResult) => - filterCategories.find((fC) => fC?.id === fact.categoryId!) !== undefined, - ), - ); + if (categories.length > 0) { + const categoryIds = new Set(categories.map((c) => c.id)); + filters.push(filter((fact: CreatedBoxesResult) => categoryIds.has(fact.categoryId!))); } let filtered = createdBoxesFacts; @@ -153,19 +64,21 @@ export default function CreatedBoxesFilterContainer({ filtered = filterByTags(filtered, includedTags, excludedTags); return filtered; - }, [ - createdBoxesFacts, - filterProductGenders, - filterProducts, - filterCategories, - includedTags, - excludedTags, - ]); + }, [createdBoxesFacts, genders, products, categories, includedTags, excludedTags]); const filteredCreatedBoxesCube = { facts: filteredFacts, dimensions: createdBoxes?.dimensions, }; - return ; + return ( + + + + ); } diff --git a/shared-components/statviz/components/visualizations/createdBoxes/TopCreatedProducts.tsx b/shared-components/statviz/components/visualizations/createdBoxes/TopCreatedProducts.tsx deleted file mode 100644 index e8a376fcd8..0000000000 --- a/shared-components/statviz/components/visualizations/createdBoxes/TopCreatedProducts.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Card, CardBody } from "@chakra-ui/react"; -import { useMemo } from "react"; -import { arrange, desc, groupBy, innerJoin, map, sum, summarize, tidy } from "@tidyjs/tidy"; -import BarChart from "../../nivo/BarChart"; -import VisHeader from "../../VisHeader"; -import getOnExport from "../../../utils/chartExport"; -import NoDataCard from "../../NoDataCard"; -import { CreatedBoxes, CreatedBoxesResult, Product } from "../../../../../graphql/types"; - -export default function TopCreatedProducts(props: { - width: string; - height: string; - boxesOrItems: string; - data: Partial; -}) { - const onExport = getOnExport(BarChart); - const { boxesOrItems, data } = { ...props }; - - const getChartData = () => - tidy( - data?.facts as CreatedBoxesResult[], - map((row) => ({ ...row, productId: row.productId })), - groupBy( - ["productId", "gender"], - [ - summarize({ - itemsCount: sum("itemsCount"), - boxesCount: sum("boxesCount"), - }), - ], - ), - innerJoin(data?.dimensions?.product as Product[], { by: { id: "productId" } as any }), - map((row) => ({ - id: `${row.name} (${row.gender})`, - value: row[boxesOrItems], - label: `${row[boxesOrItems]}`, - })), - arrange([desc("value")]), - ).splice(0, 5); - - const chartData = useMemo(getChartData, [data, boxesOrItems]); - - const chartProps = { - data: chartData, - width: props.width, - height: props.height, - }; - - const topProductsHeading = boxesOrItems === "boxesCount" ? "Boxes" : "Products"; - const heading = `Top Created ${topProductsHeading}`; - - if (chartData.length === 0) { - return ; - } - - return ( - - - - - - - ); -} diff --git a/shared-components/statviz/components/visualizations/demographic/DemographicDataContainer.tsx b/shared-components/statviz/components/visualizations/demographic/DemographicDataContainer.tsx index 9e1a53c78c..3c15a2a822 100644 --- a/shared-components/statviz/components/visualizations/demographic/DemographicDataContainer.tsx +++ b/shared-components/statviz/components/visualizations/demographic/DemographicDataContainer.tsx @@ -6,6 +6,7 @@ import { graphql } from "../../../../../graphql/graphql"; import DemographicFilterContainer from "./DemographicFilterContainer"; import ErrorCard, { predefinedErrors } from "../../ErrorCard"; import NoDataCard from "../../NoDataCard"; +import type { DemographicsAppliedFilters } from "../../../utils/dashboardFilters"; export const DEMOGRAPHIC_QUERY = graphql(` query BeneficiaryDemographics($baseId: Int!) { @@ -28,7 +29,13 @@ export const DEMOGRAPHIC_QUERY = graphql(` } `); -export default function DemographicDataContainer() { +interface DemographicDataContainerProps { + appliedFilters: DemographicsAppliedFilters; +} + +export default function DemographicDataContainer({ + appliedFilters, +}: DemographicDataContainerProps) { const { baseId } = useParams(); const { data, loading, error } = useQuery(DEMOGRAPHIC_QUERY, { variables: { baseId: parseInt(baseId!, 10) }, @@ -51,5 +58,10 @@ export default function DemographicDataContainer() { /> ); } - return ; + return ( + + ); } diff --git a/shared-components/statviz/components/visualizations/demographic/DemographicFilterContainer.tsx b/shared-components/statviz/components/visualizations/demographic/DemographicFilterContainer.tsx index 023ec4416d..fcf5bb8008 100644 --- a/shared-components/statviz/components/visualizations/demographic/DemographicFilterContainer.tsx +++ b/shared-components/statviz/components/visualizations/demographic/DemographicFilterContainer.tsx @@ -1,14 +1,8 @@ -import { useReactiveVar } from "@apollo/client"; -import { useEffect, useMemo } from "react"; -import { distinct, tidy } from "@tidyjs/tidy"; +import { useMemo } from "react"; import DemographicCharts from "./DemographicCharts"; -import { tagToFilterValue } from "../../filter/TagFilter"; -import { tagFilterIncludedId, tagFilterExcludedId } from "../../filter/TabbedTagFilter"; -import useTimerange from "../../../hooks/useTimerange"; -import { filterListByInterval } from "../../../../utils/helpers"; -import { tagFilterIncludedValuesVar, tagFilterExcludedValuesVar } from "../../../state/filter"; -import useMultiSelectFilter from "../../../hooks/useMultiSelectFilter"; import { filterByTags } from "../../../utils/filterByTags"; +import type { DemographicsAppliedFilters } from "../../../utils/dashboardFilters"; +import { AGE_RANGES } from "../../../utils/dashboardFilters"; import { BeneficiaryDemographics, BeneficiaryDemographicsResult, @@ -16,63 +10,45 @@ import { interface IDemographicFilterContainerProps { demographics: BeneficiaryDemographics; + appliedFilters: DemographicsAppliedFilters; } export default function DemographicFilterContainer({ demographics, + appliedFilters, }: IDemographicFilterContainerProps) { - const { interval } = useTimerange(); + const { ageRanges, genders, includedTags, excludedTags } = appliedFilters; - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - const { includedFilterValue: includedTags, excludedFilterValue: excludedTags } = - useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); + const demographicFacts = useMemo( + () => (demographics?.facts as BeneficiaryDemographicsResult[]) ?? [], + [demographics?.facts], + ); - // merge Beneficiary tags to Box and All tags - useEffect(() => { - const beneficiaryTagFilterValues = demographics?.dimensions?.tag?.map((e) => - tagToFilterValue(e!), - ); - - if ((beneficiaryTagFilterValues?.length ?? 0) > 0) { - const distinctTagFilterValues = tidy( - [...includedTagFilterValues, ...beneficiaryTagFilterValues!], - distinct(["id"]), - ); - - // Populate the tag filter values for both included and excluded - tagFilterIncludedValuesVar(distinctTagFilterValues); - tagFilterExcludedValuesVar(distinctTagFilterValues); + const filteredFacts = useMemo(() => { + let filtered = demographicFacts; + + // Filter by age ranges + if (ageRanges.length > 0) { + filtered = filtered.filter((fact) => { + if (fact.age === null || fact.age === undefined) return false; + return ageRanges.some((rangeLabel) => { + const range = AGE_RANGES.find((r) => r.label === rangeLabel); + if (!range) return false; + return fact.age! >= range.min && fact.age! <= range.max; + }); + }); } - // including tagFilterOptions in the dependencies can lead to infinite update loops - // between CreatedBoxes updating the TagFilter and DemographicFilter updating the TagFilter - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [demographics?.dimensions]); - const demographicFacts = useMemo(() => { - try { - return filterListByInterval( - (demographics?.facts as BeneficiaryDemographicsResult[]) ?? [], - "createdOn", - interval, - ) as BeneficiaryDemographicsResult[]; - } catch { - // TODO useError + // Filter by human gender + if (genders.length > 0) { + filtered = filtered.filter((fact) => genders.includes(fact.gender ?? "")); } - return []; - }, [demographics?.facts, interval]); - const filteredFacts = useMemo(() => { // Apply tag filter (included/excluded) - const filtered = filterByTags(demographicFacts, includedTags, excludedTags); + filtered = filterByTags(filtered, includedTags, excludedTags); return filtered; - }, [demographicFacts, includedTags, excludedTags]); + }, [demographicFacts, ageRanges, genders, includedTags, excludedTags]); const demographicCube = { ...demographics, diff --git a/shared-components/statviz/components/visualizations/movedBoxes/BoxFlowSankey.tsx b/shared-components/statviz/components/visualizations/movedBoxes/BoxFlowSankey.tsx index 9d18f3cf18..1bba3cdb75 100644 --- a/shared-components/statviz/components/visualizations/movedBoxes/BoxFlowSankey.tsx +++ b/shared-components/statviz/components/visualizations/movedBoxes/BoxFlowSankey.tsx @@ -7,7 +7,6 @@ import SankeyChart, { ISankeyData } from "../../nivo/SankeyChart"; import getOnExport from "../../../utils/chartExport"; import { BoxesOrItemsCount } from "../../../dashboard/ItemsAndBoxes"; import NoDataCard from "../../NoDataCard"; -import Targetfilter from "../../filter/LocationFilter"; import { MovedBoxes, MovedBoxesResult } from "../../../../../graphql/types"; import { TARGET_DIMENSION_INFO_FRAGMENT } from "../../../queries/fragments"; @@ -185,11 +184,6 @@ export default function BoxFlowSankey({ width, height, data, boxesOrItems }: IBo maxWidthPx={1000} /> - - - - - diff --git a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxes.test.tsx b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxes.test.tsx index fc8e70f80a..5cef37e167 100644 --- a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxes.test.tsx +++ b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxes.test.tsx @@ -2,6 +2,7 @@ import { it, expect } from "vitest"; import MovedBoxesDataContainer, { MOVED_BOXES_QUERY } from "./MovedBoxesDataContainer"; import { render, screen } from "../../../../tests/testUtils"; import { GraphQLError } from "graphql"; +import { defaultMovementFilters } from "../../../utils/dashboardFilters"; export class FakeGraphQLError extends GraphQLError { constructor(errorCode?: string, errorDescription?: string) { @@ -42,11 +43,17 @@ const movedBoxesDataTests = [ movedBoxesDataTests.forEach(({ name, mocks, alert }) => { it(name, async () => { - render(, { - routePath: "/bases/:baseId/", - initialUrl: "/bases/1/", - mocks, - }); + render( + , + { + routePath: "/bases/:baseId/", + initialUrl: "/bases/1/", + mocks, + }, + ); expect(await screen.findByText(alert)).toBeInTheDocument(); }); diff --git a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesDataContainer.tsx b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesDataContainer.tsx index a7aa59f017..a7ec8050d1 100644 --- a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesDataContainer.tsx +++ b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesDataContainer.tsx @@ -4,6 +4,8 @@ import { Box, Spinner } from "@chakra-ui/react"; import MovedBoxesFilterContainer from "./MovedBoxesFilterContainer"; import ErrorCard, { predefinedErrors } from "../../ErrorCard"; import { graphql } from "../../../../../graphql/graphql"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; +import type { MovementAppliedFilters } from "../../../utils/dashboardFilters"; export const MOVED_BOXES_QUERY = graphql(` query movedBoxes($baseId: Int!) { @@ -34,10 +36,18 @@ export const MOVED_BOXES_QUERY = graphql(` } `); +interface MovedBoxesDataContainerProps { + appliedFilters: MovementAppliedFilters; + boxesOrItems: BoxesOrItems; +} + // The data wrapper collects data and passes it to the filter-wrapper // which applys filters to the data // the filter wrapper passes it to the Chart which maps the Datacube to a VisX or Nivo Chart -export default function MovedBoxesDataContainer() { +export default function MovedBoxesDataContainer({ + appliedFilters, + boxesOrItems, +}: MovedBoxesDataContainerProps) { const { baseId } = useParams(); const { data, loading, error } = useQuery(MOVED_BOXES_QUERY, { variables: { baseId: parseInt(baseId!, 10) }, @@ -52,5 +62,11 @@ export default function MovedBoxesDataContainer() { if (data === undefined) { return ; } - return ; + return ( + + ); } diff --git a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesFilterContainer.tsx b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesFilterContainer.tsx index 908b75bbbe..8afdebd5e1 100644 --- a/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesFilterContainer.tsx +++ b/shared-components/statviz/components/visualizations/movedBoxes/MovedBoxesFilterContainer.tsx @@ -1,79 +1,34 @@ -import { useEffect, useMemo } from "react"; -import { useReactiveVar } from "@apollo/client"; +import { useMemo } from "react"; import { TidyFn, filter, tidy } from "@tidyjs/tidy"; -import useTimerange from "../../../hooks/useTimerange"; import { filterListByInterval } from "../../../../utils/helpers"; import MovedBoxesCharts from "./MovedBoxesCharts"; -import useValueFilter from "../../../hooks/useValueFilter"; -import { - boxesOrItemsFilterValues, - boxesOrItemsUrlId, - defaultBoxesOrItems, -} from "../../filter/BoxesOrItemsSelect"; -import useMultiSelectFilter from "../../../hooks/useMultiSelectFilter"; -import { - genderFilterId, - genders, - productFilterId, - categoryFilterId, -} from "../../filter/GenderProductFilter"; -import { - targetFilterValuesVar, - productFilterValuesVar, - tagFilterIncludedValuesVar, - tagFilterExcludedValuesVar, - categoryFilterValuesVar, -} from "../../../state/filter"; -import { tagFilterIncludedId, tagFilterExcludedId } from "../../filter/TabbedTagFilter"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; +import type { MovementAppliedFilters } from "../../../utils/dashboardFilters"; import { filterByTags } from "../../../utils/filterByTags"; -import { targetFilterId, targetToFilterValue } from "../../filter/LocationFilter"; import { MovedBoxes, MovedBoxesResult } from "../../../../../graphql/types"; interface IMovedBoxesFilterContainerProps { movedBoxes: MovedBoxes; + appliedFilters: MovementAppliedFilters; + boxesOrItems: BoxesOrItems; } -export default function MovedBoxesFilterContainer({ movedBoxes }: IMovedBoxesFilterContainerProps) { - const { interval } = useTimerange(); +export default function MovedBoxesFilterContainer({ + movedBoxes, + appliedFilters, + boxesOrItems, +}: IMovedBoxesFilterContainerProps) { + const { products, genders, categories, includedTags, excludedTags, dateFrom, dateTo } = + appliedFilters; - const { filterValue } = useValueFilter( - boxesOrItemsFilterValues, - defaultBoxesOrItems, - boxesOrItemsUrlId, + const interval = useMemo( + () => ({ + start: new Date(dateFrom), + end: new Date(dateTo), + }), + [dateFrom, dateTo], ); - const productsFilterValues = useReactiveVar(productFilterValuesVar); - const targetFilterValues = useReactiveVar(targetFilterValuesVar); - const categoryFilterValues = useReactiveVar(categoryFilterValuesVar); - - const { filterValue: productsFilter } = useMultiSelectFilter( - productsFilterValues, - productFilterId, - ); - - const { filterValue: genderFilter } = useMultiSelectFilter(genders, genderFilterId); - const { filterValue: excludedTargets } = useMultiSelectFilter(targetFilterValues, targetFilterId); - const { filterValue: filterCategories } = useMultiSelectFilter( - categoryFilterValues, - categoryFilterId, - ); - - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - const { includedFilterValue: includedTags, excludedFilterValue: excludedTags } = - useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); - - // fill target filter with data - useEffect(() => { - const targets = movedBoxes?.dimensions?.target?.map((t) => targetToFilterValue(t!)) ?? []; - targetFilterValuesVar(targets); - }, [movedBoxes?.dimensions]); - const movedBoxesFacts = useMemo(() => { try { return filterListByInterval(movedBoxes?.facts! as MovedBoxesResult[], "movedOn", interval); @@ -85,40 +40,23 @@ export default function MovedBoxesFilterContainer({ movedBoxes }: IMovedBoxesFil const filteredFacts = useMemo(() => { const filters: TidyFn[] = []; - if (genderFilter.length > 0) { + if (genders.length > 0) { filters.push( - filter( - (fact: MovedBoxesResult) => - genderFilter.find((fPG) => fPG.value === fact.gender?.valueOf()) !== undefined, - ), + filter((fact: MovedBoxesResult) => genders.includes(fact.gender?.valueOf() ?? "")), ); } - if (productsFilter.length > 0) { + if (products.length > 0) { filters.push( - filter( - (fact: MovedBoxesResult) => - productsFilter.find( - (fBP) => fBP?.name.toLowerCase() === fact.productName! && fBP.gender === fact.gender, - ) !== undefined, + filter((fact: MovedBoxesResult) => + products.some( + (p) => p.name.toLowerCase() === fact.productName! && p.gender === fact.gender, + ), ), ); } - if (filterCategories.length > 0) { - filters.push( - filter( - (fact: MovedBoxesResult) => - filterCategories.find((fC) => fC?.id === fact.categoryId!) !== undefined, - ), - ); - } - if (excludedTargets.length > 0) { - filters.push( - filter( - (fact: MovedBoxesResult) => - excludedTargets.find((filteredTarget) => filteredTarget.id! === fact.targetId!) === - undefined, - ), - ); + if (categories.length > 0) { + const categoryIds = new Set(categories.map((c) => c.id)); + filters.push(filter((fact: MovedBoxesResult) => categoryIds.has(fact.categoryId!))); } let filtered = movedBoxesFacts; @@ -131,19 +69,11 @@ export default function MovedBoxesFilterContainer({ movedBoxes }: IMovedBoxesFil filtered = filterByTags(filtered, includedTags, excludedTags); return filtered; - }, [ - excludedTargets, - genderFilter, - movedBoxesFacts, - productsFilter, - filterCategories, - includedTags, - excludedTags, - ]); + }, [movedBoxesFacts, genders, products, categories, includedTags, excludedTags]); const filteredMovedBoxesCube = { facts: filteredFacts, dimensions: movedBoxes?.dimensions, }; - return ; + return ; } diff --git a/shared-components/statviz/components/visualizations/stock/StockCharts.tsx b/shared-components/statviz/components/visualizations/stock/StockCharts.tsx deleted file mode 100644 index 37cc0eda53..0000000000 --- a/shared-components/statviz/components/visualizations/stock/StockCharts.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Wrap, WrapItem } from "@chakra-ui/react"; -import StockOverviewPie from "./StockOverviewPie"; -import { BoxesOrItemsCount } from "../../../dashboard/ItemsAndBoxes"; -import { StockOverview } from "../../../../../graphql/types"; - -interface IStockChartProps { - stockOverview: StockOverview; - boxesOrItems: BoxesOrItemsCount; -} - -export default function StockCharts({ stockOverview, boxesOrItems }: IStockChartProps) { - return ( - - - - - - ); -} diff --git a/shared-components/statviz/components/visualizations/stock/StockDataContainer.tsx b/shared-components/statviz/components/visualizations/stock/StockDataContainer.tsx deleted file mode 100644 index f1c544072a..0000000000 --- a/shared-components/statviz/components/visualizations/stock/StockDataContainer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useQuery } from "@apollo/client"; -import { Box, Spinner } from "@chakra-ui/react"; -import { useParams } from "react-router-dom"; -import StockDataFilter from "./StockDataFilter"; -import ErrorCard, { predefinedErrors } from "../../ErrorCard"; -import { graphql } from "../../../../../graphql/graphql"; -import { TAG_FRAGMENT } from "../../../queries/fragments"; - -export const STOCK_QUERY = graphql( - ` - query stockOverview($baseId: Int!) { - stockOverview(baseId: $baseId) { - facts { - productName - categoryId - gender - boxesCount - itemsCount - sizeId - tagIds - boxState - locationId - } - dimensions { - category { - id - name - } - size { - id - name - } - tag { - ...TagFragment - } - location { - id - name - } - } - } - } - `, - [TAG_FRAGMENT], -); - -export default function StockDataContainer() { - const { baseId } = useParams(); - const { data, loading, error } = useQuery(STOCK_QUERY, { - variables: { baseId: parseInt(baseId!, 10) }, - }); - - if (error) { - return An unexpected error happened {error.message}; - } - if (loading) { - return ; - } - if (data === undefined) { - return ; - } - return ; -} diff --git a/shared-components/statviz/components/visualizations/stock/StockDataFilter.test.tsx b/shared-components/statviz/components/visualizations/stock/StockDataFilter.test.tsx deleted file mode 100644 index 787ec8937f..0000000000 --- a/shared-components/statviz/components/visualizations/stock/StockDataFilter.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { it, expect } from "vitest"; -import { filter, tidy } from "@tidyjs/tidy"; - -import { userEvent } from "@testing-library/user-event"; -import { render, screen } from "../../../../tests/testUtils"; - -import StockDataFilter from "./StockDataFilter"; -import { StockOverviewResult } from "../../../../../graphql/types"; - -it("x.x.x.x - User clicks on 'Gender' filter in drilldown chart", async () => { - render( - , - { - routePath: "/bases/:baseId/", - initialUrl: "/bases/1/", - }, - ); - - const filterDropdown = screen.getByRole("combobox"); - await userEvent.click(filterDropdown); - - const dropdownOption = screen.getByText(/gender/i); - expect(dropdownOption).toBeInTheDocument(); - - await userEvent.click(dropdownOption); - - expect(await screen.findByText(/Drilldown Chart of Instock Boxes/)).toBeInTheDocument(); -}); - -it("should filter out only items with boxState === 'InStock'", () => { - // TODO: Make the data be returned in the mocks - const data: Partial[] = [ - { - __typename: "StockOverviewResult", - boxState: "InStock", - boxesCount: 1, - categoryId: 1, - gender: "UnisexAdult", - itemsCount: 5, - locationId: 100000036, - productName: "underwear", - sizeId: 42, - tagIds: [45], - }, - { - __typename: "StockOverviewResult", - boxState: "Donated", - boxesCount: 20, - categoryId: 2, - gender: "UnisexAdult", - itemsCount: 8, - locationId: 100000036, - productName: "underwear", - sizeId: 38, - tagIds: [3], - }, - { - __typename: "StockOverviewResult", - boxState: "InStock", - boxesCount: 15, - categoryId: 1, - gender: "UnisexAdult", - itemsCount: 6, - locationId: 100000036, - productName: "underwear", - sizeId: 40, - tagIds: [1, 4], - }, - ]; - - const inStockFilter = filter((fact: StockOverviewResult) => fact.boxState === "InStock"); - const filteredData = tidy(data, inStockFilter) as StockOverviewResult[]; - - expect(filteredData.length).toBe(2); - expect(filteredData.every((fact) => fact.boxState === "InStock")).toBe(true); -}); diff --git a/shared-components/statviz/components/visualizations/stock/StockDataFilter.tsx b/shared-components/statviz/components/visualizations/stock/StockDataFilter.tsx deleted file mode 100644 index 6178c33bbb..0000000000 --- a/shared-components/statviz/components/visualizations/stock/StockDataFilter.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useReactiveVar } from "@apollo/client"; -import { useMemo } from "react"; -import StockCharts from "./StockCharts"; -import { - boxesOrItemsFilterValues, - boxesOrItemsUrlId, - defaultBoxesOrItems, -} from "../../filter/BoxesOrItemsSelect"; -import useValueFilter from "../../../hooks/useValueFilter"; -import { tagFilterIncludedValuesVar, tagFilterExcludedValuesVar } from "../../../state/filter"; -import { tagFilterIncludedId, tagFilterExcludedId } from "../../filter/TabbedTagFilter"; -import useMultiSelectFilter from "../../../hooks/useMultiSelectFilter"; -import { filterByTags } from "../../../utils/filterByTags"; -import { StockOverview, StockOverviewResult } from "../../../../../graphql/types"; - -interface IStockDataFilterProps { - stockOverview: StockOverview; -} - -export default function StockDataFilter({ stockOverview }: IStockDataFilterProps) { - // currently not affected by the selected timerange - - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - - const { filterValue } = useValueFilter( - boxesOrItemsFilterValues, - defaultBoxesOrItems, - boxesOrItemsUrlId, - ); - - const { includedFilterValue: includedTags, excludedFilterValue: excludedTags } = - useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); - - const filteredStockOverview = useMemo(() => { - // Filter by included and excluded tags - const tagFilteredFacts = filterByTags( - (stockOverview?.facts ?? []) as StockOverviewResult[], - includedTags, - excludedTags, - ); - - // Filter by box state - const inStockFacts = tagFilteredFacts.filter((fact) => fact.boxState === "InStock"); - - return { - ...stockOverview, - facts: inStockFacts, - } as StockOverview; - }, [includedTags, excludedTags, stockOverview]); - - return ; -} diff --git a/shared-components/statviz/components/visualizations/stock/StockOverviewPie.tsx b/shared-components/statviz/components/visualizations/stock/StockOverviewPie.tsx deleted file mode 100644 index 19abec1883..0000000000 --- a/shared-components/statviz/components/visualizations/stock/StockOverviewPie.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import { - Box, - Button, - Card, - CardBody, - FormLabel, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Wrap, - WrapItem, - useDisclosure, -} from "@chakra-ui/react"; -import { filter, groupBy, innerJoin, map, sum, summarize, tidy } from "@tidyjs/tidy"; -import { useEffect, useMemo, useState } from "react"; -import { ArrowForwardIcon, ArrowLeftIcon } from "@chakra-ui/icons"; -import PieChart from "../../nivo/PieChart"; -import VisHeader from "../../VisHeader"; -import getOnExport from "../../../utils/chartExport"; -import { BoxesOrItemsCount } from "../../../dashboard/ItemsAndBoxes"; -import useValueFilter from "../../../hooks/useValueFilter"; -import ValueFilter from "../../filter/ValueFilter"; -import { StockOverview, StockOverviewResult } from "../../../../../graphql/types"; - -interface ISizeDim { - sizeId: number; - sizeName: string | undefined; -} - -interface ICategoryDim { - categoryId: number; - categoryName: string | undefined; -} - -type PreparedStock = StockOverviewResult & ICategoryDim & ISizeDim; -type PreparedStockAttributes = keyof PreparedStock; - -const mappingFunctions = { - categoryName: map((category: PreparedStock) => ({ - id: category.categoryName, - value: category.boxesCount, - })), - gender: map((gender: PreparedStock) => ({ - id: gender.gender, - value: gender.boxesCount, - })), - sizeName: map((size: PreparedStock) => ({ id: size.sizeName, value: size.boxesCount })), - productName: map((product: PreparedStock) => ({ - id: product.productName, - value: product.boxesCount, - })), -}; - -const groupOptions = [ - { - value: "categoryName", - label: "Category", - urlId: "cn", - }, - { - value: "productName", - label: "Product", - urlId: "pn", - }, - { - value: "gender", - label: "Gender", - urlId: "g", - }, - { - value: "sizeName", - label: "Size", - urlId: "s", - }, -]; - -// stg = stock group -const filterId = "stg"; - -const groupOptionValues = groupOptions.map((e) => e.value); - -interface IStockOverviewPieProps { - width: string; - height: string; - data: StockOverview; - boxesOrItems: BoxesOrItemsCount; -} - -export default function StockOverviewPie({ - width, - height, - data, - boxesOrItems, -}: IStockOverviewPieProps) { - const [chartData, setChartData] = useState([]); - const [drilldownPath, setDrilldownPath] = useState(["categoryName"]); - const [drilldownValues, setDrilldownValues] = useState([]); - const [selectedDrilldownValue, setSelectedDrilldownValue] = useState(""); - - const heading = - boxesOrItems === "boxesCount" - ? "Drilldown Chart of Instock Boxes" - : "Drilldown Chart of Instock Items"; - - const { onFilterChange, filterValue } = useValueFilter(groupOptions, groupOptions[0], filterId); - - const onExport = getOnExport(PieChart); - - const { - isOpen: showGroupOptions, - onOpen: openGroupOptions, - onClose: closeGroupOptions, - } = useDisclosure(); - - const onGroupSelect = (node) => { - setSelectedDrilldownValue(node.id); - if (drilldownPath.length <= 3) { - openGroupOptions(); - } - }; - - const drilldownFilters = drilldownValues.map((drilldownValue, index) => - filter((stockData) => stockData[drilldownPath[index]] === drilldownValue), - ); - - const availableGroupOptions = groupOptionValues.filter( - (option: PreparedStockAttributes) => drilldownPath.indexOf(option) === -1, - ); - - const setNewDrilldownPath = (base: PreparedStockAttributes, newDrilldownValues: string[]) => { - setDrilldownPath([base]); - setDrilldownValues(newDrilldownValues); - }; - - useEffect(() => { - setNewDrilldownPath(filterValue.value as PreparedStockAttributes, []); - }, [filterValue]); - - const onNextDrilldownChoice = (event) => { - setDrilldownPath([...drilldownPath, event.target.value]); - setDrilldownValues([...drilldownValues, selectedDrilldownValue]); - closeGroupOptions(); - }; - - useMemo(() => { - const sizeDim = data?.dimensions.size.map((size) => ({ - sizeId: size.id!, - sizeName: size.name!, - })); - - const categoryDim = data?.dimensions.category.map((category) => ({ - categoryId: category.id!, - categoryName: category.name!, - })); - - const preparedStockData = tidy( - data?.facts as Partial, - innerJoin(categoryDim!, { by: "categoryId" }), - innerJoin(sizeDim!, { - by: "sizeId", - }), - // @ts-expect-error spread of tidy filter functions not typed - ...drilldownFilters, - ) as PreparedStock[]; - - const grouped = tidy( - preparedStockData, - groupBy(drilldownPath, summarize({ boxesCount: sum(boxesOrItems) })), - mappingFunctions[drilldownPath[drilldownPath.length - 1]], - ); - - setChartData(grouped); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [drilldownPath, drilldownValues, data?.facts, boxesOrItems]); - - const getGroupOption = (levelsBack: number = 0) => - groupOptions.find( - (groupOption) => groupOption.value === drilldownPath[drilldownPath.length - levelsBack - 1], - ); - - const getSummarization = () => { - const level = drilldownPath.length; - const currentValueText = - drilldownValues.length > 0 ? `${drilldownValues[drilldownValues.length - 1]}` : ""; - - if (level === 1) { - return { - level, - grouping: `All Items by ${getGroupOption()?.label}`, - }; - } - - const previousGroupOption = getGroupOption(1); - if (level === 2 && previousGroupOption?.value === "gender") { - return { - level, - grouping: `${getGroupOption()?.label} for ${currentValueText}`, - }; - } - if (previousGroupOption?.value === "gender") { - return { - level: drilldownPath.length, - grouping: `${drilldownValues[drilldownValues.length - 2] ?? ""} ${currentValueText} by ${getGroupOption()?.label}`, - }; - } - if (previousGroupOption?.value === "sizeName") { - return { - level: drilldownPath.length, - grouping: `Size ${currentValueText} by ${getGroupOption()?.label}`, - }; - } - return { - level: drilldownPath.length, - grouping: `${currentValueText} by ${getGroupOption()?.label}`, - }; - }; - - const centerDataProp = getSummarization(); - - const chartProps = { - onClick: onGroupSelect, - data: chartData, - width, - height, - }; - return ( - - - - - - Select the next grouping - - {availableGroupOptions.map((groupOption) => ( - - ))} - - - - - - - - - - - - - - - - - - - - - - - {drilldownPath.map((value, index) => { - if (index === drilldownPath.length - 1) { - return ( - - {" "} - {groupOptions.find((option) => value === option.value)?.label} - - ); - } - return ( - - {" "} - {groupOptions.find((option) => value === option.value)?.label}: " - {drilldownValues[index]}" - - ); - })} - - - - - ); -} diff --git a/shared-components/statviz/components/visualizations/stock/StockOverviewRing.tsx b/shared-components/statviz/components/visualizations/stock/StockOverviewRing.tsx new file mode 100644 index 0000000000..603d4568ed --- /dev/null +++ b/shared-components/statviz/components/visualizations/stock/StockOverviewRing.tsx @@ -0,0 +1,150 @@ +import { Card, CardBody, Wrap, WrapItem } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { groupBy, innerJoin, map, sum, summarize, tidy } from "@tidyjs/tidy"; +import { StockOverview, StockOverviewResult } from "../../../../../graphql/types"; +import { BoxesOrItemsCount } from "../../../dashboard/ItemsAndBoxes"; +import PieChart from "../../nivo/PieChart"; +import VisHeader from "../../VisHeader"; +import getOnExport from "../../../utils/chartExport"; +import ValueFilter, { IFilterValue } from "../../filter/ValueFilter"; +import useValueFilter from "../../../hooks/useValueFilter"; + +type PieData = { id: string; value: number }; + +// srg = stock ring grouping +const ringGroupingUrlId = "srg"; + +const ringGroupingOptions: IFilterValue[] = [ + { value: "categoryName", label: "Category", urlId: "cn" }, + { value: "gender", label: "Gender", urlId: "g" }, + { value: "sizeName", label: "Size", urlId: "s" }, + { value: "locationName", label: "Location", urlId: "l" }, +]; + +const defaultRingGrouping = ringGroupingOptions[0]; + +interface StockOverviewRingProps { + data: StockOverview; + boxesOrItems: BoxesOrItemsCount; + width: string; + height: string; +} + +export default function StockOverviewRing({ + data, + boxesOrItems, + width, + height, +}: StockOverviewRingProps) { + const onExport = getOnExport(PieChart); + + const { onFilterChange, filterValue: groupingOption } = useValueFilter( + ringGroupingOptions, + defaultRingGrouping, + ringGroupingUrlId, + ); + + const groupKey = groupingOption.value; + + const chartData = useMemo(() => { + const facts = (data?.facts ?? []) as StockOverviewResult[]; + + if (groupKey === "categoryName") { + const categoryDim = (data?.dimensions?.category ?? []).map((c) => ({ + categoryId: c.id!, + categoryName: c.name!, + })); + return tidy( + facts, + innerJoin(categoryDim, { by: "categoryId" }), + groupBy("categoryName", summarize({ value: sum(boxesOrItems) })), + map((row) => ({ id: row.categoryName as string, value: row.value as number })), + ) as PieData[]; + } + + if (groupKey === "sizeName") { + const sizeDim = (data?.dimensions?.size ?? []).map((s) => ({ + sizeId: s.id!, + sizeName: s.name!, + })); + return tidy( + facts, + innerJoin(sizeDim, { by: "sizeId" }), + groupBy("sizeName", summarize({ value: sum(boxesOrItems) })), + map((row) => ({ id: row.sizeName as string, value: row.value as number })), + ) as PieData[]; + } + + if (groupKey === "locationName") { + const locationDim = (data?.dimensions?.location ?? []).map((l) => ({ + locationId: l.id!, + locationName: l.name!, + })); + return tidy( + facts, + innerJoin(locationDim, { by: "locationId" }), + groupBy("locationName", summarize({ value: sum(boxesOrItems) })), + map((row) => ({ id: row.locationName as string, value: row.value as number })), + ) as PieData[]; + } + + // groupKey === "gender" + return tidy( + facts, + groupBy("gender", summarize({ value: sum(boxesOrItems) })), + map((row) => ({ + id: (row.gender as string | null) ?? "Unknown", + value: row.value as number, + })), + ) as PieData[]; + }, [data, boxesOrItems, groupKey]); + + const total = useMemo(() => chartData.reduce((acc, d) => acc + d.value, 0), [chartData]); + + const groupLabel = groupingOption.label; + const heading = + boxesOrItems === "boxesCount" + ? `Instock Boxes by ${groupLabel}` + : `Instock Items by ${groupLabel}`; + + const centerData = { + level: total, + grouping: boxesOrItems === "boxesCount" ? "boxes" : "items", + }; + + const chartProps = { + data: chartData, + width, + height, + }; + + return ( + + + + + + + + + + + + ); +} diff --git a/shared-components/statviz/components/visualizations/stock/StockOverviewRingDataContainer.tsx b/shared-components/statviz/components/visualizations/stock/StockOverviewRingDataContainer.tsx new file mode 100644 index 0000000000..c15b769b6b --- /dev/null +++ b/shared-components/statviz/components/visualizations/stock/StockOverviewRingDataContainer.tsx @@ -0,0 +1,40 @@ +import { Spinner } from "@chakra-ui/react"; +import { useQuery } from "@apollo/client"; +import { useParams } from "react-router-dom"; +import ErrorCard, { predefinedErrors } from "../../ErrorCard"; +import StockOverviewRingFilterContainer from "./StockOverviewRingFilterContainer"; +import { STOCK_QUERY } from "../../../queries/queries"; +import type { StockAppliedFilters } from "../../../utils/dashboardFilters"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; + +interface StockOverviewRingDataContainerProps { + appliedFilters: StockAppliedFilters; + boxesOrItems: BoxesOrItems; +} + +export default function StockOverviewRingDataContainer({ + appliedFilters, + boxesOrItems, +}: StockOverviewRingDataContainerProps) { + const { baseId } = useParams(); + const { data, loading, error } = useQuery(STOCK_QUERY, { + variables: { baseId: parseInt(baseId!, 10) }, + }); + + if (error) { + return ; + } + if (loading) { + return ; + } + if (data === undefined) { + return ; + } + return ( + + ); +} diff --git a/shared-components/statviz/components/visualizations/stock/StockOverviewRingFilterContainer.tsx b/shared-components/statviz/components/visualizations/stock/StockOverviewRingFilterContainer.tsx new file mode 100644 index 0000000000..de6dc0cdc3 --- /dev/null +++ b/shared-components/statviz/components/visualizations/stock/StockOverviewRingFilterContainer.tsx @@ -0,0 +1,62 @@ +import { Box } from "@chakra-ui/react"; +import { useMemo } from "react"; +import { StockOverview, StockOverviewResult } from "../../../../../graphql/types"; +import { filterByTags } from "../../../utils/filterByTags"; +import type { StockAppliedFilters } from "../../../utils/dashboardFilters"; +import type { BoxesOrItems } from "../../filter/BoxesOrItemsSelect"; +import StockOverviewRing from "./StockOverviewRing"; + +interface StockOverviewRingFilterContainerProps { + stockOverview: StockOverview; + appliedFilters: StockAppliedFilters; + boxesOrItems: BoxesOrItems; +} + +export default function StockOverviewRingFilterContainer({ + stockOverview, + appliedFilters, + boxesOrItems, +}: StockOverviewRingFilterContainerProps) { + const { products, genders, categories, locations, includedTags, excludedTags } = appliedFilters; + + const filteredStockOverview = useMemo(() => { + let facts = (stockOverview?.facts ?? []) as StockOverviewResult[]; + + // Only show InStock boxes + facts = facts.filter((f) => f.boxState === "InStock"); + + if (genders.length > 0) { + facts = facts.filter((f) => genders.includes(f.gender ?? "")); + } + + if (categories.length > 0) { + const categoryIds = new Set(categories.map((c) => c.id)); + facts = facts.filter((f) => categoryIds.has(f.categoryId!)); + } + + if (locations.length > 0) { + const locationIds = new Set(locations.map((l) => l.id)); + facts = facts.filter((f) => locationIds.has(f.locationId!)); + } + + if (products.length > 0) { + const productNames = new Set(products.map((p) => p.name)); + facts = facts.filter((f) => productNames.has(f.productName ?? "")); + } + + facts = filterByTags(facts, includedTags, excludedTags); + + return { ...stockOverview, facts } as StockOverview; + }, [stockOverview, genders, categories, locations, products, includedTags, excludedTags]); + + return ( + + + + ); +} diff --git a/shared-components/statviz/dashboard/Dashboard.tsx b/shared-components/statviz/dashboard/Dashboard.tsx index 4fe30df76a..f3c0c9c6f3 100644 --- a/shared-components/statviz/dashboard/Dashboard.tsx +++ b/shared-components/statviz/dashboard/Dashboard.tsx @@ -1,89 +1,114 @@ -import { Accordion, Center, Heading, Wrap, WrapItem } from "@chakra-ui/react"; -import TimeRangeSelect from "../components/filter/TimeRangeSelect"; +import { Accordion, Heading } from "@chakra-ui/react"; +import { useQuery } from "@apollo/client"; +import { useParams } from "react-router-dom"; +import { useMemo } from "react"; import Demographics from "./Demographics"; import MovedBoxes from "./MovedBoxes"; import ItemsAndBoxes from "./ItemsAndBoxes"; -import StockOverview from "./StockOverview"; -import BoxesOrItemsSelect, { - boxesOrItemsFilterValues, -} from "../components/filter/BoxesOrItemsSelect"; -import GenderProductFilter from "../components/filter/GenderProductFilter"; -import TabbedTagFilter from "../components/filter/TabbedTagFilter"; -import { useSearchParams } from "react-router-dom"; -import { useEffect } from "react"; -import { date2String } from "../../utils/helpers"; -import { subMonths } from "date-fns"; import InfoText from "./InfoText"; +import { graphql } from "../../../graphql/graphql"; +import ErrorCard from "../components/ErrorCard"; +import type { + IProductOption, + ICategoryOption, + ILocationOption, + ITagOption, +} from "../utils/dashboardFilters"; + +export const DASHBOARD_FILTER_DATA_QUERY = graphql(` + query DashboardFilterData($baseId: ID!) { + base(id: $baseId) { + products { + id + name + gender + category { + id + name + } + } + locations { + id + name + } + tags { + id + name + color + } + } + } +`); export default function Dashboard() { - const [searchParams, setSearchParams] = useSearchParams(); + const { baseId } = useParams(); + const { data, error } = useQuery(DASHBOARD_FILTER_DATA_QUERY, { + variables: { baseId: baseId! }, + }); - // set default filter states - // TODO: this is a quick fix and we should revisit the state handling of filters since it seems to be all over the place. - useEffect(() => { - const currentQuery = searchParams.toString(); - const newSearchParams = searchParams; + const products = useMemo( + () => + (data?.base?.products ?? []).map((p) => ({ + id: Number(p.id), + name: p.name, + gender: p.gender ?? null, + })), + [data], + ); - // TimeRangeFilter - if (!searchParams.get("from")) { - newSearchParams.append("from", date2String(subMonths(new Date(), 3))); - } - if (!searchParams.get("to")) { - newSearchParams.append("to", date2String(new Date())); - } - if (!searchParams.get("boi")) { - newSearchParams.append("boi", boxesOrItemsFilterValues[0].urlId); + const categories = useMemo(() => { + const seen = new Set(); + const result: ICategoryOption[] = []; + for (const product of data?.base?.products ?? []) { + const catId = Number(product.category.id); + if (!seen.has(catId)) { + seen.add(catId); + result.push({ id: catId, name: product.category.name }); + } } + return result; + }, [data]); - if (newSearchParams.toString() !== currentQuery) { - setSearchParams(newSearchParams); - } - }, [searchParams, setSearchParams]); + const locations = useMemo( + () => + (data?.base?.locations ?? []).map((l) => ({ + id: Number(l.id), + name: l.name ?? "", + })), + [data], + ); + + const tags = useMemo( + () => + (data?.base?.tags ?? []).map((t) => ({ + id: Number(t.id), + name: t.name, + color: t.color ?? "#999", + value: String(t.id), + label: t.name, + urlId: String(t.id), + })), + [data], + ); + + if (error) { + return ; + } return (
Dashboard - - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
-
- - - - - + + +
); diff --git a/shared-components/statviz/dashboard/Demographics.tsx b/shared-components/statviz/dashboard/Demographics.tsx index ff0cdafd3b..3bc6a2885a 100644 --- a/shared-components/statviz/dashboard/Demographics.tsx +++ b/shared-components/statviz/dashboard/Demographics.tsx @@ -1,30 +1,83 @@ import { + useDisclosure, AccordionItem, AccordionButton, Heading, + HStack, AccordionIcon, AccordionPanel, Wrap, WrapItem, Box, + VStack, } from "@chakra-ui/react"; +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; import DemographicDataContainer from "../components/visualizations/demographic/DemographicDataContainer"; +import { + readDemographicsFiltersFromUrl, + writeDemographicsFiltersToUrl, + type DemographicsAppliedFilters, + type ITagOption, +} from "../utils/dashboardFilters"; +import { FilterPanel } from "../../filter/FilterPanel"; +import { DemographicsFilters } from "./../components/filter/DemographicsFilters"; + +interface DemographicsProps { + tags: ITagOption[]; +} + +export default function Demographics({ tags }: DemographicsProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const appliedFilters = useMemo( + () => readDemographicsFiltersFromUrl(searchParams, tags), + [searchParams, tags], + ); + + const handleApplyFilters = useCallback( + (filters: DemographicsAppliedFilters) => { + const newParams = new URLSearchParams(searchParams); + writeDemographicsFiltersToUrl(filters, newParams); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const { isOpen, onOpen, onClose } = useDisclosure(); -export default function Demographics() { return ( - Demographics + Beneficiary Overview - - - - - + + + + + + + + + + + + ); diff --git a/shared-components/statviz/dashboard/ItemsAndBoxes.tsx b/shared-components/statviz/dashboard/ItemsAndBoxes.tsx index efe35b67f6..b0f16f700b 100644 --- a/shared-components/statviz/dashboard/ItemsAndBoxes.tsx +++ b/shared-components/statviz/dashboard/ItemsAndBoxes.tsx @@ -1,26 +1,123 @@ import { + SimpleGrid, + useDisclosure, Box, AccordionItem, AccordionButton, Heading, AccordionIcon, AccordionPanel, + HStack, + Select, + VStack, } from "@chakra-ui/react"; +import { useCallback, useMemo } from "react"; +import type React from "react"; +import { useSearchParams } from "react-router-dom"; import CreatedBoxesDataContainer from "../components/visualizations/createdBoxes/CreatedBoxesDataContainer"; +import StockOverviewRingDataContainer from "../components/visualizations/stock/StockOverviewRingDataContainer"; +import { + STOCK_URL_PARAMS, + readStockFiltersFromUrl, + writeStockFiltersToUrl, + type StockAppliedFilters, + type IProductOption, + type ICategoryOption, + type ILocationOption, + type ITagOption, +} from "../utils/dashboardFilters"; +import type { BoxesOrItems } from "../components/filter/BoxesOrItemsSelect"; +import { FilterPanel } from "../../filter/FilterPanel"; +import { StockFilters } from "./../components/filter/StockFilters"; export type BoxesOrItemsCount = "boxesCount" | "itemsCount"; -export default function ItemsAndBoxes() { +interface ItemsAndBoxesProps { + products: IProductOption[]; + categories: ICategoryOption[]; + locations: ILocationOption[]; + tags: ITagOption[]; +} + +export default function ItemsAndBoxes({ + products, + categories, + locations, + tags, +}: ItemsAndBoxesProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const appliedFilters = useMemo( + () => readStockFiltersFromUrl(searchParams, products, categories, locations, tags), + [searchParams, products, categories, locations, tags], + ); + + const boxesOrItems: BoxesOrItems = + searchParams.get(STOCK_URL_PARAMS.boxesOrItems) === "ic" ? "itemsCount" : "boxesCount"; + + const handleApplyFilters = useCallback( + (filters: StockAppliedFilters) => { + const newParams = new URLSearchParams(searchParams); + writeStockFiltersToUrl(filters, newParams); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const handleBoxesOrItemsChange = useCallback( + (e: React.ChangeEvent) => { + const newParams = new URLSearchParams(searchParams); + newParams.set(STOCK_URL_PARAMS.boxesOrItems, e.target.value === "itemsCount" ? "ic" : "bc"); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const { isOpen, onOpen, onClose } = useDisclosure(); return ( - Items and Boxes + Stock Overview - + + + + + + + + + + + + ); diff --git a/shared-components/statviz/dashboard/MovedBoxes.tsx b/shared-components/statviz/dashboard/MovedBoxes.tsx index 3fadbbdc10..33960c19f8 100644 --- a/shared-components/statviz/dashboard/MovedBoxes.tsx +++ b/shared-components/statviz/dashboard/MovedBoxes.tsx @@ -1,24 +1,106 @@ import { + useDisclosure, AccordionItem, AccordionButton, Heading, AccordionIcon, AccordionPanel, Box, + HStack, + Select, + VStack, } from "@chakra-ui/react"; +import { useCallback, useMemo } from "react"; +import type React from "react"; +import { useSearchParams } from "react-router-dom"; import MovedBoxesDataContainer from "../components/visualizations/movedBoxes/MovedBoxesDataContainer"; +import { FilterPanel } from "../../filter/FilterPanel"; +import { MovementFilters } from "./../components/filter/MovementFilters"; +import { + MOVEMENT_URL_PARAMS, + readMovementFiltersFromUrl, + writeMovementFiltersToUrl, + type MovementAppliedFilters, + type IProductOption, + type ICategoryOption, + type ITagOption, +} from "../utils/dashboardFilters"; +import type { BoxesOrItems } from "../components/filter/BoxesOrItemsSelect"; + +interface MovedBoxesProps { + products: IProductOption[]; + categories: ICategoryOption[]; + tags: ITagOption[]; +} + +export default function MovedBoxes({ products, categories, tags }: MovedBoxesProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const appliedFilters = useMemo( + () => readMovementFiltersFromUrl(searchParams, products, categories, tags), + [searchParams, products, categories, tags], + ); + + const boxesOrItems: BoxesOrItems = + searchParams.get(MOVEMENT_URL_PARAMS.boxesOrItems) === "ic" ? "itemsCount" : "boxesCount"; + + const handleApplyFilters = useCallback( + (filters: MovementAppliedFilters) => { + const newParams = new URLSearchParams(searchParams); + writeMovementFiltersToUrl(filters, newParams); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const handleBoxesOrItemsChange = useCallback( + (e: React.ChangeEvent) => { + const newParams = new URLSearchParams(searchParams); + newParams.set( + MOVEMENT_URL_PARAMS.boxesOrItems, + e.target.value === "itemsCount" ? "ic" : "bc", + ); + setSearchParams(newParams); + }, + [searchParams, setSearchParams], + ); + + const { isOpen, onOpen, onClose } = useDisclosure(); -export default function MovedBoxes() { return ( - Shipments + Movement History - + + + + + + + + + ); diff --git a/shared-components/statviz/dashboard/StockOverview.tsx b/shared-components/statviz/dashboard/StockOverview.tsx deleted file mode 100644 index fcb21ebf6c..0000000000 --- a/shared-components/statviz/dashboard/StockOverview.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { - AccordionItem, - AccordionButton, - Heading, - AccordionIcon, - AccordionPanel, - Box, -} from "@chakra-ui/react"; -import StockDataContainer from "../components/visualizations/stock/StockDataContainer"; - -export default function StockOverview() { - return ( - - - - Stock Overview - - - - - - - - ); -} diff --git a/shared-components/statviz/hooks/useShareableLink.ts b/shared-components/statviz/hooks/useShareableLink.ts index b1075a9c7a..3d19201366 100644 --- a/shared-components/statviz/hooks/useShareableLink.ts +++ b/shared-components/statviz/hooks/useShareableLink.ts @@ -4,7 +4,7 @@ import { useParams, useSearchParams } from "react-router-dom"; // TODO: Move common utils to shared-components, use alias for imports. import { graphql } from "../../../graphql/graphql"; import { useNotification } from "../../../front/src/hooks/useNotification"; -import { useMutation, useReactiveVar } from "@apollo/client"; +import { useMutation } from "@apollo/client"; import useValueFilter from "./useValueFilter"; import { boxesOrItemsFilterValues, @@ -12,9 +12,6 @@ import { defaultBoxesOrItems, IBoxesOrItemsFilter, } from "../components/filter/BoxesOrItemsSelect"; -import { tagFilterIncludedId, tagFilterExcludedId } from "../components/filter/TabbedTagFilter"; -import { tagFilterIncludedValuesVar, tagFilterExcludedValuesVar } from "../state/filter"; -import useMultiSelectFilter from "./useMultiSelectFilter"; const BASE_PUBLIC_LINK_SHARING_URL = import.meta.env.FRONT_PUBLIC_URL; @@ -55,15 +52,6 @@ export default function useShareableLink({ defaultBoxesOrItems, boxesOrItemsUrlId, ); - const includedTagFilterValues = useReactiveVar(tagFilterIncludedValuesVar); - const excludedTagFilterValues = useReactiveVar(tagFilterExcludedValuesVar); - const { includedFilterValue: includedTags, excludedFilterValue: excludedTags } = - useMultiSelectFilter( - includedTagFilterValues, - tagFilterIncludedId, - excludedTagFilterValues, - tagFilterExcludedId, - ); const [expirationDate, setExpirationDate] = useState(); // Remove the JSX from the hook @@ -140,8 +128,6 @@ export default function useShareableLink({ isLinkSharingEnabled, copyLinkToClipboard, handleShareLinkClick, - includedTags, - excludedTags, boi, expirationDate, }; diff --git a/shared-components/statviz/queries/queries.ts b/shared-components/statviz/queries/queries.ts new file mode 100644 index 0000000000..6510e133bb --- /dev/null +++ b/shared-components/statviz/queries/queries.ts @@ -0,0 +1,40 @@ +import { graphql } from "../../../graphql/graphql"; +import { TAG_FRAGMENT } from "./fragments"; + +export const STOCK_QUERY = graphql( + ` + query stockOverview($baseId: Int!) { + stockOverview(baseId: $baseId) { + facts { + productName + categoryId + gender + boxesCount + itemsCount + sizeId + tagIds + boxState + locationId + } + dimensions { + category { + id + name + } + size { + id + name + } + tag { + ...TagFragment + } + location { + id + name + } + } + } + } + `, + [TAG_FRAGMENT], +); diff --git a/shared-components/statviz/state/filter.tsx b/shared-components/statviz/state/filter.tsx index 2047440358..b898195cb7 100644 --- a/shared-components/statviz/state/filter.tsx +++ b/shared-components/statviz/state/filter.tsx @@ -1,32 +1,6 @@ -import { makeVar } from "@apollo/client"; import { IFilterValue } from "../components/filter/ValueFilter"; -import { ProductGender, TargetType } from "../../../graphql/types"; - -export interface IProductFilterValue extends IFilterValue { - id: number; - name: string; - gender: ProductGender; -} -export const productFilterValuesVar = makeVar([]); export interface ITagFilterValue extends IFilterValue { color: string; id: number; } -// Reactive variable for included tags (tags to filter data IN) -export const tagFilterIncludedValuesVar = makeVar([]); - -// Reactive variable for excluded tags (tags to filter data OUT) -export const tagFilterExcludedValuesVar = makeVar([]); - -export interface ITargetFilterValue extends IFilterValue { - id: string; - type: TargetType; -} -export const targetFilterValuesVar = makeVar([]); - -export interface ICategoryFilterValue extends IFilterValue { - id: number; - name: string; -} -export const categoryFilterValuesVar = makeVar([]); diff --git a/shared-components/statviz/utils/analytics/constants.ts b/shared-components/statviz/utils/analytics/constants.ts index f159435489..01cb8e7f5b 100644 --- a/shared-components/statviz/utils/analytics/constants.ts +++ b/shared-components/statviz/utils/analytics/constants.ts @@ -1,6 +1,5 @@ const GRAPH_TYPES = { CREATED_BOXES: "CreatedBoxes", - TOP_CREATED_PRODUCTS: "TopCreatedProducts", OUTGOING_BOXES: "OutGoingBoxes", DEMOGRAPHICS_BENEFICIARIES_REGISTERED: "BeneficiariesRegistered", STOCK_OVERVIEW: "StockOverview", diff --git a/shared-components/statviz/utils/dashboardFilters.ts b/shared-components/statviz/utils/dashboardFilters.ts new file mode 100644 index 0000000000..51172de759 --- /dev/null +++ b/shared-components/statviz/utils/dashboardFilters.ts @@ -0,0 +1,392 @@ +import { date2String } from "../../utils/helpers"; +import { subMonths } from "date-fns"; +import { ProductGender } from "../../../graphql/types"; +import type { IFilterValue } from "../components/filter/ValueFilter"; + +// --------------------------------------------------------------------------- +// Option types for filter dropdowns +// --------------------------------------------------------------------------- + +export interface IProductOption { + id: number; + name: string; + gender: ProductGender | null; +} + +export interface ICategoryOption { + id: number; + name: string; +} + +export interface ILocationOption { + id: number; + name: string; +} + +/** + * Tag option for dashboard filters. + * Structurally compatible with ITagFilterValue so it can be used directly + * with TabbedTagDropdown. + */ +export interface ITagOption { + id: number; + /** Same as label; populated when created from backend data. */ + name?: string; + color: string; + /** = String(id) — required by chakra-react-select */ + value: string; + /** = name — required by react-select for display */ + label: string; + /** = String(id) — used for URL serialisation */ + urlId: string; +} + +// --------------------------------------------------------------------------- +// Applied filter state types (one per dashboard section) +// --------------------------------------------------------------------------- + +export interface StockAppliedFilters { + products: IProductOption[]; + genders: string[]; + categories: ICategoryOption[]; + locations: ILocationOption[]; + includedTags: ITagOption[]; + excludedTags: ITagOption[]; +} + +export interface MovementAppliedFilters { + dateFrom: string; + dateTo: string; + products: IProductOption[]; + genders: string[]; + categories: ICategoryOption[]; + includedTags: ITagOption[]; + excludedTags: ITagOption[]; +} + +export interface DemographicsAppliedFilters { + /** e.g. ["0-7", "8-15"] */ + ageRanges: string[]; + /** HumanGender values: "Male", "Female", "Diverse" */ + genders: string[]; + includedTags: ITagOption[]; + excludedTags: ITagOption[]; +} + +// --------------------------------------------------------------------------- +// Age range definitions for demographics filter +// --------------------------------------------------------------------------- + +export interface IAgeRange { + label: string; + min: number; + max: number; +} + +export const AGE_RANGES: IAgeRange[] = [ + { label: "0-7", min: 0, max: 7 }, + { label: "8-15", min: 8, max: 15 }, + { label: "16-25", min: 16, max: 25 }, + { label: "26-40", min: 26, max: 40 }, + { label: "41-65", min: 41, max: 65 }, + { label: "66+", min: 66, max: Infinity }, +]; + +// --------------------------------------------------------------------------- +// URL parameter names per section +// --------------------------------------------------------------------------- + +export const STOCK_URL_PARAMS = { + products: "sp", + genders: "sg", + categories: "sc", + locations: "sl", + includedTags: "st", + excludedTags: "snt", + boxesOrItems: "sboi", +} as const; + +export const MOVEMENT_URL_PARAMS = { + dateFrom: "md1", + dateTo: "md2", + products: "mp", + genders: "mg", + categories: "mc", + includedTags: "mt", + excludedTags: "mnt", + boxesOrItems: "mboi", +} as const; + +export const DEMOGRAPHICS_URL_PARAMS = { + ageRanges: "ba", + genders: "bg", + includedTags: "bt", + excludedTags: "bnt", +} as const; + +// --------------------------------------------------------------------------- +// Default filters +// --------------------------------------------------------------------------- + +export const DEFAULT_STOCK_FILTERS: StockAppliedFilters = { + products: [], + genders: [], + categories: [], + locations: [], + includedTags: [], + excludedTags: [], +}; + +export const DEFAULT_DEMOGRAPHICS_FILTERS: DemographicsAppliedFilters = { + ageRanges: [], + genders: [], + includedTags: [], + excludedTags: [], +}; + +export function defaultMovementFilters(): MovementAppliedFilters { + return { + dateFrom: date2String(subMonths(new Date(), 3)), + dateTo: date2String(new Date()), + products: [], + genders: [], + categories: [], + includedTags: [], + excludedTags: [], + }; +} + +// --------------------------------------------------------------------------- +// URL serialization / deserialization helpers +// --------------------------------------------------------------------------- + +/** Parse a comma-separated list of positive integers. */ +export function parseIdsParam(value: string | null): number[] { + if (!value) return []; + return value + .split(",") + .map(Number) + .filter((n) => !isNaN(n) && n > 0); +} + +/** Parse a comma-separated list of non-empty strings. */ +export function parseValuesParam(value: string | null): string[] { + if (!value) return []; + return value.split(",").filter(Boolean); +} + +/** Serialize array of numbers to a URL param value (undefined if empty). */ +export function serializeIds(ids: number[]): string | undefined { + return ids.length === 0 ? undefined : ids.join(","); +} + +/** Serialize array of strings to a URL param value (undefined if empty). */ +export function serializeValues(values: string[]): string | undefined { + return values.length === 0 ? undefined : values.join(","); +} + +// --------------------------------------------------------------------------- +// Resolver helpers — look up objects from option arrays by ID or value +// --------------------------------------------------------------------------- + +export function resolveProductIds(ids: number[], allProducts: IProductOption[]): IProductOption[] { + return allProducts.filter((p) => ids.includes(p.id)); +} + +export function resolveCategoryIds( + ids: number[], + allCategories: ICategoryOption[], +): ICategoryOption[] { + return allCategories.filter((c) => ids.includes(c.id)); +} + +export function resolveLocationIds( + ids: number[], + allLocations: ILocationOption[], +): ILocationOption[] { + return allLocations.filter((l) => ids.includes(l.id)); +} + +export function resolveTagIds(ids: number[], allTags: ITagOption[]): ITagOption[] { + return allTags.filter((t) => ids.includes(t.id)); +} + +// --------------------------------------------------------------------------- +// Read applied filters from URL search params +// --------------------------------------------------------------------------- + +export function readStockFiltersFromUrl( + searchParams: URLSearchParams, + allProducts: IProductOption[], + allCategories: ICategoryOption[], + allLocations: ILocationOption[], + allTags: ITagOption[], +): StockAppliedFilters { + return { + products: resolveProductIds( + parseIdsParam(searchParams.get(STOCK_URL_PARAMS.products)), + allProducts, + ), + genders: parseValuesParam(searchParams.get(STOCK_URL_PARAMS.genders)), + categories: resolveCategoryIds( + parseIdsParam(searchParams.get(STOCK_URL_PARAMS.categories)), + allCategories, + ), + locations: resolveLocationIds( + parseIdsParam(searchParams.get(STOCK_URL_PARAMS.locations)), + allLocations, + ), + includedTags: resolveTagIds( + parseIdsParam(searchParams.get(STOCK_URL_PARAMS.includedTags)), + allTags, + ), + excludedTags: resolveTagIds( + parseIdsParam(searchParams.get(STOCK_URL_PARAMS.excludedTags)), + allTags, + ), + }; +} + +export function readMovementFiltersFromUrl( + searchParams: URLSearchParams, + allProducts: IProductOption[], + allCategories: ICategoryOption[], + allTags: ITagOption[], +): MovementAppliedFilters { + const defaults = defaultMovementFilters(); + return { + dateFrom: searchParams.get(MOVEMENT_URL_PARAMS.dateFrom) ?? defaults.dateFrom, + dateTo: searchParams.get(MOVEMENT_URL_PARAMS.dateTo) ?? defaults.dateTo, + products: resolveProductIds( + parseIdsParam(searchParams.get(MOVEMENT_URL_PARAMS.products)), + allProducts, + ), + genders: parseValuesParam(searchParams.get(MOVEMENT_URL_PARAMS.genders)), + categories: resolveCategoryIds( + parseIdsParam(searchParams.get(MOVEMENT_URL_PARAMS.categories)), + allCategories, + ), + includedTags: resolveTagIds( + parseIdsParam(searchParams.get(MOVEMENT_URL_PARAMS.includedTags)), + allTags, + ), + excludedTags: resolveTagIds( + parseIdsParam(searchParams.get(MOVEMENT_URL_PARAMS.excludedTags)), + allTags, + ), + }; +} + +export function readDemographicsFiltersFromUrl( + searchParams: URLSearchParams, + allTags: ITagOption[], +): DemographicsAppliedFilters { + return { + ageRanges: parseValuesParam(searchParams.get(DEMOGRAPHICS_URL_PARAMS.ageRanges)), + genders: parseValuesParam(searchParams.get(DEMOGRAPHICS_URL_PARAMS.genders)), + includedTags: resolveTagIds( + parseIdsParam(searchParams.get(DEMOGRAPHICS_URL_PARAMS.includedTags)), + allTags, + ), + excludedTags: resolveTagIds( + parseIdsParam(searchParams.get(DEMOGRAPHICS_URL_PARAMS.excludedTags)), + allTags, + ), + }; +} + +// --------------------------------------------------------------------------- +// Write applied filters to URL search params (mutates the passed URLSearchParams) +// --------------------------------------------------------------------------- + +function setOrDelete(params: URLSearchParams, key: string, value: string | undefined) { + params.delete(key); + if (value !== undefined) params.set(key, value); +} + +export function writeStockFiltersToUrl( + filters: StockAppliedFilters, + params: URLSearchParams, +): void { + setOrDelete(params, STOCK_URL_PARAMS.products, serializeIds(filters.products.map((p) => p.id))); + setOrDelete(params, STOCK_URL_PARAMS.genders, serializeValues(filters.genders)); + setOrDelete( + params, + STOCK_URL_PARAMS.categories, + serializeIds(filters.categories.map((c) => c.id)), + ); + setOrDelete(params, STOCK_URL_PARAMS.locations, serializeIds(filters.locations.map((l) => l.id))); + setOrDelete( + params, + STOCK_URL_PARAMS.includedTags, + serializeIds(filters.includedTags.map((t) => t.id)), + ); + setOrDelete( + params, + STOCK_URL_PARAMS.excludedTags, + serializeIds(filters.excludedTags.map((t) => t.id)), + ); +} + +export function writeMovementFiltersToUrl( + filters: MovementAppliedFilters, + params: URLSearchParams, +): void { + setOrDelete(params, MOVEMENT_URL_PARAMS.dateFrom, filters.dateFrom || undefined); + setOrDelete(params, MOVEMENT_URL_PARAMS.dateTo, filters.dateTo || undefined); + setOrDelete( + params, + MOVEMENT_URL_PARAMS.products, + serializeIds(filters.products.map((p) => p.id)), + ); + setOrDelete(params, MOVEMENT_URL_PARAMS.genders, serializeValues(filters.genders)); + setOrDelete( + params, + MOVEMENT_URL_PARAMS.categories, + serializeIds(filters.categories.map((c) => c.id)), + ); + setOrDelete( + params, + MOVEMENT_URL_PARAMS.includedTags, + serializeIds(filters.includedTags.map((t) => t.id)), + ); + setOrDelete( + params, + MOVEMENT_URL_PARAMS.excludedTags, + serializeIds(filters.excludedTags.map((t) => t.id)), + ); +} + +export function writeDemographicsFiltersToUrl( + filters: DemographicsAppliedFilters, + params: URLSearchParams, +): void { + setOrDelete(params, DEMOGRAPHICS_URL_PARAMS.ageRanges, serializeValues(filters.ageRanges)); + setOrDelete(params, DEMOGRAPHICS_URL_PARAMS.genders, serializeValues(filters.genders)); + setOrDelete( + params, + DEMOGRAPHICS_URL_PARAMS.includedTags, + serializeIds(filters.includedTags.map((t) => t.id)), + ); + setOrDelete( + params, + DEMOGRAPHICS_URL_PARAMS.excludedTags, + serializeIds(filters.excludedTags.map((t) => t.id)), + ); +} + +export function toFilterValues(items: { id: number; name: string }[]): IFilterValue[] { + return items.map((item) => ({ + value: String(item.id), + label: item.name, + urlId: String(item.id), + })); +} + +export function toProductFilterValues(products: IProductOption[]): IFilterValue[] { + return products.map((p) => ({ + value: String(p.id), + label: p.gender ? `${p.name} (${p.gender})` : p.name, + urlId: String(p.id), + })); +} diff --git a/shared-components/statviz/utils/filterByTags.ts b/shared-components/statviz/utils/filterByTags.ts index fe73f91372..3dc7161f3d 100644 --- a/shared-components/statviz/utils/filterByTags.ts +++ b/shared-components/statviz/utils/filterByTags.ts @@ -1,5 +1,3 @@ -import { ITagFilterValue } from "../state/filter"; - /** * Utility function to filter data by included and excluded tags. * @@ -15,8 +13,8 @@ import { ITagFilterValue } from "../state/filter"; */ export function filterByTags( data: T[], - includedTags: ITagFilterValue[], - excludedTags: ITagFilterValue[], + includedTags: { id: number }[], + excludedTags: { id: number }[], ): T[] { return data.filter((item) => { const itemTagIds = item.tagIds || []; diff --git a/shared-front/src/App.tsx b/shared-front/src/App.tsx index d1a67482f0..4983fd3cd8 100644 --- a/shared-front/src/App.tsx +++ b/shared-front/src/App.tsx @@ -1,20 +1,23 @@ -import { ReactNode, useEffect } from "react"; +import { ReactNode, useMemo } from "react"; import { gql, useQuery } from "@apollo/client"; +import { useSearchParams } from "react-router-dom"; import { Alert, AlertIcon, Flex, Heading, Skeleton, Center, WrapItem } from "@chakra-ui/react"; import BoxtributeLogo from "./BoxtributeLogo"; -import StockDataFilter from "@boxtribute/shared-components/statviz/components/visualizations/stock/StockDataFilter"; +import StockOverviewRingFilterContainer from "@boxtribute/shared-components/statviz/components/visualizations/stock/StockOverviewRingFilterContainer"; import ErrorCard, { predefinedErrors, } from "@boxtribute/shared-components/statviz/components/ErrorCard"; -import { - tagFilterIncludedValuesVar, - tagFilterExcludedValuesVar, -} from "@boxtribute/shared-components/statviz/state/filter"; -import { tagToFilterValue } from "@boxtribute/shared-components/statviz/components/filter/TagFilter"; import BoxesOrItemsSelect, { boxesOrItemsFilterValues, + type BoxesOrItems, } from "@boxtribute/shared-components/statviz/components/filter/BoxesOrItemsSelect"; +import { + readStockFiltersFromUrl, + type ICategoryOption, + type ILocationOption, + type ITagOption, +} from "@boxtribute/shared-components/statviz/utils/dashboardFilters"; const RESOLVE_LINK = gql(` query resolveLink($code: String!) { @@ -95,16 +98,49 @@ function App() { const code = searchParams.get("code"); const view = searchParams.get("view"); + const [routerSearchParams] = useSearchParams(); + const boiUrlId = routerSearchParams.get("sboi"); + const boxesOrItems: BoxesOrItems = ( + boxesOrItemsFilterValues.find((f) => f.urlId === boiUrlId) ?? boxesOrItemsFilterValues[0] + ).value; + const { data, loading, error } = useQuery(RESOLVE_LINK, { variables: { code } }); - // Get tag filters. - useEffect(() => { - const tags = data?.resolveLink?.data[0].dimensions?.tag?.map((t) => tagToFilterValue(t!)); - if (tags?.length) { - tagFilterIncludedValuesVar(tags); - tagFilterExcludedValuesVar(tags); - } - }, [data?.resolveLink?.data]); + const allCategories = useMemo( + () => + (data?.resolveLink?.data[0]?.dimensions?.category ?? []).map((c) => ({ + id: Number(c.id), + name: c.name ?? "", + })), + [data], + ); + + const allLocations = useMemo( + () => + (data?.resolveLink?.data[0]?.dimensions?.location ?? []).map((l) => ({ + id: Number(l.id), + name: l.name ?? "", + })), + [data], + ); + + const allTags = useMemo( + () => + (data?.resolveLink?.data[0]?.dimensions?.tag ?? []).map((t) => ({ + id: Number(t.id), + name: t.name ?? "", + color: t.color ?? "#999", + value: String(t.id), + label: t.name ?? "", + urlId: String(t.id), + })), + [data], + ); + + const appliedFilters = useMemo( + () => readStockFiltersFromUrl(routerSearchParams, [], allCategories, allLocations, allTags), + [routerSearchParams, allCategories, allLocations, allTags], + ); if (error) { return {matchErrorMessage(error.message)}; @@ -168,7 +204,11 @@ function App() {
{/* TODO: Match view with view returned from data once other views are implemented. */} - + ); }