react-app/src/components/projects/ProjectDetail.tsx
import React, { useContext, useEffect, useMemo, useState } from 'react';
import DataCard from '../display/DataCard';
import {
Box,
Checkbox,
FormControlLabel,
FormGroup,
Typography,
Skeleton,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import DeleteDialog from '../dialog/DeleteDialog';
import usePimsApi from '@/hooks/usePimsApi';
import useDataLoader from '@/hooks/useDataLoader';
import {
Project,
ProjectMetadata,
ProjectMonetary,
ProjectNote,
ProjectTask,
ProjectTimestamp,
TierLevel,
} from '@/hooks/api/useProjectsApi';
import DetailViewNavigation from '../display/DetailViewNavigation';
import { useNavigate, useParams } from 'react-router-dom';
import { ProjectStatus } from '@/hooks/api/useLookupApi';
import DisposalPropertiesTable from './DisposalPropertiesSimpleTable';
import ProjectNotificationsTable from './ProjectNotificationsTable';
import {
ProjectAgencyResponseDialog,
ProjectDocumentationDialog,
ProjectFinancialDialog,
ProjectGeneralInfoDialog,
ProjectPropertiesDialog,
ProjectNotificationDialog,
} from './ProjectDialog';
import { AgencySimpleTable } from './AgencyResponseSearchTable';
import CollapsibleSidebar from '../layout/CollapsibleSidebar';
import useGroupedAgenciesApi from '@/hooks/api/useGroupedAgenciesApi';
import { enumReverseLookup } from '@/utilities/helperFunctions';
import { AgencyResponseType } from '@/constants/agencyResponseTypes';
import useDataSubmitter from '@/hooks/useDataSubmitter';
import { Roles } from '@/constants/roles';
import { UserContext } from '@/contexts/userContext';
import { ExpandMoreOutlined } from '@mui/icons-material';
import { columnNameFormatter, dateFormatter, formatMoney } from '@/utilities/formatters';
import { LookupContext } from '@/contexts/lookupContext';
import { Agency } from '@/hooks/api/useAgencyApi';
import { getStatusString } from '@/constants/chesNotificationStatus';
import { NoteTypes } from '@/constants/noteTypes';
import EnhancedReferralDates from './EnhancedReferralDates';
import { NotificationType } from '@/constants/notificationTypes';
interface IProjectDetail {
onClose: () => void;
}
interface ProjectInfo extends Project {
Classification: ProjectStatus;
AssignTier: TierLevel;
AssessedValue: number;
NetBookValue: number;
EstimatedMarketValue: number;
AppraisedValue: number;
EstimatedSalesCost: ProjectMetadata;
EstimatedProgramRecoveryFees: ProjectMetadata;
SurplusDeclaration: boolean;
TripleBottom: boolean;
}
const ProjectDetail = (props: IProjectDetail) => {
const navigate = useNavigate();
const { id } = useParams();
const { pimsUser } = useContext(UserContext);
const lookup = useContext(LookupContext);
const api = usePimsApi();
const { data: lookupData, getLookupValueById } = useContext(LookupContext);
const { data, refreshData, isLoading } = useDataLoader(() =>
api.projects.getProjectById(Number(id)),
);
const { data: notifications, refreshData: refreshNotifications } = useDataLoader(() =>
api.notifications.getNotificationsByProjectId(Number(id)),
);
useEffect(() => {
if (data && data.retStatus !== 200) {
navigate('/');
}
}, [data]);
const isAuditor = pimsUser.hasOneOfRoles([Roles.AUDITOR]);
const isAdmin = pimsUser.hasOneOfRoles([Roles.ADMIN]);
const { submit: deleteProject, submitting: deletingProject } = useDataSubmitter(
api.projects.deleteProjectById,
);
const { submit: resendNotification } = useDataSubmitter(api.notifications.resendNotification);
const { submit: cancelNotification } = useDataSubmitter(api.notifications.cancelNotification);
const hasERPNotifications = useMemo(() => {
// Check if notifications is an object and has an items array
if (!notifications || !Array.isArray(notifications.items)) {
return false;
}
const notificationItems = notifications.items;
if (notificationItems.length === 0) {
return false;
}
const types = [
NotificationType.THIRTY_DAY_ERP_NOTIFICATION_OWNING_AGENCY,
NotificationType.SIXTY_DAY_ERP_NOTIFICATION_OWNING_AGENCY,
NotificationType.NINTY_DAY_ERP_NOTIFICATION_OWNING_AGENCY,
];
// Check if any of the notifications match the types
return notificationItems.some((n) => types.includes(n.TemplateId));
}, [notifications]);
const { ungroupedAgencies, agencyOptions } = useGroupedAgenciesApi();
interface IStatusHistoryStruct {
Notes: Array<ProjectNote & { Name: string }>;
Tasks: Array<ProjectTask & { Name: string }>;
Timestamps: Array<ProjectTimestamp & { Name: string }>;
Monetaries: Array<ProjectMonetary & { Name: string }>;
}
const collectedDocumentationByStatus = useMemo((): Record<string, IStatusHistoryStruct> => {
if (!data || !lookupData) {
return {};
}
if (!data.parsedBody?.Tasks) return {};
//Somewhat evil reduce where we collect information from the status and tasks lookup so that we can
//get data for the status and task names to be displayed when we enumarete the tasks associated to the project itself
//in the documentation history section.
const reduceMap = data?.parsedBody.Tasks.reduce(
(acc: Record<string, IStatusHistoryStruct>, curr) => {
if (!curr.IsCompleted) {
return acc; //Since this is just for display purposes, no point showing non-completed in results.
}
const fullTask = lookupData?.Tasks.find((a) => a.Id === curr.TaskId);
const fullStatus = lookupData?.ProjectStatuses.find((a) => a.Id === fullTask.StatusId) ?? {
Name: 'Uncategorized',
};
if (!acc[fullStatus.Name]) {
acc[fullStatus.Name] = { Tasks: [], Notes: [], Timestamps: [], Monetaries: [] };
}
acc[fullStatus.Name].Tasks.push({ ...curr, Name: fullTask.Name });
return acc;
},
{},
);
data?.parsedBody.Notes.filter((a) => a.Note).reduce(
(acc: Record<string, IStatusHistoryStruct>, curr) => {
const fullNote = lookupData?.NoteTypes.find((a) => a.Id === curr.NoteTypeId);
const fullStatus = lookupData?.ProjectStatuses.find((a) => a.Id === fullNote.StatusId) ?? {
Name: 'Uncategorized',
};
if (!acc[fullStatus.Name]) {
acc[fullStatus.Name] = { Tasks: [], Notes: [], Timestamps: [], Monetaries: [] };
}
acc[fullStatus.Name].Notes.push({ ...curr, Name: fullNote.Description });
return acc;
},
reduceMap,
);
data?.parsedBody?.Monetaries?.filter((a) => a.Value).reduce(
(acc: Record<string, IStatusHistoryStruct>, curr) => {
const fullMonetary = lookupData?.MonetaryTypes.find((a) => a.Id === curr.MonetaryTypeId);
const fullStatus = lookupData?.ProjectStatuses.find(
(a) => a.Id === fullMonetary.StatusId,
) ?? {
Name: 'Uncategorized',
};
if (!acc[fullStatus.Name]) {
acc[fullStatus.Name] = { Tasks: [], Notes: [], Timestamps: [], Monetaries: [] };
}
acc[fullStatus.Name].Monetaries.push({ ...curr, Name: fullMonetary.Name });
return acc;
},
reduceMap,
);
data?.parsedBody?.Timestamps?.filter((a) => a.Date).reduce(
(acc: Record<string, IStatusHistoryStruct>, curr) => {
const fullTimestamp = lookupData?.TimestampTypes.find((a) => a.Id === curr.TimestampTypeId);
const fullStatus = lookupData?.ProjectStatuses.find(
(a) => a.Id === fullTimestamp.StatusId,
) ?? {
Name: 'Uncategorized',
};
if (!acc[fullStatus.Name]) {
acc[fullStatus.Name] = { Tasks: [], Notes: [], Timestamps: [], Monetaries: [] };
}
acc[fullStatus.Name].Timestamps.push({ ...curr, Name: fullTimestamp.Name });
return acc;
},
reduceMap,
);
return reduceMap;
}, [data, lookupData]);
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [openProjectInfoDialog, setOpenProjectInfoDialog] = useState(false);
const [openDisposalPropDialog, setOpenDisposalPropDialog] = useState(false);
const [openFinancialInfoDialog, setOpenFinancialInfoDialog] = useState(false);
const [openDocumentationDialog, setOpenDocumentationDialog] = useState(false);
const [openAgencyInterestDialog, setOpenAgencyInterestDialog] = useState(false);
const [openNotificationDialog, setOpenNotificationDialog] = useState(false);
const ProjectInfoData = {
Status: getLookupValueById('ProjectStatuses', data?.parsedBody.StatusId),
ProjectNumber: data?.parsedBody.ProjectNumber,
Name: data?.parsedBody.Name,
AssignTier: getLookupValueById('ProjectTiers', data?.parsedBody.TierLevelId),
Description: data?.parsedBody.Description,
Agency: getLookupValueById('Agencies', data?.parsedBody.AgencyId),
};
const FinancialInformationData = useMemo(() => {
const salesCostType = lookupData?.MonetaryTypes?.find((a) => a.Name === 'SalesCost');
const programCostType = lookupData?.MonetaryTypes?.find((a) => a.Name === 'ProgramCost');
return {
AssessedValue: data?.parsedBody.Assessed,
NetBookValue: data?.parsedBody.NetBook,
EstimatedMarketValue: data?.parsedBody.Market,
AppraisedValue: data?.parsedBody.Appraised,
EstimatedSalesCost: data?.parsedBody.Monetaries?.find(
(a) => a.MonetaryTypeId === salesCostType?.Id,
)?.Value,
EstimatedProgramRecoveryFees: data?.parsedBody.Monetaries?.find(
(a) => a.MonetaryTypeId === programCostType?.Id,
)?.Value,
};
}, [data, lookupData]);
const customFormatter = (key: keyof ProjectInfo, val: any) => {
switch (key) {
case 'Status':
return <Typography>{(val as ProjectStatus)?.Name}</Typography>;
case 'AssignTier':
return <Typography>{(val as TierLevel)?.Name}</Typography>;
case 'Agency':
return <Typography>{(val as Agency)?.Name}</Typography>;
default:
return <Typography>{val}</Typography>;
}
};
const showNotes = (note) => {
// noteId 2 are SRES only notes
if (note.NoteTypeId == NoteTypes.PRIVATE && !(isAdmin || isAuditor)) {
return null;
}
return (
<Box key={`${note.NoteTypeId}-note`}>
<Typography variant="h5">{note.Name}</Typography>
<Typography>{note.Note}</Typography>
</Box>
);
};
useEffect(() => {
if (id) {
refreshData();
refreshNotifications();
}
}, [id]);
const projectInformation = 'Project Information';
const disposalProperties = 'Disposal Properties';
const financialInformation = 'Financial Information';
const agencyInterest = 'Agency Interest';
const documentationHistory = 'Documentation History';
const notificationsHeader = 'Notifications';
const enhancedReferralDates = 'Enhanced Referral Dates';
const sideBarList = [
{ title: projectInformation },
{ title: disposalProperties },
{ title: financialInformation },
{ title: documentationHistory },
];
//
if (hasERPNotifications) {
sideBarList.splice(1, 0, { title: enhancedReferralDates });
}
// only show Agency Interest and notifications for admins
if (isAdmin) {
sideBarList.splice(3, 0, { title: agencyInterest });
sideBarList.push({ title: notificationsHeader });
}
return (
<CollapsibleSidebar items={sideBarList}>
<Box
display={'flex'}
gap={'1rem'}
mt={'2rem'}
mb={'2rem'}
flexDirection={'column'}
maxWidth={'60rem'}
minWidth={'45rem'}
width={'80%'}
marginX={'auto'}
>
<DetailViewNavigation
navigateBackTitle={'Back to Disposal Project Overview'}
deleteTitle={'Delete project'}
onDeleteClick={() => setOpenDeleteDialog(true)}
onBackClick={() => props.onClose()}
disableDelete={!isAdmin}
/>
<DataCard
loading={isLoading}
customFormatter={customFormatter}
values={ProjectInfoData}
id={projectInformation}
title={projectInformation}
onEdit={() => setOpenProjectInfoDialog(true)}
disableEdit={!isAdmin}
/>
{hasERPNotifications && (
<DataCard
values={undefined}
loading={isLoading}
title={enhancedReferralDates}
id={enhancedReferralDates}
onEdit={() => {}}
disableEdit={true}
>
<EnhancedReferralDates rows={notifications?.items} />
</DataCard>
)}
<DataCard
values={undefined}
id={disposalProperties}
title={disposalProperties}
onEdit={() => setOpenDisposalPropDialog(true)}
disableEdit={!isAdmin}
>
{isLoading ? (
<Skeleton variant="rectangular" height={'150px'} />
) : (
<DisposalPropertiesTable
rows={[
...(data?.parsedBody?.Parcels?.map((p) => ({ ...p, PropertyType: 'Parcel' })) ??
[]),
...(data?.parsedBody?.Buildings?.map((b) => ({ ...b, PropertyType: 'Building' })) ??
[]),
]}
/>
)}
</DataCard>
<DataCard
loading={isLoading}
customFormatter={customFormatter}
values={Object.fromEntries(
Object.entries(FinancialInformationData).map(([k, v]) => [
k,
formatMoney(v != null ? v : 0), //This cast spaghetti sucks but hard to avoid when receiving money as a string from the API.
]),
)}
title={financialInformation}
id={financialInformation}
onEdit={() => setOpenFinancialInfoDialog(true)}
disableEdit={!isAdmin}
/>
{isAdmin && (
<DataCard
loading={isLoading}
title={agencyInterest}
values={undefined}
id={agencyInterest}
onEdit={() => setOpenAgencyInterestDialog(true)}
disableEdit={!isAdmin}
>
{!data?.parsedBody.AgencyResponses?.length ? ( //TODO: Logic will depend on precense of agency responses
<Box display={'flex'} justifyContent={'center'}>
<Typography>No agencies registered.</Typography>
</Box>
) : (
<AgencySimpleTable
editMode={false}
sx={{
borderStyle: 'none',
'& .MuiDataGrid-columnHeaders': {
borderBottom: 'none',
},
'& div div div div >.MuiDataGrid-cell': {
borderBottom: 'none',
borderTop: '1px solid rgba(224, 224, 224, 1)',
},
}}
rows={
data?.parsedBody.AgencyResponses && ungroupedAgencies
? (data?.parsedBody.AgencyResponses?.map((resp) => ({
...ungroupedAgencies?.find((agc) => agc.Id === resp.AgencyId),
ReceivedOn: resp.ReceivedOn,
Note: resp.Note,
Response: enumReverseLookup(AgencyResponseType, resp.Response),
})) as (Agency & { ReceivedOn: Date; Note: string })[])
: []
}
/>
)}
</DataCard>
)}
<DataCard
customFormatter={customFormatter}
values={undefined}
disableEdit={true}
title={documentationHistory}
id={documentationHistory}
onEdit={() => setOpenDocumentationDialog(true)}
>
<Box display={'flex'} flexDirection={'column'} gap={'1rem'}>
{Object.entries(collectedDocumentationByStatus)?.map(
(
[key, value], //Each key here is a status name. Each value contains an array for each field type.
) => (
<Box key={`${key}-group`}>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreOutlined />}>
<Typography>{key}</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display={'flex'} flexDirection={'column'} gap={'1rem'}>
{value.Tasks.map((task) => (
<FormGroup key={`${task.TaskId}-task-formgroup`}>
<FormControlLabel
sx={{
'& .MuiButtonBase-root': {
padding: 0,
paddingX: '9px',
},
}}
control={
<Checkbox
checked={task.IsCompleted}
sx={{
'&.MuiCheckbox-root': {
color: 'rgba(0, 0, 0, 0.26)',
},
}}
/>
}
style={{ pointerEvents: 'none' }}
value={task.IsCompleted}
label={task.Name}
/>
</FormGroup>
))}
{value.Notes.map((note) => showNotes(note))}
{value.Timestamps.map((ts) => (
<Box key={`${ts.TimestampTypeId}-timestamp`}>
<Typography variant="h5">{columnNameFormatter(ts.Name)}</Typography>
<Typography>{dateFormatter(ts.Date)}</Typography>
</Box>
))}
{value.Monetaries.map((mon) => (
<Box key={`${mon.MonetaryTypeId}-monetary`}>
<Typography variant="h5">{columnNameFormatter(mon.Name)}</Typography>
<Typography>{formatMoney(mon.Value)}</Typography>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
</Box>
),
)}
</Box>
</DataCard>
{isAdmin && (
<DataCard
loading={isLoading}
title={notificationsHeader}
values={undefined}
id={notificationsHeader}
onEdit={() => setOpenNotificationDialog(true)}
disableEdit={!data?.parsedBody?.Notifications?.length}
editButtonText="Expand Notifications"
>
{!data?.parsedBody.Notifications?.length ? ( //TODO: Logic will depend on precense of agency responses
<Box display={'flex'} justifyContent={'center'}>
<Typography>No notifications were sent for this project.</Typography>
</Box>
) : (
<ProjectNotificationsTable
rows={
notifications?.items
? notifications.items.map((resp) => ({
AgencyName: lookup.getLookupValueById('Agencies', resp.ToAgencyId)?.Name,
ChesStatusName: getStatusString(resp.Status),
...resp,
}))
: []
}
onResendClick={(id) => resendNotification(id).then(() => refreshNotifications())}
onCancelClick={(id) => cancelNotification(id).then(() => refreshNotifications())}
/>
)}
</DataCard>
)}
<DeleteDialog
open={openDeleteDialog}
confirmButtonProps={{ loading: deletingProject }}
title={'Delete property'}
message={'Are you sure you want to delete this project?'}
onDelete={async () => deleteProject(+id).then(() => navigate('/projects'))}
onClose={async () => setOpenDeleteDialog(false)}
/>
<ProjectGeneralInfoDialog
initialValues={data?.parsedBody}
open={openProjectInfoDialog}
postSubmit={() => {
setOpenProjectInfoDialog(false);
refreshData();
}}
onCancel={() => setOpenProjectInfoDialog(false)}
/>
<ProjectFinancialDialog
initialValues={data?.parsedBody}
open={openFinancialInfoDialog}
postSubmit={() => {
setOpenFinancialInfoDialog(false);
refreshData();
}}
onCancel={() => setOpenFinancialInfoDialog(false)}
/>
<ProjectDocumentationDialog
initialValues={data?.parsedBody}
open={openDocumentationDialog}
postSubmit={() => {
setOpenDocumentationDialog(false);
refreshData();
}}
onCancel={() => setOpenDocumentationDialog(false)}
/>
<ProjectPropertiesDialog
initialValues={data?.parsedBody}
open={openDisposalPropDialog}
postSubmit={() => {
setOpenDisposalPropDialog(false);
refreshData();
}}
onCancel={() => setOpenDisposalPropDialog(false)}
/>
<ProjectAgencyResponseDialog
agencies={ungroupedAgencies as Agency[]}
options={agencyOptions}
initialValues={data?.parsedBody}
open={openAgencyInterestDialog}
postSubmit={() => {
setOpenAgencyInterestDialog(false);
refreshData();
refreshNotifications();
}}
onCancel={() => {
setOpenAgencyInterestDialog(false);
}}
/>
<ProjectNotificationDialog
ungroupedAgencies={ungroupedAgencies as Agency[]}
initialValues={notifications?.items ?? []}
open={openNotificationDialog}
onRowCancelClick={(id: number) =>
cancelNotification(id).then(() => refreshNotifications())
}
onRowResendClick={(id: number) =>
resendNotification(id).then(() => refreshNotifications())
}
onCancel={() => {
setOpenNotificationDialog(false);
}}
/>
</Box>
</CollapsibleSidebar>
);
};
export default ProjectDetail;