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 00000000..2775c236 Binary files /dev/null and b/frontend/public/llsc-logo.png differ 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..ac5f362d --- /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 day = String(date.getDate()).padStart(2, '0'); + 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) => { + 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..8d30799a --- /dev/null +++ b/frontend/src/pages/admin/tasks.tsx @@ -0,0 +1,905 @@ +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 { + // 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') { + 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; +};