diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 51959bdb48..3def323e76 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -22,7 +22,7 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload { return { projectRuleId: projectRule.projectRuleId, - rule: projectRule.rule, + rule: ruleTransformer(projectRule.rule), projectId: projectRule.projectId, currentStatus: projectRule.currentStatus, statusHistory: projectRule.statusHistory diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 962facde88..aeac616fd6 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -4,22 +4,14 @@ */ import axios from '../utils/axios'; -import { Rule, RulesetType, Ruleset } from 'shared'; +import { ProjectRule, Rule, RuleCompletion, Ruleset, RulesetType } from 'shared'; import { apiUrls } from '../utils/urls'; - -/** - * Gets all top-level rules (rules with no parent) for a ruleset - */ -export const getTopLevelRules = (rulesetId: string) => { - return axios.get(apiUrls.rulesTopLevel(rulesetId)); -}; - -/** - * Gets all child rules of a specific rule - */ -export const getChildRules = (ruleId: string) => { - return axios.get(apiUrls.rulesChildRules(ruleId)); -}; +import { + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer, + ruleTransformer +} from './transformers/rules.transformers'; /** * Toggles team assignment for a rule @@ -38,7 +30,7 @@ export const getTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) /** * Creates a new ruleset type * - * @param payload the data for creating the ruleset type + * @param payload the data for creating the ruleset type. * @returns the created ruleset type */ export const createRulesetType = (payload: { name: string }) => { @@ -46,13 +38,97 @@ export const createRulesetType = (payload: { name: string }) => { }; /** - * Fetches all Ruleset Types for the current organization. - * - * @returns A list of Ruleset Types. + * Fetches all ruleset types for the organization. */ export const getAllRulesetTypes = () => { return axios.get(apiUrls.rulesetTypes(), { - transformResponse: (data) => JSON.parse(data) + transformResponse: (data) => JSON.parse(data).map(rulesetTypeTransformer) + }); +}; + +/** + * Gets the active ruleset for a given ruleset type. + * + * @param rulesetTypeId The ID of the ruleset type. + */ +export const getActiveRuleset = (rulesetTypeId: string) => { + return axios.get(apiUrls.rulesGetActiveRuleset(rulesetTypeId), { + transformResponse: (data) => rulesetTransformer(JSON.parse(data)) + }); +}; + +/** + * Gets all project rules for a given ruleset and project. + * + * @param rulesetId The ID of the ruleset. + * @param projectId The ID of the project. + */ +export const getProjectRules = (rulesetId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetProjectRules(rulesetId, projectId), { + transformResponse: (data) => JSON.parse(data).map(projectRuleTransformer) + }); +}; + +/** + * Gets unassigned rules for a ruleset and team. + * + * @param rulesetId The ID of the ruleset. + * @param teamId The ID of the team. + */ +export const getUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Creates a project rule (assigns a rule to a project). + * + * @param ruleId The ID of the rule to assign. + * @param projectId The ID of the project. + */ +export const createProjectRule = (ruleId: string, projectId: string) => { + return axios.post(apiUrls.rulesCreateProjectRule(), { ruleId, projectId }); +}; + +/** + * Deletes a project rule. + * + * @param projectRuleId The ID of the project rule to delete. + */ +export const deleteProjectRule = (projectRuleId: string) => { + return axios.post(apiUrls.rulesDeleteProjectRule(projectRuleId)); +}; + +/** + * Updates the status of a project rule. + * + * @param projectRuleId The ID of the project rule. + * @param newStatus The new status to set. + */ +export const editProjectRuleStatus = (projectRuleId: string, newStatus: RuleCompletion) => { + return axios.post(apiUrls.rulesEditProjectRuleStatus(projectRuleId), { newStatus }); +}; + +/** + * Gets all child rules of a given rule. + * + * @param ruleId The ID of the parent rule. + */ +export const getChildRules = (ruleId: string) => { + return axios.get(apiUrls.rulesChildRules(ruleId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Gets all top-level rules (rules with no parent) for a ruleset. + * + * @param rulesetId The ID of the ruleset. + */ +export const getTopLevelRules = (rulesetId: string) => { + return axios.get(apiUrls.rulesTopLevel(rulesetId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) }); }; diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index cf7faadfac..7d187ed7b4 100644 --- a/src/frontend/src/apis/transformers/rules.transformers.ts +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -1 +1,65 @@ -// write transformer functions below here! +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { ProjectRule, Rule, RulesetType, Ruleset } from 'shared'; + +/** + * Transforms a rule to proper field types. + * + * @param rule Incoming rule object + * @returns Properly transformed rule object. + */ +export const ruleTransformer = (rule: Rule): Rule => { + return { + ...rule, + subRuleIds: rule.subRuleIds || [], + referencedRuleIds: rule.referencedRuleIds || [] + }; +}; + +/** + * Transforms a project rule (support Date objects) + * + * @param projectRule Incoming project rule object + * @returns Properly transformed project rule object. + */ +export const projectRuleTransformer = (projectRule: ProjectRule): ProjectRule => { + return { + ...projectRule, + rule: ruleTransformer(projectRule.rule), + statusHistory: (projectRule.statusHistory || []).map((history) => ({ + ...history, + dateCreated: new Date(history.dateCreated) + })) + }; +}; + +/** + * Transforms a ruleset type (support Date objects) + * + * @param rulesetType Incoming ruleset type object + * @returns Properly transformed ruleset type object. + */ +export const rulesetTypeTransformer = (rulesetType: RulesetType): RulesetType => { + return { + ...rulesetType, + lastUpdated: new Date(rulesetType.lastUpdated), + revisionFiles: rulesetType.revisionFiles || [] + }; +}; + +/** + * Transforms a ruleset (support Date objects) + * + * @param ruleset Incoming ruleset object + * @returns Properly transformed ruleset object. + */ +export const rulesetTransformer = (ruleset: Ruleset): Ruleset => { + return { + ...ruleset, + dateCreated: new Date(ruleset.dateCreated), + rulesetType: rulesetTypeTransformer(ruleset.rulesetType) + }; +}; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 7ba3ef0a7a..2bb8b0f2af 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -3,21 +3,112 @@ * See the LICENSE file in the repository root folder for details. */ -import { useQuery, useMutation, useQueryClient } from 'react-query'; -import { Rule, Ruleset, RulesetType } from 'shared'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { ProjectRule, Rule, RuleCompletion, Ruleset, RulesetType } from 'shared'; import { - getTopLevelRules, + createRulesetType, + getAllRulesetTypes, + getActiveRuleset, + getProjectRules, + getUnassignedRulesForRuleset, + createProjectRule, + deleteProjectRule, + editProjectRuleStatus, getChildRules, + getTopLevelRules, toggleRuleTeam, getTeamRulesInRulesetType, - createRulesetType, - getAllRulesetTypes, getRulesetsByRulesetType, updateRuleset, deleteRuleset, deleteRulesetType } from '../apis/rules.api'; +/** + * Hook to supply all ruleset types. + */ +export const useAllRulesetTypes = () => { + return useQuery(['rules', 'rulesetTypes'], async () => { + const { data } = await getAllRulesetTypes(); + return data; + }); +}; + +/** + * Hook to get the active ruleset for a given ruleset type. + */ +export const useActiveRuleset = (rulesetTypeId: string) => { + return useQuery( + ['rules', 'activeRuleset', rulesetTypeId], + async () => { + try { + const { data } = await getActiveRuleset(rulesetTypeId); + return data; + } catch { + // Return undefined if no active ruleset exists + return undefined; + } + }, + { enabled: !!rulesetTypeId } + ); +}; + +/** + * Hook to get all project rules for a given ruleset and project. + */ +export const useProjectRules = (rulesetId: string, projectId: string) => { + return useQuery( + ['rules', 'projectRules', rulesetId, projectId], + async () => { + const { data } = await getProjectRules(rulesetId, projectId); + return data; + }, + { enabled: !!rulesetId && !!projectId } + ); +}; + +/** + * Hook to get unassigned rules for a ruleset and team. + */ +export const useUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { + return useQuery( + ['rules', 'unassigned', rulesetId, teamId], + async () => { + const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId); + return data; + }, + { enabled: !!rulesetId && !!teamId } + ); +}; + +/** + * Hook to get child rules of a rule. + */ +export const useChildRules = (ruleId: string) => { + return useQuery( + ['rules', 'children', ruleId], + async () => { + const { data } = await getChildRules(ruleId); + return data; + }, + { enabled: !!ruleId } + ); +}; + +/** + * Hook to get top-level rules for a ruleset. + */ +export const useTopLevelRules = (rulesetId: string) => { + return useQuery( + ['rules', 'topLevel', rulesetId], + async () => { + const { data } = await getTopLevelRules(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; + interface CreateRulesetTypePayload { name: string; } @@ -60,7 +151,7 @@ export const useGetTeamRulesInRulesetType = (rulesetTypeId: string, teamId: stri }; /** - * Custom React Hook to create a new ruleset type + * Hook to create a new ruleset type. */ export const useCreateRulesetType = () => { const queryClient = useQueryClient(); @@ -72,22 +163,69 @@ export const useCreateRulesetType = () => { }, { onSuccess: () => { - queryClient.invalidateQueries(['rulesetTypes']); + queryClient.invalidateQueries(['rules', 'rulesetTypes']); } } ); }; /** - * React Query hook to fetch all Ruleset Types. - * - * @returns Query result containing Ruleset Types data, loading state, and error state. + * Hook to create a project rule (assign a rule to a project). */ -export const useAllRulesetTypes = () => { - return useQuery(['rulesetTypes'], async () => { - const { data } = await getAllRulesetTypes(); - return data; - }); +export const useCreateProjectRule = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'create'], + async ({ ruleId, projectId: pId }) => { + const { data } = await createProjectRule(ruleId, pId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules']); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to delete a project rule. + */ +export const useDeleteProjectRule = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'delete'], + async (projectRuleId: string) => { + const { data } = await deleteProjectRule(projectRuleId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to update project rule status. + */ +export const useEditProjectRuleStatus = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'editStatus'], + async ({ projectRuleId, newStatus }) => { + const { data } = await editProjectRuleStatus(projectRuleId, newStatus); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + } + } + ); }; /** diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx new file mode 100644 index 0000000000..3afcff73e5 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -0,0 +1,246 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useState, useMemo } from 'react'; +import { + Box, + Typography, + CircularProgress, + Alert, + FormControl, + Select, + MenuItem, + SelectChangeEvent, + IconButton, + useTheme +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { Rule } from 'shared'; +import NERModal from '../../../../components/NERModal'; +import { useUnassignedRulesForRuleset } from '../../../../hooks/rules.hooks'; + +interface AddRuleModalProps { + open: boolean; + onHide: () => void; + rulesetId: string; + teamId: string; + onSubmit: (ruleIds: string[]) => void; +} + +const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModalProps) => { + const theme = useTheme(); + const [selectedRuleIds, setSelectedRuleIds] = useState([]); + + const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId); + + type ParentInfo = { ruleId: string; ruleCode: string }; + + const uniqueParents = useMemo(() => { + if (!unassignedRules) return []; + const parentMap = new Map(); + unassignedRules.forEach((rule: Rule) => { + if (rule.parentRule) { + parentMap.set(rule.parentRule.ruleId, rule.parentRule); + } + }); + return Array.from(parentMap.values()).sort((a, b) => a.ruleCode.localeCompare(b.ruleCode)); + }, [unassignedRules]); + + const [selectedParentId, setSelectedParentId] = useState(''); + + const availableRules = useMemo(() => { + if (!unassignedRules || !selectedParentId) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedParentId); + }, [unassignedRules, selectedParentId]); + + const handleParentChange = (event: SelectChangeEvent) => { + setSelectedParentId(event.target.value); + setSelectedRuleIds([]); + }; + + const handleRuleSelect = (event: SelectChangeEvent) => { + const ruleId = event.target.value; + if (ruleId && !selectedRuleIds.includes(ruleId)) { + setSelectedRuleIds((prev) => [...prev, ruleId]); + } + }; + + const handleRemoveRule = (ruleId: string) => { + setSelectedRuleIds((prev) => prev.filter((id) => id !== ruleId)); + }; + + const handleSubmit = () => { + onSubmit(selectedRuleIds); + resetForm(); + onHide(); + }; + + const handleClose = () => { + resetForm(); + onHide(); + }; + + const resetForm = () => { + setSelectedRuleIds([]); + setSelectedParentId(''); + }; + + // Get rule display name + const getRuleName = (ruleId: string): string => { + const rule = unassignedRules?.find((r: Rule) => r.ruleId === ruleId); + return rule ? rule.ruleCode : ruleId; + }; + + // Dropdown styling + const selectStyles = { + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + color: theme.palette.text.primary, + '& .MuiSelect-select': { + py: 1.5, + px: 2.5 + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' + }, + '& .MuiSvgIcon-root': { + color: theme.palette.text.primary + } + }; + + const labelStyles = { + color: '#ef4345', + fontWeight: 700, + textDecoration: 'underline', + fontSize: '20px', + mb: 1.5 + }; + + // Selected rule row styling + const selectedRuleStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + px: 2.5, + py: 1.5, + mb: 1.5 + }; + + return ( + + + {isLoading ? ( + + + + ) : isError ? ( + Failed to load rules + ) : !unassignedRules || unassignedRules.length === 0 ? ( + + No unassigned rules available for this team. + + ) : ( + + {/* Select Section */} + + Select Section + + + + + + {/* Select Rules */} + + Select Rules + + {/* Selected Rules */} + {selectedRuleIds.map((ruleId) => ( + + + handleRemoveRule(ruleId)} + sx={{ color: theme.palette.text.primary, p: 0.5, mr: 1 }} + > + + + {getRuleName(ruleId)} + + + + ))} + + {/* Add Subtask dropdown */} + + + + + + )} + + + ); +}; + +export default AddRuleModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index ae8577fb37..29cd678e33 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -3,9 +3,406 @@ * See the LICENSE file in the repository root folder for details. */ -import { Box } from '@mui/material'; -import { Project } from 'shared'; +import { useState, useMemo } from 'react'; +import { + Box, + Button, + Typography, + CircularProgress, + Alert, + Tab, + Tabs as MuiTabs, + Table, + TableBody, + TableContainer, + Paper, + useTheme +} from '@mui/material'; +import { Project, ProjectRule, Rule, RuleCompletion } from 'shared'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import RuleRow from '../../../RulesPage/RuleRow'; +import UpdateStatusPopover from './UpdateStatusPopover'; +import AddRuleModal from './AddRuleModal'; +import { + useAllRulesetTypes, + useActiveRuleset, + useProjectRules, + useEditProjectRuleStatus, + useCreateProjectRule +} from '../../../../hooks/rules.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; -export const ProjectRulesTab = ({ project: _project }: { project: Project }) => { - return ; +interface ProjectRulesTabProps { + project: Project; +} + +/** + * Get the status chip configuration + */ +const getStatusConfig = (status: RuleCompletion) => { + switch (status) { + case RuleCompletion.COMPLETED: + return { label: 'Complete', color: '#4caf50' }; + case RuleCompletion.INCOMPLETE: + return { label: 'Incomplete', color: '#f44336' }; + case RuleCompletion.REVIEW: + default: + return { label: 'Review', color: '#ff9800' }; + } +}; + +export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { + const toast = useToast(); + const theme = useTheme(); + + // State for modals and popovers + const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); + const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); + const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); + const [selectedProjectRule, setSelectedProjectRule] = useState(null); + + // Fetch all ruleset types + const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); + + // Get the currently selected ruleset type + const selectedRulesetType = rulesetTypes?.[selectedRulesetTypeIndex]; + + // Fetch the active ruleset for the selected ruleset type + const { data: activeRuleset, isLoading: activeRulesetLoading } = useActiveRuleset( + selectedRulesetType?.rulesetTypeId || '' + ); + + // Fetch project rules for the active ruleset + const { + data: projectRules, + isLoading: projectRulesLoading, + isError: projectRulesError + } = useProjectRules(activeRuleset?.rulesetId || '', project.id); + + // Mutations + const { mutateAsync: editStatusMutation, isLoading: isUpdatingStatus } = useEditProjectRuleStatus( + activeRuleset?.rulesetId || '', + project.id + ); + + const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule(); + + // Get the first team's ID for fetching unassigned rules + const teamId = project.teams[0]?.teamId || ''; + + // Convert project rules to rules + const allRules = useMemo(() => { + if (!projectRules) return []; + return projectRules.map((pr) => pr.rule); + }, [projectRules]); + + // Get top-level rules (rules without a parent) + const topLevelRules = useMemo(() => { + return allRules.filter((rule) => !rule.parentRule); + }, [allRules]); + + // Helper function to get all descendant leaf rules for a given rule + const getDescendantLeafRules = (rule: Rule): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + // This is a leaf rule + return [rule]; + } + // Recursively get leaf rules from all children + return children.flatMap((child) => getDescendantLeafRules(child)); + }; + + // Helper function to calculate aggregated status from leaf rules + const getAggregatedStatus = (rule: Rule): RuleCompletion => { + const leafRules = getDescendantLeafRules(rule); + if (leafRules.length === 0) { + return RuleCompletion.REVIEW; + } + + const leafStatuses = leafRules.map((leafRule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === leafRule.ruleId); + return projectRule?.currentStatus || RuleCompletion.REVIEW; + }); + + if (leafStatuses.every((s) => s === RuleCompletion.COMPLETED)) { + return RuleCompletion.COMPLETED; + } + + if (leafStatuses.some((s) => s === RuleCompletion.INCOMPLETE)) { + return RuleCompletion.INCOMPLETE; + } + + return RuleCompletion.REVIEW; + }; + + // Handle status update + const handleStatusUpdate = async (projectRuleId: string, newStatus: RuleCompletion) => { + try { + await editStatusMutation({ projectRuleId, newStatus }); + toast.success('Rule status updated successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle add rules + const handleAddRules = async (ruleIds: string[]) => { + try { + for (const ruleId of ruleIds) { + await createProjectRuleMutation({ ruleId, projectId: project.id }); + } + toast.success(`${ruleIds.length} rule${ruleIds.length !== 1 ? 's' : ''} added successfully`); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle opening status popover + const handleStatusClick = (event: React.MouseEvent, rule: Rule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + if (projectRule) { + // Only allow status updates for leaf rules + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + if (!hasChildren) { + setSelectedProjectRule(projectRule); + setStatusPopoverAnchor(event.currentTarget); + } + } + }; + + // Handle closing status popover + const handleStatusPopoverClose = () => { + setStatusPopoverAnchor(null); + setSelectedProjectRule(null); + }; + + // Handle tab change + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setSelectedRulesetTypeIndex(newValue); + }; + + // Loading state + if (rulesetTypesLoading) { + return ; + } + + // Error state + if (rulesetTypesError) { + return ; + } + + // No ruleset types + if (!rulesetTypes || rulesetTypes.length === 0) { + return ( + + + No ruleset types configured for this organization. + + + ); + } + + // Check if we have no active ruleset + const hasNoActiveRuleset = !activeRulesetLoading && !activeRuleset; + + // Right content for rule rows - status badge + const renderRightContent = (rule: Rule) => { + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + const isLeafRule = !hasChildren; + + // Get status - for leaf rules use their own status, for parents calculate from children + const status = isLeafRule + ? projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId)?.currentStatus || RuleCompletion.REVIEW + : getAggregatedStatus(rule); + const statusConfig = getStatusConfig(status); + + return ( + ) => { + e.stopPropagation(); + handleStatusClick(e, rule); + } + : undefined + } + sx={{ + backgroundColor: statusConfig.color, + color: 'white', + fontSize: '11px', + fontWeight: 600, + px: 0.75, + py: 0.25, + borderRadius: '3px', + cursor: isLeafRule ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + whiteSpace: 'nowrap', + '&:hover': isLeafRule + ? { + opacity: 0.85 + } + : {} + }} + > + {statusConfig.label} + + ); + }; + + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + return ( + + {/* Ruleset Type Tabs */} + + + {rulesetTypes.map((rulesetType, idx) => ( + + ))} + + + + {/* Rules Content */} + {activeRulesetLoading || projectRulesLoading ? ( + + + + ) : hasNoActiveRuleset ? ( + + + No active ruleset configured for this ruleset type. + + + ) : projectRulesError ? ( + Failed to load rules + ) : topLevelRules.length === 0 ? ( + + + No rules assigned to this project yet. + + + ) : ( + + + + + {topLevelRules.map((rule) => ( + + ))} + +
+
+
+ )} + + {/* Add Rule Button */} + + + + + + + + {/* Update Status Popover */} + {selectedProjectRule && ( + + )} + + {/* Add Rule Modal */} + {activeRuleset && teamId && ( + setAddRuleModalOpen(false)} + rulesetId={activeRuleset.rulesetId} + teamId={teamId} + onSubmit={handleAddRules} + /> + )} + + {/* Loading overlay */} + {(isUpdatingStatus || isCreating) && ( + + + + )} + + ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx new file mode 100644 index 0000000000..ff5f877ed6 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -0,0 +1,81 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; +import { ProjectRule, RuleCompletion } from 'shared'; + +interface UpdateStatusPopoverProps { + anchorEl: HTMLElement | null; + onClose: () => void; + projectRule: ProjectRule; + onStatusChange: (projectRuleId: string, newStatus: RuleCompletion) => void; +} + +const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: UpdateStatusPopoverProps) => { + const open = Boolean(anchorEl); + + const handleStatusChange = (status: RuleCompletion) => { + onStatusChange(projectRule.projectRuleId, status); + onClose(); + }; + + const statusOptions = [ + { value: RuleCompletion.COMPLETED, label: 'Completed' }, + { value: RuleCompletion.INCOMPLETE, label: 'Incomplete' } + ]; + + return ( + + + {statusOptions.map((option) => ( + handleStatusChange(option.value)} + sx={{ + color: 'white', + '&.Mui-checked': { + color: 'white' + }, + p: 0.5 + }} + /> + } + label={{option.label}} + sx={{ + display: 'flex', + m: 0, + py: 0.5 + }} + /> + ))} + + + ); +}; + +export default UpdateStatusPopover; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 3a3373c227..9e8de5310b 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -447,6 +447,14 @@ const rulesetsByType = (rulesetTypeId: string) => `${rules()}/rulesets/${ruleset const ruleset = () => `${rules()}/ruleset`; const rulesetTypeCreate = () => `${rules()}/rulesetType/create`; const rulesetsCreate = () => `${ruleset()}/create`; +const rulesGetActiveRuleset = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/active`; +const rulesGetProjectRules = (rulesetId: string, projectId: string) => + `${rules()}/ruleset/${rulesetId}/project/${projectId}/rules`; +const rulesGetUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => + `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned`; +const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; +const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; +const rulesEditProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/editStatus`; const rulesetUpdate = (rulesetId: string) => `${ruleset()}/${rulesetId}/update`; const rulesetDelete = (rulesetId: string) => `${ruleset()}/${rulesetId}/delete`; const rulesetTypeDelete = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/delete`; @@ -763,6 +771,12 @@ export const apiUrls = { rulesetsByType, rulesetTypeCreate, rulesetsCreate, + rulesGetActiveRuleset, + rulesGetProjectRules, + rulesGetUnassignedRulesForRuleset, + rulesCreateProjectRule, + rulesDeleteProjectRule, + rulesEditProjectRuleStatus, rulesetUpdate, rulesetDelete, rulesetTypeDelete,