diff --git a/package-lock.json b/package-lock.json index e018a19..b76cedb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "lint-staged": "^16.2.7", "lucide-react": "^0.564.0", "next": "^15.5.9", - "papaparse": "^5.5.3", "react": "19.1.0", "react-dom": "19.1.0" }, @@ -27,7 +26,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/node": "^20", - "@types/papaparse": "^5.5.2", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.24", @@ -70,7 +68,6 @@ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -86,7 +83,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1996,6 +1992,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz", "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", @@ -2424,8 +2421,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2471,20 +2467,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/papaparse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", - "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/phoenix": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", @@ -2497,6 +2484,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2507,6 +2495,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2565,6 +2554,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -3162,6 +3152,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3240,7 +3231,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3622,6 +3612,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4078,8 +4069,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "17.2.3", @@ -4402,6 +4392,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4490,6 +4481,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4591,6 +4583,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6463,7 +6456,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6997,12 +6989,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/papaparse": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", - "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", - "license": "MIT" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7109,6 +7095,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7141,6 +7128,7 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7170,7 +7158,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7186,7 +7173,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7259,6 +7245,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7268,6 +7255,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7280,8 +7268,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/read-cmd-shim": { "version": "6.0.0", @@ -8231,6 +8218,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8435,6 +8423,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8557,6 +8546,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8650,6 +8640,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/components/volunteers/FilterBar.tsx b/src/components/volunteers/FilterBar.tsx new file mode 100644 index 0000000..d9a1fbe --- /dev/null +++ b/src/components/volunteers/FilterBar.tsx @@ -0,0 +1,135 @@ +import React, { useState } from "react"; +import { FilterTuple } from "@/lib/api/getVolunteersByMultipleColumns"; +import { ChevronDown, Plus } from "lucide-react"; +import clsx from "clsx"; +import { FILTERABLE_COLUMNS } from "./volunteerColumns"; +import { FilterModal, filterModalAlignRight } from "./FilterModal"; + +interface FilterBarProps { + filters: FilterTuple[]; + setFilters: React.Dispatch>; + globalOp: "AND" | "OR"; + setGlobalOp: (op: "AND" | "OR") => void; + optionsData: Record; +} + +export const FilterBar = ({ + filters, + setFilters, + globalOp, + setGlobalOp, + optionsData, +}: FilterBarProps): React.JSX.Element | null => { + const [editingIndex, setEditingIndex] = useState(null); + const [editAlignRight, setEditAlignRight] = useState(false); + const [isAddingNew, setIsAddingNew] = useState(false); + const [newAlignRight, setNewAlignRight] = useState(false); + + if (filters.length === 0) return null; + + const handleEditClick = (e: React.MouseEvent, index: number): void => { + setIsAddingNew(false); + if (editingIndex === index) { + setEditingIndex(null); + return; + } + setEditAlignRight(filterModalAlignRight(e.currentTarget as HTMLElement)); + setEditingIndex(index); + }; + + const handleApplyEdit = ( + index: number, + newFilter: FilterTuple | null + ): void => { + setEditingIndex(null); + if (newFilter === null) { + setFilters((prev) => prev.filter((_, i) => i !== index)); + } else { + setFilters((prev) => prev.map((f, i) => (i === index ? newFilter : f))); + } + }; + + const handleAddNewClick = (e: React.MouseEvent): void => { + setEditingIndex(null); + if (isAddingNew) { + setIsAddingNew(false); + return; + } + setNewAlignRight(filterModalAlignRight(e.currentTarget as HTMLElement)); + setIsAddingNew(true); + }; + + const handleApplyNew = (newFilter: FilterTuple | null): void => { + setIsAddingNew(false); + if (newFilter) { + setFilters((prev) => [...prev, newFilter]); + } + }; + + return ( +
+
+ Matches + +
+ + {/* Filters List */} + {filters.map((filter, index) => { + const colDef = FILTERABLE_COLUMNS.find((c) => c.id === filter.field); + const isCurrentlyEditing = editingIndex === index; + const Icon = colDef?.icon; + + return ( +
+ + + setEditingIndex(null)} + onApply={(f) => handleApplyEdit(index, f)} + initialFilter={filter} + optionsData={optionsData} + alignRight={editAlignRight} + /> +
+ ); + })} + + {/* New Filter Button */} +
+ + + setIsAddingNew(false)} + onApply={handleApplyNew} + optionsData={optionsData} + alignRight={newAlignRight} + /> +
+
+ ); +}; diff --git a/src/components/volunteers/FilterModal.tsx b/src/components/volunteers/FilterModal.tsx new file mode 100644 index 0000000..c115ad2 --- /dev/null +++ b/src/components/volunteers/FilterModal.tsx @@ -0,0 +1,304 @@ +import React, { useState, useRef, useEffect } from "react"; +import { FilterTuple } from "@/lib/api/getVolunteersByMultipleColumns"; +import { Trash2 } from "lucide-react"; +import { VolunteerTag } from "./VolunteerTag"; +import clsx from "clsx"; +import { FILTERABLE_COLUMNS } from "./volunteerColumns"; + +const MODAL_WIDTH_PX = 288; +const SCREEN_BUFFER_PX = 24; + +export const filterModalAlignRight = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect(); + return window.innerWidth - rect.left < MODAL_WIDTH_PX + SCREEN_BUFFER_PX; +}; + +interface FilterModalProps { + isOpen: boolean; + onClose: () => void; + onApply: (filter: FilterTuple | null) => void; + optionsData: Record; + initialFilter?: FilterTuple; + alignRight?: boolean; +} + +export const FilterModal = ({ + isOpen, + onClose, + onApply, + optionsData, + initialFilter, + alignRight = false, +}: FilterModalProps): React.JSX.Element | null => { + const [activeStep, setActiveStep] = useState< + "SELECT_COLUMN" | "SELECT_VALUES" + >("SELECT_COLUMN"); + const [selectedCol, setSelectedCol] = useState(null); + const [columnSearch, setColumnSearch] = useState(""); + const [inputValue, setInputValue] = useState(""); + const [selectedOptions, setSelectedOptions] = useState([]); + const [miniOp, setMiniOp] = useState<"AND" | "OR">("OR"); + + const colDef = FILTERABLE_COLUMNS.find((c) => c.id === selectedCol); + const availableOptions = selectedCol ? optionsData[selectedCol] || [] : []; + const visibleColumns = FILTERABLE_COLUMNS.filter((col) => + col.label.toLowerCase().includes(columnSearch.toLowerCase()) + ); + + const columnInputRef = useRef(null); + const valueInputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + if (initialFilter) { + setSelectedCol(initialFilter.field); + setMiniOp(initialFilter.miniOp || "OR"); + const initialColDef = FILTERABLE_COLUMNS.find( + (c) => c.id === initialFilter.field + ); + if (initialColDef?.type === "text") { + setInputValue(initialFilter.values[0] as string); + setSelectedOptions([]); + } else { + setSelectedOptions(initialFilter.values as string[]); + setInputValue(""); + } + setActiveStep("SELECT_VALUES"); + } else { + setActiveStep("SELECT_COLUMN"); + setSelectedCol(null); + setColumnSearch(""); + setInputValue(""); + setSelectedOptions([]); + setMiniOp("OR"); + } + } + }, [isOpen, initialFilter]); + + useEffect(() => { + if (isOpen) { + if (activeStep === "SELECT_COLUMN") { + setTimeout(() => columnInputRef.current?.focus(), 0); + } else if (activeStep === "SELECT_VALUES") { + setTimeout(() => valueInputRef.current?.focus(), 0); + } + } + }, [isOpen, activeStep]); + + const compareArrays = (a: string[], b: string[]): boolean => { + if (!a || !b) return a === b; + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((val, index) => val === sortedB[index]); + }; + + const handleApplyFilter = (): void => { + if (!selectedCol) { + onClose(); + return; + } + + const valuesToApply = + colDef?.type === "text" ? [inputValue.trim()] : selectedOptions; + const isEmpty = valuesToApply.length === 0 || valuesToApply[0] === ""; + if (isEmpty) { + onApply(null); + return; + } + + const newFilter: FilterTuple = { + field: selectedCol, + miniOp: miniOp, + values: valuesToApply, + }; + + if (initialFilter) { + if ( + initialFilter.field === newFilter.field && + initialFilter.miniOp === newFilter.miniOp && + compareArrays( + initialFilter.values as string[], + newFilter.values as string[] + ) + ) { + onClose(); + return; + } + } + onApply(newFilter); + }; + + const handleCloseAllRef = useRef(handleApplyFilter); + useEffect(() => { + handleCloseAllRef.current = handleApplyFilter; + }); + + if (!isOpen) return null; + + return ( + <> + {/* 25% Dim to background */} +