= ({
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"
+ />
+
+
+
+
+
+ Apply
+
+
+ Clear filters
+
+
+
+
+ );
+}
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"
+ />
+
+
+
+
+
+ Apply
+
+
+ Clear filters
+
+
+
+
+ );
+}
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"
+ />
+
+
+
+
+
+ Apply
+
+
+ Clear filters
+
+
+
+
+ );
+}
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) => (
-
- {groupOptions.find((ago) => ago.value === groupOption)!.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
- setNewDrilldownPath(drilldownPath[0], [])}
- >
- Reset
-
-
-
-
-
- {
- const newDrilldownPath = drilldownPath.slice(0, drilldownPath.length - 1);
- const newDrilldownValues = drilldownValues.slice(0, drilldownValues.length - 1);
-
- setDrilldownPath(newDrilldownPath);
- setDrilldownValues(newDrilldownValues);
- }}
- >
-
-
-
-
-
-
- {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
-
+
+
+
+ Boxes
+ Items
+
+
+
+
+
+
+
+
+
+
);
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
-
+
+
+
+ Boxes
+ Items
+
+
+
+
+
+
+
);
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. */}
-
+
>
);
}