From 69baeea6052e1dbb6122fe1138c641aa2af395e1 Mon Sep 17 00:00:00 2001 From: Eddy Zheng Date: Sat, 4 Oct 2025 02:54:03 -0400 Subject: [PATCH 1/3] connect to backend --- .gitignore | 3 +- frontend/package-lock.json | 204 +++- frontend/package.json | 6 + frontend/public/llsc-logo.png | Bin 0 -> 43920 bytes frontend/src/APIClients/authAPIClient.ts | 33 + frontend/src/APIClients/taskAPIClient.ts | 70 ++ frontend/src/components/admin/AdminHeader.tsx | 48 + .../src/components/admin/FilterDropdown.tsx | 211 +++++ frontend/src/components/admin/TableHeader.tsx | 122 +++ .../src/components/admin/TaskEditModal.tsx | 821 ++++++++++++++++ frontend/src/components/admin/TaskRow.tsx | 152 +++ .../src/components/admin/ViewDropdown.tsx | 111 +++ frontend/src/components/ui/text-styles.tsx | 72 ++ frontend/src/constants/colors.ts | 111 +++ frontend/src/pages/admin/tasks.tsx | 886 ++++++++++++++++++ frontend/src/types/adminTypes.ts | 61 ++ frontend/src/utils/taskHelpers.ts | 33 + 17 files changed, 2936 insertions(+), 8 deletions(-) create mode 100644 frontend/public/llsc-logo.png create mode 100644 frontend/src/APIClients/taskAPIClient.ts create mode 100644 frontend/src/components/admin/AdminHeader.tsx create mode 100644 frontend/src/components/admin/FilterDropdown.tsx create mode 100644 frontend/src/components/admin/TableHeader.tsx create mode 100644 frontend/src/components/admin/TaskEditModal.tsx create mode 100644 frontend/src/components/admin/TaskRow.tsx create mode 100644 frontend/src/components/admin/ViewDropdown.tsx create mode 100644 frontend/src/components/ui/text-styles.tsx create mode 100644 frontend/src/constants/colors.ts create mode 100644 frontend/src/pages/admin/tasks.tsx create mode 100644 frontend/src/types/adminTypes.ts create mode 100644 frontend/src/utils/taskHelpers.ts diff --git a/.gitignore b/.gitignore index e465fdcf..2cdf4b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ **/.DS_Store **/*.cache **/*.egg-info -**/test.db \ No newline at end of file +**/test.db +.cursor/ \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66d44a39..fbca868b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,10 @@ "version": "0.1.0", "dependencies": { "@chakra-ui/react": "^3.13.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "axios": "^1.11.0", "firebase": "^11.10.0", "humps": "^2.0.1", @@ -16,6 +20,7 @@ "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", + "react-datepicker": "^8.7.0", "react-dom": "^18", "react-hook-form": "^7.57.0", "react-icons": "^5.5.0" @@ -23,6 +28,7 @@ "devDependencies": { "@types/node": "^20", "@types/react": "^18", + "@types/react-datepicker": "^6.2.0", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.13", @@ -282,6 +288,73 @@ "react-dom": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1035,12 +1108,12 @@ "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==" }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { @@ -1053,10 +1126,48 @@ "@floating-ui/utils": "^0.2.9" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom/node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, "node_modules/@grpc/grpc-js": { @@ -1578,6 +1689,45 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-datepicker": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-6.2.0.tgz", + "integrity": "sha512-+JtO4Fm97WLkJTH8j8/v3Ldh7JCNRwjMYjRaKh4KHH0M3jJoXtwiD3JBCsdlg3tsFIw9eQSqyAPeVDN2H2oM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.2", + "@types/react": "*", + "date-fns": "^3.3.1" + } + }, + "node_modules/@types/react-datepicker/node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@types/react-datepicker/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/@types/react-dom": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", @@ -3164,6 +3314,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3339,6 +3498,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -6425,6 +6594,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-datepicker": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.7.0.tgz", + "integrity": "sha512-r5OJbiLWc3YiVNy69Kau07/aVgVGsFVMA6+nlqCV7vyQ8q0FUOnJ+wAI4CgVxHejG3i5djAEiebrF8/Eip4rIw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.15", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -7163,6 +7347,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index b14c3943..cff8b022 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,10 @@ }, "dependencies": { "@chakra-ui/react": "^3.13.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "axios": "^1.11.0", "firebase": "^11.10.0", "humps": "^2.0.1", @@ -17,6 +21,7 @@ "next": "^14.2.24", "next-themes": "^0.4.6", "react": "^18", + "react-datepicker": "^8.7.0", "react-dom": "^18", "react-hook-form": "^7.57.0", "react-icons": "^5.5.0" @@ -24,6 +29,7 @@ "devDependencies": { "@types/node": "^20", "@types/react": "^18", + "@types/react-datepicker": "^6.2.0", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.13", diff --git a/frontend/public/llsc-logo.png b/frontend/public/llsc-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2775c2366557c5b75979a525d40dbab3b4f7d739 GIT binary patch literal 43920 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 *)reFG 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 literal 0 HcmV?d00001 diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 9ad7cfcc..04ba2813 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -386,3 +386,36 @@ export const refresh = async (): Promise => { return false; } }; + +// User types for admin and user management +export interface UserResponse { + id: string; + firstName: string | null; + lastName: string | null; + email: string; + roleId: number; + authId: string; + approved: boolean; + formStatus: string; +} + +export interface UserListResponse { + users: UserResponse[]; + total: number; +} + +/** + * Get all admin users + */ +export const getAdmins = async (): Promise => { + const response = await baseAPIClient.get('/users?admin=true'); + return response.data; +}; + +/** + * Get user by ID + */ +export const getUserById = async (userId: string): Promise => { + const response = await baseAPIClient.get(`/users/${userId}`); + return response.data; +}; diff --git a/frontend/src/APIClients/taskAPIClient.ts b/frontend/src/APIClients/taskAPIClient.ts new file mode 100644 index 00000000..32004f12 --- /dev/null +++ b/frontend/src/APIClients/taskAPIClient.ts @@ -0,0 +1,70 @@ +import baseAPIClient from './baseAPIClient'; + +export interface BackendTask { + id: string; + participantId: string | null; + type: 'intake_form_review' | 'volunteer_app_review' | 'profile_update' | 'matching'; + priority: 'no_status' | 'low' | 'medium' | 'high'; + status: 'pending' | 'in_progress' | 'completed'; + assigneeId: string | null; + startDate: string; // ISO datetime string + endDate: string | null; // ISO datetime string + createdAt: string; + updatedAt: string; +} + +export interface TaskListResponse { + tasks: BackendTask[]; + total: number; +} + +export interface UpdateTaskRequest { + participantId?: string; + type?: 'intake_form_review' | 'volunteer_app_review' | 'profile_update' | 'matching'; + priority?: 'no_status' | 'low' | 'medium' | 'high'; + status?: 'pending' | 'in_progress' | 'completed'; + assigneeId?: string | null; + startDate?: string; + endDate?: string; +} + +class TaskAPIClient { + /** + * Get all tasks with optional filters + */ + async getTasks(params?: { + status?: string; + priority?: string; + taskType?: string; + assigneeId?: string; + }): Promise { + const response = await baseAPIClient.get('/tasks', { params }); + return response.data; + } + + /** + * Get a single task by ID + */ + async getTaskById(taskId: string): Promise { + const response = await baseAPIClient.get(`/tasks/${taskId}`); + return response.data; + } + + /** + * Update an existing task + */ + async updateTask(taskId: string, updates: UpdateTaskRequest): Promise { + const response = await baseAPIClient.put(`/tasks/${taskId}`, updates); + return response.data; + } + + /** + * Mark a task as completed + */ + async completeTask(taskId: string): Promise { + const response = await baseAPIClient.put(`/tasks/${taskId}/complete`); + return response.data; + } +} + +export const taskAPIClient = new TaskAPIClient(); diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx new file mode 100644 index 00000000..beb1fd74 --- /dev/null +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Image from 'next/image'; +import { Box, Flex } from '@chakra-ui/react'; +import { FiFolder, FiLoader, FiLogOut } from 'react-icons/fi'; +import { LabelSmall } from '@/components/ui/text-styles'; +import { COLORS, shadow } from '@/constants/colors'; + +export const AdminHeader: React.FC = () => { + return ( + + + {/* Organization Logo */} + + LLSC Logo + + + {/* Navigation Items */} + + + + Task List + + + + Progress Tracker + + + + Sign Out + + + + + ); +}; diff --git a/frontend/src/components/admin/FilterDropdown.tsx b/frontend/src/components/admin/FilterDropdown.tsx new file mode 100644 index 00000000..7256c234 --- /dev/null +++ b/frontend/src/components/admin/FilterDropdown.tsx @@ -0,0 +1,211 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { FiFilter } from 'react-icons/fi'; +import { Checkbox } from '@/components/ui/checkbox'; +import { NavText, LabelSmall } from '@/components/ui/text-styles'; +import { + textPrimary, + borderLightGray, + borderTopGray, + teal, + tealDarker, + hoverBg, + shadow, +} from '@/constants/colors'; + +interface FilterState { + participant: boolean; + volunteer: boolean; + high: boolean; + medium: boolean; + low: boolean; + noStatus: boolean; +} + +interface FilterDropdownProps { + appliedFilters: FilterState; + onApplyFilters: (filters: FilterState) => void; +} + +export const FilterDropdown: React.FC = ({ + appliedFilters, + onApplyFilters, +}) => { + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [tempFilters, setTempFilters] = useState(appliedFilters); + const filterRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (filterRef.current && !filterRef.current.contains(event.target as Node)) { + setIsFilterOpen(false); + } + }; + + if (isFilterOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isFilterOpen]); + + const handleFilterChange = (filterName: keyof FilterState) => { + setTempFilters((prev) => ({ + ...prev, + [filterName]: !prev[filterName], + })); + }; + + const handleApply = () => { + onApplyFilters(tempFilters); + setIsFilterOpen(false); + }; + + const handleClearAll = () => { + const clearedFilters: FilterState = { + participant: false, + volunteer: false, + high: false, + medium: false, + low: false, + noStatus: false, + }; + setTempFilters(clearedFilters); + onApplyFilters(clearedFilters); + setIsFilterOpen(false); + }; + + return ( + + setIsFilterOpen(!isFilterOpen)} + > + + Filter + + + {isFilterOpen && ( + + + {/* 1. User type - Title with gap */} + + User type + + + {/* 2. Participant - Checkbox item */} + + handleFilterChange('participant')} + /> + Participant + + + {/* 3. Volunteer - Checkbox item */} + + handleFilterChange('volunteer')} + /> + Volunteer + + + {/* 4. Status - Title with top border and gap */} + + Status + + + {/* 5. High - Checkbox item */} + + handleFilterChange('high')} + /> + High + + + {/* 6. Medium - Checkbox item */} + + handleFilterChange('medium')} + /> + Medium + + + {/* 7. Low - Checkbox item */} + + handleFilterChange('low')} + /> + Low + + + {/* 8. No status - Checkbox item */} + + handleFilterChange('noStatus')} + /> + No status + + + {/* 9. Apply Button - Green box with white border */} + + Apply + + + {/* 10. Clear all Button */} + + Clear all + + + + )} + + ); +}; diff --git a/frontend/src/components/admin/TableHeader.tsx b/frontend/src/components/admin/TableHeader.tsx new file mode 100644 index 00000000..6ef783a1 --- /dev/null +++ b/frontend/src/components/admin/TableHeader.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { FiChevronUp, FiChevronDown } from 'react-icons/fi'; +import { LabelBold } from '@/components/ui/text-styles'; +import { gray300, headerText } from '@/constants/colors'; + +interface TableHeaderProps { + showTypeColumn?: boolean; + sortColumn: 'name' | 'startDate' | 'endDate' | 'priority' | null; + sortDirection: 'asc' | 'desc'; + onSort: (column: 'name' | 'startDate' | 'endDate' | 'priority') => void; +} + +export const TableHeader: React.FC = ({ + showTypeColumn = true, + sortColumn, + sortDirection, + onSort, +}) => { + return ( + + + {/* Name Column Header */} + onSort('name')} + _hover={{ opacity: 0.7 }} + > + Name + {sortColumn === 'name' ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* Type Column Header (conditional) */} + {showTypeColumn && ( + + Type + + )} + + {/* Start Date Column Header */} + onSort('startDate')} + _hover={{ opacity: 0.7 }} + > + Start Date + {sortColumn === 'startDate' ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* End Date Column Header */} + onSort('endDate')} + _hover={{ opacity: 0.7 }} + > + End Date + {sortColumn === 'endDate' ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* Priority Column Header */} + onSort('priority')} + _hover={{ opacity: 0.7 }} + > + Priority + {sortColumn === 'priority' ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* Assignee Column Header */} + + Assignee + + + + + ); +}; diff --git a/frontend/src/components/admin/TaskEditModal.tsx b/frontend/src/components/admin/TaskEditModal.tsx new file mode 100644 index 00000000..8ab8ba0a --- /dev/null +++ b/frontend/src/components/admin/TaskEditModal.tsx @@ -0,0 +1,821 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import DatePicker from 'react-datepicker'; +import { FiX, FiChevronRight, FiTag, FiClock, FiFlag, FiUser, FiCheckCircle } from 'react-icons/fi'; +import { Checkbox } from '@/components/ui/checkbox'; +import { getTypeColor, getPriorityColor } from '@/utils/taskHelpers'; +import { Admin, Task, categoryLabels } from '@/types/adminTypes'; +import { + bgOverlay, + white, + textSecondary, + lightGray, + divider, + black, + veniceBlue, + textPrimary, + borderLightGray, + hoverBg, + textMuted, + tealBlue, + shadow, +} from '@/constants/colors'; +import 'react-datepicker/dist/react-datepicker.css'; + +interface TaskEditModalProps { + task: Task | null; + isOpen: boolean; + onClose: () => void; + onUpdateField: ( + taskId: string, + field: string | number | symbol, + value: string | boolean, + ) => Promise; + admins: Admin[]; + currentUser: Admin | null; +} + +export const TaskEditModal: React.FC = ({ + task, + isOpen, + onClose, + onUpdateField, + admins, + currentUser, +}) => { + const [isPriorityDropdownOpen, setIsPriorityDropdownOpen] = useState(false); + const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); + const [isAssigneeDropdownOpen, setIsAssigneeDropdownOpen] = useState(false); + + const popupRef = useRef(null); + const priorityDropdownRef = useRef(null); + const assigneeDropdownRef = useRef(null); + + // Helper function to determine which tab a task belongs to + const getTaskTab = (task: Task): string => { + if (task.completed) { + return 'Completed'; + } + if (!task.assignee) { + return 'Unassigned'; + } + if (currentUser && task.assignee === currentUser.name) { + return 'My Tasks'; + } + return 'Team Tasks'; + }; + + // Close dropdown on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + priorityDropdownRef.current && + !priorityDropdownRef.current.contains(event.target as Node) + ) { + setIsPriorityDropdownOpen(false); + } + if ( + assigneeDropdownRef.current && + !assigneeDropdownRef.current.contains(event.target as Node) + ) { + setIsAssigneeDropdownOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + if (!task) return null; + + const parseDateString = (dateStr: string): Date => { + const [day, month, year] = dateStr.split('/').map(Number); + // Convert 2-digit year to 4-digit (25 -> 2025) + const fullYear = year < 100 ? 2000 + year : year; + return new Date(fullYear, month - 1, day); + }; + + const formatDateString = (date: Date): string => { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + return `${month}/${day}/${year}`; + }; + + const handleStartDateChange = (date: Date | null) => { + if (date) { + onUpdateField(task.id, 'startDate', formatDateString(date)); + setIsStartDatePickerOpen(false); + } + }; + + const handleEndDateChange = (date: Date | null) => { + if (date) { + onUpdateField(task.id, 'endDate', formatDateString(date)); + setIsEndDatePickerOpen(false); + } + }; + + const updateField = (field: keyof Task, value: Task[keyof Task]) => { + if (value !== undefined) { + onUpdateField(task.id, field, value); + } + }; + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > + {/* Popup Header */} + + + {/* Breadcrumb */} + + + + {getTaskTab(task)} + + + + {task.type} + + + + + {/* Close button */} + + + + + + + {/* Divider */} + + + {/* Popup Content */} + + + {/* Task Title with Checkbox */} + + e.stopPropagation()}> + updateField('completed', !task.completed)} + /> + + + {categoryLabels[task.category]} + + + + {/* Participant Name Field */} + + + + Participant Name + + + {task.name} + + + + {/* Type Field */} + + + + Type + + + {task.type} + + + + {/* Start Date Field */} + + + + Start Date + + + setIsStartDatePickerOpen(!isStartDatePickerOpen)} + _hover={{ opacity: 0.7 }} + > + {task.startDate} + + {isStartDatePickerOpen && ( + + setIsStartDatePickerOpen(false)} + inline + /> + + )} + + + + {/* End Date Field */} + + + + End Date + + + setIsEndDatePickerOpen(!isEndDatePickerOpen)} + _hover={{ opacity: 0.7 }} + > + {task.endDate} + + {isEndDatePickerOpen && ( + + setIsEndDatePickerOpen(false)} + inline + /> + + )} + + + + {/* Priority Field */} + + + + Priority + + + setIsPriorityDropdownOpen(!isPriorityDropdownOpen)} + _hover={{ opacity: 0.8 }} + > + {task.priority} + + + {/* Priority Dropdown Menu */} + {isPriorityDropdownOpen && ( + + + {/* High Priority */} + { + updateField('priority', 'High'); + setIsPriorityDropdownOpen(false); + }} + _hover={{ bg: hoverBg }} + borderBottom={`1px solid ${borderLightGray}`} + > + + High + + + + {/* Low Priority */} + { + updateField('priority', 'Low'); + setIsPriorityDropdownOpen(false); + }} + _hover={{ bg: hoverBg }} + borderBottom={`1px solid ${borderLightGray}`} + > + + Low + + + + {/* Medium Priority */} + { + updateField('priority', 'Medium'); + setIsPriorityDropdownOpen(false); + }} + _hover={{ bg: hoverBg }} + > + + Medium + + + + + )} + + + + {/* Assignee Field */} + + + + Assignee + + + {task.assignee ? ( + setIsAssigneeDropdownOpen(!isAssigneeDropdownOpen)} + _hover={{ opacity: 0.7 }} + > + admin.name === task.assignee)?.bgColor || '#F4F4F4' // Fallback avatar color + } + borderRadius="full" + display="flex" + alignItems="center" + justifyContent="center" + > + + {task.assignee.charAt(0).toUpperCase()} + + + + {task.assignee} + + + ) : ( + setIsAssigneeDropdownOpen(!isAssigneeDropdownOpen)} + _hover={{ opacity: 0.7 }} + > + Unassigned + + )} + + {/* Assignee Dropdown Menu */} + {isAssigneeDropdownOpen && ( + + + {admins.map((admin, index) => ( + { + updateField('assignee', admin.name); + setIsAssigneeDropdownOpen(false); + }} + _hover={{ bg: hoverBg }} + borderBottom={index < admins.length - 1 ? '1px solid #EEEEEC' : 'none'} + borderRadius={ + index === 0 + ? '8px 8px 0 0' + : index === admins.length - 1 + ? '0 0 8px 8px' + : '0' + } + > + + + + {admin.initial} + + + + {admin.name} + + + + ))} + + + )} + + + + {/* Task Description */} + + + + Task Description + + + {task.description || + 'Check incoming Peer Connection intake forms for accuracy, follow up on missing information, and schedule screening calls with applicants.'} + + + + + + + + ); +}; diff --git a/frontend/src/components/admin/TaskRow.tsx b/frontend/src/components/admin/TaskRow.tsx new file mode 100644 index 00000000..ea2156b1 --- /dev/null +++ b/frontend/src/components/admin/TaskRow.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { FiUserPlus } from 'react-icons/fi'; +import { Task, Admin } from '@/types/adminTypes'; +import { getTypeColor, getPriorityColor } from '@/utils/taskHelpers'; +import { gray300, textPrimary, black } from '@/constants/colors'; + +interface TaskRowProps { + task: Task; + onCheck: (id: string) => void; + onTaskClick: (task: Task) => void; + admins: Admin[]; + showTypeColumn?: boolean; + showDivider?: boolean; +} + +const getAdminByName = (name: string, admins: Admin[]): Admin | undefined => { + return admins.find((admin) => admin.name === name); +}; + +export const TaskRow: React.FC = ({ + task, + onCheck, + onTaskClick, + admins, + showTypeColumn = true, + showDivider = false, +}) => { + return ( + <> + onTaskClick(task)}> + {/* Name with Checkbox */} + + e.stopPropagation()}> + onCheck(task.id)} /> + + + {task.name} + + + + {/* Type Badge (conditional) */} + {showTypeColumn && ( + + + {task.type} + + + )} + + {/* Start Date */} + + + {task.startDate} + + + + {/* End Date */} + + + {task.endDate} + + + + {/* Priority Badge */} + + + {task.priority} + + + + {/* Assignee */} + + {task.assignee ? ( + + + {getAdminByName(task.assignee, admins)?.initial || + task.assignee.charAt(0).toUpperCase()} + + + ) : ( + + + + )} + + + + {/* Optional divider */} + {showDivider && } + + ); +}; diff --git a/frontend/src/components/admin/ViewDropdown.tsx b/frontend/src/components/admin/ViewDropdown.tsx new file mode 100644 index 00000000..db7fb133 --- /dev/null +++ b/frontend/src/components/admin/ViewDropdown.tsx @@ -0,0 +1,111 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { FiChevronDown, FiChevronUp, FiCheck } from 'react-icons/fi'; +import { NavText } from '@/components/ui/text-styles'; +import { gray700, divider, hoverBg, shadow } from '@/constants/colors'; + +type ViewMode = 'list' | 'grouped'; + +interface ViewDropdownProps { + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; +} + +export const ViewDropdown: React.FC = ({ viewMode, onViewModeChange }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const handleSelect = (mode: ViewMode) => { + onViewModeChange(mode); + setIsOpen(false); + }; + + return ( + + setIsOpen(!isOpen)} + > + View + {isOpen ? ( + + ) : ( + + )} + + + {/* Dropdown Menu */} + {isOpen && ( + + + {/* List option */} + handleSelect('list')} + _hover={{ bg: hoverBg }} + > + {viewMode === 'list' && } + {viewMode !== 'list' && } + List + + + {/* Divider */} + + + {/* Grouped option */} + handleSelect('grouped')} + _hover={{ bg: hoverBg }} + > + {viewMode === 'grouped' && } + {viewMode !== 'grouped' && } + Grouped + + + + )} + + ); +}; diff --git a/frontend/src/components/ui/text-styles.tsx b/frontend/src/components/ui/text-styles.tsx new file mode 100644 index 00000000..17ddea38 --- /dev/null +++ b/frontend/src/components/ui/text-styles.tsx @@ -0,0 +1,72 @@ +import { Text as ChakraText, TextProps } from '@chakra-ui/react'; + +// Base text with Open Sans +const baseTextProps: TextProps = { + fontFamily: "'Open Sans', sans-serif", +}; + +// Heading styles +export const Heading1 = (props: TextProps) => ( + +); + +export const Heading2 = (props: TextProps) => ( + +); + +export const Heading3 = (props: TextProps) => ( + +); + +// Body text styles +export const BodyLarge = (props: TextProps) => ( + +); + +export const BodyMedium = (props: TextProps) => ( + +); + +export const BodySmall = (props: TextProps) => ( + +); + +// Label/header styles +export const LabelBold = (props: TextProps) => ( + +); + +export const LabelSmall = (props: TextProps) => ( + +); + +// Navigation/button text +export const NavText = (props: TextProps) => ( + +); + +// Small body text +export const TextSmall = (props: TextProps) => ( + +); diff --git a/frontend/src/constants/colors.ts b/frontend/src/constants/colors.ts new file mode 100644 index 00000000..2663296f --- /dev/null +++ b/frontend/src/constants/colors.ts @@ -0,0 +1,111 @@ +// Flat color constants for easy import +export const veniceBlue = '#1D3448'; +export const tealBlue = '#5F989D'; // For drag overlay when not hovering +export const lightGray = '#BBC2C8'; // For drag overlay when hovering +export const gray700 = '#414651'; +export const gray300 = '#D5D7DA'; +export const textPrimary = '#495D6C'; +export const white = '#FFFFFF'; +export const black = '#000000'; +export const grayBorder = '#E2E8F0'; + +// Additional UI colors +export const lightBg = '#F6F6F6'; +export const lightBgHover = '#F0F0F0'; +export const hoverBg = '#F9F9F9'; +export const divider = '#E9EAEB'; +export const borderLightGray = '#EEEEEC'; +export const borderTopGray = '#F6F6F6'; +export const headerText = '#535862'; +export const mutedText = '#9E9E9E'; +export const textSecondary = '#717680'; +export const textMuted = '#616161'; +export const borderActive = 'rgba(187, 194, 200, 0.5)'; +export const bgOverlay = 'rgba(0, 0, 0, 0.15)'; + +// Avatar colors for admin users +export const avatarColors = [ + '#AAD3FF', + '#F4F4F4', + '#FFD4A3', + '#C7E9C0', + '#FFB3C1', + '#D5C4E8', + '#A3D9FF', + '#FFE4A3', + '#C0E9D7', + '#FFD1DC', + '#E8D5FF', + '#B3E5FC', + '#FFF9C4', + '#C8E6C9', + '#F8BBD0', + '#D1C4E9', + '#B2EBF2', + '#FFECB3', + '#DCEDC8', + '#F0F4C3', + '#E1BEE7', + '#BBDEFB', + '#FFE082', + '#C5E1A5', + '#FFCCBC', + '#CE93D8', +] as const; + +// Badge background and text colors +export const bgPurpleLight = '#F4F0FA'; +export const purple = '#6740C2'; +export const bgTealLight = 'rgba(179, 206, 209, 0.3)'; +export const teal = '#056067'; +export const tealDarker = '#044d52'; +export const bgPinkLight = 'rgba(232, 188, 189, 0.3)'; +export const red = '#A70000'; +export const bgGrayLight = '#EEEEEC'; +export const bgYellowLight = '#F5E9E1'; +export const orange = '#8E4C20'; + +// Shadow constants +export const shadow = { + sm: '0px 1px 2px 0px rgba(10, 13, 18, 0.05)', + md: '0px 4px 6px -2px rgba(10, 13, 18, 0.03), 0px 12px 16px -4px rgba(10, 13, 18, 0.08)', + lg: '0px 4px 4px 0px rgba(0, 0, 0, 0.25)', + filter: '0px 2px 8px 0px rgba(0, 0, 0, 0.3)', + header: '0px 2px 4px rgba(0, 0, 0, 0.08)', +} as const; + +// Full color palette object (for structured access) +export const COLORS = { + veniceBlue, + tealBlue, + lightGray, + gray700, + gray300, + textPrimary, + white, + black, + grayBorder, + lightBg, + lightBgHover, + hoverBg, + divider, + borderLightGray, + borderTopGray, + headerText, + mutedText, + textSecondary, + textMuted, + borderActive, + bgOverlay, + bgPurpleLight, + purple, + bgTealLight, + teal, + tealDarker, + bgPinkLight, + red, + bgGrayLight, + bgYellowLight, + orange, + shadow, +} as const; diff --git a/frontend/src/pages/admin/tasks.tsx b/frontend/src/pages/admin/tasks.tsx new file mode 100644 index 00000000..32b0efd6 --- /dev/null +++ b/frontend/src/pages/admin/tasks.tsx @@ -0,0 +1,886 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; +import { TaskRow } from '@/components/admin/TaskRow'; +import { TaskEditModal } from '@/components/admin/TaskEditModal'; +import { FilterDropdown } from '@/components/admin/FilterDropdown'; +import { AdminHeader } from '@/components/admin/AdminHeader'; +import { TableHeader } from '@/components/admin/TableHeader'; +import { ViewDropdown } from '@/components/admin/ViewDropdown'; +import { + FiClipboard, + FiUser, + FiUsers, + FiCheckCircle, + FiSearch, + FiFolder, + FiLoader, + FiChevronDown, + FiChevronRight, +} from 'react-icons/fi'; +import { taskAPIClient, BackendTask } from '@/APIClients/taskAPIClient'; +import { getAdmins, getUserById, UserResponse, getCurrentUser } from '@/APIClients/authAPIClient'; +import { + DndContext, + DragOverlay, + pointerWithin, + PointerSensor, + useSensor, + useSensors, + DragStartEvent, + DragEndEvent, + DragOverEvent, +} from '@dnd-kit/core'; +import { useDraggable, useDroppable } from '@dnd-kit/core'; +import { snapCenterToCursor } from '@dnd-kit/modifiers'; +import { Task, Admin, taskCategories } from '@/types/adminTypes'; +import { + veniceBlue, + gray300, + avatarColors, + white, + black, + lightGray, + tealBlue, + lightBg, + lightBgHover, + textPrimary, + borderActive, + mutedText, + shadow, +} from '@/constants/colors'; +import { Heading1 } from '@/components/ui/text-styles'; + +// Helper to map backend user to Admin +const mapUserToAdmin = (user: UserResponse, index: number): Admin => { + const firstName = user.firstName || ''; + const lastName = user.lastName || ''; + const fullName = `${firstName} ${lastName}`.trim() || user.email; + const initial = firstName.charAt(0).toUpperCase() || user.email.charAt(0).toUpperCase(); + + return { + id: user.id, + name: fullName, + initial, + bgColor: avatarColors[index % avatarColors.length], + }; +}; + +type ViewMode = 'list' | 'grouped'; + +// Helper function to map backend task to frontend format +const mapAPITaskToFrontend = ( + apiTask: BackendTask, + participant?: UserResponse | null, + assignee?: Admin | null, +): Task => { + // Map backend type to frontend type + const typeMap: Record = { + intake_form_review: 'Intake Form Review', + volunteer_app_review: 'Volunteer App. Review', + profile_update: 'Profile Update', + matching: 'Matching', + }; + + // Map backend priority to frontend priority + const priorityMap: Record = { + no_status: 'Add status', + low: 'Low', + medium: 'Medium', + high: 'High', + }; + + // Determine category based on type + const categoryMap: Record = { + intake_form_review: 'intake_screening', + volunteer_app_review: 'secondary_app', + profile_update: 'profile_updates', + matching: 'matching_requests', + }; + + // Format dates from ISO to DD/MM/YY + const formatDate = (isoDate: string): string => { + const date = new Date(isoDate); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()).slice(-2); + return `${day}/${month}/${year}`; + }; + + // Get participant name + const participantName = participant + ? `${participant.firstName || ''} ${participant.lastName || ''}`.trim() || participant.email + : 'Unknown Participant'; + + // Determine user type from participant's role + const userType: 'Participant' | 'Volunteer' = + participant && participant.roleId === 2 ? 'Volunteer' : 'Participant'; + + return { + id: apiTask.id, + name: participantName, + startDate: formatDate(apiTask.startDate), + endDate: apiTask.endDate ? formatDate(apiTask.endDate) : formatDate(apiTask.startDate), + priority: priorityMap[apiTask.priority] || 'Add status', + type: typeMap[apiTask.type] || 'Intake Form Review', + assignee: assignee?.name, + completed: apiTask.status === 'completed', + userType, + category: categoryMap[apiTask.type] || 'intake_screening', + description: `Task for ${typeMap[apiTask.type]}`, + }; +}; + +// Helper to map frontend values back to backend format +const mapPriorityToBackend = (priority: string): string => { + const priorityMap: Record = { + 'Add status': 'no_status', + Low: 'low', + Medium: 'medium', + High: 'high', + }; + return priorityMap[priority] || 'no_status'; +}; + +const formatDateToISO = (dateStr: string): string => { + const [day, month, year] = dateStr.split('/'); + const fullYear = 2000 + parseInt(year); + return new Date(fullYear, parseInt(month) - 1, parseInt(day)).toISOString(); +}; + +interface FilterState { + participant: boolean; + volunteer: boolean; + high: boolean; + medium: boolean; + low: boolean; + noStatus: boolean; +} + +export default function AdminTasks() { + const [activeTab, setActiveTab] = useState('Unassigned'); + const [tasks, setTasks] = useState([]); + const [admins, setAdmins] = useState([]); + const [loading, setLoading] = useState(true); + const [currentUser, setCurrentUser] = useState(null); + const [viewMode, setViewMode] = useState('list'); + const [expandedCategories, setExpandedCategories] = useState(['1']); // First category expanded by default + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [appliedFilters, setAppliedFilters] = useState({ + participant: false, + volunteer: false, + high: false, + medium: false, + low: false, + noStatus: false, + }); + const searchInputRef = useRef(null); + + // Popup state for task editing + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [selectedTask, setSelectedTask] = useState(null); + + // Sorting state + const [sortColumn, setSortColumn] = useState< + 'name' | 'startDate' | 'endDate' | 'priority' | null + >(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + // Drag & Drop state + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + + // Configure drag sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px movement required to start drag + }, + }), + ); + + // Fetch admins and tasks on mount + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + + // Fetch admins + const adminsResponse = await getAdmins(); + const mappedAdmins = adminsResponse.users.map((user, index) => mapUserToAdmin(user, index)); + setAdmins(mappedAdmins); + + // Get the currently logged-in user + const authenticatedUser = getCurrentUser(); + if (authenticatedUser) { + // Find the current user in the admins list by ID + const loggedInAdmin = mappedAdmins.find((admin) => admin.id === authenticatedUser.id); + if (loggedInAdmin) { + setCurrentUser(loggedInAdmin); + } else { + // Fallback: If logged-in user not found in admins list (shouldn't happen), use first admin + console.warn('Logged-in user not found in admins list, using first admin as fallback'); + if (mappedAdmins.length > 0) { + setCurrentUser(mappedAdmins[0]); + } + } + } else { + // No authenticated user found (shouldn't happen with auth guard), use first admin as fallback + console.warn('No authenticated user found, using first admin as fallback'); + if (mappedAdmins.length > 0) { + setCurrentUser(mappedAdmins[0]); + } + } + + // Fetch tasks + const tasksResponse = await taskAPIClient.getTasks(); + + // Fetch participants for tasks + const participantIds = [ + ...new Set( + tasksResponse.tasks + .filter((t) => t.participantId) + .map((t) => t.participantId as string), + ), + ]; + + const participantMap = new Map(); + await Promise.all( + participantIds.map(async (id) => { + try { + const participant = await getUserById(id); + participantMap.set(id, participant); + } catch (error) { + console.error(`Error fetching participant ${id}:`, error); + } + }), + ); + + // Map tasks to frontend format + const mappedTasks = tasksResponse.tasks.map((apiTask) => { + const participant = apiTask.participantId + ? participantMap.get(apiTask.participantId) + : null; + const assignee = apiTask.assigneeId + ? mappedAdmins.find((a) => a.id === apiTask.assigneeId) + : null; + return mapAPITaskToFrontend(apiTask, participant, assignee); + }); + + setTasks(mappedTasks); + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + // Focus search input when search is opened + useEffect(() => { + if (isSearchOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isSearchOpen]); + + const toggleCategoryExpansion = (categoryId: string) => { + setExpandedCategories((prev) => + prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId], + ); + }; + + const handleTaskCheck = async (taskId: string) => { + try { + const task = tasks.find((t) => t.id === taskId); + if (!task) return; + + if (!task.completed) { + // Mark as completed + await taskAPIClient.completeTask(taskId); + } else { + // Update to pending + await taskAPIClient.updateTask(taskId, { status: 'pending' }); + } + + // Update local state + setTasks(tasks.map((t) => (t.id === taskId ? { ...t, completed: !t.completed } : t))); + } catch (error) { + console.error('Error updating task:', error); + } + }; + + const openTaskPopup = (task: Task) => { + setSelectedTask(task); + setIsPopupOpen(true); + }; + + const closeTaskPopup = () => { + setIsPopupOpen(false); + setSelectedTask(null); + }; + + const updateTaskField = async ( + taskId: string, + field: string | number | symbol, + value: string | boolean, + ) => { + const taskToUpdate = tasks.find((t) => t.id === taskId); + if (!taskToUpdate) return; + + try { + const updates: Record = {}; + + if (field === 'priority') { + updates.priority = mapPriorityToBackend(value as string); + } else if (field === 'assignee') { + const admin = admins.find((a) => a.name === value); + updates.assigneeId = admin?.id || null; + } else if (field === 'startDate') { + updates.startDate = formatDateToISO(value as string); + } else if (field === 'endDate') { + updates.endDate = formatDateToISO(value as string); + } + + // Call API + await taskAPIClient.updateTask(taskId, updates); + + // Update local state + const updatedTask = { ...taskToUpdate, [field]: value }; + if (selectedTask?.id === taskId) { + setSelectedTask(updatedTask); + } + setTasks(tasks.map((task) => (task.id === taskId ? updatedTask : task))); + } catch (error) { + console.error('Error updating task field:', error); + } + }; + + // Helper to parse date string (DD/MM/YY) to Date object + const parseDateString = (dateStr: string): Date | null => { + const parts = dateStr.split('/'); + if (parts.length !== 3) return null; + const day = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed + const year = 2000 + parseInt(parts[2], 10); // Assuming 20XX + return new Date(year, month, day); + }; + + // Sorting handler + const handleSort = (column: 'name' | 'startDate' | 'endDate' | 'priority') => { + if (sortColumn === column) { + // Toggle direction if same column + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + // Set new column and default to ascending + setSortColumn(column); + setSortDirection('asc'); + } + }; + + // Drag & Drop handlers + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + const handleDragOver = (event: DragOverEvent) => { + setOverId(event.over?.id as string | null); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + setActiveId(null); + setOverId(null); + + if (!over) return; + + const taskId = active.id as string; + const dropZone = over.id as string; + const task = tasks.find((t) => t.id === taskId); + + if (!task) return; + + try { + // Handle drop based on zone + if (dropZone === 'Unassigned') { + // Unassign task + await taskAPIClient.updateTask(taskId, { + assigneeId: null, + status: 'pending', + }); + + // Update local state + setTasks((prevTasks) => + prevTasks.map((t) => + t.id === taskId ? { ...t, assignee: undefined, completed: false } : t, + ), + ); + } else if (dropZone === 'My Tasks' || dropZone === 'Team Tasks') { + // Assign to current user + if (currentUser) { + await taskAPIClient.updateTask(taskId, { + assigneeId: currentUser.id, + status: 'pending', + }); + + setTasks((prevTasks) => + prevTasks.map((t) => + t.id === taskId ? { ...t, assignee: currentUser.name, completed: false } : t, + ), + ); + } + } else if (dropZone === 'Completed') { + // Mark as completed + await taskAPIClient.completeTask(taskId); + + setTasks((prevTasks) => + prevTasks.map((t) => (t.id === taskId ? { ...t, completed: true } : t)), + ); + } + } catch (error) { + console.error('Error updating task:', error); + } + }; + + // Filter handler for FilterDropdown component + const handleApplyFilters = (filters: FilterState) => { + setAppliedFilters(filters); + }; + + // Filter tasks based on active tab, applied filters, and search query + const getFilteredTasks = () => { + let filtered = tasks; + + // First filter by tab + if (activeTab === 'Completed') { + filtered = filtered.filter((task) => task.completed); + } else if (activeTab === 'Unassigned') { + filtered = filtered.filter((task) => !task.completed && !task.assignee); + } else if (activeTab === 'My Tasks') { + // Show tasks assigned to the current user + if (currentUser) { + filtered = filtered.filter((task) => !task.completed && task.assignee === currentUser.name); + } + } else if (activeTab === 'Team Tasks') { + // Show ALL assigned tasks (all admins are on one team) + filtered = filtered.filter((task) => !task.completed && task.assignee); + } + + // Then apply user type filters + const userTypeFiltersActive = appliedFilters.participant || appliedFilters.volunteer; + if (userTypeFiltersActive) { + filtered = filtered.filter((task) => { + if (appliedFilters.participant && task.userType === 'Participant') return true; + if (appliedFilters.volunteer && task.userType === 'Volunteer') return true; + return false; + }); + } + + // Then apply priority filters + const priorityFiltersActive = + appliedFilters.high || appliedFilters.medium || appliedFilters.low || appliedFilters.noStatus; + if (priorityFiltersActive) { + filtered = filtered.filter((task) => { + if (appliedFilters.high && task.priority === 'High') return true; + if (appliedFilters.medium && task.priority === 'Medium') return true; + if (appliedFilters.low && task.priority === 'Low') return true; + if (appliedFilters.noStatus && task.priority === 'Add status') return true; + return false; + }); + } + + // Finally apply search filter + if (searchQuery.trim()) { + filtered = filtered.filter((task) => + task.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + } + + // Apply sorting + if (sortColumn) { + filtered = [...filtered].sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + + if (sortColumn === 'name') { + aValue = a.name.toLowerCase(); + bValue = b.name.toLowerCase(); + } else if (sortColumn === 'startDate' || sortColumn === 'endDate') { + const aDate = parseDateString(a[sortColumn]); + const bDate = parseDateString(b[sortColumn]); + aValue = aDate ? aDate.getTime() : 0; + bValue = bDate ? bDate.getTime() : 0; + } else if (sortColumn === 'priority') { + // Priority order: High > Medium > Low > Add status + const priorityOrder: Record = { + High: 3, + Medium: 2, + Low: 1, + 'Add status': 0, + }; + aValue = priorityOrder[a.priority] || 0; + bValue = priorityOrder[b.priority] || 0; + } else { + return 0; + } + + if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1; + if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + } + + return filtered; + }; + + const filteredTasks = getFilteredTasks(); + + // Draggable Task Row Component + const DraggableTask = ({ task, children }: { task: Task; children: React.ReactNode }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: task.id, + }); + + return ( + + {children} + + ); + }; + + // Droppable Zone Component + const DroppableZone = ({ id, children }: { id: string; children: React.ReactNode }) => { + const { setNodeRef } = useDroppable({ id }); + + return ( + + {children} + + ); + }; + + if (loading) { + return ( + + + + + Loading tasks... + + + + ); + } + + return ( + + + + + + {/* Main Content */} + + + {/* Header */} + + + Tasks + + + + {/* Navigation Tabs */} + + {[ + { name: 'Unassigned', icon: FiClipboard }, + { name: 'My Tasks', icon: FiUser }, + { name: 'Team Tasks', icon: FiUsers }, + { name: 'Completed', icon: FiCheckCircle }, + ].map((tab) => ( + + setActiveTab(tab.name)} + _hover={{ + bg: activeTab === tab.name ? white : lightBgHover, + }} + minW="fit-content" + whiteSpace="nowrap" + cursor="pointer" + > + + {tab.name} + + + ))} + + + {/* Controls */} + + {/* View Dropdown - hidden when search is open */} + {!isSearchOpen && ( + + )} + + {/* Filter Dropdown - hidden when search is open */} + {!isSearchOpen && ( + + )} + + {/* Search */} + {!isSearchOpen ? ( + setIsSearchOpen(true)} + cursor="pointer" + display="flex" + alignItems="center" + justifyContent="center" + > + + + ) : ( + + + setSearchQuery(e.target.value)} + onBlur={() => { + if (!searchQuery.trim()) { + setIsSearchOpen(false); + } + }} + placeholder="Type to search..." + style={{ + flex: 1, + border: 'none', + outline: 'none', + background: 'transparent', + fontFamily: "'Open Sans', sans-serif", + fontSize: '20px', + fontWeight: 400, + color: textPrimary, + }} + /> + + )} + + + + + {/* Tasks Table - List View */} + {viewMode === 'list' && ( + + {/* Table Header */} + + + {/* Task Rows */} + + {filteredTasks.map((task, index) => ( + + + + + + ))} + + + )} + + {/* Tasks Table - Grouped View */} + {viewMode === 'grouped' && ( + + {taskCategories.map((category) => { + const categoryTasks = filteredTasks.filter( + (task) => task.category === category.categoryKey, + ); + const isExpanded = expandedCategories.includes(category.id); + + return ( + + {/* Category Header */} + toggleCategoryExpansion(category.id)} + _hover={{ opacity: 0.9 }} + > + + {isExpanded ? ( + + ) : ( + + )} + + {category.name} + + + {categoryTasks.length} + + + + + {/* Category Tasks */} + {isExpanded && categoryTasks.length > 0 && ( + + {/* Table Header for Category */} + + + {/* Task Rows for Category */} + + {categoryTasks.map((task, index) => ( + + + + + + ))} + + + )} + + ); + })} + + )} + + + + + {/* Task Popup Modal */} + + + {/* Drag Overlay */} + + {activeId ? ( + + + + Move 1 task + + + ) : null} + + + + ); +} diff --git a/frontend/src/types/adminTypes.ts b/frontend/src/types/adminTypes.ts new file mode 100644 index 00000000..4144046d --- /dev/null +++ b/frontend/src/types/adminTypes.ts @@ -0,0 +1,61 @@ +export interface Task { + id: string; + name: string; + type: 'Intake Form Review' | 'Volunteer App. Review' | 'Matching' | 'Profile Update'; + startDate: string; + endDate: string; + priority: 'High' | 'Medium' | 'Low' | 'Add status'; + assignee?: string; + completed: boolean; + userType: 'Participant' | 'Volunteer'; + category: 'intake_screening' | 'secondary_app' | 'matching_requests' | 'profile_updates'; + description?: string; +} + +export interface Admin { + id: string; + name: string; + initial: string; + bgColor: string; +} + +export interface TaskCategory { + id: string; + name: string; + categoryKey: Task['category']; + bgColor: string; +} + +export const categoryLabels: Record = { + intake_screening: 'Review intake forms and schedule screening call', + secondary_app: 'Review secondary application form', + matching_requests: 'Participants requesting a match', + profile_updates: 'User profile updates', +}; + +export const taskCategories: TaskCategory[] = [ + { + id: '1', + name: 'Review intake forms and schedule screening call', + categoryKey: 'intake_screening', + bgColor: '#F4F0FA', + }, + { + id: '2', + name: 'Review secondary application form', + categoryKey: 'secondary_app', + bgColor: 'rgba(179, 206, 209, 0.3)', + }, + { + id: '3', + name: 'Participants requesting a match', + categoryKey: 'matching_requests', + bgColor: 'rgba(232, 188, 189, 0.3)', + }, + { + id: '4', + name: 'User profile updates', + categoryKey: 'profile_updates', + bgColor: '#EEEEEC', + }, +]; diff --git a/frontend/src/utils/taskHelpers.ts b/frontend/src/utils/taskHelpers.ts new file mode 100644 index 00000000..2340bb63 --- /dev/null +++ b/frontend/src/utils/taskHelpers.ts @@ -0,0 +1,33 @@ +import { COLORS } from '@/constants/colors'; + +// Helper functions for task styling + +export const getTypeColor = (type: string): { bg: string; color: string } => { + const typeColors: Record = { + 'Intake Form Review': { bg: COLORS.bgPurpleLight, color: COLORS.purple }, + 'Volunteer App. Review': { bg: COLORS.bgTealLight, color: COLORS.teal }, + Matching: { bg: COLORS.bgPinkLight, color: COLORS.red }, + 'Profile Update': { bg: COLORS.bgGrayLight, color: COLORS.gray700 }, + }; + return typeColors[type] || { bg: COLORS.bgGrayLight, color: COLORS.gray700 }; +}; + +export const getPriorityColor = (priority: string): { bg: string; color: string } => { + const priorityColors: Record = { + High: { bg: COLORS.bgPinkLight, color: COLORS.red }, + Medium: { bg: COLORS.bgYellowLight, color: COLORS.orange }, + Low: { bg: COLORS.bgTealLight, color: COLORS.teal }, + 'No status': { bg: COLORS.bgGrayLight, color: COLORS.gray700 }, + }; + return priorityColors[priority] || { bg: COLORS.bgGrayLight, color: COLORS.gray700 }; +}; + +export const getCategoryColor = (categoryKey: string): string => { + const categoryColors: Record = { + intake_screening: COLORS.bgPurpleLight, + secondary_app: COLORS.bgTealLight, + matching_requests: COLORS.bgPinkLight, + profile_updates: COLORS.bgGrayLight, + }; + return categoryColors[categoryKey] || COLORS.bgGrayLight; +}; From 76116120212a2a5d65595ffec4df382e6ffcc2d4 Mon Sep 17 00:00:00 2001 From: richieb21 <76607899+richieb21@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:03:41 -0400 Subject: [PATCH 2/3] fix codex bugs --- .../src/components/admin/TaskEditModal.tsx | 10 ++++---- frontend/src/pages/admin/tasks.tsx | 23 +++++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/admin/TaskEditModal.tsx b/frontend/src/components/admin/TaskEditModal.tsx index 8ab8ba0a..737f0504 100644 --- a/frontend/src/components/admin/TaskEditModal.tsx +++ b/frontend/src/components/admin/TaskEditModal.tsx @@ -99,10 +99,10 @@ export const TaskEditModal: React.FC = ({ }; const formatDateString = (date: Date): string => { - const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); - const year = date.getFullYear(); - return `${month}/${day}/${year}`; + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()).slice(-2); // Get last 2 digits of year + return `${day}/${month}/${year}`; }; const handleStartDateChange = (date: Date | null) => { @@ -743,8 +743,8 @@ export const TaskEditModal: React.FC = ({ index === 0 ? '8px 8px 0 0' : index === admins.length - 1 - ? '0 0 8px 8px' - : '0' + ? '0 0 8px 8px' + : '0' } > diff --git a/frontend/src/pages/admin/tasks.tsx b/frontend/src/pages/admin/tasks.tsx index 32b0efd6..b10bf994 100644 --- a/frontend/src/pages/admin/tasks.tsx +++ b/frontend/src/pages/admin/tasks.tsx @@ -332,6 +332,25 @@ export default function AdminTasks() { if (!taskToUpdate) return; try { + // Handle completed field separately using the dedicated endpoints + if (field === 'completed') { + if (value === true) { + // Mark as completed + await taskAPIClient.completeTask(taskId); + } else { + // Update to pending + await taskAPIClient.updateTask(taskId, { status: 'pending' }); + } + + // Update local state + const updatedTask = { ...taskToUpdate, completed: value as boolean }; + if (selectedTask?.id === taskId) { + setSelectedTask(updatedTask); + } + setTasks(tasks.map((task) => (task.id === taskId ? updatedTask : task))); + return; + } + const updates: Record = {}; if (field === 'priority') { @@ -629,8 +648,8 @@ export default function AdminTasks() { overId === tab.name ? lightGray : activeTab === tab.name - ? borderActive - : lightBg + ? borderActive + : lightBg } borderRadius="8px" fontFamily="'Open Sans', sans-serif" From 8683b569b3ba54d04b7dd31f2870fd2470e3d530 Mon Sep 17 00:00:00 2001 From: richieb21 <76607899+richieb21@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:06:45 -0400 Subject: [PATCH 3/3] lint --- frontend/src/components/admin/TaskEditModal.tsx | 4 ++-- frontend/src/pages/admin/tasks.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/admin/TaskEditModal.tsx b/frontend/src/components/admin/TaskEditModal.tsx index 737f0504..ac5f362d 100644 --- a/frontend/src/components/admin/TaskEditModal.tsx +++ b/frontend/src/components/admin/TaskEditModal.tsx @@ -743,8 +743,8 @@ export const TaskEditModal: React.FC = ({ index === 0 ? '8px 8px 0 0' : index === admins.length - 1 - ? '0 0 8px 8px' - : '0' + ? '0 0 8px 8px' + : '0' } > diff --git a/frontend/src/pages/admin/tasks.tsx b/frontend/src/pages/admin/tasks.tsx index b10bf994..8d30799a 100644 --- a/frontend/src/pages/admin/tasks.tsx +++ b/frontend/src/pages/admin/tasks.tsx @@ -648,8 +648,8 @@ export default function AdminTasks() { overId === tab.name ? lightGray : activeTab === tab.name - ? borderActive - : lightBg + ? borderActive + : lightBg } borderRadius="8px" fontFamily="'Open Sans', sans-serif"