This commit is contained in:
Zeeshaun
2026-03-23 11:21:52 -05:00
parent de9ddd5001
commit a17bc4b4a9
426 changed files with 60574 additions and 1 deletions
@@ -0,0 +1,545 @@
import React from "react";
import { mount } from "enzyme";
import * as reactRedux from "react-redux";
import ActivityDetailPage from "./ActivityDetailPage";
import ReactRouter from "react-router";
import ActivityService from "../../shared/services/ActivityService";
import AsyncState from "../../shared/enumerations/AsyncState";
import { act } from "react-dom/test-utils";
import Product2 from "../../shared/enumerations/Product";
import {
ActivityState,
ActivityTransferState,
ActivityType,
IActivityStateChange,
IActivityTransferAction,
IAuditableActivity,
SystemType
} from "../../shared/models/Activity";
import moment from "moment";
import { UserType } from "../../shared/models/Auditable";
import { IDepartmentState } from "../../reducers/DepartmentReducer";
import { IProfileState } from "../../reducers/ProfileReducer";
import PermissionCode from "../../shared/enumerations/PermissionCode";
import Translate from "../../translate/Translate";
import { getMockAppSettings } from "../../shared/testing/mocks/MockAppSettings";
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const pageTranslate = Translate.Get.containers.activityDetailPage;
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
describe("ActivityDetailPage", () => {
it("should display components if data is retrieved successfully", async () => {
const useContextMock = jest.spyOn(React, "useContext");
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "1" });
useDispatchMock.mockImplementation(() => jest.fn());
const getActivitySpy = jest.spyOn(
ActivityService,
"getViewableActivity"
);
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
getActivitySpy.mockResolvedValue({
state: AsyncState.Success,
data: {
activity: activity(),
authorized: {
canApproveTransferTo: true,
canApproveTransferFrom: true,
allowedChanges: []
},
transferAction: mockActivityTransferAction(),
stateChanges: changes()
}
});
let wrapper: any;
await act(async () => {
wrapper = mount(<ActivityDetailPage />);
});
wrapper.update();
expect(
wrapper.find("[data-test='activity-details-components']").length
).toBeTruthy();
});
it("should call the activity service when button is clicked", async () => {
const useContextMock = jest.spyOn(React, "useContext");
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "1" });
useDispatchMock.mockImplementation(() => jest.fn());
const getActivitySpy = jest.spyOn(
ActivityService,
"getViewableActivity"
);
const postStateChangeSpy = jest.spyOn(
ActivityService,
"postActivityStateChange"
);
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
const activityWithoutPayouts = { ...activity(), activityPayouts: [] };
getActivitySpy.mockResolvedValue({
state: AsyncState.Success,
data: {
activity: activityWithoutPayouts,
authorized: {
canApproveTransferTo: true,
canApproveTransferFrom: true,
allowedChanges: [ActivityState.Rejected]
},
transferAction: mockActivityTransferAction(),
stateChanges: changes()
}
});
postStateChangeSpy.mockResolvedValue({
state: AsyncState.Success,
data: activityWithoutPayouts
});
let wrapper: any;
await act(async () => {
wrapper = mount(<ActivityDetailPage />);
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(1);
await act(async () => {
wrapper
.find("button[data-test='Rejected-activity-button']")
.simulate("click");
});
expect(postStateChangeSpy).toHaveBeenCalledTimes(1);
expect(postStateChangeSpy).toHaveBeenCalledWith({
activityId: 1,
note: pageTranslate.stateChangeNote,
state: "Rejected"
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(2);
});
it("should not call fetch activity again if post fails", async () => {
const useContextMock = jest.spyOn(React, "useContext");
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "1" });
useDispatchMock.mockImplementation(() => jest.fn());
const getActivitySpy = jest.spyOn(
ActivityService,
"getViewableActivity"
);
const postStateChangeSpy = jest.spyOn(
ActivityService,
"postActivityStateChange"
);
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
const activityWithoutPayouts = { ...activity(), activityPayouts: [] };
getActivitySpy.mockResolvedValue({
state: AsyncState.Success,
data: {
activity: activityWithoutPayouts,
authorized: {
canApproveTransferTo: true,
canApproveTransferFrom: true,
allowedChanges: [ActivityState.Rejected]
},
transferAction: mockActivityTransferAction(),
stateChanges: changes()
}
});
// return failure when posting
postStateChangeSpy.mockResolvedValue({
state: AsyncState.Failure
});
let wrapper: any;
await act(async () => {
wrapper = mount(<ActivityDetailPage />);
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(1);
await act(async () => {
wrapper
.find("button[data-test='Rejected-activity-button']")
.simulate("click");
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(1);
});
it("should post the activity transfer request", async () => {
const useContextMock = jest.spyOn(React, "useContext");
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "1" });
useDispatchMock.mockImplementation(() => jest.fn());
const getActivitySpy = jest.spyOn(
ActivityService,
"getViewableActivity"
);
const postRequestTransferSpy = jest.spyOn(
ActivityService,
"postRequestTransfer"
);
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
const activityWithoutPayouts = { ...activity(), activityPayouts: [] };
getActivitySpy.mockResolvedValue({
state: AsyncState.Success,
data: {
activity: activityWithoutPayouts,
authorized: {
canApproveTransferTo: true,
canApproveTransferFrom: true,
allowedChanges: []
},
transferAction: mockActivityTransferAction(),
stateChanges: changes()
}
});
const returnedAction: IActivityTransferAction = {
id: 1,
activityStateBeforeTransfer: ActivityState.Accepted,
requestedActivityId: 2,
state: ActivityTransferState.Requested,
newActivityDepartmentId: 3,
newActivityEmployeeId: "C000"
};
postRequestTransferSpy.mockResolvedValue({
state: AsyncState.Success,
data: returnedAction
});
let wrapper: any;
await act(async () => {
wrapper = mount(<ActivityDetailPage />);
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(1);
// select department and appUser
await act(async () => {
wrapper
.find("select[data-test='appUser-search-select']")
.simulate("change", { target: { value: "C0002" } });
wrapper
.find("select[data-test='dept-input']")
.simulate("change", { target: { value: "25" } });
});
// submit transfer request
await act(async () => {
wrapper
.find("button[data-test='activity-transfer-request-button']")
.simulate("click");
});
wrapper.update();
expect(postRequestTransferSpy).toHaveBeenCalledTimes(1);
expect(postRequestTransferSpy).toHaveBeenCalledWith({
activityStateBeforeTransfer: "Accepted",
newDepartmentId: 25,
newEmployeeId: "C0002",
requestedActivityId: 1
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(2);
});
it("should post transfer approval request", async () => {
const useContextMock = jest.spyOn(React, "useContext");
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(ReactRouter, "useParams").mockReturnValue({
id: "1"
});
useDispatchMock.mockImplementation(() => jest.fn());
const getActivitySpy = jest.spyOn(
ActivityService,
"getViewableActivity"
);
const postApproveTransferSpy = jest.spyOn(
ActivityService,
"postApproveTransfer"
);
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
const pendingTransferActivity = activity();
pendingTransferActivity.state = ActivityState.PendingTransfer;
getActivitySpy.mockResolvedValue({
state: AsyncState.Success,
data: {
activity: activity(),
authorized: {
canApproveTransferTo: true,
canApproveTransferFrom: true,
allowedChanges: []
},
transferAction: mockActivityTransferAction(),
stateChanges: changes()
}
});
let wrapper: any;
await act(async () => {
wrapper = mount(<ActivityDetailPage />);
});
wrapper.update();
expect(getActivitySpy).toHaveBeenCalledTimes(1);
expect(
wrapper.find("[data-test='activity-approval-component']").length
).toBeTruthy();
await act(async () => {
wrapper
.find("button[data-test='reject-transfer-button']")
.simulate("click");
});
expect(postApproveTransferSpy).toHaveBeenCalledTimes(1);
expect(postApproveTransferSpy).toHaveBeenCalledWith({
id: 11,
approvalState: ActivityTransferState.Rejected
});
});
});
const activity = (): IAuditableActivity => {
return {
id: 1,
product: ("ATMCard" as unknown) as Product2,
type: ActivityType.Referral,
sourceSystem: SystemType.Dna,
sourceId: "11",
sourceCreated: moment.utc(),
departmentId: "25",
employeeId: "2",
employeeEmail: "[email protected]",
accepted: moment.utc(),
state: ActivityState.Accepted,
created: moment.utc(),
createdBy: "Created Doe",
createdByType: UserType.Associate,
lastUpdated: moment.utc(),
lastUpdatedBy: "Updated Doe",
lastUpdatedByType: UserType.Associate,
detail: {
id: 1,
accountNumber: "11",
dnaPersonNbr: "12",
memberName: "Member Doe",
accountStatus: "Open",
accountBalance: 21,
accountNewBalance: 11
},
metaData: [
{
id: 1,
metaData: "test data",
activityRef: 1,
metaDataType: "Unknown"
}
],
activityPayouts: [
{
payoutLineItem: {
id: 1,
activityPeriod: moment.utc(),
departmentId: 25,
role: {
description: "Teller",
name: "Teller Doe",
permissions: [],
hasCommissions: true,
rank: 99
},
product: "ATMCard",
goalId: 2,
goalValue: 3,
commissionId: 4,
commissionValue: 5,
employeeId: "123",
activityTotal: 100,
payoutTotal: 200,
slidingPayout: 100
}
},
{
payoutLineItem: {
id: 2,
activityPeriod: moment.utc(),
departmentId: 25,
role: {
description: "Branch Manager",
name: "B.M. Doe",
permissions: [],
hasCommissions: true,
rank: 99
},
product: "ATMCard",
goalId: 2,
goalValue: 3,
commissionId: 4,
commissionValue: 5,
employeeId: "456",
activityTotal: 100,
payoutTotal: 200,
slidingPayout: 100
}
}
]
};
};
const changes = () => {
return [
{
id: 1,
state: "Created",
note: "Auto created",
created: moment.utc()
},
{
id: 2,
state: "Created",
note: "Auto created",
created: moment.utc()
}
] as IActivityStateChange[];
};
const mockDepartmentState: IDepartmentState = {
departments: {
data: [
{
departmentId: 25,
id: 25,
departmentName: "WAUWATOSA",
businessLineCode: "OPS",
state: "Active",
dnaOrgNbr: "111",
regionCode: "2",
dayForceGlCode: "33"
}
],
state: AsyncState.Success
},
submitState: { state: AsyncState.Inactive }
};
const mockProfileState: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
baseRole: "Test Role",
departments: [
{
employeeId: "",
departmentId: 25,
startDate: moment(),
note: "",
isHome: true,
slidingPayout: 100,
role: {
name: "Manager",
description: "Manager",
hasCommissions: true,
permissions: [
{
code:
PermissionCode.TRANSFER_ACTIVITIES_BETWEEN_DEPARTMENTS,
description: ""
},
{
code:
PermissionCode.SET_ACTIVITY_TO_MANUAL_REVIEW,
description: ""
}
],
rank: 99
},
created: moment(),
createdBy: "",
createdByType: UserType.Associate,
lastUpdated: moment(),
lastUpdatedBy: "",
lastUpdatedByType: UserType.Associate
}
],
employeeId: "12345",
fullName: "John Tester",
homeDepartment: "",
personId: 0,
role: {
name: "myrole",
permissions: [
{
code:
PermissionCode.TRANSFER_ACTIVITIES_BETWEEN_DEPARTMENTS,
description: ""
}
],
description: "My Role",
hasCommissions: true,
rank: 99
},
additionalPermissions: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Inactive
}
};
const mockActivityTransferAction = (): IActivityTransferAction => {
return {
id: 11,
activityStateBeforeTransfer: ActivityState.Accepted,
newActivityDepartmentId: 25,
newActivityEmployeeId: "C0002",
requestedActivityId: 1,
state: ActivityTransferState.Requested
};
};
@@ -0,0 +1,272 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Col, Row } from "reactstrap";
import AsyncState from "../../shared/enumerations/AsyncState";
import {
ActivityState,
ActivityTransferState,
IActivityStateChangeDto,
IActivityTransferAction,
IActivityTransferApprovalRequestDto,
IActivityTransferRequestDto,
IAuditableActivity,
IViewableActivityDetail
} from "../../shared/models/Activity";
import ActivityService from "../../shared/services/ActivityService";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import ActivityDetailsAlertBar from "../../components/activity-detail/ActivityDetailAlertBar";
import ActivityDisplay from "../../components/activity-detail/ActivityDisplay";
import ActivityDetailDisplay from "../../components/activity-detail/ActivityDetailDisplay";
import ActivityStateChangesTable from "../../components/activity-detail/ActivityStateChangesTable";
import ActivityMetaDataDisplay from "../../components/activity-detail/ActivityMetaDataDisplay";
import ActivityPayoutsDisplay from "../../components/activity-detail/ActivityPayoutsDisplay";
import ActivityTransferRequest from "../../components/activity-detail/ActivityTransferRequest";
import ActivityTransferApproval from "../../components/activity-detail/ActivityTransferApproval";
import { useSelector } from "react-redux";
import { IAppState } from "../../store/Store";
import { IProfileState } from "../../reducers/ProfileReducer";
import Translate from "../../translate/Translate";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import ActivityTransferToOwnDepartmentRequest from "../../components/activity-detail/ActivityTransferToOwnDepartmentRequest";
import { GetCurrentActiveDepartments } from "../../shared/models/Employee";
import ActivityDetailUpdatesTable from "../../components/activity-detail/ActivityDetailUpdatesTable";
import { Moment } from "moment";
const pageTranslate = Translate.Get.containers.activityDetailPage;
const ActivityDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const settings = React.useContext(SettingsContext);
const [activity, setActivity] = useState<
IAsyncStateContainer<IViewableActivityDetail>
>({
state: AsyncState.Inactive
});
const [activityStateChange, setActivityStateChange] = useState<
IAsyncStateContainer<IAuditableActivity>
>({
state: AsyncState.Inactive
});
const [activityTransferChange, setActivityTransferChange] = useState<
IAsyncStateContainer<IActivityTransferAction>
>({ state: AsyncState.Inactive });
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const myProfile = profileState.profile;
const departments = settings?.departments.filter(
(x) => x.state == "Active"
);
useEffect(() => {
loadActivity();
}, []);
useEffect(() => {
if (activityStateChange.state === AsyncState.Success) {
loadActivity();
}
}, [activityStateChange]);
useEffect(() => {
if (activityTransferChange.state === AsyncState.Success) {
loadActivity();
}
}, [activityTransferChange]);
const loadActivity = async () => {
setActivity({ state: AsyncState.Loading });
const activity = await ActivityService.getViewableActivity(+id);
setActivity(activity);
};
const changeActivityState = async (
newState: ActivityState,
newDate: Moment | undefined
) => {
if (activity.data?.activity) {
setActivityStateChange({ state: AsyncState.Loading });
const change: IActivityStateChangeDto = {
activityId: activity.data.activity.id,
note: pageTranslate.stateChangeNote,
acceptedDate: newDate,
state: newState
};
const changedActivity = await ActivityService.postActivityStateChange(
change
);
setActivityStateChange(changedActivity);
}
};
const submitTransferRequest = async (
departmentId: string,
employeeId: string
) => {
if (activity.data && activity.data.activity) {
setActivityTransferChange({ state: AsyncState.Loading });
const transferRequest: IActivityTransferRequestDto = {
activityStateBeforeTransfer: activity.data.activity.state,
requestedActivityId: activity.data.activity.id,
newDepartmentId: +departmentId,
newEmployeeId: employeeId
};
const requestResult = await ActivityService.postRequestTransfer(
transferRequest
);
setActivityTransferChange(requestResult);
}
return;
};
const submitTransferToOwnDepartmentRequest = async (
departmentId: string,
employeeId: string
) => {
if (activity.data && activity.data.activity) {
setActivityTransferChange({ state: AsyncState.Loading });
const transferRequest: IActivityTransferRequestDto = {
activityStateBeforeTransfer: activity.data.activity.state,
requestedActivityId: activity.data.activity.id,
newDepartmentId: +departmentId,
newEmployeeId: employeeId
};
const requestResult = await ActivityService.postRequestTransferToOwnDepartment(
transferRequest
);
setActivityTransferChange(requestResult);
}
return;
};
const approveTransferRequest = async (
requestId: number,
decision: ActivityTransferState
) => {
setActivityTransferChange({ state: AsyncState.Loading });
const transferApprovalRequest: IActivityTransferApprovalRequestDto = {
id: requestId,
approvalState: decision
};
const requestResult = await ActivityService.postApproveTransfer(
transferApprovalRequest
);
setActivityTransferChange(requestResult);
};
return (
<>
<ActivityDetailsAlertBar
activity={activity}
activityStateChange={activityStateChange}
activityTransferChange={activityTransferChange}
/>
<h3>{pageTranslate.title}</h3>
{activity.data &&
activity.data.activity &&
activity.state === AsyncState.Success &&
myProfile.data &&
departments && (
<div>
<Row data-test="activity-details-components">
<Col md="12" lg="6">
<Row>
<Col md="12" lg="6">
<ActivityDisplay
activity={activity.data.activity}
onSetActivityState={(
newState,
newDate
) =>
changeActivityState(
newState,
newDate
)
}
profile={myProfile.data}
authorization={
activity.data.authorized
}
/>
</Col>
<Col md="12" lg="6">
<ActivityDetailDisplay
detail={
activity.data.activity.detail
}
/>
</Col>
</Row>
</Col>
<Col md="12" lg="6">
<Col lg="12">
<ActivityPayoutsDisplay
payouts={activity.data.activity.activityPayouts?.map(
(activityPayouts) =>
activityPayouts.payoutLineItem
)}
/>
<ActivityStateChangesTable
activityStateChanges={
activity.data.stateChanges || []
}
/>
<ActivityDetailUpdatesTable
activityDetailUpdates={
activity.data.detailUpdates || []
}
/>
<ActivityMetaDataDisplay
activityMetaData={activity.data.activity.metaData?.map(
(data) => data
)}
/>
</Col>
</Col>
</Row>
<Row>
<Col md="12" lg="6">
<Col lg="12">
<ActivityTransferRequest
activity={activity.data.activity}
departments={departments}
onFormSubmit={submitTransferRequest}
/>
<ActivityTransferApproval
transferAction={
activity.data.transferAction
}
activity={activity.data.activity}
authorization={activity.data.authorized}
approveTransfer={approveTransferRequest}
/>
</Col>
</Col>
<Col md="12" lg="6">
<Col lg="12">
<ActivityTransferToOwnDepartmentRequest
activity={activity.data.activity}
allDepartments={departments}
ownedDepartments={GetCurrentActiveDepartments(
myProfile.data.departments
)}
currentEmployeeId={
myProfile.data.employeeId
}
onFormSubmit={
submitTransferToOwnDepartmentRequest
}
/>
</Col>
</Col>
</Row>
</div>
)}
</>
);
};
export default ActivityDetailPage;
@@ -0,0 +1,52 @@
import React, { useState } from "react";
import { ManualActivityWizard } from "../../components/activity/manual-activity-wizard/ManualActivityWizard";
import {
generateDefaultActivityForm,
IManualActivityFormState
} from "../../shared/models/Activity";
import ActivityService from "../../shared/services/ActivityService";
import AsyncState from "../../shared/enumerations/AsyncState";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import { IDepartment } from "../../shared/models/Department";
const ActivityFormPage: React.FC = () => {
const [activityForm, setActivityForm] = useState<IManualActivityFormState>(
generateDefaultActivityForm()
);
// This is an experimental approach to showing loading state locally without breaking the pattern alex set.
const [submitState, setSubmitState] = useState<AsyncState>(
AsyncState.Inactive
);
const settings = React.useContext(SettingsContext);
const submitActivity = async () => {
setSubmitState(AsyncState.Loading);
await ActivityService.submitManualActivityForm(activityForm);
setSubmitState(AsyncState.Success);
setActivityForm(generateDefaultActivityForm());
};
let departments: IDepartment[] = [];
if (settings?.departments) {
departments = settings.departments.filter((x) => x.state == "Active");
}
return (
<ManualActivityWizard
formState={activityForm}
submitStatus={submitState}
handleChange={(newState: IManualActivityFormState) => {
setActivityForm(newState);
}}
submitManualActivity={submitActivity}
clearForm={() => {
setActivityForm(generateDefaultActivityForm());
}}
departments={departments}
products={settings?.productDetails ?? []}
/>
);
};
export default ActivityFormPage;
@@ -0,0 +1,54 @@
import { shallow } from "enzyme";
import * as React from "react";
import * as reactRedux from "react-redux";
import ActivityPage from "./ActivityPage";
import { IActivityState } from "../../reducers/ActivityReducer";
import AsyncState from "../../shared/enumerations/AsyncState";
import ActivityPageFilterForm from "./ActivityPageFilterForm";
import ActivityAuditLog from "../../components/activity/activity-audit-log/ActivityAuditLog";
describe("A component for finding activities", () => {
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useEffectMock = jest.spyOn(React, "useEffect");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
useEffectMock.mockClear();
});
it("Displays a blank search form", () => {
const dummyDispatch = jest.fn();
const mockActivities: IActivityState = {
activityStateFilter: [],
currentUserDepartmentProgress: {
state: AsyncState.Inactive
},
currentUserProgress: {
state: AsyncState.Inactive
},
filteredActivitiesPageCache: [],
filteredActivities: {
data: {
items: [],
totalPages: 1,
totalItems: 0,
currentPage: 1
},
state: AsyncState.Success
}
};
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(mockActivities);
const wrapper = shallow(<ActivityPage />);
const found = wrapper.find(ActivityPageFilterForm);
expect(found.length).toBe(1);
const found2 = wrapper.find(ActivityAuditLog);
expect(found2.length).toBe(1);
expect(dummyDispatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import React from "react";
import { IAppState } from "../../store/Store";
import {
getAllActivities,
getLastPageOfActivities,
getNextPageOfActivities
} from "../../actions/ActivityActions";
import { useDispatch, useSelector } from "react-redux";
import { IActivityState } from "../../reducers/ActivityReducer";
import ActivityAuditLog from "../../components/activity/activity-audit-log/ActivityAuditLog";
import Configuration from "./../../shared/configuration/Configuration";
import ActivityPageFilterForm from "./ActivityPageFilterForm";
import Translate from "../../translate/Translate";
import { debounce } from "debounce";
import { IActivitySearchForm } from "../../shared/models/Activity";
const ActivityAuditLogTranslate =
Translate.Get.components.activity.activityAuditLog;
const ActivityPage: React.FC = () => {
const dispatch = useDispatch();
const [form, setForm] = React.useState<IActivitySearchForm>({
state: [],
dateHigh: null,
dateLow: null,
query: "",
product: "",
type: ""
});
const activityState = useSelector<IAppState, IActivityState>(
(state) => state.activityState
);
React.useEffect(() => {
search(form);
}, [form]);
const search = React.useCallback(
debounce((query: IActivitySearchForm) => {
dispatch(
getAllActivities(
1,
Configuration.Get.defaultPaginationLimit,
query
)
);
}, 500),
[]
);
const changeSort = (headerName: string) => {
setForm({
...form,
sortBy: headerName,
sortDescending:
headerName === form.sortBy ? !form.sortDescending : false
});
};
return (
<div>
<h5>{ActivityAuditLogTranslate.auditTableTitle}</h5>
<ActivityPageFilterForm filterResults={setForm} />
<ActivityAuditLog
activityContainer={activityState.filteredActivities}
getNextPage={() => dispatch(getNextPageOfActivities(form))}
getPrevPage={() => dispatch(getLastPageOfActivities(form))}
changeSort={changeSort}
form={form}
/>
</div>
);
};
export default ActivityPage;
@@ -0,0 +1,67 @@
import React from "react";
import { mount } from "enzyme";
import ActivityPageFilterForm from "./ActivityPageFilterForm";
import { IActivitySearchForm } from "../../shared/models/Activity";
import { FormGroup } from "reactstrap";
describe("ActivePageFilterForm", () => {
it("should display form correctly", () => {
let testCalls = 0;
const testCall = (filter: IActivitySearchForm) => {
testCalls++;
};
const wrapper = mount(
<ActivityPageFilterForm filterResults={testCall} />
);
expect(wrapper.find(FormGroup).length).toEqual(8);
expect(testCalls).toEqual(1);
});
it("should respond to changes correctly", () => {
let testCalls = 0;
let lastFilter: IActivitySearchForm = {
query: "",
dateHigh: null,
dateLow: null,
state: [],
product: "",
type: "",
employeeId: "",
departmentId: 0
};
const testCall = (filter: IActivitySearchForm) => {
lastFilter = filter;
testCalls++;
};
const wrapper = mount(
<ActivityPageFilterForm filterResults={testCall} />
);
expect(wrapper.find(FormGroup).length).toEqual(8);
wrapper
.find("[name='query']")
.first()
.simulate("change", { target: { value: "3" } });
expect(testCalls).toEqual(2);
expect(lastFilter.query).toEqual("3");
wrapper
.find("[name='ActivityProduct']")
.first()
.simulate("change", { target: { value: "Vehicle" } });
expect(testCalls).toEqual(3);
expect(lastFilter.product).toEqual("Vehicle");
expect(lastFilter.query).toEqual("3");
wrapper
.find("[name='ActivityType']")
.first()
.simulate("change", { target: { value: "Sale" } });
expect(testCalls).toEqual(4);
expect(lastFilter.type).toEqual("Sale");
expect(lastFilter.product).toEqual("Vehicle");
expect(lastFilter.query).toEqual("3");
});
});
@@ -0,0 +1,265 @@
import React, { useEffect, useState } from "react";
import { FormFeedback, FormGroup, Input, Label } from "reactstrap";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import { ActivityState, ActivityType } from "../../shared/models/Activity";
import moment, { Moment } from "moment";
import Translate from "../../translate/Translate";
import { IActivitySearchForm } from "../../shared/models/Activity";
import UserSearchSelect from "../../components/activity/manual-activity-wizard/UserSearchSelect";
import Select from "react-select";
interface IActivityPageFilterFormProps {
filterResults: (filter: IActivitySearchForm) => void;
}
const activityFilterFormTranslate =
Translate.Get.components.activity.filterForm;
const ActivityPageFilterForm: React.FC<IActivityPageFilterFormProps> = ({
filterResults
}) => {
const [form, setForm] = useState<IActivitySearchForm>({
state: ["Accepted"],
dateHigh: null,
dateLow: null,
query: "",
product: "",
type: "",
departmentId: 0,
employeeId: ""
});
const settings = React.useContext(SettingsContext);
useEffect(() => {
filterResults(form);
}, [form]);
const departments = settings?.departments || [];
const updateProduct = (newProduct: string) => {
setForm({
...form,
product: newProduct
});
};
const updateType = (newType: string) => {
setForm({
...form,
type: newType
});
};
const updateState = (newState: string[]) => {
setForm({
...form,
state: newState
});
};
const setSearchQuery = (newQuery: string) => {
setForm({
...form,
query: newQuery
});
};
const updateLowDate = (date: Moment) => {
setForm({
...form,
dateLow: date
});
};
const updateHighDate = (date: Moment) => {
setForm({
...form,
dateHigh: date
});
};
const updateDepartment = (departmentId: number) => {
setForm({
...form,
departmentId: departmentId
});
};
const updateEmployee = (employeeId?: string) => {
setForm({
...form,
employeeId: employeeId
});
};
const activityStateOptions = Object.values(ActivityState).map(
(state) => new Option(state, state)
);
const acceptedActivityStateOption = activityStateOptions.find(
(state) => state.value === "Accepted"
);
return (
<div className="row">
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.productType.label}</Label>
<Input
name="ActivityProduct"
type="select"
bsSize="sm"
value={form.product === "" ? "" : form.product}
onChange={(event) => {
updateProduct(event.target.value);
}}
>
<option value="" key="default_prod_type_option">
{activityFilterFormTranslate.productType.default}
</option>
{settings?.productDetails.map((product) => {
return (
<option
key={product.product}
value={product.product}
>
{product.title}
</option>
);
})}
</Input>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.activityType.label}</Label>
<Input
name="ActivityType"
type="select"
bsSize="sm"
value={form.type === "" ? "" : form.type}
onChange={(event) => {
updateType(event.target.value);
}}
>
<option value="" key="default_activity_state">
{activityFilterFormTranslate.activityType.default}
</option>
{Object.values(ActivityType).map((activityType) => {
return (
<option key={activityType}>{activityType}</option>
);
})}
</Input>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.activityState.label}</Label>
<Select
isMulti
name="ActivityState"
className="basic-multi-select"
placeholder="All States"
options={activityStateOptions}
defaultValue={acceptedActivityStateOption}
onChange={(event) => {
updateState(Array.from(event.map((e) => e.value)));
}}
/>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>
{activityFilterFormTranslate.departmentId.label}
<span className="text-danger">*</span>
</Label>
<Input
name="CreditToDepartment"
bsSize="sm"
type="select"
data-test="dept-input"
value={form.departmentId}
onChange={(event) => {
updateDepartment(parseInt(event.target.value, 10));
}}
>
<option
data-test="placeholder-option"
key={undefined}
value=""
>
{activityFilterFormTranslate.departmentId.placeHolder}
</option>
{departments.map((department) => {
return (
<option key={department.id} value={department.id}>
{department.departmentName}
</option>
);
})}
</Input>
<FormFeedback>Please select a value.</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-6 col-lg-4">
<Label>
{activityFilterFormTranslate.employeeId.label}
<span className="text-danger">*</span>
</Label>
<UserSearchSelect
data-test="CreditToAssociate"
employeeId={form.employeeId ?? ""}
onUserSelected={(field, value) => {
updateEmployee(value);
}}
showValidation={false}
placeHolder={
activityFilterFormTranslate.employeeId.placeHolder
}
/>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.lowDate.label}</Label>
<Input
type="date"
bsSize="sm"
data-test="low-date-input"
value={form.dateLow?.format("YYYY-MM-DD").toString() ?? ""}
invalid={false}
onChange={(event) => {
updateLowDate(moment.utc(event.target.value));
}}
/>
<FormFeedback>
DeptAssignmentTranslate.validation.endDate
</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.highDate.label}</Label>
<Input
type="date"
bsSize="sm"
data-test="high-date-input"
value={form.dateHigh?.format("YYYY-MM-DD").toString() ?? ""}
invalid={false}
onChange={(event) => {
updateHighDate(moment.utc(event.target.value));
}}
/>
<FormFeedback>
DeptAssignmentTranslate.validation.endDate
</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.searchQuery.label}</Label>
<Input
name="query"
bsSize="sm"
value={form.query}
onChange={(event) => {
setSearchQuery(event.target.value);
}}
type="text"
placeholder={
activityFilterFormTranslate.searchQuery.placeHolder
}
/>
</FormGroup>
</div>
);
};
export default ActivityPageFilterForm;
@@ -0,0 +1,250 @@
import React from "react";
import { mount } from "enzyme";
import CommissionRatesEditor from "./CommissionRatesEditor";
import * as reactRedux from "react-redux";
import { IAppRole, ISettings } from "../../shared/models/Settings";
import AsyncState from "../../shared/enumerations/AsyncState";
import moment from "moment";
import { UserType } from "../../shared/models/Auditable";
import { IProductDetail } from "../../shared/enumerations/Product";
import CommissionService from "../../shared/services/CommissionService";
import { act } from "react-dom/test-utils";
import { getMockAppSettings } from "../../shared/testing/mocks/MockAppSettings";
describe("A container for setting commission rates", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const useContextMock = jest.spyOn(React, "useContext");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
useContextMock.mockClear();
});
it("Displays a table with a row and cell for every product and role with commissions", () => {
const mockSelectorValue = defaultMockSelectorValue();
const settings = getMockAppSettings();
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(mockSelectorValue);
useContextMock.mockReturnValue(settings);
const editor = mount(<CommissionRatesEditor />);
const rolesWithCommissions =
settings?.appRoles.filter((role) => role.hasCommissions) ?? [];
const productsWithCommissions =
settings?.productDetails.filter(
(product) => product.hasCommissions
) ?? [];
const totalCells =
productsWithCommissions.length * rolesWithCommissions.length;
expect(editor.find("[data-test='commission-rates-table']").length).toBe(
1
);
expect(editor.find("[data-test='commission-rate-cell']").length).toBe(
totalCells
);
expect(editor.find("[data-test='commission-rate-row']").length).toBe(
productsWithCommissions.length
);
});
it("saves when there are pending changes", async () => {
const mockSelectorValue = defaultMockSelectorValue();
useDispatchMock.mockImplementation(() => jest.fn());
useSelectorMock.mockReturnValue(mockSelectorValue);
useContextMock.mockReturnValue(getMockAppSettings());
const setCommissionsMock = jest
.spyOn(CommissionService, "setCommissions")
.mockResolvedValue({ state: AsyncState.Success });
const editor = mount(<CommissionRatesEditor />);
const saveButton = () =>
editor.find("[data-test='commission-save-btn']");
expect(saveButton().props().disabled).toBe(true);
// make 2 pending commission rate changes
const cells = editor.find("[data-test='commission-rate-cell']");
cells
.at(0)
.find("input")
.simulate("change", { target: { value: "3" } });
cells
.at(1)
.find("input")
.simulate("change", { target: { value: "4" } });
// verify save button
expect(saveButton().props().disabled).toBe(false);
await act(async () => {
saveButton().simulate("click");
});
editor.update();
expect(setCommissionsMock).toHaveBeenCalledTimes(1);
expect(setCommissionsMock).toHaveBeenCalledWith([
{
id: 0,
product: "ATMCard",
ratePerProduct: 3,
role: {
name: "Mortgage",
description: "Mortgage",
permissions: [
{
code: "ELIGIBLE_FOR_REFERRALS",
description: "User is eligible for referrals"
},
{
code: "VIEW_DASHBOARD",
description:
"User can see a dashboard showing their referral totals and their sales totals compared to goal"
}
],
hasCommissions: true,
rank: 1
}
},
{
id: 0,
product: "ATMCard",
ratePerProduct: 4,
role: {
name: "Manager",
description: "Manager",
permissions: [
{
code: "VIEW_PERFORMANCE_FOR_DEPARTMENT",
description:
"User can see performance for their department"
},
{
code: "MANAGE_DEPARTMENT_GOALS",
description:
"User can manange their department goals"
},
{
code: "ASSIGN_ASSOCIATES_TO_DEPARTMENT",
description:
"User can assign associates to additional departments"
},
{
code: "TRANSFER_ACTIVITIES_BETWEEN_DEPARTMENTS",
description:
"User can transfer activities between deparments"
},
{
code: "TRANSFER_ACTIVITIES_BETWEEN_ASSOCIATES",
description:
"User can transfer activities between associates"
}
],
hasCommissions: true,
rank: 1
}
}
]);
const saveSpan = editor.find("[data-test='commission-save-span']");
expect(saveSpan.text()).toContain("Changes Saved");
expect(saveButton().props().disabled).toBe(true);
});
it("Displays error if success not returned from commission service", async () => {
const mockSelectorValue = defaultMockSelectorValue();
useDispatchMock.mockImplementation(() => jest.fn());
useSelectorMock.mockReturnValue(mockSelectorValue);
useContextMock.mockReturnValue(getMockAppSettings());
jest.spyOn(CommissionService, "setCommissions").mockResolvedValue({
state: AsyncState.Failure,
data: "There was an error saving commissions"
});
const editor = mount(<CommissionRatesEditor />);
const saveButton = () =>
editor.find("[data-test='commission-save-btn']");
expect(saveButton().props().disabled).toBe(true);
// make a pending commission rate changes
const cells = editor.find("[data-test='commission-rate-cell']");
cells
.at(0)
.find("input")
.simulate("change", { target: { value: "3" } });
// save
await act(async () => {
saveButton().simulate("click");
});
editor.update();
// verify failure message
const saveSpan = editor.find("[data-test='commission-save-span']");
expect(saveSpan.text()).toEqual(
"There was an error saving commissions"
);
});
it("Displays please wait... if no commissions returned from state", () => {
const mockSelectorValue = {
appSettings: {
data: {
appRoles: [] as IAppRole[],
productDetails: [] as IProductDetail[]
} as ISettings
},
commissions: {
state: AsyncState.Inactive
},
submitState: {
state: AsyncState.Inactive
}
};
useDispatchMock.mockReturnValue(jest.fn());
useSelectorMock.mockReturnValue(mockSelectorValue);
useContextMock.mockReturnValue(getMockAppSettings());
const editor = mount(<CommissionRatesEditor />);
expect(editor.find("[data-test='commissions-wait']").length).toBe(1);
});
});
function defaultMockSelectorValue() {
return {
commissions: {
data: [
{
product: "Commissions 1",
role: {
name: "BOM",
description: "Branch Operations Manager",
permissions: [{ code: "HAVE_SALES_GOALS" }],
hasCommissions: true
},
id: 1,
ratePerProduct: 11,
created: moment(),
createdBy: "",
createdByType: UserType.Associate,
lastUpdated: moment(),
lastUpdatedBy: "",
lastUpdatedByType: UserType.Associate
}
],
state: AsyncState.Inactive
},
submitState: {
state: AsyncState.Inactive
}
};
}
@@ -0,0 +1,139 @@
import React from "react";
import { ICommissionRateChange } from "../../shared/models/Commission";
import { IAppState } from "../../store/Store";
import CommissionRatesTable from "../../components/commissions/CommissionRatesTable";
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getAllCommissions } from "../../actions/CommissionActions";
import { ICommissionState } from "../../reducers/CommissionReducer";
import CommissionService from "../../shared/services/CommissionService";
import AsyncState from "../../shared/enumerations/AsyncState";
import moment from "moment";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
const CommissionRatesEditor: React.FC = () => {
const dispatch = useDispatch();
const settings = React.useContext(SettingsContext);
const commissionsState = useSelector<IAppState, ICommissionState>(
(state) => state.commissionState
);
const commissions = CommissionService.mapToChange(
commissionsState.commissions.data ?? []
);
const [pendingChanges, setPendingChanges] = useState<
ICommissionRateChange[]
>([]);
const [saveMessage, setSaveMessage] = useState<string>("");
const [submitState, setSubmitState] = useState<AsyncState>(
AsyncState.Inactive
);
const rolesWithCommissions =
settings?.appRoles.filter((role) => role.hasCommissions) ?? [];
const productsWithCommissions =
settings?.productDetails.filter((product) => product.hasCommissions) ??
[];
const isValidChanges = (changes: ICommissionRateChange[]): boolean => {
let isValid = true;
changes.forEach((change) => {
if (!CommissionService.isValidRateChange(change)) {
isValid = false;
}
});
return isValid;
};
const changeRate = (change: ICommissionRateChange) => {
setSubmitState(AsyncState.Inactive);
const changes = [...pendingChanges];
const original = commissions.find(
(com) =>
com.role.name === change.role.name &&
com.product === change.product
);
const index = pendingChanges.findIndex(
(com) =>
com.role.name === change.role.name &&
com.product === change.product
);
if (index < 0) {
changes.push(change); // add new
} else if (original?.ratePerProduct === change.ratePerProduct) {
changes.splice(index, 1); // remove change if it is the same as original value
} else {
changes[index] = { ...changes[index], ...change }; // update already pending change
}
setPendingChanges(changes);
};
const save = async () => {
setSubmitState(AsyncState.Loading);
await CommissionService.setCommissions(pendingChanges).then(
(responseState) => {
setSubmitState(responseState.state);
if (responseState.state === AsyncState.Success) {
setSaveMessage("Changes Saved " + moment().format("LTS"));
setPendingChanges([]);
dispatch(getAllCommissions());
} else {
setSaveMessage(responseState.data ?? "");
}
}
);
};
const canSave = () => {
return (
(submitState === AsyncState.Inactive ||
submitState === AsyncState.Success) &&
isValidChanges(pendingChanges) &&
pendingChanges.length > 0
);
};
return (
<>
<div className="d-flex justify-content-end align-items-center">
<span className="mr-2" data-test="commission-save-span">
{saveMessage}
</span>
<button
data-test="commission-save-btn"
className="btn btn-outline-success my-2"
onClick={save}
type="button"
disabled={!canSave()}
>
Save
</button>
</div>
{commissionsState.commissions.state &&
commissionsState.commissions.data &&
rolesWithCommissions.length &&
productsWithCommissions.length ? (
<CommissionRatesTable
commissions={commissions}
roles={rolesWithCommissions}
products={productsWithCommissions}
onRateChange={changeRate}
/>
) : (
<div data-test="commissions-wait">Please Wait... </div>
)}
</>
);
};
export default CommissionRatesEditor;
@@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getAllCommissions } from "../../actions/CommissionActions";
import { ICommissionState } from "../../reducers/CommissionReducer";
import { IAppState } from "../../store/Store";
import { IAppRole } from "../../shared/models/Settings";
import { Card, CardTitle, Table } from "reactstrap";
import Translate from "../../translate/Translate";
import ProductDisplay from "../shared/ProductDisplay";
export interface IPayoutProfileProps {
role: IAppRole | undefined;
}
const payoutTranslate = Translate.Get.components.payouts;
const CommissionsForRole: React.FC<IPayoutProfileProps> = (props) => {
const dispatch = useDispatch();
const commissionState = useSelector<IAppState, ICommissionState>(
(state) => state.commissionState
);
let commissions = commissionState.commissions.data ?? [];
if (commissions) {
commissions = commissions.filter(
(commission) =>
props.role?.name === commission.role.name &&
commission.ratePerProduct > 0
);
}
useEffect(() => {
dispatch(getAllCommissions());
}, []);
if (
!commissions ||
commissions.length === 0 ||
!props.role?.hasCommissions
) {
return <> No Commissions for {props.role?.name}.</>;
}
return (
<Card body outline color="info">
<CardTitle>
<strong>Commissions for {props.role?.name}</strong>
</CardTitle>
<Table
striped
bordered
size="sm"
className=" tablecenterCSS tablecenterheadCSS"
>
<thead>
<tr>
<th>{payoutTranslate.product}</th>
<th>{payoutTranslate.pays}</th>
</tr>
</thead>
<tbody>
{commissions.map((commission) => {
return (
<tr key={commission.id}>
<td>
<ProductDisplay
productCode={commission.product}
/>
</td>
<td>${commission.ratePerProduct}.00</td>
</tr>
);
})}
</tbody>
</Table>
</Card>
);
};
export default CommissionsForRole;
@@ -0,0 +1,30 @@
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getAllCommissions } from "../../actions/CommissionActions";
import { ICommissionState } from "../../reducers/CommissionReducer";
import Translate from "../../translate/Translate";
import { IAppState } from "../../store/Store";
import TabbedCommissionView from "../../components/commissions/TabbedCommissionView";
const CommissionPage: React.FC = () => {
const dispatch = useDispatch();
const commissionState = useSelector<IAppState, ICommissionState>(
(state) => state.commissionState
);
const commissions = commissionState.commissions.data ?? [];
useEffect(() => {
dispatch(getAllCommissions());
}, []);
return (
<div>
<h2>{Translate.Get.containers.commissionPage.title}</h2>
<TabbedCommissionView commissions={commissions} />
</div>
);
};
export default CommissionPage;
@@ -0,0 +1,94 @@
import React, { useEffect, useState } from "react";
import { IDashboardArgs } from "./DashboardPage";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { IBusinessLineProgress } from "../../shared/models/Activity";
import AsyncState from "../../shared/enumerations/AsyncState";
import ActivityService from "../../shared/services/ActivityService";
import AsyncGuard from "../shared/AsyncGuard";
import { Alert, Form, Input } from "reactstrap";
import MultiDepartmentProgressDisplay from "../../components/department-progress/MultiDepartmentProgressDisplay";
import BusinessLineSummaryTable from "./BusinessLineSummaryTable";
const BusinessLineDashboard: React.FC<IDashboardArgs> = (props) => {
const settings = React.useContext(SettingsContext);
const [blProgress, setBlProgress] = useState<
IAsyncStateContainer<IBusinessLineProgress[]>
>({ state: AsyncState.Inactive });
const [selectedBl, setSelectedBl] = useState<string>("ALL");
const range = props.range;
const allBl = settings?.businessLines || [];
useEffect(() => {
setBlProgress({ state: AsyncState.Loading });
retrieveProgress();
}, [
range.toMonth,
range.toYear,
range.fromMonth,
range.fromYear,
selectedBl
]);
const retrieveProgress = () => {
if (range) {
ActivityService.getBusinessProgress(range, selectedBl)
.then((response) => {
setBlProgress(response);
})
.catch(() => {
setBlProgress({ state: AsyncState.Failure });
});
}
};
return (
<AsyncGuard container={blProgress}>
{blProgress.data ? (
<>
<Form inline noValidate>
<Input
inline
bsSize="sm"
type="select"
data-test="dept-input"
value={selectedBl}
onChange={(event) => {
setSelectedBl(event.target.value);
}}
>
<option data-test="all-option" key="" value="ALL">
LCU
</option>
{allBl.map((bl) => {
return (
<option key={bl.code} value={bl.code}>
{bl.name}
</option>
);
})}
</Input>
</Form>
{selectedBl === "ALL" ? (
<BusinessLineSummaryTable
blProgress={blProgress.data}
/>
) : (
<MultiDepartmentProgressDisplay
departments={blProgress.data[0].departments}
monthRange={blProgress.data[0].monthRange}
label={blProgress.data[0].businessLine.name}
showDetails
/>
)}
</>
) : (
<Alert color="warning">
Unable to load your business information.
</Alert>
)}
</AsyncGuard>
);
};
export default BusinessLineDashboard;
@@ -0,0 +1,57 @@
import React from "react";
import ActivityService from "../../shared/services/ActivityService";
import {
IBusinessLineProgress,
IProgressTotal
} from "../../shared/models/Activity";
import ProductTableHeader from "../../components/shared/ProductTableHeader";
import ProgressTableRow from "../../components/shared/ProgressTableRow";
import { IBusinessLine } from "../../shared/models/Settings";
import ProgressPercentageTableRow from "../../components/shared/ProgressPercentageTableRow";
export interface IRegionSummaryArgs {
blProgress: IBusinessLineProgress[];
}
const BusinessLineSummaryTable: React.FC<IRegionSummaryArgs> = (props) => {
const blTotals: IProgressTotal[] = [];
const bls: IBusinessLine[] = [];
props.blProgress.forEach((bl) => {
const totals = ActivityService.calculateTotalProgress(bl.departments);
blTotals.push(totals);
bls.push(bl.businessLine);
});
const allBusinessLineTotal = ActivityService.joinTotalProgress(blTotals);
return (
<table
className="table table-striped tablecenterCSS tablecenterheadCSS"
data-test="business-line-summary-table"
>
<ProductTableHeader />
<tbody>
{blTotals.map((eachTotal, idx) => (
<ProgressTableRow
label={bls[idx].name}
progress={eachTotal.activityTotal}
goals={eachTotal.goalTotal}
key={idx}
/>
))}
<ProgressPercentageTableRow
label="Goal Progress"
progress={allBusinessLineTotal.activityTotal}
goals={allBusinessLineTotal.goalTotal}
/>
<ProgressTableRow
label="Totals"
progress={allBusinessLineTotal.activityTotal}
goals={allBusinessLineTotal.goalTotal}
/>
</tbody>
</table>
);
};
export default BusinessLineSummaryTable;
@@ -0,0 +1,95 @@
import React, { useState } from "react";
import { IAppState } from "../../store/Store";
import { useSelector } from "react-redux";
import { IProfileState } from "../../reducers/ProfileReducer";
import ProfileService from "../../shared/services/ProfileService";
import AsyncGuard from "../shared/AsyncGuard";
import PermissionCode from "../../shared/enumerations/PermissionCode";
import DepartmentDashboard from "./DepartmentDashboard";
import EmployeeDashboard from "./EmployeeDashboard";
import EmptyDashboard from "./EmptyDashboard";
import { IEmployee } from "../../shared/models/Employee";
import { useLocation } from "react-router-dom";
import RegionDashboard from "./RegionDashboard";
import BusinessLineDashboard from "./BusinessLineDashboard";
import GoalProgressControls from "../../components/goal/GoalProgressControls";
import { IMonthRange } from "../../shared/types/IMonthRange";
import moment from "moment";
export interface IDashboardArgs {
range: IMonthRange;
}
const DashboardPage: React.FC = () => {
const [range, setRange] = useState<IMonthRange>({
fromMonth: moment().month() + 1,
fromYear: moment().year(),
toMonth: moment().month() + 1,
toYear: moment().year()
});
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const myProfile = profileState.profile;
const search = useLocation().search;
const view = new URLSearchParams(search).get("dashboard");
if (!myProfile || !myProfile.data) {
return <EmptyDashboard />;
}
const noParamFinder = () => {
const permission = ProfileService.highestRole(
myProfile.data as IEmployee
);
switch (permission) {
case PermissionCode.VIEW_PERFORMANCE_FOR_REGION:
return <RegionDashboard range={range} />;
case PermissionCode.VIEW_PERFORMANCE_FOR_BUSINESS_LINE:
return <BusinessLineDashboard range={range} />;
case PermissionCode.VIEW_PERFORMANCE_FOR_DEPARTMENT:
return <DepartmentDashboard range={range} />;
case PermissionCode.VIEW_DASHBOARD:
return <EmployeeDashboard range={range} />;
case PermissionCode.UNKNOWN:
default:
return <EmptyDashboard />;
}
};
const paramFinder = (param: string) => {
switch (param) {
case "region":
return <RegionDashboard range={range} />;
case "business":
return <BusinessLineDashboard range={range} />;
case "department":
return <DepartmentDashboard range={range} />;
case "employee":
return <EmployeeDashboard range={range} />;
case "empty":
default:
return <EmptyDashboard />;
}
};
const onGoalProgressRangeChange = (range: IMonthRange) => {
setRange(range);
};
const selectedDashboard = view ? paramFinder(view) : noParamFinder();
return (
<div data-test="dashboard-chooser">
<AsyncGuard container={myProfile}>
<>
<GoalProgressControls
onChange={onGoalProgressRangeChange}
/>
{selectedDashboard}
</>
</AsyncGuard>
</div>
);
};
export default DashboardPage;
@@ -0,0 +1,62 @@
import React, { useEffect, useState } from "react";
import { IDepartmentProgress } from "../../shared/models/Activity";
import AsyncState from "../../shared/enumerations/AsyncState";
import TabbedDepartmentProgressView from "../../components/department-progress/TabbedDepartmentProgressView";
import { IDashboardArgs } from "./DashboardPage";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import ActivityService from "../../shared/services/ActivityService";
import AsyncGuard from "../shared/AsyncGuard";
import { useLocation } from "react-router-dom";
const DepartmentDashboard: React.FC<IDashboardArgs> = (props) => {
const range = props.range;
const [myProgress, setMyProgress] = useState<
IAsyncStateContainer<IDepartmentProgress[]>
>({ state: AsyncState.Loading });
const search = useLocation().search;
const departmentId =
new URLSearchParams(search).get("departmentId") || undefined;
useEffect(() => {
setMyProgress({ state: AsyncState.Loading });
retrieveProgress();
}, [
range.toMonth,
range.toYear,
range.fromMonth,
range.fromYear,
departmentId
]);
const retrieveProgress = () => {
if (range) {
ActivityService.getMyDepartmentProgress(range, departmentId)
.then((response) => {
setMyProgress(response);
})
.catch(() => {
setMyProgress({ state: AsyncState.Failure });
});
}
};
return (
<AsyncGuard container={myProgress}>
{myProgress.data && myProgress.data.length > 0 ? (
<>
{myProgress.data.map((progressData) => {
return (
<TabbedDepartmentProgressView
departmentProgress={progressData}
/>
);
})}
</>
) : (
<></>
)}
</AsyncGuard>
);
};
export default DepartmentDashboard;
@@ -0,0 +1,60 @@
import React, { useEffect, useState } from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import TabbedProgressView from "../../components/goal/TabbedProgressView";
import { IDashboardArgs } from "./DashboardPage";
import ActivityService from "../../shared/services/ActivityService";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { IEmployeeProgress } from "../../shared/models/Activity";
import AsyncGuard from "../shared/AsyncGuard";
import { useLocation } from "react-router-dom";
import UserDisplay from "../shared/UserDisplay";
const EmployeeDashboard: React.FC<IDashboardArgs> = (props) => {
const range = props.range;
const [myProgress, setMyProgress] = useState<
IAsyncStateContainer<IEmployeeProgress>
>({ state: AsyncState.Loading });
const search = useLocation().search;
const employeeId =
new URLSearchParams(search).get("employeeId") || undefined;
useEffect(() => {
setMyProgress({ state: AsyncState.Loading });
retrieveProgress();
}, [
range.toMonth,
range.toYear,
range.fromMonth,
range.fromYear,
employeeId
]);
const retrieveProgress = () => {
if (range) {
ActivityService.getMyProgress(range, employeeId)
.then((response) => {
setMyProgress(response);
})
.catch(() => {
setMyProgress({ state: AsyncState.Failure });
});
}
};
return (
<AsyncGuard container={myProgress}>
<div>
{employeeId ? (
<h4>
<UserDisplay employeeId={employeeId} />
</h4>
) : (
""
)}
<TabbedProgressView userProgress={myProgress.data!} />
</div>
</AsyncGuard>
);
};
export default EmployeeDashboard;
@@ -0,0 +1,10 @@
import React from "react";
import { shallow } from "enzyme";
import EmptyDashboard from "./EmptyDashboard";
describe("A Component For displaying an empty dashboard", () => {
it("Displays a basic component", () => {
const wrapper = shallow(<EmptyDashboard />);
expect(wrapper.find("[data-test='empty-dashboard']").length).toBe(1);
});
});
@@ -0,0 +1,15 @@
import React from "react";
import { Alert } from "reactstrap";
const EmptyDashboard: React.FC = () => {
return (
<div data-test="empty-dashboard" className="mt-2">
<Alert color="danger">
Sorry, you currently don’t have permission to access this
application. Contact IT if you require access.
</Alert>
</div>
);
};
export default EmptyDashboard;
@@ -0,0 +1,97 @@
import React, { useEffect, useState } from "react";
import ActivityService from "../../shared/services/ActivityService";
import { IRegionProgress } from "../../shared/models/Activity";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import AsyncState from "../../shared/enumerations/AsyncState";
import AsyncGuard from "../shared/AsyncGuard";
import MultiDepartmentProgressDisplay from "../../components/department-progress/MultiDepartmentProgressDisplay";
import { Alert, Form, Input } from "reactstrap";
import { IDashboardArgs } from "./DashboardPage";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import RegionSummaryTable from "./RegionSummaryTable";
const RegionDashboard: React.FC<IDashboardArgs> = (props) => {
const settings = React.useContext(SettingsContext);
const [regionProgress, setRegionProgress] = useState<
IAsyncStateContainer<IRegionProgress[]>
>({ state: AsyncState.Inactive });
const [selectedRegion, setSelectedRegion] = useState<string>("ALL");
const range = props.range;
const allRegions = settings?.regions || [];
useEffect(() => {
setRegionProgress({ state: AsyncState.Loading });
retrieveProgress();
}, [
range.toMonth,
range.toYear,
range.fromMonth,
range.fromYear,
selectedRegion
]);
const retrieveProgress = () => {
if (range) {
ActivityService.getRegionProgress(range, selectedRegion)
.then((response) => {
setRegionProgress(response);
})
.catch(() => {
setRegionProgress({ state: AsyncState.Failure });
});
}
};
return (
<AsyncGuard container={regionProgress}>
{regionProgress.data ? (
<>
<Form inline noValidate>
<Input
inline
bsSize="sm"
type="select"
data-test="dept-input"
value={selectedRegion}
onChange={(event) => {
setSelectedRegion(event.target.value);
}}
>
<option data-test="all-option" key="" value="ALL">
All Regions
</option>
{allRegions.map((region) => {
return (
<option
key={region.code}
value={region.code}
>
{region.name} Region
</option>
);
})}
</Input>
</Form>
{selectedRegion === "ALL" ? (
<RegionSummaryTable
regionProgress={regionProgress.data}
/>
) : (
<MultiDepartmentProgressDisplay
departments={regionProgress.data[0].departments}
monthRange={regionProgress.data[0].monthRange}
label={regionProgress.data[0].region.name}
showDetails
/>
)}
</>
) : (
<Alert color="warning">
Unable to load your region information.
</Alert>
)}
</AsyncGuard>
);
};
export default RegionDashboard;
@@ -0,0 +1,54 @@
import React from "react";
import ActivityService from "../../shared/services/ActivityService";
import { IProgressTotal, IRegionProgress } from "../../shared/models/Activity";
import ProductTableHeader from "../../components/shared/ProductTableHeader";
import ProgressTableRow from "../../components/shared/ProgressTableRow";
import { IRegion } from "../../shared/models/Settings";
import ProgressPercentageTableRow from "../../components/shared/ProgressPercentageTableRow";
export interface IRegionSummaryArgs {
regionProgress: IRegionProgress[];
}
const RegionSummaryTable: React.FC<IRegionSummaryArgs> = (props) => {
const regionTotals: IProgressTotal[] = [];
const regions: IRegion[] = [];
props.regionProgress.forEach((region) => {
const totals = ActivityService.calculateTotalProgress(
region.departments
);
regionTotals.push(totals);
regions.push(region.region);
});
const allRegionTotal = ActivityService.joinTotalProgress(regionTotals);
return (
<table
className="table table-striped tablecenterCSS tablecenterheadCSS"
data-test="multi-department-goal-progress-table-view"
>
<ProductTableHeader />
<tbody>
{regionTotals.map((eachTotal, idx) => (
<ProgressTableRow
label={regions[idx].name}
progress={eachTotal.activityTotal}
goals={eachTotal.goalTotal}
/>
))}
<ProgressPercentageTableRow
label="Goal Progress"
progress={allRegionTotal.activityTotal}
goals={allRegionTotal.goalTotal}
/>
<ProgressTableRow
label="Totals"
progress={allRegionTotal.activityTotal}
goals={allRegionTotal.goalTotal}
/>
</tbody>
</table>
);
};
export default RegionSummaryTable;
@@ -0,0 +1,41 @@
import { shallow } from "enzyme";
import * as React from "react";
import * as reactRedux from "react-redux";
import DeadActivityPage from "./DeadActivityPage";
import { IDeadActivityState } from "../../reducers/DeadActivityReducer";
import AsyncState from "../../shared/enumerations/AsyncState";
import DeadActivityAuditLog from "../../components/dead-activity/DeadActivityAuditLog";
import DeadActivityPageFilterForm from "./DeadActivityPageFilterForm";
describe("A component for finding activities", () => {
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useEffectMock = jest.spyOn(React, "useEffect");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
useEffectMock.mockClear();
});
it("Displays a blank search form", () => {
const dummyDispatch = jest.fn();
const mockActivities: IDeadActivityState = {
filteredDeadActivities: {
state: AsyncState.Inactive
},
deadActivityStateFilter: []
};
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(mockActivities);
const wrapper = shallow(<DeadActivityPage />);
const found = wrapper.find(DeadActivityPageFilterForm);
expect(found.length).toBe(1);
const found2 = wrapper.find(DeadActivityAuditLog);
expect(found2.length).toBe(1);
expect(dummyDispatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import React from "react";
import { IAppState } from "../../store/Store";
import { useDispatch, useSelector } from "react-redux";
import Configuration from "./../../shared/configuration/Configuration";
import Translate from "../../translate/Translate";
import { debounce } from "debounce";
import { IDeadActivitySearchForm } from "../../shared/models/DeadActivity";
import {
getAllDeadActivities,
getLastPageOfDeadActivities,
getNextPageOfDeadActivities
} from "../../actions/DeadActivityActions";
import DeadActivityPageFilterForm from "./DeadActivityPageFilterForm";
import DeadActivityAuditLog from "../../components/dead-activity/DeadActivityAuditLog";
import { IDeadActivityState } from "../../reducers/DeadActivityReducer";
const ActivityAuditLogTranslate =
Translate.Get.components.deadActivity.deadActivityAuditLog;
const DeadActivityPage: React.FC = () => {
const dispatch = useDispatch();
const [form, setForm] = React.useState<IDeadActivitySearchForm>({
state: [],
dateHigh: null,
dateLow: null,
query: "",
product: "",
type: ""
});
const activityState = useSelector<IAppState, IDeadActivityState>(
(state) => state.deadActivityState
);
React.useEffect(() => {
search(form);
}, [form]);
const search = React.useCallback(
debounce((query: IDeadActivitySearchForm) => {
dispatch(
getAllDeadActivities(
1,
Configuration.Get.defaultPaginationLimit,
query
)
);
}, 500),
[]
);
const changeSort = (headerName: string) => {
setForm({
...form,
sortBy: headerName,
sortDescending:
headerName === form.sortBy ? !form.sortDescending : false
});
};
return (
<div>
<h5>{ActivityAuditLogTranslate.auditTableTitle}</h5>
<DeadActivityPageFilterForm filterResults={setForm} />
<DeadActivityAuditLog
deadActivityContainer={activityState.filteredDeadActivities}
getNextPage={() => dispatch(getNextPageOfDeadActivities(form))}
getPrevPage={() => dispatch(getLastPageOfDeadActivities(form))}
refresh={() => search(form)}
changeSort={changeSort}
form={form}
/>
</div>
);
};
export default DeadActivityPage;
@@ -0,0 +1,67 @@
import React from "react";
import { mount } from "enzyme";
import DeadActivityPageFilterForm from "./DeadActivityPageFilterForm";
import { IDeadActivitySearchForm } from "../../shared/models/DeadActivity";
import { FormGroup } from "reactstrap";
describe("ActivePageFilterForm", () => {
it("should display form correctly", () => {
let testCalls = 0;
const testCall = (filter: IDeadActivitySearchForm) => {
testCalls++;
};
const wrapper = mount(
<DeadActivityPageFilterForm filterResults={testCall} />
);
expect(wrapper.find(FormGroup).length).toEqual(7);
expect(testCalls).toEqual(1);
});
it("should respond to changes correctly", () => {
let testCalls = 0;
let lastFilter: IDeadActivitySearchForm = {
query: "",
dateHigh: null,
dateLow: null,
state: [],
product: "",
type: "",
employeeId: "",
departmentId: 0
};
const testCall = (filter: IDeadActivitySearchForm) => {
lastFilter = filter;
testCalls++;
};
const wrapper = mount(
<DeadActivityPageFilterForm filterResults={testCall} />
);
expect(wrapper.find(FormGroup).length).toEqual(7);
wrapper
.find("[name='query']")
.first()
.simulate("change", { target: { value: "3" } });
expect(testCalls).toEqual(2);
expect(lastFilter.query).toEqual("3");
wrapper
.find("[name='ActivityProduct']")
.first()
.simulate("change", { target: { value: "Vehicle" } });
expect(testCalls).toEqual(3);
expect(lastFilter.product).toEqual("Vehicle");
expect(lastFilter.query).toEqual("3");
wrapper
.find("[name='ActivityType']")
.first()
.simulate("change", { target: { value: "Sale" } });
expect(testCalls).toEqual(4);
expect(lastFilter.type).toEqual("Sale");
expect(lastFilter.product).toEqual("Vehicle");
expect(lastFilter.query).toEqual("3");
});
});
@@ -0,0 +1,237 @@
import React, { useEffect, useState } from "react";
import { FormFeedback, FormGroup, Input, Label } from "reactstrap";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import moment, { Moment } from "moment";
import Translate from "../../translate/Translate";
import {
ActivityType,
IActivitySearchForm
} from "../../shared/models/Activity";
import UserSearchSelect from "../../components/activity/manual-activity-wizard/UserSearchSelect";
interface IDeadActivityPageFilterForm {
filterResults: (filter: IActivitySearchForm) => void;
}
const activityFilterFormTranslate =
Translate.Get.components.deadActivity.deadActivityFilterForm;
const DeadActivityPageFilterForm: React.FC<IDeadActivityPageFilterForm> = ({
filterResults
}) => {
const [form, setForm] = useState<IActivitySearchForm>({
state: [],
dateHigh: null,
dateLow: null,
query: "",
product: "",
type: "",
departmentId: 0,
employeeId: ""
});
const settings = React.useContext(SettingsContext);
useEffect(() => {
filterResults(form);
}, [form]);
const departments = settings?.departments || [];
const updateProduct = (newProduct: string) => {
setForm({
...form,
product: newProduct
});
};
const updateType = (newType: string) => {
setForm({
...form,
type: newType
});
};
const setSearchQuery = (newQuery: string) => {
setForm({
...form,
query: newQuery
});
};
const updateLowDate = (date: Moment) => {
setForm({
...form,
dateLow: date
});
};
const updateHighDate = (date: Moment) => {
setForm({
...form,
dateHigh: date
});
};
const updateDepartment = (departmentId: number) => {
setForm({
...form,
departmentId: departmentId
});
};
const updateEmployee = (employeeId?: string) => {
setForm({
...form,
employeeId: employeeId
});
};
return (
<div className="row">
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.productType.label}</Label>
<Input
name="ActivityProduct"
type="select"
bsSize="sm"
value={form.product === "" ? "" : form.product}
onChange={(event) => {
updateProduct(event.target.value);
}}
>
<option value="" key="default_prod_type_option">
{activityFilterFormTranslate.productType.default}
</option>
{settings?.productDetails.map((product) => {
return (
<option
key={product.product}
value={product.product}
>
{product.title}
</option>
);
})}
</Input>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.activityType.label}</Label>
<Input
name="ActivityType"
type="select"
bsSize="sm"
value={form.type === "" ? "" : form.type}
onChange={(event) => {
updateType(event.target.value);
}}
>
<option value="" key="default_activity_state">
{activityFilterFormTranslate.activityType.default}
</option>
{Object.values(ActivityType).map((activityType) => {
return (
<option key={activityType}>{activityType}</option>
);
})}
</Input>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>
{activityFilterFormTranslate.departmentId.label}
<span className="text-danger">*</span>
</Label>
<Input
name="CreditToDepartment"
bsSize="sm"
type="select"
data-test="dept-input"
value={form.departmentId}
onChange={(event) => {
updateDepartment(parseInt(event.target.value, 10));
}}
>
<option
data-test="placeholder-option"
key={undefined}
value=""
>
{activityFilterFormTranslate.departmentId.placeHolder}
</option>
{departments.map((department) => {
return (
<option key={department.id} value={department.id}>
{department.departmentName}
</option>
);
})}
</Input>
<FormFeedback>Please select a value.</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-6 col-lg-4">
<Label>
{activityFilterFormTranslate.employeeId.label}
<span className="text-danger">*</span>
</Label>
<UserSearchSelect
data-test="CreditToAssociate"
employeeId={form.employeeId ?? ""}
onUserSelected={(field, value) => {
updateEmployee(value);
}}
showValidation={false}
placeHolder={
activityFilterFormTranslate.employeeId.placeHolder
}
/>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.lowDate.label}</Label>
<Input
type="date"
bsSize="sm"
data-test="low-date-input"
value={form.dateLow?.format("YYYY-MM-DD").toString() ?? ""}
invalid={false}
onChange={(event) => {
updateLowDate(moment.utc(event.target.value));
}}
/>
<FormFeedback>
DeptAssignmentTranslate.validation.endDate
</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.highDate.label}</Label>
<Input
type="date"
bsSize="sm"
data-test="high-date-input"
value={form.dateHigh?.format("YYYY-MM-DD").toString() ?? ""}
invalid={false}
onChange={(event) => {
updateHighDate(moment.utc(event.target.value));
}}
/>
<FormFeedback>
DeptAssignmentTranslate.validation.endDate
</FormFeedback>
</FormGroup>
<FormGroup className="col-sm-12 col-md-4 col-lg-2">
<Label>{activityFilterFormTranslate.searchQuery.label}</Label>
<Input
name="query"
bsSize="sm"
value={form.query}
onChange={(event) => {
setSearchQuery(event.target.value);
}}
type="text"
placeholder={
activityFilterFormTranslate.searchQuery.placeHolder
}
/>
</FormGroup>
</div>
);
};
export default DeadActivityPageFilterForm;
@@ -0,0 +1,135 @@
import React from "react";
import { mount } from "enzyme";
import ReactRouter from "react-router";
import DepartmentDetailPage from "./DepartmentDetailPage";
import Translate from "../../translate/Translate";
import { getMockAppSettings } from "../../shared/testing/mocks/MockAppSettings";
import { getMockDepartments } from "../../shared/testing/mocks/MockDepartments";
import DepartmentService from "../../shared/services/DepartmentService";
import AsyncState from "../../shared/enumerations/AsyncState";
import { act } from "react-dom/test-utils";
import PermissionCode from "../../shared/enumerations/PermissionCode";
import moment from "moment";
const pageTranslate = Translate.Get.containers.departmentDetailPage;
const useContextMock = jest.spyOn(React, "useContext");
beforeEach(() => {
useContextMock.mockClear();
});
describe("DepartmentDetailPage", () => {
it("should display unavailable", async () => {
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "9999999" });
jest.spyOn(DepartmentService, "getDepartmentDetails").mockResolvedValue(
{
state: AsyncState.Success,
data: {
assignments: []
}
}
);
let wrapper: any;
await act(async () => {
wrapper = mount(<DepartmentDetailPage />);
});
expect(wrapper.find("[data-test='department-name']").length).toEqual(0);
expect(wrapper.html()).toEqual(pageTranslate.unavailable);
});
it("should display the department details", async () => {
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "55" });
jest.spyOn(DepartmentService, "getDepartmentDetails").mockResolvedValue(
{
state: AsyncState.Success,
data: {
assignments: []
}
}
);
let wrapper: any;
const settings = getMockAppSettings();
settings.departments = getMockDepartments();
useContextMock.mockReturnValue(settings);
await act(async () => {
wrapper = mount(<DepartmentDetailPage />);
});
wrapper.update();
expect(
wrapper.find("[data-test='department-detail-display']").length
).toEqual(1);
});
it("should display personnel", async () => {
jest.spyOn(ReactRouter, "useParams").mockReturnValue({ id: "55" });
const manager = mockUser();
const departmentAssignment = {
departmentId: 55,
role: {
name: "Manager",
description: "manager",
permissions: [],
hasCommissions: false
},
employee: manager,
employeeId: manager.employeeId,
isHome: true,
note: "Manager",
slidingPayout: 100,
startDate: moment()
};
manager.departments;
jest.spyOn(DepartmentService, "getDepartmentDetails").mockResolvedValue(
{
state: AsyncState.Success,
data: {
assignments: [departmentAssignment]
}
}
);
let wrapper: any;
const settings = getMockAppSettings();
settings.departments = getMockDepartments();
useContextMock.mockReturnValue(settings);
await act(async () => {
wrapper = mount(<DepartmentDetailPage />);
});
wrapper.update();
expect(
wrapper.find("[data-test='no-department-detail-assignments']")
.length
).toEqual(1);
expect(
wrapper.find("[data-test='department-detail-assignments']").length
).toEqual(1);
});
});
const mockUser = () => {
return {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT,
description: "ASSIGN_ASSOCIATES_TO_DEPARTMENT"
}
],
hasCommissions: false
},
additionalPermissions: [],
departments: [],
assignments: []
};
};
@@ -0,0 +1,86 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import Translate from "../../translate/Translate";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { IDepartmentDetails } from "../../shared/models/Department";
import AsyncState from "../../shared/enumerations/AsyncState";
import DepartmentService from "../../shared/services/DepartmentService";
import DepartmentDetailDisplay from "../../components/department-detail/DepartmentDetailDisplay";
import DepartmentDetailAssignments from "../../components/department-detail/DepartmentDetailAssignments";
import ProfileService from "../../shared/services/ProfileService";
import { Col, Row } from "reactstrap";
import DepartmentDisplay, {
DepartmentDisplayTargets
} from "../shared/DepartmentDisplay";
const pageTranslate = Translate.Get.containers.departmentDetailPage;
const DepartmentDetailPage: React.FC = () => {
const [details, setDetails] = useState<
IAsyncStateContainer<IDepartmentDetails>
>({ state: AsyncState.Inactive });
const settings = React.useContext(SettingsContext);
const { id } = useParams<{ id: string }>();
const departments = settings?.departments;
const department = departments?.find((x) => x.id.toString() === id);
useEffect(() => {
loadDepartments();
}, []);
const loadDepartments = async () => {
setDetails({ state: AsyncState.Loading });
const details = await DepartmentService.getDepartmentDetails(+id);
setDetails(details);
};
if (!department) {
return <>{pageTranslate.unavailable}</>;
}
const managers = details.data?.assignments.filter((x) =>
ProfileService.isDepartmentManager(x.employee, department.id)
);
const personnel = details.data?.assignments.filter(
(x) => !ProfileService.isDepartmentManager(x.employee, department.id)
);
return (
<>
<h3>
{pageTranslate.title} - {department.departmentName}
</h3>
<Row>
<Col lg="3">
<div style={{ display: "inline-flex" }}>
<h5>{pageTranslate.details}&nbsp;&nbsp;&nbsp;</h5>
<h5>
<DepartmentDisplay
departmentId={department.id}
target={DepartmentDisplayTargets.Dashboard}
nameOverride={pageTranslate.progress}
/>
</h5>
</div>
{details.data && (
<DepartmentDetailDisplay
department={department}
details={details.data}
/>
)}
</Col>
<Col>
<h5>{pageTranslate.managers}</h5>
<DepartmentDetailAssignments assignments={managers} />
<h5 className="mt-2">{pageTranslate.personnel}</h5>
<DepartmentDetailAssignments assignments={personnel} />
</Col>
</Row>
</>
);
};
export default DepartmentDetailPage;
@@ -0,0 +1,49 @@
import React from "react";
import { Alert, Spinner } from "reactstrap";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import AsyncState from "./../../shared/enumerations/AsyncState";
export interface IEmployeeDetailsEditAlertBarProps {
submitStatusMessage: string;
submitState: IAsyncStateContainer<string>;
}
const EmployeeDetailsEditAlertBar: React.FC<IEmployeeDetailsEditAlertBarProps> = (
props
) => {
if (props.submitState.state == AsyncState.Success) {
return (
<Alert className="py-2 mx-1 mb-0" color="success">
<p className="mb-0">{props.submitStatusMessage}</p>
</Alert>
);
}
if (props.submitState.state == AsyncState.Loading) {
return (
<Alert
className="py-1 mx-3 mb-0 d-flex flex-row align-items-center"
color="warning"
>
<Spinner className="mr-2" size="sm" />
<p className="mb-0">{props.submitStatusMessage}</p>
</Alert>
);
}
if (props.submitState.state == AsyncState.Failure) {
return (
<Alert
className="d-flex mb-0 flex-row align-items-center py-1 mx-3"
color="danger"
>
<span className="fa fa-times-circle mr-2" />
<p className="mb-0">{props.submitStatusMessage}</p>
</Alert>
);
}
return <div />;
};
export default EmployeeDetailsEditAlertBar;
@@ -0,0 +1,129 @@
import React from "react";
import { Button, ButtonGroup, Input } from "reactstrap";
import Translate from "../../translate/Translate";
import {
IEmployee,
IEmployeeDetailsUpdate
} from "../../shared/models/Employee";
import { tryUpdateStateWithInput } from "../../shared/services/InputService";
export interface IEmployeeDetailsEditProps {
profile: IEmployee;
employeeDetailsUpdateResult: string;
formState: IEmployeeDetailsUpdate;
submitEmployeeDetailsUpdate: (update: IEmployeeDetailsUpdate) => void;
disableEdit: () => void;
updateFormState: (newState: IEmployeeDetailsUpdate) => void;
}
const profileTranslate = Translate.Get.components.profileEdit;
const EmployeeDetailsEdit: React.FC<IEmployeeDetailsEditProps> = (props) => {
const submitForm = () => {
props.submitEmployeeDetailsUpdate(props.formState);
props.disableEdit();
};
return (
<div data-test="profile-page-wrapper" style={{ width: "100%" }}>
<h6>{profileTranslate.header}</h6>
<table
className="table striped"
style={{ width: "100%" }}
data-test="profile-page-table"
>
<tbody>
<tr>
<td colSpan={3}>{profileTranslate.employeeEmail}</td>
<td>
<Input
name="newEmail"
bsSize="sm"
value={props.formState.newEmail}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"newEmail",
event.target.value
)
);
}}
type="text"
placeholder={props.formState.newEmail}
/>
</td>
</tr>
<tr>
<td colSpan={3}>{profileTranslate.employeeId}</td>
<td>
<Input
name="newEmployeeId"
bsSize="sm"
value={props.formState.newEmployeeId}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"newEmployeeId",
event.target.value
)
);
}}
type="text"
placeholder={props.formState.newEmployeeId}
/>
</td>
</tr>
<tr>
<td colSpan={3}>{profileTranslate.employeeFullName}</td>
<td>
<Input
name="newEmployeeName"
bsSize="sm"
value={props.formState.newFullName}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"newFullName",
event.target.value
)
);
}}
type="text"
placeholder={props.formState.newFullName}
/>
</td>
</tr>
</tbody>
</table>
<hr />
<ButtonGroup>
<Button
color="danger"
size="sm"
onClick={() => {
submitForm();
}}
data-test="submit-button"
>
{profileTranslate.submitButton}
</Button>
<Button
className="m-0"
color="secondary"
size="sm"
onClick={() => {
props.disableEdit();
}}
data-test="clear-button"
>
<span className="fa fa-times" />
</Button>
</ButtonGroup>
</div>
);
};
export default EmployeeDetailsEdit;
@@ -0,0 +1,109 @@
import React, { useState } from "react";
import { IEmployee } from "../../shared/models/Employee";
import Translate from "../../translate/Translate";
import { Button, FormFeedback } from "reactstrap";
import { IEmployeeDetailsUpdate } from "../../shared/models/Employee";
import EmployeeDetailsEdit from "./EmployeeDetailsEditPage";
import EmployeeDetailsEditAlertBar from "./EmployeeDetailsEditAlertBar";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
export interface IEmployeeDetailsPageParams {
profile: IEmployee;
employeeDetailsUpdateResult: string;
submitStatus: IAsyncStateContainer<string>;
refreshProfile: () => void;
submitEmployeeDetailsUpdate: (update: IEmployeeDetailsUpdate) => void;
}
const profileTranslate = Translate.Get.components.profileEdit;
const EmployeeDetailsPage: React.FC<IEmployeeDetailsPageParams> = (props) => {
const profile = props.profile;
const createFormStateFromAssignment = (
employee: IEmployee
): IEmployeeDetailsUpdate => {
return {
oldEmail: employee.adEmail,
oldEmployeeId: employee.employeeId,
oldFullName: employee.fullName,
newEmail: employee.adEmail,
newEmployeeId: employee.employeeId,
newFullName: employee.fullName
};
};
const initialFormState = createFormStateFromAssignment(props.profile);
const [isEditing, setEdit] = useState<boolean>(false);
const [formState, setFormState] = useState<IEmployeeDetailsUpdate>(
initialFormState
);
const enableEdit = () => {
setFormState(createFormStateFromAssignment(props.profile));
setEdit(true);
};
if (!isEditing) {
return (
<div data-test="profile-page-wrapper" style={{ width: "100%" }}>
<h6>{profileTranslate.header}</h6>
<FormFeedback>{props.employeeDetailsUpdateResult}</FormFeedback>
<table
className="table striped"
style={{ width: "100%" }}
data-test="profile-page-table"
>
<tbody>
<tr>
<td>{profileTranslate.employeeEmail}</td>
<td colSpan={2}>{profile.adEmail}</td>
</tr>
<tr>
<td>{profileTranslate.employeeId}</td>
<td>{profile.employeeId}</td>
</tr>
<tr>
<td>{profileTranslate.employeeFullName}</td>
<td>{profile.fullName}</td>
</tr>
<tr>
<td>
<Button
color="secondary"
size="sm"
onClick={() => enableEdit()}
>
{profileTranslate.edit}
</Button>
</td>
<td>
<EmployeeDetailsEditAlertBar
submitStatusMessage={
props.employeeDetailsUpdateResult
}
submitState={props.submitStatus}
/>
</td>
</tr>
</tbody>
</table>
<hr />
</div>
);
}
return (
<EmployeeDetailsEdit
formState={formState}
profile={props.profile}
employeeDetailsUpdateResult={props.employeeDetailsUpdateResult}
submitEmployeeDetailsUpdate={props.submitEmployeeDetailsUpdate}
disableEdit={() => setEdit(false)}
updateFormState={(newState) => setFormState(newState)}
data-test="edit-row"
/>
);
};
export default EmployeeDetailsPage;
@@ -0,0 +1,90 @@
import "../../components/tab-view/TabView.css";
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
submitDepartmentAssignment,
submitDepartmentAssignmentUpdate,
trySelectEmployee
} from "../../actions/EmployeeActions";
import { IEmployeeState } from "./../../reducers/EmployeeReducer";
import { IAppState } from "./../../store/Store";
import AsyncState from "./../../shared/enumerations/AsyncState";
import EmployeeAlertBar from "./../../components/employee/EmployeeAlertBar";
import DeptAssignmentTabGroup from "./department-assignments/DeptAssignmentTabGroup";
import {
IDepartmentAssignmentDto,
IDepartmentAssignmentUpdateDto
} from "../../shared/models/DepartmentAssignment";
import EmployeeService from "../../shared/services/EmployeeService";
export interface IEmployeeParam {
employeeId: string;
}
const EmployeePage: React.FC<IEmployeeParam> = (props) => {
const employeeId = props.employeeId;
const dispatch = useDispatch();
const employeeState = useSelector<IAppState, IEmployeeState>(
(state) => state.employeeState
);
useEffect(() => {
dispatch(trySelectEmployee(employeeId));
}, []);
const submitNewDeptAssignment = async (
deptAssignment: Partial<IDepartmentAssignmentDto>
) => {
if (employeeState.selectedEmployee?.data) {
await dispatch(
submitDepartmentAssignment(
employeeState.selectedEmployee.data.employeeId,
deptAssignment
)
);
await dispatch(trySelectEmployee(employeeId));
}
};
const submitDeptAssignmentUpdate = async (
update: IDepartmentAssignmentUpdateDto
) => {
update.employeeId = employeeId;
if (
employeeState.selectedEmployee?.data &&
EmployeeService.isValidDepartmentAssignmentUpdate(update)
) {
await dispatch(submitDepartmentAssignmentUpdate(update));
await dispatch(trySelectEmployee(employeeId));
}
};
return (
<div>
<EmployeeAlertBar
employeeContainer={
employeeState.selectedEmployee ?? {
state: AsyncState.Inactive
}
}
searchEmployeeId={employeeId}
/>
<div className="p-2">
<DeptAssignmentTabGroup
employeeDepartmentAssignments={
employeeState.selectedEmployee?.data?.departments ?? []
}
submitState={employeeState.submitState}
submitNewDeptAssignment={(assignmentData) =>
submitNewDeptAssignment(assignmentData)
}
submitDeptAssignmentUpdate={(
deptUpdate: IDepartmentAssignmentUpdateDto
) => submitDeptAssignmentUpdate(deptUpdate)}
/>
</div>
</div>
);
};
export default EmployeePage;
@@ -0,0 +1,66 @@
import Translate from "../../../translate/Translate";
import { useDispatch, useSelector } from "react-redux";
import { IAppState } from "../../../store/Store";
import { IDepartmentState } from "../../../reducers/DepartmentReducer";
import { AutoTable } from "../../../components/auto-table/AutoTable";
import React, { useEffect } from "react";
import { getAllDepartments } from "../../../actions/DepartmentActions";
import moment from "moment";
import { IDepartmentAssignment } from "../../../shared/models/DepartmentAssignment";
export interface IDepartmentAssignmentTableProps {
assignments: IDepartmentAssignment[];
}
const departmentAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable;
const BasicDeptAssignmentTable: React.FC<IDepartmentAssignmentTableProps> = (
props
) => {
const dispatch = useDispatch();
const departmentState = useSelector<IAppState, IDepartmentState>(
(state) => state.departmentState
);
const getDepartmentNameFromId = (id: number): string => {
return (
departmentState.departments.data?.find((dept) => dept.id === id)
?.departmentName ?? "-"
);
};
useEffect(() => {
dispatch(getAllDepartments());
}, []);
const headers = [
departmentAssignmentTranslate.headers.isHome,
departmentAssignmentTranslate.headers.departmentId,
departmentAssignmentTranslate.headers.note,
departmentAssignmentTranslate.headers.startDate,
departmentAssignmentTranslate.headers.endDate
];
const cells = [
(x: IDepartmentAssignment) => (x.isHome ? "Home Dept" : ""),
(x: IDepartmentAssignment) => getDepartmentNameFromId(x.departmentId),
(x: IDepartmentAssignment) => x.note,
(x: IDepartmentAssignment) =>
moment(x.startDate).format("MM-DD-YYYY").toString(),
(x: IDepartmentAssignment) =>
x.endDate
? moment(x.endDate).format("MM-DD-YYYY").toString()
: "active"
];
return (
<AutoTable
className="border rounded"
headers={headers}
sortedColumn={{ index: 4, isReversed: false }}
cells={cells}
elements={props.assignments}
/>
);
};
export default BasicDeptAssignmentTable;
@@ -0,0 +1,54 @@
import React from "react";
import { Alert, Spinner } from "reactstrap";
import IAsyncStateContainer from "../../../shared/types/IAsyncStateContainer";
import AsyncState from "./../../../shared/enumerations/AsyncState";
import Translate from "./../../../translate/Translate";
export interface IDeptAssignmentAlertBarProps {
submitState: IAsyncStateContainer<string>;
}
const alertBarTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentAlertBar;
const DeptAssignmentAlertBar: React.FC<IDeptAssignmentAlertBarProps> = (
props
) => {
if (props.submitState.state == AsyncState.Success) {
return (
<Alert className="py-2 mx-1 mb-0" color="success">
<p className="mb-0">{alertBarTranslate.success}</p>
</Alert>
);
}
if (props.submitState.state == AsyncState.Loading) {
return (
<Alert
className="py-1 mx-3 mb-0 d-flex flex-row align-items-center"
color="warning"
>
<Spinner className="mr-2" size="sm" />
<p className="mb-0">{alertBarTranslate.loading}</p>
</Alert>
);
}
if (props.submitState.state == AsyncState.Failure) {
return (
<Alert
className="d-flex mb-0 flex-row align-items-center py-1 mx-3"
color="danger"
>
<span className="fa fa-times-circle mr-2" />
<p className="mb-0">
{alertBarTranslate.error} {props.submitState.data}
</p>
</Alert>
);
}
return <div />;
};
export default DeptAssignmentAlertBar;
@@ -0,0 +1,95 @@
import Translate from "../../../translate/Translate";
import { useDispatch, useSelector } from "react-redux";
import { IAppState } from "../../../store/Store";
import { IDepartmentState } from "../../../reducers/DepartmentReducer";
import { AutoTable } from "../../../components/auto-table/AutoTable";
import React, { useEffect } from "react";
import { getAllDepartments } from "../../../actions/DepartmentActions";
import moment from "moment";
import HomeIcon from "./HomeIcon";
import { IDepartmentAssignment } from "../../../shared/models/DepartmentAssignment";
export interface IDeptAssignmentAuditTable {
assignments: IDepartmentAssignment[];
}
const departmentAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment
.deptAssignmentAuditTable;
const DeptAssignmentAuditTable: React.FC<IDeptAssignmentAuditTable> = (
props
) => {
const dispatch = useDispatch();
const departmentState = useSelector<IAppState, IDepartmentState>(
(state) => state.departmentState
);
const getDepartmentNameFromId = (id: number): string => {
return (
departmentState.departments.data?.find((dept) => dept.id === id)
?.departmentName ?? "-"
);
};
const sortDataAudits = (
a: IDepartmentAssignment,
b: IDepartmentAssignment
): number => {
const momenta = moment(a.lastUpdated);
const momentb = moment(b.lastUpdated);
return momenta.isBefore(momentb)
? 1
: momenta.isAfter(momentb)
? -1
: 0;
};
useEffect(() => {
dispatch(getAllDepartments());
}, []);
const headers = [
departmentAssignmentTranslate.headers.isHome,
departmentAssignmentTranslate.headers.departmentId,
departmentAssignmentTranslate.headers.note,
departmentAssignmentTranslate.headers.startDate,
departmentAssignmentTranslate.headers.endDate,
departmentAssignmentTranslate.headers.created,
departmentAssignmentTranslate.headers.createdBy,
departmentAssignmentTranslate.headers.createdByType,
departmentAssignmentTranslate.headers.lastUpdated,
departmentAssignmentTranslate.headers.lastUpdatedBy,
departmentAssignmentTranslate.headers.UpdatedByType
];
const cells = [
(x: IDepartmentAssignment) => <HomeIcon isHome={x.isHome}></HomeIcon>,
(x: IDepartmentAssignment) => getDepartmentNameFromId(x.departmentId),
(x: IDepartmentAssignment) => x.note,
(x: IDepartmentAssignment) =>
moment(x.startDate).format("MM-DD-YYY").toString(),
(x: IDepartmentAssignment) =>
x.endDate
? moment(x.endDate).format("MM-DD-YYY").toString()
: "active",
(x: IDepartmentAssignment) => moment(x.created).toISOString(),
(x: IDepartmentAssignment) => x.createdBy,
(x: IDepartmentAssignment) => x.createdByType,
(x: IDepartmentAssignment) => moment(x.lastUpdated).toISOString(),
(x: IDepartmentAssignment) => x.lastUpdatedBy,
(x: IDepartmentAssignment) => x.lastUpdatedByType
];
return (
<AutoTable
className="border rounded"
headers={headers}
cells={cells}
sortedColumn={{ index: 8, isReversed: false }}
elements={props.assignments.sort((a, b) => {
return sortDataAudits(a, b);
})}
/>
);
};
export default DeptAssignmentAuditTable;
@@ -0,0 +1,162 @@
import { mount } from "enzyme";
import React from "react";
import AsyncState from "../../../shared/enumerations/AsyncState";
import DeptAssignmentTabGroup from "./DeptAssignmentTabGroup";
import * as reactRedux from "react-redux";
import { IDepartmentState } from "../../../reducers/DepartmentReducer";
import PermissionCode from "../../../shared/enumerations/PermissionCode";
import { IProfileState } from "../../../reducers/ProfileReducer";
import { mockDeptAssignments } from "../../../shared/testing/mocks/MockDepartmentAssignments";
import { mockRoles } from "../../../shared/testing/mocks/MockRoles";
import { ISettings } from "../../../shared/models/Settings";
import { getMockDepartments } from "../../../shared/testing/mocks/MockDepartments";
import { IDepartmentAssignment } from "../../../shared/models/DepartmentAssignment";
const mockSettingsState: ISettings = {
appRoles: mockRoles,
permissions: [],
productDetails: [],
businessLines: [],
productSet: [],
regions: [],
payoutProfiles: [],
activityStateSet: {},
departmentStateSet: {},
activityTypeSet: {},
organizationSet: {},
systemTypeSet: {},
userTypeSet: {},
departments: getMockDepartments()
};
const mockDepartmentState: IDepartmentState = {
departments: {
data: getMockDepartments(),
state: AsyncState.Success
},
submitState: { state: AsyncState.Inactive }
};
// setup appUser to get past permission guard
const mockProfileState: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT,
description: "ASSIGN_ASSOCIATES_TO_DEPARTMENT"
}
],
hasCommissions: false
},
additionalPermissions: [],
departments: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
beforeEach(() => {
useDispatchMock.mockClear();
useSelectorMock.mockClear();
});
describe("User wants to switch views of department assignments", () => {
it("Only displays active department assignments in the active tab and sorts by end date desc", () => {
useDispatchMock.mockReturnValue(jest.fn());
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
const wrapper = mount(
<DeptAssignmentTabGroup
submitNewDeptAssignment={() => jest.fn()}
submitDeptAssignmentUpdate={() => jest.fn()}
submitState={{ state: AsyncState.Inactive }}
employeeDepartmentAssignments={mockDeptAssignments}
/>
);
const assignments: [] = wrapper
.find("DeptAssignmentTable")
.prop("assignments");
expect(assignments.length).toBe(2);
expect(assignments.map((x: IDepartmentAssignment) => x.id)).toEqual([
2,
1
]);
});
it("Only displays inactive department assignments in the closed tab and sorts by end date desc", () => {
useDispatchMock.mockReturnValue(jest.fn());
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState,
...mockSettingsState
});
const wrapper = mount(
<DeptAssignmentTabGroup
submitNewDeptAssignment={() => jest.fn()}
submitDeptAssignmentUpdate={() => jest.fn()}
submitState={{ state: AsyncState.Inactive }}
employeeDepartmentAssignments={mockDeptAssignments}
/>
);
//make tab group display 2nd tab
wrapper.find('[data-test="nav-link-1"]').simulate("click");
wrapper.update();
const assignments: [] = wrapper
.find("BasicDeptAssignmentTable")
.prop("assignments");
expect(assignments.length).toBe(2);
expect(assignments.map((x: IDepartmentAssignment) => x.id)).toEqual([
3,
0
]);
});
it("Displays everything in the audit", () => {
useDispatchMock.mockReturnValue(jest.fn());
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState,
...mockSettingsState
});
const wrapper = mount(
<DeptAssignmentTabGroup
submitNewDeptAssignment={() => jest.fn()}
submitDeptAssignmentUpdate={() => jest.fn()}
submitState={{ state: AsyncState.Inactive }}
employeeDepartmentAssignments={mockDeptAssignments}
/>
);
//make tab group display 3rd tab
wrapper.find('[data-test="nav-link-2"]').simulate("click");
wrapper.update();
const assignments: [] = wrapper
.find("DeptAssignmentAuditTable")
.prop("assignments");
expect(assignments.length).toBe(4);
expect(assignments.map((x: IDepartmentAssignment) => x.id)).toEqual([
2,
3,
1,
0
]);
});
});
@@ -0,0 +1,107 @@
import React from "react";
import TabView from "../../../components/tab-view/TabView";
import DeptAssignmentTable from "./department-assignment-table/DeptAssignmentTable";
import BasicDeptAssignmentTable from "./BasicDeptAssignmentTable";
import DeptAssignmentAuditTable from "./DeptAssignmentAuditTable";
import moment from "moment";
import IAsyncStateContainer from "../../../shared/types/IAsyncStateContainer";
import Translate from "./../../../translate/Translate";
import {
IDepartmentAssignment,
IDepartmentAssignmentDto,
IDepartmentAssignmentUpdateDto
} from "../../../shared/models/DepartmentAssignment";
export interface IDeptAssignmentTabGroupProps {
employeeDepartmentAssignments: IDepartmentAssignment[];
submitState: IAsyncStateContainer<string>;
submitNewDeptAssignment: (data: Partial<IDepartmentAssignmentDto>) => void;
submitDeptAssignmentUpdate: (
deptUpdate: IDepartmentAssignmentUpdateDto
) => void;
}
const TabViewTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTabGroup;
const DeptAssignmentTabGroup: React.FC<IDeptAssignmentTabGroupProps> = (
props
) => {
const activeAssignments = (): IDepartmentAssignment[] => {
return (
props.employeeDepartmentAssignments
.filter(
(x) =>
x.endDate == undefined ||
moment(x.endDate).isAfter(moment.now())
)
.sort((a, b) =>
b.endDate == undefined ||
moment(b.endDate).isBefore(moment(a.endDate))
? 1
: a.endDate == undefined ||
moment(b.endDate).isBefore(moment(a.endDate))
? -1
: 0
) ?? []
);
};
const inActiveAssignments = (): IDepartmentAssignment[] => {
return (
props.employeeDepartmentAssignments
.filter(
(x) =>
x.endDate !== undefined &&
moment(x.endDate).isBefore(moment.now())
)
.sort((a, b) =>
moment(a.endDate).isBefore(b.endDate)
? 1
: moment(a.endDate).isAfter(b.endDate)
? -1
: -0
) ?? []
);
};
return (
<TabView
pages={[
{
header: TabViewTranslate.deptAssignmentTable,
element: (
<DeptAssignmentTable
submitNewDeptAssignment={(data) =>
props.submitNewDeptAssignment(data)
}
submitDeptAssignmentUpdate={
props.submitDeptAssignmentUpdate
}
assignments={activeAssignments()}
submitStatus={props.submitState}
/>
)
},
{
header: TabViewTranslate.basicDeptAssignmentTable,
element: (
<BasicDeptAssignmentTable
assignments={inActiveAssignments()}
/>
)
},
{
header: TabViewTranslate.deptAssignmentAduitTable,
element: (
<DeptAssignmentAuditTable
assignments={props.employeeDepartmentAssignments}
/>
)
}
]}
/>
);
};
export default DeptAssignmentTabGroup;
@@ -0,0 +1,24 @@
import React from "react";
import { Badge } from "reactstrap";
export interface IHomeIconProps {
isHome?: boolean;
}
const HomeIcon: React.FC<IHomeIconProps> = (props) => {
return (
<Badge color="dark" hidden={!props.isHome}>
<div className="d-flex flex-row justify-content-center align-items-center">
<div>
<p className="fa fa-home p-0 m-0" />
</div>
<b>
<p className="mb-0 ml-2">Home</p>
<p className="mb-0 ml-2">Dept</p>
</b>
</div>
</Badge>
);
};
export default HomeIcon;
@@ -0,0 +1,229 @@
import React from "react";
import moment from "moment";
import { UserType } from "../../../../shared/models/Auditable";
import { mount } from "enzyme";
import DeptAssignmentEdit from "./DeptAssignmentEdit";
import {
IDepartmentAssignment,
IDepartmentAssignmentUpdateDto
} from "../../../../shared/models/DepartmentAssignment";
import { mockRoles } from "../../../../shared/testing/mocks/MockRoles";
const mockDeptAssignment: IDepartmentAssignment = {
id: 5,
employeeId: "5",
departmentId: 55,
startDate: moment("2021-01-02"),
endDate: moment("2021-01-03"),
note: "Note",
isHome: false,
createdByType: UserType.Associate,
createdBy: "me",
created: moment("2021-01-04"),
lastUpdated: moment("2021-01-04"),
lastUpdatedByType: UserType.Associate,
lastUpdatedBy: "me",
role: {
name: "Teller",
description: "Teller Desc",
hasCommissions: true,
permissions: []
},
slidingPayout: 100
};
const mockGetDeptNameFromId = (id: any) => {
return "WAUWATOSA";
};
const mockDisableEdit = jest.fn();
const mockUpdateFormState = jest.fn();
const mockSubmit = jest.fn();
beforeEach(() => {
mockDisableEdit.mockClear();
mockUpdateFormState.mockClear();
mockSubmit.mockClear();
});
describe("A appUser wants to edit an employee's department assignments", () => {
it("displays the expected data", () => {
const mockState: IDepartmentAssignmentUpdateDto = {
endDate: moment("2021-01-03"),
role: "Teller",
employeeId: "11771",
assignmentId: 99,
slidingPayout: 100
};
const wrapper = mount(
<DeptAssignmentEdit
departmentAssignment={mockDeptAssignment}
formState={mockState}
getDepartmentNameFromId={mockGetDeptNameFromId}
submitDeptAssignmentUpdate={() => mockSubmit()}
updateFormState={() => mockUpdateFormState()}
roles={mockRoles}
disableEdit={() => mockDisableEdit()}
/>
);
expect(wrapper.find('[data-test="department"]').text()).toContain(
"WAUWATOSA"
);
expect(wrapper.find('[data-test="note"]').text()).toContain("Note");
expect(wrapper.find('[data-test="start-date"]').text()).toContain(
"01-02-2021"
);
const endInput = wrapper.find('[data-test="end-date-input"]');
// expect( endInput.first().html()).toEqual("");
const endInputValue = endInput.first().prop("value");
expect(endInputValue).toEqual("2021-01-03");
expect(
wrapper
.find('[data-test="submit-button"]')
.hostNodes()
.prop("disabled")
).toBe(false);
});
it("handles null end date", () => {
const mockState: IDepartmentAssignmentUpdateDto = {
endDate: undefined,
role: "Teller",
employeeId: "11771",
assignmentId: 99,
slidingPayout: 100
};
const wrapper = mount(
<DeptAssignmentEdit
departmentAssignment={mockDeptAssignment}
formState={mockState}
getDepartmentNameFromId={mockGetDeptNameFromId}
submitDeptAssignmentUpdate={() => mockSubmit()}
updateFormState={() => mockUpdateFormState()}
roles={mockRoles}
disableEdit={() => mockDisableEdit()}
/>
);
expect(wrapper.find('[data-test="department"]').text()).toContain(
"WAUWATOSA"
);
expect(wrapper.find('[data-test="note"]').text()).toContain("Note");
expect(wrapper.find('[data-test="start-date"]').text()).toContain(
"01-02-2021"
);
const endInput = wrapper.find('[data-test="end-date-input"]');
const endInputValue = endInput.first().prop("value");
expect(endInputValue).toEqual("");
expect(
wrapper
.find('[data-test="submit-button"]')
.hostNodes()
.prop("disabled")
).toBe(false);
});
});
describe("A appUser wants to submit an edit to an employee's department assignments", () => {
it("disables submit and displays feedback when end date is invalid - end is before start", () => {
const mockState: IDepartmentAssignmentUpdateDto = {
endDate: moment("2021-01-01"),
role: "Teller",
employeeId: "11771",
assignmentId: 99,
slidingPayout: 100
};
const wrapper = mount(
<DeptAssignmentEdit
departmentAssignment={mockDeptAssignment}
formState={mockState}
getDepartmentNameFromId={mockGetDeptNameFromId}
submitDeptAssignmentUpdate={() => mockSubmit()}
updateFormState={() => mockUpdateFormState()}
roles={mockRoles}
disableEdit={() => mockDisableEdit()}
/>
);
const endInput = wrapper.find('[data-test="end-date-input"]');
const endInputValue = endInput.first().prop("value");
expect(endInputValue).toEqual("2021-01-01");
expect(
wrapper
.find('[data-test="submit-button"]')
.hostNodes()
.prop("disabled")
).toBe(true);
//Invalid and therefore displays feedback message, we trust reactstrap
expect(endInput.first().prop("invalid")).toBe(true);
});
it("calls submit with proper data when submit button is clicked", () => {
const mockState: IDepartmentAssignmentUpdateDto = {
endDate: moment("2021-01-03"),
role: "Teller",
employeeId: "11771",
assignmentId: 99,
slidingPayout: 100
};
const wrapper = mount(
<DeptAssignmentEdit
departmentAssignment={mockDeptAssignment}
formState={mockState}
getDepartmentNameFromId={mockGetDeptNameFromId}
submitDeptAssignmentUpdate={(form) => mockSubmit(form)}
updateFormState={() => mockUpdateFormState()}
roles={mockRoles}
disableEdit={() => mockDisableEdit()}
/>
);
expect(
wrapper
.find('[data-test="submit-button"]')
.hostNodes()
.prop("disabled")
).toBe(false);
wrapper
.find('[data-test="submit-button"]')
.hostNodes()
.simulate("click");
wrapper.update();
expect(mockSubmit).toBeCalledTimes(1);
expect(mockDisableEdit).toBeCalledTimes(1);
expect(mockSubmit.mock.calls).toEqual([[mockState]]);
});
});
describe("A appUser wants to cancel an edit to an employee's department assignments", () => {
it("does not submit and toggles edit to false", () => {
const mockState: IDepartmentAssignmentUpdateDto = {
endDate: moment("2021-01-03"),
role: "Teller",
employeeId: "11771",
assignmentId: 99,
slidingPayout: 100
};
const wrapper = mount(
<DeptAssignmentEdit
departmentAssignment={mockDeptAssignment}
formState={mockState}
getDepartmentNameFromId={mockGetDeptNameFromId}
submitDeptAssignmentUpdate={(form) => mockSubmit(form)}
updateFormState={() => mockUpdateFormState()}
roles={mockRoles}
disableEdit={() => mockDisableEdit()}
/>
);
wrapper
.find('[data-test="clear-button"]')
.hostNodes()
.simulate("click");
wrapper.update();
expect(mockSubmit).toBeCalledTimes(0);
expect(mockDisableEdit).toBeCalledTimes(1);
});
});
@@ -0,0 +1,196 @@
import React from "react";
import { Button, ButtonGroup, FormFeedback, Input } from "reactstrap";
import { tryUpdateStateWithInput } from "../../../../shared/services/InputService";
import moment, { Moment } from "moment";
import HomeIcon from "../HomeIcon";
import Translate from "./../../../../translate/Translate";
import {
IDepartmentAssignment,
IDepartmentAssignmentUpdateDto
} from "../../../../shared/models/DepartmentAssignment";
import { IAppRole } from "../../../../shared/models/Settings";
const newDepartmentAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable
.newDeptAssignmentRow;
export interface IDeptAssignmentTableRowEditProps {
departmentAssignment: IDepartmentAssignment;
formState: IDepartmentAssignmentUpdateDto;
getDepartmentNameFromId: (deptId: number) => string;
submitDeptAssignmentUpdate: (
update: IDepartmentAssignmentUpdateDto
) => void;
disableEdit: () => void;
updateFormState: (newState: IDepartmentAssignmentUpdateDto) => void;
roles: IAppRole[];
}
const DeptAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable
.deptAssignmentTableEdit;
const DeptAssignmentEdit: React.FC<IDeptAssignmentTableRowEditProps> = (
props
) => {
const castMomentToStringOrPlaceholder = (
value: Moment | undefined,
fallBack: string
): string => {
if (value === undefined || value === null || !moment(value).isValid()) {
return fallBack;
}
return moment(value).format("MM-DD-YYYY").toString();
};
const isEndDateInvalid = () => {
if (props.formState.endDate && props.departmentAssignment.startDate) {
return moment(props.formState.endDate).isBefore(
props.departmentAssignment.startDate
);
}
return false;
};
const submitForm = () => {
props.submitDeptAssignmentUpdate(props.formState);
props.disableEdit();
};
const isConfirmButtonDisabled = () => {
return isEndDateInvalid();
};
return (
<tr>
<td>
<HomeIcon isHome={props.departmentAssignment.isHome} />
</td>
<td data-test="department">
{props.getDepartmentNameFromId(
props.departmentAssignment.departmentId
)}
</td>
<td data-test="start-date">
{castMomentToStringOrPlaceholder(
props.departmentAssignment.startDate,
"-"
)}
</td>
<td>
<Input
type="date"
bsSize="sm"
data-test="end-date-input"
disabled={props.departmentAssignment.isHome}
value={
props.formState.endDate
?.format("YYYY-MM-DD")
.toString() ?? ""
}
invalid={isEndDateInvalid()}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"endDate",
// FORCE to utc. Prevents a timezone conversion
// you don't normally have to do this.
moment.utc(event.target.value).endOf("month")
)
);
}}
/>
<FormFeedback>
{DeptAssignmentTranslate.validation.endDate}
</FormFeedback>
</td>
<td>
<Input
bsSize="sm"
type="select"
data-test="role-input"
disabled={props.departmentAssignment.isHome}
value={
props.formState.role === undefined
? newDepartmentAssignmentTranslate.rolePlaceholder
: props.formState.role
}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"role",
event.target.value
)
);
}}
>
<option
data-test="placeholder-role-option"
hidden={props.formState.role !== undefined}
key={undefined}
>
{newDepartmentAssignmentTranslate.rolePlaceholder}
</option>
{props.roles.map((role) => {
return (
<option key={role.name} value={role.name}>
{role.name}
</option>
);
})}
</Input>
</td>
<td>
<Input
type="number"
max={100}
min={0}
bsSize="sm"
data-test="sliding-payout-input"
maxLength={3}
value={props.formState.slidingPayout ?? ""}
onChange={(event) => {
props.updateFormState(
tryUpdateStateWithInput(
props.formState,
"slidingPayout",
event.target.value
)
);
}}
/>
</td>
<td data-test="note">{props.departmentAssignment.note}</td>
<td>
<ButtonGroup>
<Button
color="danger"
size="sm"
onClick={() => {
submitForm();
}}
data-test="submit-button"
disabled={isConfirmButtonDisabled()}
>
{DeptAssignmentTranslate.submitButton}
</Button>
<Button
className="m-0"
color="secondary"
size="sm"
onClick={() => {
props.disableEdit();
}}
data-test="clear-button"
>
<span className="fa fa-times" />
</Button>
</ButtonGroup>
</td>
</tr>
);
};
export default DeptAssignmentEdit;
@@ -0,0 +1,259 @@
import { shallow } from "enzyme";
import moment from "moment";
import React from "react";
import DeptAssignmentNew from "./DeptAssignmentNew";
import { mockRoles } from "../../../../shared/testing/mocks/MockRoles";
import { MultiSelectOption } from "../../../../shared/types/IMultiSelectOption";
const mockDepartments = [
{
departmentId: 0,
id: 55,
departmentName: "WAUWATOSA",
businessLineCode: "OPS",
state: "Active"
},
{
departmentId: 1,
id: 56,
departmentName: "HEADQUARTERS",
businessLineCode: "OPS",
state: "Active"
}
];
const departmentOptions = mockDepartments.map(
(department) =>
({
key: department.id?.toString(),
label: department.departmentName,
value: department.id?.toString()
} as MultiSelectOption)
);
const mockSubmit = jest.fn();
beforeEach(() => {
mockSubmit.mockClear();
});
describe("User wants to add a new dept assignment", () => {
it("updates state when input changed", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
expect(wrapper.find('[data-test="dept-input"]').prop("value")).toEqual(
[]
);
expect(wrapper.find('[data-test="note-input"]').prop("value")).toBe("");
expect(wrapper.find('[data-test="start-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
false
);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
wrapper
.find('[data-test="note-input"]')
.simulate("change", { target: { value: "note" } });
wrapper
.find('[data-test="start-input"]')
.simulate("change", { target: { value: moment("2021-01-01") } });
wrapper
.find('[data-test="end-input"]')
.simulate("change", { target: { value: moment("2021-01-02") } });
wrapper.update();
expect(wrapper.find('[data-test="dept-input"]').prop("value")).toEqual({
target: { value: [departmentOptions[0]] }
});
expect(wrapper.find('[data-test="note-input"]').prop("value")).toBe(
"note"
);
expect(wrapper.find('[data-test="start-input"]').prop("value")).toEqual(
"2021-01-01"
);
expect(wrapper.find('[data-test="end-input"]').prop("value")).toEqual(
"2021-01-31"
);
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
false
);
});
it("disables submit until required data is entered", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
expect(
wrapper.find('[data-test="submit-button"]').prop("disabled")
).toBe(true);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
//Note not required
wrapper
.find('[data-test="start-input"]')
.simulate("change", { target: { value: moment("2021-01-01") } });
//EndDate Not Required
wrapper.update();
expect(
wrapper.find('[data-test="submit-button"]').prop("disabled")
).toBe(false);
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
false
);
});
it("disables submit if endDate is before startDate", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
expect(
wrapper.find('[data-test="submit-button"]').prop("disabled")
).toBe(true);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
wrapper
.find('[data-test="note-input"]')
.simulate("change", { target: { value: "note" } });
wrapper
.find('[data-test="start-input"]')
.simulate("change", { target: { value: moment("2021-02-01") } });
wrapper
.find('[data-test="end-input"]')
.simulate("change", { target: { value: moment("2021-01-02") } });
wrapper.update();
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
true
);
expect(
wrapper.find('[data-test="submit-button"]').prop("disabled")
).toBe(true);
});
it("hides the clear button when there is nothing to clear", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
expect(wrapper.find('[data-test="clear-button"]').prop("hidden")).toBe(
true
);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
wrapper
.find('[data-test="note-input"]')
.simulate("change", { target: { value: "note" } });
expect(wrapper.find('[data-test="clear-button"]').prop("hidden")).toBe(
false
);
});
});
describe("User no longer wishes to add a new dept assignment", () => {
it("resets all inputs when the clear button is clicked", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
expect(wrapper.find('[data-test="clear-button"]').prop("hidden")).toBe(
true
);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
wrapper
.find('[data-test="note-input"]')
.simulate("change", { target: { value: "note" } });
expect(wrapper.find('[data-test="clear-button"]').prop("hidden")).toBe(
false
);
wrapper.find('[data-test="clear-button"]').simulate("click");
wrapper.update();
expect(wrapper.find('[data-test="dept-input"]').prop("value")).toEqual(
[]
);
expect(wrapper.find('[data-test="note-input"]').prop("value")).toBe("");
expect(wrapper.find('[data-test="start-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
false
);
});
});
describe("User submits a new dept assignment", () => {
it("passes the proper data on submit", () => {
const wrapper = shallow(
<DeptAssignmentNew
roles={mockRoles}
departments={mockDepartments}
submitNewDepartmentAssignment={(data) => mockSubmit(data)}
/>
);
wrapper
.find('[data-test="dept-input"]')
.simulate("change", { target: { value: [departmentOptions[0]] } });
wrapper
.find('[data-test="role-input"]')
.simulate("change", { target: { value: "Admin" } });
wrapper
.find('[data-test="note-input"]')
.simulate("change", { target: { value: "note" } });
wrapper
.find('[data-test="sliding-payout-input"]')
.simulate("change", { target: { value: "65" } });
wrapper
.find('[data-test="start-input"]')
.simulate("change", { target: { value: "2021-01-01" } });
wrapper
.find('[data-test="end-input"]')
.simulate("change", { target: { value: "2021-01-02" } });
wrapper.update();
expect(
wrapper.find('[data-test="submit-button"]').prop("disabled")
).toBe(false);
wrapper.find('[data-test="submit-button"]').simulate("click");
wrapper.update();
expect(wrapper.find('[data-test="dept-input"]').prop("value")).toEqual(
[]
);
expect(wrapper.find('[data-test="note-input"]').prop("value")).toBe("");
expect(wrapper.find('[data-test="start-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("value")).toEqual(
""
);
expect(wrapper.find('[data-test="end-input"]').prop("invalid")).toBe(
false
);
//start and end date should be cast as UTC!
});
});
@@ -0,0 +1,264 @@
import React, { useState } from "react";
import Translate from "../../../../translate/Translate";
import { IDepartment } from "../../../../shared/models/Department";
import { Button, ButtonGroup, FormFeedback, Input } from "reactstrap";
import { tryUpdateStateWithInput } from "../../../../shared/services/InputService";
import moment from "moment";
import { IDepartmentAssignmentDto } from "../../../../shared/models/DepartmentAssignment";
import { IAppRole } from "../../../../shared/models/Settings";
import MultiSelect from "react-multi-select-component";
import { MultiSelectOption } from "../../../../shared/types/IMultiSelectOption";
export interface INewDeptAssignmentRowProps {
departments: IDepartment[];
roles: IAppRole[];
submitNewDepartmentAssignment: (
newDepartmentData: Partial<IDepartmentAssignmentDto>
) => void;
}
const newDepartmentAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable
.newDeptAssignmentRow;
const DeptAssignmentNew: React.FC<INewDeptAssignmentRowProps> = (props) => {
const emptyFormState = {
departmentId: [],
startDate: undefined,
endDate: undefined,
note: undefined,
role: undefined,
slidingPayout: 100
};
const isSubmitDisabled = (): boolean => {
return (
selectedNewDepartmentIds?.length === 0 ||
formState.startDate === undefined ||
!formState.startDate.isValid() ||
isEndDateInvalid()
);
};
const submitForm = () => {
props.submitNewDepartmentAssignment(formState);
clearFormState();
};
const clearFormState = () => {
setFormState(emptyFormState);
setSelectedNewDepartmentIds([]);
};
const shouldHideClear = () => {
return (
selectedNewDepartmentIds?.length === 0 &&
formState.startDate === undefined &&
formState.endDate === undefined &&
formState.note === undefined
);
};
const isEndDateInvalid = () => {
if (formState.endDate && formState.startDate) {
return moment(formState.endDate).isBefore(formState.startDate);
}
return false;
};
const [formState, setFormState] = useState<
Partial<IDepartmentAssignmentDto>
>(emptyFormState);
const departmentOptions = props.departments
.filter((x) => x.state === "Active")
.map(
(department) =>
({
key: department.id?.toString(),
label: department.departmentName,
value: department.id?.toString()
} as MultiSelectOption)
);
const [selectedNewDepartmentIds, setSelectedNewDepartmentIds] = useState<
MultiSelectOption[]
>([]);
const handleSelected = (options: MultiSelectOption[]) => {
formState.departmentId = Array.from(
options
).map((e: MultiSelectOption) => parseInt(e.value, 10));
setSelectedNewDepartmentIds(options);
};
return (
<tr>
<td />
<td>
<MultiSelect
data-test="dept-input"
options={departmentOptions}
value={selectedNewDepartmentIds}
onChange={handleSelected}
labelledBy="Select"
/>
</td>
<td>
<Input
type="date"
bsSize="sm"
data-test="start-input"
value={
formState.startDate?.format("YYYY-MM-DD").toString() ??
""
}
onChange={(event) => {
setFormState(
tryUpdateStateWithInput(
formState,
"startDate",
// FORCE to utc. Prevents a timezone conversion
// you don't normally have to do this.
moment.utc(event.target.value).startOf("month")
)
);
}}
/>
</td>
<td>
<Input
type="date"
bsSize="sm"
value={
formState.endDate?.format("YYYY-MM-DD").toString() ?? ""
}
data-test="end-input"
invalid={isEndDateInvalid()}
onChange={(event) => {
setFormState(
tryUpdateStateWithInput(
formState,
"endDate",
// FORCE to utc. Prevents a timezone conversion
// you don't normally have to do this.
moment.utc(event.target.value).endOf("month")
)
);
}}
/>
<FormFeedback>
{
newDepartmentAssignmentTranslate.endDate.validation
.invalid
}
</FormFeedback>
</td>
<td>
<Input
bsSize="sm"
type="select"
data-test="role-input"
value={
formState.role === undefined
? newDepartmentAssignmentTranslate.rolePlaceholder
: formState.role
}
onChange={(event) => {
setFormState(
tryUpdateStateWithInput(
formState,
"role",
event.target.value
)
);
}}
>
<option
data-test="placeholder-role-option"
hidden={formState.role !== undefined}
key={undefined}
>
{newDepartmentAssignmentTranslate.rolePlaceholder}
</option>
{props.roles.map((role) => {
return (
<option key={role.name} value={role.name}>
{role.name}
</option>
);
})}
</Input>
</td>
<td>
<Input
type="number"
max={100}
min={0}
bsSize="sm"
data-test="sliding-payout-input"
maxLength={3}
value={formState.slidingPayout ?? ""}
onChange={(event) => {
setFormState(
tryUpdateStateWithInput(
formState,
"slidingPayout",
event.target.value
)
);
}}
/>
</td>
<td>
<Input
type="text"
bsSize="sm"
data-test="note-input"
maxLength={255}
placeholder={
newDepartmentAssignmentTranslate.notePlaceholder
}
value={formState.note ?? ""}
onChange={(event) => {
setFormState(
tryUpdateStateWithInput(
formState,
"note",
event.target.value
)
);
}}
/>
</td>
<td>
<ButtonGroup>
<Button
color="primary"
size="sm"
data-test="submit-button"
disabled={isSubmitDisabled()}
onClick={() => {
submitForm();
}}
>
{newDepartmentAssignmentTranslate.submitButton}
</Button>
<Button
color="secondary"
size="sm"
data-test="clear-button"
onClick={() => {
clearFormState();
}}
hidden={shouldHideClear()}
>
<span className="fa fa-times" />
</Button>
</ButtonGroup>
</td>
</tr>
);
};
export default DeptAssignmentNew;
@@ -0,0 +1,245 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { mount } from "enzyme";
import moment from "moment";
import React from "react";
import * as reactRedux from "react-redux";
import { IProfileState } from "../../../../reducers/ProfileReducer";
import AsyncState from "../../../../shared/enumerations/AsyncState";
import PermissionCode from "../../../../shared/enumerations/PermissionCode";
import { UserType } from "../../../../shared/models/Auditable";
import Translate from "../../../../translate/Translate";
import DeptAssignmentRow from "./DeptAssignmentRow";
import { IDepartmentAssignment } from "../../../../shared/models/DepartmentAssignment";
import { mockRoles } from "../../../../shared/testing/mocks/MockRoles";
export interface IFakePermissionProps {
requiredPermissions: string[];
showWarning?: boolean;
children: JSX.Element;
}
export const FakePermissionGuard: React.FC<IFakePermissionProps> = (props) => {
return props.children;
};
jest.mock("../../../shared/PermissionGuard", () => FakePermissionGuard);
const departments = [
{
departmentId: 5,
id: 55,
departmentName: "WAUWATOSA",
businessLineCode: "OPS",
state: "Active"
},
{
departmentId: 1,
id: 56,
departmentName: "HEADQUARTERS",
businessLineCode: "OPS",
state: "Active"
}
];
const mockDeptAssignment: IDepartmentAssignment = {
id: 5,
employeeId: "5",
departmentId: 55,
startDate: moment("2021-01-02"),
endDate: moment("2021-01-03"),
note: "Note",
isHome: false,
createdByType: UserType.Associate,
createdBy: "me",
created: moment("2021-01-04"),
lastUpdated: moment("2021-01-04"),
lastUpdatedByType: UserType.Associate,
lastUpdatedBy: "me",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT,
description: "ASSIGN_ASSOCIATES_TO_DEPARTMENT"
}
],
hasCommissions: false
},
slidingPayout: 100
};
// setup appUser to get past permission guard
const mockProfileState: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT,
description: "ASSIGN_ASSOCIATES_TO_DEPARTMENT"
}
],
hasCommissions: false
},
additionalPermissions: [],
departments: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
beforeEach(() => {
useSelectorMock.mockClear();
});
describe("An Associate Logs in and views the DeptAssignmentTable", () => {
it("Should display expected data", () => {
useSelectorMock.mockReturnValue(mockProfileState);
const wrapper = mount(
<DeptAssignmentRow
roles={mockRoles}
departments={departments}
departmentAssignment={mockDeptAssignment}
submitDeptAssignmentUpdate={() => jest.fn()}
/>
);
expect(wrapper.find('[data-test="department"]').text()).toContain(
"WAUWATOSA"
);
expect(wrapper.find('[data-test="note"]').text()).toContain("Note");
expect(wrapper.find('[data-test="start-date"]').text()).toContain(
"01-02-2021"
);
expect(wrapper.find('[data-test="end-date"]').text()).toContain(
"01-03-2021"
);
});
it("handles missing deparment", () => {
useSelectorMock.mockReturnValue(mockProfileState);
const wrapper = mount(
<DeptAssignmentRow
roles={mockRoles}
departments={departments}
departmentAssignment={{
...mockDeptAssignment,
departmentId: 0
}}
submitDeptAssignmentUpdate={() => jest.fn()}
/>
);
expect(wrapper.find('[data-test="department"]').text()).toContain("-");
expect(wrapper.find('[data-test="note"]').text()).toContain("Note");
expect(wrapper.find('[data-test="start-date"]').text()).toContain(
"01-02-2021"
);
expect(wrapper.find('[data-test="end-date"]').text()).toContain(
"01-03-2021"
);
});
it("defaults missing start/end date", () => {
useSelectorMock.mockReturnValue(mockProfileState);
const wrapper = mount(
<DeptAssignmentRow
roles={mockRoles}
departments={departments}
/* @ts-ignore */
departmentAssignment={{
...mockDeptAssignment,
startDate: moment(),
endDate: undefined
}}
submitDeptAssignmentUpdate={() => jest.fn()}
/>
);
expect(wrapper.find('[data-test="department"]').text()).toContain(
"WAUWATOSA"
);
expect(wrapper.find('[data-test="note"]').text()).toContain("Note");
expect(wrapper.find('[data-test="start-date"]').text()).toContain("-");
expect(wrapper.find('[data-test="end-date"]').text()).toContain(
Translate.Get.containers.employeePage.deptAssignment
.deptAssignmentTable.deptAssignmentTableRow.noEndDate
);
});
});
describe("An Associate Logs in and wants to update the end date of a DeptAssignmentTable", () => {
it("Toggling Edit Displays Edit Row", () => {
useSelectorMock.mockReturnValue(mockProfileState);
const wrapper = mount(
<DeptAssignmentRow
roles={mockRoles}
departments={departments}
/* @ts-ignore */
departmentAssignment={{
...mockDeptAssignment,
endDate: undefined
}}
submitDeptAssignmentUpdate={() => jest.fn()}
/>
);
expect(wrapper.find('[data-test="edit-row"]').length).toBe(0);
wrapper.find("Button").simulate("click");
wrapper.update();
expect(wrapper.find('[data-test="edit-row"]').length).toBe(1);
expect(
wrapper.find('[data-test="edit-row"]').prop("formState")
).toEqual({
assignmentId: 5,
employeeId: "5",
endDate: undefined,
role: "Admin",
slidingPayout: 100
});
});
it("Toggling Edit for a row with Data sets formState to include the existing end date", () => {
useSelectorMock.mockReturnValue(mockProfileState);
const wrapper = mount(
<DeptAssignmentRow
roles={mockRoles}
departments={departments}
departmentAssignment={{
...mockDeptAssignment,
endDate: moment("2021-02-01")
}}
submitDeptAssignmentUpdate={() => jest.fn()}
/>
);
expect(wrapper.find('[data-test="edit-row"]').length).toBe(0);
wrapper.find("Button").simulate("click");
wrapper.update();
expect(wrapper.find('[data-test="edit-row"]').length).toBe(1);
expect(
wrapper.find('[data-test="edit-row"]').prop("formState")
).toEqual({
assignmentId: 5,
employeeId: "5",
endDate: moment("2021-02-01"),
role: "Admin",
slidingPayout: 100
});
});
});
@@ -0,0 +1,143 @@
import React, { useState } from "react";
import { IDepartment } from "../../../../shared/models/Department";
import moment, { Moment } from "moment";
import DeptAssignmentEdit from "./DeptAssignmentEdit";
import { Button } from "reactstrap";
import HomeIcon from "../HomeIcon";
import Translate from "./../../../../translate/Translate";
import PermissionGuard from "../../../shared/PermissionGuard";
import PermissionCode from "../../../../shared/enumerations/PermissionCode";
import {
IDepartmentAssignment,
IDepartmentAssignmentUpdateDto
} from "../../../../shared/models/DepartmentAssignment";
import { IAppRole } from "../../../../shared/models/Settings";
export interface IDeptAssignmentTableRowProps {
departments: IDepartment[];
departmentAssignment: IDepartmentAssignment;
submitDeptAssignmentUpdate: (
update: IDepartmentAssignmentUpdateDto
) => void;
roles: IAppRole[];
}
const DeptAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable
.deptAssignmentTableRow;
const DeptAssignmentRow: React.FC<IDeptAssignmentTableRowProps> = (props) => {
const createFormStateFromAssignment = (
departmentAssignment: IDepartmentAssignment
): IDepartmentAssignmentUpdateDto => {
const dto = {
assignmentId: departmentAssignment.id!,
employeeId: departmentAssignment.employeeId,
endDate: departmentAssignment.endDate
? moment(departmentAssignment.endDate)
: undefined,
role: departmentAssignment.role?.name || "MISSING",
slidingPayout: departmentAssignment.slidingPayout
};
return dto;
};
const initialFormState = createFormStateFromAssignment(
props.departmentAssignment
);
const [isEditing, setEdit] = useState<boolean>(false);
const [formState, setFormState] = useState<IDepartmentAssignmentUpdateDto>(
initialFormState
);
const enableEdit = () => {
setFormState(createFormStateFromAssignment(props.departmentAssignment));
setEdit(true);
};
const getDepartmentNameFromId = (id: number): string => {
return (
props.departments.find((dept) => dept.id === id)?.departmentName ??
"-"
);
};
const castMomentToStringOrPlaceholder = (
value: Moment | undefined,
fallBack: string
): string => {
if (value === undefined || value === null || !moment(value).isValid()) {
return fallBack;
}
return moment(value).format("MM-DD-YYYY").toString();
};
if (!isEditing) {
return (
<tr>
<td>
<HomeIcon isHome={props.departmentAssignment.isHome} />
</td>
<td data-test="department">
{getDepartmentNameFromId(
props.departmentAssignment.departmentId
)}
</td>
<td data-test="start-date">
{castMomentToStringOrPlaceholder(
props.departmentAssignment.startDate,
"-"
)}
</td>
<td data-test="end-date">
{castMomentToStringOrPlaceholder(
props.departmentAssignment.endDate,
DeptAssignmentTranslate.noEndDate
)}
</td>
<td data-test="role">
{props.departmentAssignment.role
? props.departmentAssignment.role.name
: "Missing!"}
</td>
<td data-test="payout">
{props.departmentAssignment.slidingPayout}%
</td>
<td data-test="note">{props.departmentAssignment.note}</td>
<PermissionGuard
requiredPermissions={[
PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT
]}
>
<td>
<Button
color="secondary"
size="sm"
onClick={() => enableEdit()}
>
{DeptAssignmentTranslate.editButton}
</Button>
</td>
</PermissionGuard>
</tr>
);
}
return (
<DeptAssignmentEdit
formState={formState}
roles={props.roles}
departmentAssignment={props.departmentAssignment}
getDepartmentNameFromId={(deptId: number) =>
getDepartmentNameFromId(deptId)
}
submitDeptAssignmentUpdate={props.submitDeptAssignmentUpdate}
disableEdit={() => setEdit(false)}
updateFormState={(newState) => setFormState(newState)}
data-test="edit-row"
/>
);
};
export default DeptAssignmentRow;
@@ -0,0 +1,77 @@
import { mount } from "enzyme";
import React from "react";
import DeptAssignmentTable from "./DeptAssignmentTable";
import AsyncState from "./../../../../shared/enumerations/AsyncState";
import * as reactRedux from "react-redux";
import { IDepartmentState } from "../../../../reducers/DepartmentReducer";
import { mockDeptAssignments } from "../../../../shared/testing/mocks/MockDepartmentAssignments";
import { mockProfileState } from "../../../../shared/testing/mocks/MockProfileState";
import { getMockAppSettings } from "../../../../shared/testing/mocks/MockAppSettings";
export interface IFakePermissionProps {
requiredPermissions: string[];
showWarning?: boolean;
children: JSX.Element;
}
export const FakePermissionGuard: React.FC<IFakePermissionProps> = (props) => {
return props.children;
};
jest.mock("../../../shared/PermissionGuard", () => FakePermissionGuard);
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useContextMock = jest.spyOn(React, "useContext");
beforeEach(() => {
useDispatchMock.mockClear();
useSelectorMock.mockClear();
useContextMock.mockClear();
});
const mockDepartmentState: IDepartmentState = {
departments: {
data: [
{
departmentId: 0,
id: 55,
departmentName: "WAUWATOSA",
businessLineCode: "OPS",
state: "Active"
},
{
departmentId: 1,
id: 56,
departmentName: "HEADQUARTERS",
businessLineCode: "OPS",
state: "Active"
}
],
state: AsyncState.Success
},
submitState: { state: AsyncState.Inactive }
};
describe("Associate views Emoloyees Department Assignments", () => {
it("Displays all available dept assignment table row", () => {
useDispatchMock.mockReturnValue(jest.fn());
useSelectorMock.mockReturnValue({
...mockDepartmentState,
...mockProfileState
});
useContextMock.mockReturnValue(getMockAppSettings());
const wrapper = mount(
<DeptAssignmentTable
assignments={mockDeptAssignments}
submitStatus={{ state: AsyncState.Inactive }}
submitDeptAssignmentUpdate={() => jest.fn()}
submitNewDeptAssignment={() => jest.fn()}
/>
);
expect(wrapper.find("DeptAssignmentRow").length).toBe(4);
expect(wrapper.find("DeptAssignmentNew").length).toBe(1);
});
});
@@ -0,0 +1,133 @@
import React from "react";
import Translate from "../../../../translate/Translate";
import DeptAssignmentRow from "./DeptAssignmentRow";
import IAsyncStateContainer from "../../../../shared/types/IAsyncStateContainer";
import DeptAssignmentAlertBar from "../DeptAssignmentAlertBar";
import PermissionGuard from "../../../shared/PermissionGuard";
import PermissionCode from "../../../../shared/enumerations/PermissionCode";
import {
IDepartmentAssignment,
IDepartmentAssignmentDto,
IDepartmentAssignmentUpdateDto
} from "../../../../shared/models/DepartmentAssignment";
import DeptAssignmentNew from "./DeptAssignmentNew";
import { SettingsContext } from "../../../../shared/providers/SettingsProvider";
import SettingsService from "../../../../shared/services/SettingsService";
import { useSelector } from "react-redux";
import { IAppState } from "../../../../store/Store";
import { IProfileState } from "../../../../reducers/ProfileReducer";
import ProfileService from "../../../../shared/services/ProfileService";
export interface IDeptAssignmentTableProps {
assignments: IDepartmentAssignment[];
submitStatus: IAsyncStateContainer<string>;
submitNewDeptAssignment: (
deptAssignment: Partial<IDepartmentAssignmentDto>
) => void;
submitDeptAssignmentUpdate: (
deptUpdate: IDepartmentAssignmentUpdateDto
) => void;
}
const departmentAssignmentTranslate =
Translate.Get.containers.employeePage.deptAssignment.deptAssignmentTable;
const DeptAssignmentTable: React.FC<IDeptAssignmentTableProps> = (props) => {
const settings = React.useContext(SettingsContext);
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const profile = profileState.profile;
const maxRank = profile.data
? ProfileService.findHighestRank(profile.data)
: 1;
const assignableRoles = SettingsService.getAssignableRoles(
settings?.appRoles,
maxRank
);
return (
<div>
<DeptAssignmentAlertBar submitState={props.submitStatus} />
<div className="border-top-0">
<table className="table table-striped border tablecenterCSS tablecenterheadCSS">
<thead>
<tr>
<th>
{departmentAssignmentTranslate.headers.isHome}
</th>
<th>
{
departmentAssignmentTranslate.headers
.departmentId
}
</th>
<th>
{
departmentAssignmentTranslate.headers
.startDate
}
</th>
<th>
<i className="fa fa-long-arrow-down">&nbsp;</i>
{departmentAssignmentTranslate.headers.endDate}
</th>
<th>
{departmentAssignmentTranslate.headers.role}
</th>
<th>
{departmentAssignmentTranslate.headers.payout}
</th>
<th>
{departmentAssignmentTranslate.headers.note}
</th>
<PermissionGuard
requiredPermissions={[
PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT
]}
>
<th />
</PermissionGuard>
</tr>
</thead>
<tbody>
<PermissionGuard
requiredPermissions={[
PermissionCode.ASSIGN_ASSOCIATES_TO_DEPARTMENT
]}
>
<DeptAssignmentNew
departments={settings?.departments ?? []}
roles={assignableRoles}
submitNewDepartmentAssignment={(
deptAssignment: Partial<IDepartmentAssignmentDto>
) => {
props.submitNewDeptAssignment(
deptAssignment
);
}}
/>
</PermissionGuard>
{props.assignments.map((assignment) => {
return (
<DeptAssignmentRow
roles={assignableRoles}
key={
assignment.id +
assignment.lastUpdated.toString()
}
departments={settings?.departments ?? []}
departmentAssignment={assignment}
submitDeptAssignmentUpdate={
props.submitDeptAssignmentUpdate
}
/>
);
})}
</tbody>
</table>
</div>
</div>
);
};
export default DeptAssignmentTable;
@@ -0,0 +1,140 @@
import React, { useState } from "react";
import moment, { Moment } from "moment";
import Translate from "../../translate/Translate";
import { Alert, Input } from "reactstrap";
import PayoutService from "../../shared/services/PayoutService";
import AsyncState from "../../shared/enumerations/AsyncState";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import AsyncGuard from "../shared/AsyncGuard";
import PayoutEntryTable from "../../components/payouts/PayoutEntryTable";
import ExportCsvService from "../../shared/services/ExportCsvService";
import { IEmployeeSummary } from "../../shared/models/Employee";
import EndOfEmploymentPayoutView from "../../components/payouts/EndOfEmploymentPayoutView";
import TabView from "../../components/tab-view/TabView";
import ProfileDao from "../../shared/dao/ProfileDao";
const EndOfEmploymentPage: React.FC = () => {
const [date, setDate] = useState<Moment>(moment());
const [payouts, setPayouts] = useState<
IAsyncStateContainer<IEmployeeSummary[]>
>({
state: AsyncState.Inactive
});
const submitForm = () => {
fetchData().then((employeeSummaries) => {
setPayouts(employeeSummaries);
});
};
const fetchData = async () => {
return await ProfileDao.GetEndOfEmploymentList(
date.year(),
date.month() + 1 // moment months are 0 index.
);
};
const exportCsv = () => {
if (payouts.data && payouts.data.length > 0) {
const converted = PayoutService.CreateEndOfEmploymentPayouts(
payouts.data
);
ExportCsvService.exportDayforcePayoutCSV(converted);
}
};
return (
<div data-test="export-payout-page">
<div className="row">
<div className="col">
<Alert color="info">
{
Translate.Get.containers.endOfEmploymentPage
.instructions
}
</Alert>
</div>
</div>
<div className="row">
<div className="col-sm-6 col-md-2 col-lg-2">
<Input
name="AcceptedStateDateTime"
type="month"
bsSize="sm"
required
className="mb-1 ml-2 inline"
value={date.format("YYYY-MM")}
onChange={(event) => {
setDate(
moment(event.target.value).startOf("month")
);
}}
/>
</div>
<div className="col">
<button
className="btn btn-outline-info btn-sm mx-2"
type="button"
onClick={submitForm}
>
{
Translate.Get.containers.endOfEmploymentPage
.exportButton
}
</button>
<AsyncGuard container={payouts}>
{payouts.data && payouts.data.length > 0 ? (
<button
className="btn btn-secondary btn-sm"
onClick={exportCsv}
>
{" "}
{
Translate.Get.containers.endOfEmploymentPage
.downloadButton
}
</button>
) : (
<></>
)}
</AsyncGuard>
</div>
</div>
<div className="row mt-2">
<div className="col">
<AsyncGuard container={payouts}>
<>
<TabView
data-test="progress-tabbed-view"
pages={[
{
header: "Payouts",
element: (
<PayoutEntryTable
payoutEntries={
PayoutService.CreateEndOfEmploymentPayouts(
payouts.data!
) || []
}
/>
)
},
{
header: "Details",
element: (
<EndOfEmploymentPayoutView
eoeEmployees={payouts.data!}
/>
)
}
]}
/>
</>
</AsyncGuard>
</div>
</div>
</div>
);
};
export default EndOfEmploymentPage;
@@ -0,0 +1,25 @@
import { shallow } from "enzyme";
import React from "react";
import GoalEditCell from "./GoalEditCell";
import { IGoalEditInput } from "../../shared/models/Goal";
import { Product2 } from "../../shared/enumerations/Product";
describe("A cell for editing goals", () => {
it("Displays a valid goal edit cell", () => {
const goal: IGoalEditInput = {
value: 1,
year: 2021,
type: Product2.Vehicle,
departmentId: 99,
personId: "[email protected]",
month: 1
};
const onAction = () => {};
let wrapper = shallow(
<GoalEditCell goal={goal} tabIndex={1} requestAction={onAction} />
);
expect(wrapper.find('[data-test="goal-edit-cell"]').length).toBe(1);
expect(wrapper.find('[data-test="valid-feedback"]').length).toBe(1);
expect(wrapper.find('[data-test="invalid-feedback"]').length).toBe(1);
});
});
@@ -0,0 +1,48 @@
import React, { useState } from "react";
import { Product2 } from "../../shared/enumerations/Product";
import { IGoalEditInput } from "../../shared/models/Goal";
import GoalService from "../../shared/services/GoalService";
interface GoalEditorRowProps {
goal: IGoalEditInput;
tabIndex: number;
requestAction: (type: Product2, value: number) => void;
}
const GoalEditCell: React.FC<GoalEditorRowProps> = (
props: GoalEditorRowProps
) => {
const goal = props.goal;
const originalValue = goal.value + "";
const [newValue, setNewValue] = useState<string>(goal.value + "");
const pushChange = (value: string) => {
const filtered = value.replace(/\D/g, "").trim();
props.requestAction(goal.type, parseInt(filtered, 10));
setNewValue(filtered);
};
return (
<div className="input-group input-group-sm" data-test="goal-edit-cell">
<input
type="text"
className={GoalService.getInputCSSClassList(
goal.type,
originalValue,
newValue
)}
value={newValue}
onChange={(event) => pushChange(event.target.value)}
tabIndex={props.tabIndex}
/>
<div className="valid-feedback" data-test="valid-feedback">
Unsaved: {originalValue} {"->"} {newValue}
</div>
<div className="invalid-feedback" data-test="invalid-feedback">
Range: {goal.type.minValue} to {goal.type.maxValue}
</div>
</div>
);
};
export default GoalEditCell;
@@ -0,0 +1,39 @@
import React from "react";
import {
IDepartmentGoals,
IGoalChangeRequest
} from "../../../shared/models/Goal";
import { Product2 } from "../../../shared/enumerations/Product";
import GoalService from "../../../shared/services/GoalService";
interface GoalTotalProps {
departmentGoals: IDepartmentGoals;
pendingChanges: IGoalChangeRequest[];
product: Product2;
}
const AnnualProductTotal: React.FC<GoalTotalProps> = (
props: GoalTotalProps
) => {
const total = GoalService.sumDepartmentGoalsForProduct(
props.departmentGoals,
props.product
);
const totalPending = GoalService.sumPendingDepartmentGoalsForProduct(
props.departmentGoals,
props.pendingChanges,
props.product
);
return (
<span>
{GoalService.formatMonthlyGoal(total, props.product)}
{totalPending !== total
? " -> " +
GoalService.formatMonthlyGoal(totalPending, props.product)
: ""}
</span>
);
};
export default AnnualProductTotal;
@@ -0,0 +1,37 @@
import { shallow } from "enzyme";
import React from "react";
import DepartmentGoalsControls from "./DepartmentGoalsControls";
import { IDepartmentGoalsInput } from "../../../shared/models/Goal";
import { IDepartment } from "../../../shared/models/Department";
describe("A component for controlling the department goals view", () => {
it("Displays a valid department goal controls", () => {
const input: IDepartmentGoalsInput = {
year: 2021,
departmentId: "99"
};
const onChange = () => {};
const onSave = () => {};
const departments: IDepartment[] = [
{
state: "Active",
departmentName: "Test Dept",
id: 99,
businessLineCode: "TEST",
departmentId: 56
}
];
let wrapper = shallow(
<DepartmentGoalsControls
inputForm={input}
onChange={onChange}
onSave={onSave}
departments={departments}
allowSave={false}
/>
);
expect(
wrapper.find('[data-test="department-goals-controls"]').length
).toBe(1);
});
});
@@ -0,0 +1,125 @@
import React, { useEffect, useState } from "react";
import {
IDepartmentGoals,
IDepartmentGoalsInput,
IGoalChangeRequest
} from "../../../shared/models/Goal";
import { IDepartment } from "../../../shared/models/Department";
import Translate from "../../../translate/Translate";
import XLSX from "xlsx";
import GoalsDisplay from "../../../components/goal/GoalsDisplay";
import GoalService from "../../../shared/services/GoalService";
import { Product2 } from "../../../shared/enumerations/Product";
import { FormGroup } from "reactstrap";
export interface IProps {
inputForm: IDepartmentGoalsInput;
departments: IDepartment[];
onChange: (newInput: IDepartmentGoalsInput) => void;
onSave: () => void;
onExport: () => void;
onImport: (file: File) => void;
display?: string;
allowSave: boolean;
}
const DepartmentGoalsControls: React.FC<IProps> = (input: IProps) => {
const [departmentId, setDepartmentId] = useState(
input.inputForm.departmentId
);
const [year, setYear] = useState(input.inputForm.year);
useEffect(() => {
input.onChange({
year: year,
departmentId: departmentId
});
}, [departmentId, year]);
const years = [year - 2, year - 1, year, year + 1, year + 2];
function fileChange(e: React.ChangeEvent<HTMLInputElement>) {
const fileDom = e.target;
if (fileDom && fileDom.files && fileDom.files.length) {
input.onImport(fileDom.files[0]);
}
}
return (
<nav
className="navbar navbar-expand-lg navbar-light bg-light"
data-test="department-goals-controls"
>
<a className="navbar-brand" href="#">
Department Goals for{" "}
<select
id="deptSelect"
value={departmentId}
onChange={(event) => {
setDepartmentId(event.target.value);
}}
>
{input.departments.map((dept: IDepartment) => (
<option
key={dept.id}
value={dept.id}
disabled={dept.state === "Inactive"}
>
{dept.departmentName}{" "}
{dept.state === "Inactive" ? "(Inactive)" : ""}
</option>
))}
</select>{" "}
for{" "}
<select
id="deptGoalYear"
value={year}
onChange={(event) => {
setYear(parseInt(event.target.value, 10));
}}
>
{years.map((year: number) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</a>
<div id="navbarSupportedContent">
<button
className="btn btn-outline-success ml-2"
type="button"
onClick={input.onSave}
disabled={!input.allowSave}
>
Save
</button>
{/*<button className="btn btn-outline-warning ml-2" type="button">Discard</button>*/}
<button
className="btn btn-outline-info ml-2"
type="button"
onClick={input.onExport}
>
{
Translate.Get.containers.goals.department
.departmentGoalsControls.exportButton
}
</button>
<span className="ml-2 btn btn-sm btn-outline-info">
Import Goals:
<input
type="file"
name="File Upload"
id="goalFileUpload"
className="ml-1"
onChange={(e) => fileChange(e)}
accept=".xls,.xlsx"
/>
</span>
</div>
<span style={{ margin: "1em" }}>{input.display}</span>
</nav>
);
};
export default DepartmentGoalsControls;
@@ -0,0 +1,133 @@
import { mount } from "enzyme";
import React from "react";
import * as reactRedux from "react-redux";
import AsyncState from "../../../shared/enumerations/AsyncState";
import GoalTable from "../../../components/goal/GoalTable";
import DepartmentGoalsDetail, {
IDepartmentGoalsDetailProps
} from "./DepartmentGoalsDetail";
describe("DepartmentGoalsDetail", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("should display waiting while pending", () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(testGoalsDataLoading);
const detailInput: IDepartmentGoalsDetailProps = {
input: {
departmentId: "1",
year: 2021
}
};
const wrapper = mount(
<DepartmentGoalsDetail input={detailInput.input} />
);
expect(wrapper.find("div").html().toString()).toBe(
"<div>Please Wait.... </div>"
);
});
it("should display data when successful", () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(testGoalsData);
const detailInput: IDepartmentGoalsDetailProps = {
input: {
departmentId: "1",
year: 2021
}
};
const wrapper = mount(
<DepartmentGoalsDetail input={detailInput.input} />
);
expect(wrapper.find("GoalTable").length).toBe(1);
});
});
// Simulating IGoalState
const testGoalsDataLoading = {
userGoals: {
state: AsyncState.Loading,
data: []
}
};
// Simulating IGoalState
const testGoalsData = {
userGoals: {
state: AsyncState.Success,
data: [
{
id: 1077,
departmentId: 103,
personId: null,
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:22.5617363",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:22.5616949",
createdByType: "Associate"
},
{
id: 1078,
departmentId: 103,
personId: null,
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5158345",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5157728",
createdByType: "Associate"
},
{
id: 1079,
departmentId: 103,
personId: null,
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5228421",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5226656",
createdByType: "Associate"
},
{
id: 1080,
departmentId: 103,
personId: null,
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5330519",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5329406",
createdByType: "Associate"
}
]
}
};
@@ -0,0 +1,44 @@
import React, { useEffect } from "react";
import { queryGoals } from "../../../actions/GoalActions";
import { IAppState } from "../../../store/Store";
import { useDispatch, useSelector } from "react-redux";
import { IGoalState } from "../../../reducers/GoalReducer";
import AsyncState from "../../../shared/enumerations/AsyncState";
import GoalTable from "../../../components/goal/GoalTable";
import { IDepartmentGoalsInput } from "../../../shared/models/Goal";
import { IMonthRange } from "../../../shared/types/IMonthRange";
export interface IDepartmentGoalsDetailProps {
input: IDepartmentGoalsInput;
}
const DepartmentGoalsDetail: React.FC<IDepartmentGoalsDetailProps> = (
props
) => {
const dispatch = useDispatch();
const goalState = useSelector<IAppState, IGoalState>(
(state) => state.goalState
);
const departmentGoals = goalState.userGoals;
useEffect(() => {
const range: IMonthRange = {
fromMonth: 1,
fromYear: props.input.year,
toMonth: 12,
toYear: props.input.year
};
const includePersonnel = false;
dispatch(queryGoals(range, includePersonnel, props.input.departmentId));
}, []);
return departmentGoals.state == AsyncState.Success &&
departmentGoals.data ? (
<GoalTable goals={departmentGoals.data} />
) : (
<div>Please Wait.... </div>
);
};
export default DepartmentGoalsDetail;
@@ -0,0 +1,73 @@
import { shallow } from "enzyme";
import React from "react";
import DepartmentGoalsEditor, {
IDepartmentGoalsEditorProps
} from "./DepartmentGoalsEditor";
import * as reactRedux from "react-redux";
import GoalService from "../../../shared/services/GoalService";
describe("DepartmentGoalsEditor", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const exportXlsSpy = jest.spyOn(
GoalService,
"exportAllGoalsForYearByProduct"
);
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("should call the export service with product goals and a monthly totals", () => {
useSelectorMock.mockImplementation(() => ({
departmentGoals: {
data: {
departmentId: 1,
year: 2021,
monthlyGoals: [
{
productGoals: {
CreditCard: 3,
GAPInsurance: 8,
LIC: 9,
Mortgage: 11,
NewChecking: 12,
Vehicle: 13
}
},
{
productGoals: {
CreditCard: 3,
GAPInsurance: 8,
LIC: 9,
Mortgage: 11,
NewChecking: 12,
Vehicle: 13
}
}
]
}
}
}));
const wrapper = renderDepartmentGoalsEditor();
wrapper.find("DepartmentGoalsControls").simulate("export");
expect(exportXlsSpy).toHaveBeenCalledTimes(1);
});
});
function renderDepartmentGoalsEditor(args = {}) {
const defaultProps: IDepartmentGoalsEditorProps = {
departments: [
{
businessLineCode: "BCHOPS",
departmentName: "TestBranch",
id: 1,
state: "Active"
}
]
} as IDepartmentGoalsEditorProps;
const props = { ...defaultProps, ...args };
return shallow(<DepartmentGoalsEditor {...props} />);
}
@@ -0,0 +1,238 @@
import React, { useEffect, useState } from "react";
import { getDepartmentGoals } from "../../../actions/GoalActions";
import { IAppState } from "../../../store/Store";
import { useDispatch, useSelector } from "react-redux";
import moment from "moment";
import { IGoalState } from "../../../reducers/GoalReducer";
import AsyncState from "../../../shared/enumerations/AsyncState";
import DepartmentGoalsTable from "./DepartmentGoalsTable";
import {
IDepartmentGoals,
IDepartmentGoalsInput,
IGoalChangeRequest
} from "../../../shared/models/Goal";
import DepartmentGoalsControls from "./DepartmentGoalsControls";
import { Product2 } from "../../../shared/enumerations/Product";
import GoalService from "../../../shared/services/GoalService";
import { IDepartment } from "../../../shared/models/Department";
import TabView from "../../../components/tab-view/TabView";
import DepartmentGoalsDetail from "./DepartmentGoalsDetail";
import Translate from "../../../translate/Translate";
import XLSX from "xlsx";
import { Alert } from "reactstrap";
const DepartmentGoalsTranslate = Translate.Get.containers.goals.department;
export interface IDepartmentGoalsEditorProps {
departments: IDepartment[];
}
enum ImportState {
NONE,
LOADING,
PROCESSING,
SUBMITTING
}
const DepartmentGoalsEditor: React.FC<IDepartmentGoalsEditorProps> = (
props
) => {
const dispatch = useDispatch();
const [input, setInput] = useState<IDepartmentGoalsInput>({
departmentId: props.departments[0].id + "",
year: moment().year()
});
const [importState, setImportState] = useState<ImportState>(
ImportState.NONE
);
const [lastSave, setLastSave] = useState<string>("");
const [pendingChanges, setPendingChanges] = useState<IGoalChangeRequest[]>(
[]
);
const goalState = useSelector<IAppState, IGoalState>(
(state) => state.goalState
);
const departmentGoals = goalState.departmentGoals;
useEffect(() => {
setPendingChanges([]);
dispatch(getDepartmentGoals(input));
}, [input, lastSave]);
const queueChanges = (label: string, type: Product2, value: number) => {
const index = pendingChanges.findIndex(
(element: IGoalChangeRequest) =>
element.product == type &&
element.startDate.month() == GoalService.getMonthIndex(label)
);
const change = GoalService.convertToGoalChanges(
label,
type,
value,
parseInt(input.departmentId, 10),
input.year
);
const changes = pendingChanges.slice();
if (index >= 0) {
if (isNaN(value)) {
changes.splice(index, 1); // remove it.
} else {
changes.splice(index, 1, change); // replace
}
} else {
if (isNaN(value)) {
return;
}
changes.push(change); // add
}
setPendingChanges(changes);
};
const isValidChanges = (): boolean => {
if (pendingChanges && pendingChanges.length > 0) {
return GoalService.isValidDepartmentChanges(pendingChanges);
}
return false;
};
const saveChanges = () => {
if (isValidChanges()) {
GoalService.savePendingChanges(pendingChanges).then(() => {
setLastSave("Changes Saved " + moment().format("LTS"));
setImportState(ImportState.NONE);
});
}
};
const importCsv = (file: File) => {
setImportState(ImportState.LOADING);
const reader = new FileReader();
let existingData: IDepartmentGoals[] = [];
reader.onerror = function () {
setImportState(ImportState.NONE);
};
reader.onload = (e) => {
setImportState(ImportState.PROCESSING);
const wb = XLSX.read(e.target!.result, { type: "array" });
/* Get first worksheet */
let allChanges: IGoalChangeRequest[] = [];
wb.SheetNames.forEach((sheetName, sheetIdx) => {
const ws = wb.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(ws, { header: 1 });
allChanges.push(
...GoalService.ImportGoalSheet(
sheetName,
input.year,
data,
existingData
)
);
});
if (allChanges.length) {
setPendingChanges(allChanges);
setImportState(ImportState.SUBMITTING);
} else {
setImportState(ImportState.NONE);
}
};
GoalService.getAllDepartmentGoals(input.year).then(
(allDepartmentsAllGoals) => {
if (allDepartmentsAllGoals.data) {
existingData = allDepartmentsAllGoals.data;
reader.readAsArrayBuffer(file);
}
}
);
};
const exportCsv = () => {
const year = input.year;
const departments = props.departments;
GoalService.exportAllGoalsForYearByProduct(year, departments);
};
const getControls = () => {
switch (importState) {
default:
case ImportState.NONE:
return (
<DepartmentGoalsControls
inputForm={input}
onChange={(change) => setInput(change)}
onSave={saveChanges}
onExport={exportCsv}
display={lastSave}
departments={props.departments}
allowSave={isValidChanges()}
onImport={importCsv}
/>
);
case ImportState.LOADING:
return (
<Alert color="info">
<p className="m-0">
{DepartmentGoalsTranslate.states.loading}
</p>
</Alert>
);
case ImportState.PROCESSING:
return (
<Alert color="info">
<p className="m-0">
{DepartmentGoalsTranslate.states.processing}
</p>
</Alert>
);
case ImportState.SUBMITTING:
return (
<Alert color="info">
<p className="m-0">
Processed {pendingChanges.length} changes.{" "}
<button
className="btn btn-outline-success ml-2"
type="button"
onClick={saveChanges}
>
{DepartmentGoalsTranslate.buttons.confirmSave}
</button>
</p>
</Alert>
);
}
};
return (
<div>
{getControls()}
{departmentGoals.state == AsyncState.Success &&
departmentGoals.data ? (
<TabView
data-test="tabbed-commission-view"
pages={[
{
header: DepartmentGoalsTranslate.tabs.editor,
element: (
<DepartmentGoalsTable
departmentGoals={departmentGoals.data}
pendingChanges={pendingChanges}
onChange={queueChanges}
/>
)
},
{
header: DepartmentGoalsTranslate.tabs.detail,
element: <DepartmentGoalsDetail input={input} />
}
]}
/>
) : (
<div>Please Wait.... </div>
)}
</div>
);
};
export default DepartmentGoalsEditor;
@@ -0,0 +1,35 @@
import React, { useEffect, useState } from "react";
import DepartmentGoalsEditor from "./DepartmentGoalsEditor";
import DepartmentService from "../../../shared/services/DepartmentService";
import { IDepartment } from "../../../shared/models/Department";
import { Alert } from "reactstrap";
const DepartmentGoalsPage: React.FC = () => {
const [departments, setDepartments] = useState<IDepartment[] | null>(null);
useEffect(() => {
DepartmentService.getAllDepartments(true)
.then((response) => {
setDepartments(response.data || []);
})
.catch(() => {
setDepartments([]);
});
}, []);
if (departments && departments.length === 0) {
return (
<Alert color="warning" data-test="not-found">
No departments found for your role
</Alert>
);
}
return departments && departments.length > 0 ? (
<DepartmentGoalsEditor departments={departments} />
) : (
<div>Please Wait.... </div>
);
};
export default DepartmentGoalsPage;
@@ -0,0 +1,140 @@
import { shallow } from "enzyme";
import React from "react";
import {
IDepartmentGoals,
IGoalChangeRequest,
IPersonnelGoalsInput
} from "../../../shared/models/Goal";
import { Product2 } from "../../../shared/enumerations/Product";
import DepartmentGoalsTable from "./DepartmentGoalsTable";
describe("A Table for editing department goals", () => {
it("Displays a valid department goal edit table", () => {
const departmentGoals: IDepartmentGoals = {
departmentId: 99,
year: 2021,
monthlyGoals: [
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} }
]
};
const pendingChanges: IGoalChangeRequest[] = [];
const input: IPersonnelGoalsInput = {
year: 2021,
departmentId: "99",
month: 2
};
const onChange = () => {};
let wrapper = shallow(
<DepartmentGoalsTable
departmentGoals={departmentGoals}
onChange={onChange}
pendingChanges={pendingChanges}
/>
);
expect(wrapper.find("[id='department-goals-table']").length).toBe(1);
});
describe("Products with department goals", () => {
let departmentGoals: IDepartmentGoals;
let productWithDepartmentGoals: Product2[];
beforeAll(() => {
departmentGoals = {
departmentId: 99,
year: 2021,
monthlyGoals: [
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} }
]
};
productWithDepartmentGoals = Product2.Products.filter(
(product) => product.hasDepartmentGoals
);
});
it("Displays titles in the table header for products that have department goals", () => {
const wrapper = shallow(
<DepartmentGoalsTable
departmentGoals={departmentGoals}
onChange={() => {}}
pendingChanges={[]}
/>
);
const productTitles = productWithDepartmentGoals.map(
(product) => product.title
);
const tableHeaders = wrapper.find("thead > tr > th").slice(1);
expect(tableHeaders.length).toEqual(
productWithDepartmentGoals.length
);
tableHeaders.forEach((th) =>
expect(productTitles).toContain(th.text())
);
});
it("Displays only products with department goals in each GoalEditCell", () => {
const wrapper = shallow(
<DepartmentGoalsTable
departmentGoals={departmentGoals}
onChange={() => {}}
pendingChanges={[]}
/>
);
const tableRows = wrapper.find("tbody > tr");
tableRows.forEach((tr) => {
const goalEditCells = tr.find("GoalEditCell");
expect(goalEditCells.length).toBe(
productWithDepartmentGoals.length
);
goalEditCells.forEach((cell) => {
const goal: { type: Product2 } = cell.prop("goal");
expect(goal.type.hasDepartmentGoals).toBe(true);
});
});
});
it("Displays only products with department goals in each AnnualProductTotal cell ", () => {
const wrapper = shallow(
<DepartmentGoalsTable
departmentGoals={departmentGoals}
onChange={() => {}}
pendingChanges={[]}
/>
);
const productsInFooter: Product2[] = wrapper
.find("tfoot AnnualProductTotal")
.map((cell) => cell.prop("product"));
expect(productsInFooter.length).toEqual(
productWithDepartmentGoals.length
);
productsInFooter.forEach((product) =>
expect(product.hasDepartmentGoals).toBe(true)
);
});
});
});
@@ -0,0 +1,91 @@
import React from "react";
import {
IDepartmentGoals,
IGoalChangeRequest
} from "../../../shared/models/Goal";
import { Product2 } from "../../../shared/enumerations/Product";
import GoalEditCell from "../GoalEditCell";
import GoalService from "../../../shared/services/GoalService";
import AnnualProductTotal from "./AnnualProductTotal";
export interface IProps {
departmentGoals: IDepartmentGoals;
pendingChanges: IGoalChangeRequest[];
onChange: (label: string, type: Product2, value: number) => void;
}
const DepartmentGoalsTable: React.FC<IProps> = (props: IProps) => {
const onAction = (label: string, type: Product2, value: number) => {
props.onChange(label, type, value);
};
const productsWithDepartmentGoals = Product2.Products.filter(
(product) => product.hasDepartmentGoals
);
return (
<table
className="table table-striped tablecenterCSS tablecenterheadCSS"
id="department-goals-table"
>
<thead className="thead-light">
<tr>
<th scope="col">Month</th>
{productsWithDepartmentGoals.map((product) => (
<th key={product.code} scope="col" title={product.code}>
{product.title}
</th>
))}
</tr>
</thead>
<tbody className="form-group">
{GoalService.Months.map((month, index: number) => (
<tr key={month}>
<td>{month}</td>
{productsWithDepartmentGoals.map((product, index2) => (
<td
key={month + "_" + product.code}
scope="col"
title="figure me out"
>
<GoalEditCell
goal={{
year: props.departmentGoals.year,
type: product,
value:
props.departmentGoals.monthlyGoals[
index
].productGoals[product.code],
departmentId:
props.departmentGoals.departmentId
}}
tabIndex={index + (index2 + 1) * 100}
requestAction={(
type: Product2,
value: number
) => onAction(month, type, value)}
/>
</td>
))}
</tr>
))}
</tbody>
<tfoot>
<tr className="alert alert-secondary">
<td>Totals</td>
{productsWithDepartmentGoals.map((product) => (
<td key={"total_" + product.code} scope="col">
<AnnualProductTotal
departmentGoals={props.departmentGoals}
pendingChanges={props.pendingChanges}
product={product}
/>
</td>
))}
</tr>
</tfoot>
</table>
);
};
export default DepartmentGoalsTable;
@@ -0,0 +1,150 @@
import { mount } from "enzyme";
import React from "react";
import MonthlyProductTotal from "./MonthlyProductTotal";
import { Product2 } from "../../../shared/enumerations/Product";
import { IEmployee } from "../../../shared/models/Employee";
import moment from "moment";
import { UserType } from "../../../shared/models/Auditable";
describe("A component for monthly product totals", () => {
const mockUser: IEmployee = {
role: {
name: "baseRole",
rank: 1,
permissions: [],
description: "fake role",
hasCommissions: true
},
departments: [
{
departmentId: 55,
employeeId: "11771",
isHome: true,
note: "fake home assignment",
startDate: moment(),
endDate: moment(),
created: moment(),
createdBy: "nobody",
createdByType: UserType.System,
lastUpdated: moment(),
id: 5,
lastUpdatedBy: "nobody",
lastUpdatedByType: UserType.System
}
],
baseRole: "none",
homeDepartment: "none",
personId: 555,
adEmail: "[email protected]",
employeeId: "11771",
fullName: "Test McTester",
additionalPermissions: []
};
it("Displays a valid total when everything is -zero-", () => {
const data = {
personnelGoals: {
departmentId: 5,
year: 2020,
month: 1,
personnel: [mockUser], // IEmployee[];
goals: {
"11771": {
productGoals: {
NewChecking: 0,
CreditCard: 0,
LIC: 0,
Mortgage: 0,
Vehicle: 0
}
}
} // PerPersonGoalType;
}, // IPersonnelGoals;
pendingChanges: [], // IGoalChangeRequest[];
product: Product2.Vehicle,
departmentTotal: 0
};
const wrapper = mount(
<MonthlyProductTotal
product={data.product}
departmentTotal={data.departmentTotal}
personnelGoals={data.personnelGoals}
pendingChanges={data.pendingChanges}
/>
);
expect(wrapper.html()).toBe('<span class="text-normal">0 (0%)</span>');
});
it("Displays a valid total when everything is 100%", () => {
const data = {
personnelGoals: {
departmentId: 5,
year: 2020,
month: 1,
personnel: [mockUser], // IEmployee[];
goals: {
"11771": {
productGoals: {
NewChecking: 5,
CreditCard: 5,
LIC: 0,
Mortgage: 0,
Vehicle: 25
}
}
} // PerPersonGoalType;
}, // IPersonnelGoals;
pendingChanges: [], // IGoalChangeRequest[];
product: Product2.Vehicle,
departmentTotal: 25
};
const wrapper = mount(
<MonthlyProductTotal
product={data.product}
departmentTotal={data.departmentTotal}
personnelGoals={data.personnelGoals}
pendingChanges={data.pendingChanges}
/>
);
expect(wrapper.html()).toBe(
'<span class="text-normal">25 (100%)</span>'
);
});
it("Displays a valid total when everything is 50%", () => {
const data = {
personnelGoals: {
departmentId: 5,
year: 2020,
month: 1,
personnel: [mockUser], // IEmployee[];
goals: {
"11771": {
productGoals: {
NewChecking: 3,
CreditCard: 510,
GAPInsurance: 610,
LIC: 9,
Mortgage: 4,
Vehicle: 10
}
}
} // PerPersonGoalType;
}, // IPersonnelGoals;
pendingChanges: [], // IGoalChangeRequest[];
product: Product2.Vehicle,
departmentTotal: 20
};
const wrapper = mount(
<MonthlyProductTotal
product={data.product}
departmentTotal={data.departmentTotal}
personnelGoals={data.personnelGoals}
pendingChanges={data.pendingChanges}
/>
);
expect(wrapper.html()).toBe(
'<span class="text-normal">10 (50%)</span>'
);
});
});
@@ -0,0 +1,54 @@
import React from "react";
import {
IGoalChangeRequest,
IPersonnelGoals
} from "../../../shared/models/Goal";
import { Product2 } from "../../../shared/enumerations/Product";
import GoalService from "../../../shared/services/GoalService";
interface GoalTotalProps {
personnelGoals: IPersonnelGoals;
pendingChanges: IGoalChangeRequest[];
product: Product2;
departmentTotal: number;
}
const MonthlyProductTotal: React.FC<GoalTotalProps> = (
props: GoalTotalProps
) => {
const total = GoalService.sumPersonnelGoalsForProduct(
props.personnelGoals,
props.product
);
const totalPending = GoalService.sumPendingPersonnelGoalsForProduct(
props.personnelGoals,
props.pendingChanges,
props.product
);
let percentage = Math.floor(
((totalPending !== total ? totalPending : total) /
props.departmentTotal) *
100
);
if (isNaN(percentage)) {
percentage = 0;
}
const percentStr = " (" + percentage + "%)";
let outputStr =
GoalService.formatMonthlyGoal(total, props.product) + percentStr;
let className = "text-normal";
if (totalPending !== total) {
outputStr =
GoalService.formatMonthlyGoal(total, props.product) +
" -> " +
GoalService.formatMonthlyGoal(totalPending, props.product) +
percentStr;
className = percentage < 100 ? "text-danger" : "text-success";
}
return <span className={className}>{outputStr}</span>;
};
export default MonthlyProductTotal;
@@ -0,0 +1,129 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { mount, shallow } from "enzyme";
import React from "react";
import { IPersonnelGoalsInput } from "../../../shared/models/Goal";
import PersonnelGoalsControls from "./PersonnelGoalsControls";
import { IDepartment } from "../../../shared/models/Department";
import { act } from "react-dom/test-utils";
import AsyncState from "../../../shared/enumerations/AsyncState";
import { IGoalDispersalReceiptItem } from "../../../shared/models/GoalDispersal";
describe("A component for controlling personnel goals view", () => {
it("Displays a valid personnel goals controls", () => {
const input: IPersonnelGoalsInput = {
year: 2021,
departmentId: "99",
month: 2,
isShowAllPersonnelChecked: false
};
const onChange = () => {};
const onSave = () => {};
const onExport = () => {};
const departments: IDepartment[] = [
{
state: "Active",
departmentName: "Test Dept",
id: 99,
businessLineCode: "TEST",
departmentId: 99
}
];
const receiptContainer = {
state: AsyncState.Success,
data: {
departmentId: 99,
goalDispersalErrors: []
}
};
const wrapper = shallow(
<PersonnelGoalsControls
inputForm={input}
onChange={onChange}
onSave={onSave}
onExport={onExport}
departments={departments}
allowSave={false}
dispersalReceiptContainer={receiptContainer}
submitDispersalViaExtrapolation={(request) => {}}
/>
);
expect(
wrapper.find("[data-test='personnel-goals-controls']").length
).toBe(1);
});
it("Calls onChange show all personnel is changed", async () => {
const input: IPersonnelGoalsInput = {
year: 2021,
departmentId: "99",
month: 2,
isShowAllPersonnelChecked: false
};
const stubOnChange = jest.fn();
const onSave = () => {};
const onExport = () => {};
const departments: IDepartment[] = [
{
state: "Active",
departmentName: "Test Dept",
id: 99,
businessLineCode: "TEST",
departmentId: 99
}
];
const receiptContainer = {
state: AsyncState.Success,
data: {
departmentId: 99,
goalDispersalErrors: []
}
};
const wrapper = mount(
<PersonnelGoalsControls
inputForm={input}
onChange={stubOnChange}
onSave={onSave}
onExport={onExport}
departments={departments}
allowSave={false}
dispersalReceiptContainer={receiptContainer}
submitDispersalViaExtrapolation={(request) => {}}
/>
);
wrapper.update();
stubOnChange.mockClear();
expect(stubOnChange).not.toHaveBeenCalled();
await act(async () => {
wrapper
.find("#toggle-all-personnel input")
.simulate("change", { target: { checked: true } });
});
wrapper.update();
expect(stubOnChange).toHaveBeenCalledTimes(1);
expect(stubOnChange).toHaveBeenLastCalledWith({
year: 2021,
departmentId: "99",
month: 2,
isShowAllPersonnelChecked: true
});
await act(async () => {
wrapper
.find("#toggle-all-personnel input")
.simulate("change", { target: { checked: false } });
});
wrapper.update();
expect(stubOnChange).toHaveBeenCalledTimes(2);
expect(stubOnChange).toHaveBeenLastCalledWith({
year: 2021,
departmentId: "99",
month: 2,
isShowAllPersonnelChecked: false
});
});
});
@@ -0,0 +1,203 @@
import React, { useEffect, useState } from "react";
import { IPersonnelGoalsInput } from "../../../shared/models/Goal";
import GoalService from "../../../shared/services/GoalService";
import { IDepartment } from "../../../shared/models/Department";
import Translate from "../../../translate/Translate";
import { CustomInput } from "reactstrap";
import { Button } from "reactstrap";
import DispersalForm from "../../../components/goal/dispersal/DispersalForm";
import {
AllowedGoalDispersalStrategy,
ICustomWeightedDispersalRequest,
IGoalDispersalReceipt,
IGoalDispersalRequest
} from "../../../shared/models/GoalDispersal";
import IAsyncStateContainer from "../../../shared/types/IAsyncStateContainer";
import moment from "moment";
import { ICustomWeightedDispersalFormData } from "./../../../shared/models/GoalDispersal";
export interface IProps {
inputForm: IPersonnelGoalsInput;
departments: IDepartment[];
onChange: (newInput: IPersonnelGoalsInput) => void;
onExport: () => void;
onSave: () => void;
submitDispersalViaExtrapolation: (request: IGoalDispersalRequest) => void;
submitCustomWeightedDispersal: (
request: ICustomWeightedDispersalRequest
) => void;
dispersalReceiptContainer: IAsyncStateContainer<IGoalDispersalReceipt>;
display?: string;
allowSave: boolean;
}
const PersonnelGoalsControls: React.FC<IProps> = (input: IProps) => {
const [departmentId, setDepartmentId] = useState(
input.inputForm.departmentId
);
const [year, setYear] = useState(input.inputForm.year);
const [month, setMonth] = useState(input.inputForm.month);
const [isShowAllPersonnelChecked, setIsShowAllPersonnelChecked] = useState(
input.inputForm.isShowAllPersonnelChecked
);
const [isDispersalFormOpen, toggleDispersalForm] = useState(false);
useEffect(() => {
input.onChange({
year: year,
departmentId: departmentId,
month: month,
isShowAllPersonnelChecked: isShowAllPersonnelChecked
});
toggleDispersalForm(false);
}, [departmentId, year, month, isShowAllPersonnelChecked]);
const years = [year - 2, year - 1, year, year + 1, year + 2];
//Expand if add more strategies, expand if requires additional arguments
const handleStatelessDispersal = (
strategy: AllowedGoalDispersalStrategy
) => {
switch (strategy) {
default:
input.submitDispersalViaExtrapolation({
departmentId: departmentId,
month: month,
year: year
});
}
};
const handleCustomWeightedDispersal = (
formData: ICustomWeightedDispersalFormData
) => {
input.submitCustomWeightedDispersal({
departmentId: departmentId,
month: month,
year: year,
percentageByRole: formData.weightsByRole,
dispersalProduct: formData.dispersalProduct
});
};
return (
<div className="d-flex flex-column bg-light">
<nav
className="navbar navbar-expand-lg navbar-light "
data-test="personnel-goals-controls"
>
<a className="navbar-brand" href="#">
Personnel Goals for{" "}
<select
id="deptSelect"
value={departmentId}
onChange={(event) => {
setDepartmentId(event.target.value);
}}
>
{input.departments.map((dept: IDepartment) => (
<option
key={dept.id}
value={dept.id}
disabled={dept.state === "Inactive"}
>
{dept.departmentName}{" "}
{dept.state === "Inactive" ? "(Inactive)" : ""}
</option>
))}
</select>{" "}
for month{" "}
<select
id="deptGoalMonth"
value={month}
onChange={(event) => {
setMonth(parseInt(event.target.value, 10));
}}
>
{GoalService.Months.map(
(monthStr: string, monthNum: number) => (
<option key={monthNum} value={monthNum + 1}>
{monthStr}
</option>
)
)}
</select>{" "}
<select
id="deptGoalYear"
value={year}
onChange={(event) => {
setYear(parseInt(event.target.value, 10));
}}
>
{years.map((year: number) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</a>
<div>
<CustomInput
className="ml-2"
type="checkbox"
id="toggle-all-personnel"
checked={isShowAllPersonnelChecked}
onChange={(event) => {
setIsShowAllPersonnelChecked(event.target.checked);
}}
label="Show all personnel"
inline
/>
</div>
<div id="navbarSupportedContent">
<button
className="btn btn-outline-success ml-2"
type="button"
onClick={input.onSave}
disabled={!input.allowSave}
>
Save
</button>
{/*<button className="btn btn-outline-warning ml-2" type="button">Discard</button>*/}
<button
className="btn btn-outline-info ml-2"
type="button"
onClick={input.onExport}
>
{
Translate.Get.containers.goals.personnel
.personnelGoalsControls.exportButton
}
</button>
</div>
<Button
onClick={() => toggleDispersalForm(!isDispersalFormOpen)}
className="ml-auto"
color={isDispersalFormOpen ? "danger" : "primary"}
outline={!isDispersalFormOpen}
>
{isDispersalFormOpen
? Translate.Get.containers.goals.personnel
.personnelGoalsControls.closeDispersal
: Translate.Get.containers.goals.personnel
.personnelGoalsControls.openDispersal}
</Button>
<span style={{ margin: "1em" }}>{input.display}</span>
</nav>
<div>
<DispersalForm
isOpen={isDispersalFormOpen}
confirmStatelessDispersal={(strategy) => {
handleStatelessDispersal(strategy);
}}
submitState={input.dispersalReceiptContainer.state}
confirmWeightedDispersal={(formData) => {
handleCustomWeightedDispersal(formData);
}}
/>
</div>
</div>
);
};
export default PersonnelGoalsControls;
@@ -0,0 +1,137 @@
import { mount } from "enzyme";
import React from "react";
import * as reactRedux from "react-redux";
import AsyncState from "../../../shared/enumerations/AsyncState";
import PersonnelGoalsDetail, {
IPersonnelGoalsDetailProps
} from "./PersonnelGoalsDetail";
import GoalTable from "../../../components/goal/GoalTable";
describe("PersonnelGoalsDetail", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("should display waiting while pending", () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(testGoalsDataLoading);
const detailInput: IPersonnelGoalsDetailProps = {
input: {
departmentId: "1",
year: 2021,
month: 6,
isShowAllPersonnelChecked: true
}
};
const wrapper = mount(
<PersonnelGoalsDetail input={detailInput.input} />
);
expect(wrapper.find("div").html().toString()).toBe(
"<div>Please Wait.... </div>"
);
});
it("should display data when successful", () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(testGoalsData);
const detailInput: IPersonnelGoalsDetailProps = {
input: {
departmentId: "1",
year: 2021,
month: 6,
isShowAllPersonnelChecked: true
}
};
const wrapper = mount(
<PersonnelGoalsDetail input={detailInput.input} />
);
expect(wrapper.find("GoalTable").length).toBe(1);
});
});
// Simulating IGoalState
const testGoalsDataLoading = {
userGoals: {
state: AsyncState.Loading,
data: []
}
};
// Simulating IGoalState
const testGoalsData = {
userGoals: {
state: AsyncState.Success,
data: [
{
id: 1077,
departmentId: 103,
personId: "11412",
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:22.5617363",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:22.5616949",
createdByType: "Associate"
},
{
id: 1078,
departmentId: 103,
personId: "C00001",
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5158345",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5157728",
createdByType: "Associate"
},
{
id: 1079,
departmentId: 103,
personId: "11928",
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5228421",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5226656",
createdByType: "Associate"
},
{
id: 1080,
departmentId: 103,
personId: "11685",
startDate: "2021-08-01T00:00:00",
endDate: "2021-08-31T23:59:59",
product: "NewChecking",
value: 1,
lastUpdatedBy: "George Frick",
lastUpdated: "2021-08-02T13:29:23.5330519",
lastUpdatedByType: "Associate",
createdBy: "George Frick",
created: "2021-08-02T13:29:23.5329406",
createdByType: "Associate"
}
]
}
};
@@ -0,0 +1,42 @@
import React, { useEffect } from "react";
import { queryGoals } from "../../../actions/GoalActions";
import { IAppState } from "../../../store/Store";
import { useDispatch, useSelector } from "react-redux";
import { IGoalState } from "../../../reducers/GoalReducer";
import AsyncState from "../../../shared/enumerations/AsyncState";
import GoalTable from "../../../components/goal/GoalTable";
import { IPersonnelGoalsInput } from "../../../shared/models/Goal";
import { IMonthRange } from "../../../shared/types/IMonthRange";
export interface IPersonnelGoalsDetailProps {
input: IPersonnelGoalsInput;
}
const PersonnelGoalsDetail: React.FC<IPersonnelGoalsDetailProps> = (props) => {
const dispatch = useDispatch();
const goalState = useSelector<IAppState, IGoalState>(
(state) => state.goalState
);
const departmentGoals = goalState.userGoals;
useEffect(() => {
const range: IMonthRange = {
fromMonth: props.input.month,
fromYear: props.input.year,
toMonth: props.input.month,
toYear: props.input.year
};
const includePersonnel = true;
dispatch(queryGoals(range, includePersonnel, props.input.departmentId));
}, []);
return departmentGoals.state == AsyncState.Success &&
departmentGoals.data ? (
<GoalTable goals={departmentGoals.data} />
) : (
<div>Please Wait.... </div>
);
};
export default PersonnelGoalsDetail;
@@ -0,0 +1,176 @@
import { shallow } from "enzyme";
import React from "react";
import PersonnelGoalsEditor, {
IGoalsEditorProps
} from "./PersonnelGoalsEditor";
import ExportCsvService from "../../../shared/services/ExportCsvService";
import * as reactRedux from "react-redux";
import PermissionCode from "../../../shared/enumerations/PermissionCode";
describe("PersonnelGoalsEditor", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
const exportCsvSpy = jest.spyOn(
ExportCsvService,
"exportMonthlyPersonnelGoalsAsCSV"
);
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("should call the export service with personal goals and totals", () => {
useSelectorMock.mockImplementation(() => ({
departmentGoals: testdepartmentGoalsData(),
personnelGoals: testPersonnelGoalsData()
}));
const wrapper = renderPersonnelGoalsEditor();
wrapper.find("PersonnelGoalsControls").simulate("export");
expect(exportCsvSpy).toHaveBeenCalledWith([
{
goals: {
CreditCard: 3,
GAPInsurance: 8,
LIC: 9,
Mortgage: 11,
NewChecking: 12,
Vehicle: 13
},
rowHeader: "Department Goal"
},
{
goals: {
NewChecking: 0,
CreditCard: 0,
GAPInsurance: 0,
LIC: 0,
Mortgage: 0,
Vehicle: 0
},
rowHeader: "One, Test E"
},
{
goals: {
CreditCard: 0,
GAPInsurance: 0,
LIC: 0,
Mortgage: 0,
NewChecking: 0,
Vehicle: 0
},
rowHeader: "Three, Test"
},
{
goals: {
CreditCard: 0,
GAPInsurance: 0,
LIC: 0,
Mortgage: 0,
NewChecking: 0,
Vehicle: 0
},
rowHeader: "Totals"
}
]);
expect(exportCsvSpy).toHaveBeenCalledTimes(1);
});
});
function renderPersonnelGoalsEditor(args = {}) {
const defaultProps: IGoalsEditorProps = {
departments: [
{
businessLineCode: "BCHOPS",
departmentName: "TestBranch",
id: 1,
state: "Active"
}
]
} as IGoalsEditorProps;
const props = { ...defaultProps, ...args };
return shallow(<PersonnelGoalsEditor {...props} />);
}
const testdepartmentGoalsData = () => ({
data: {
departmentId: 1,
year: 2021,
monthlyGoals: [
{
productGoals: {
ATMCard: 1,
CheckCard: 2,
CreditCard: 3,
CreditCardBalanceTransfer: 4,
DirectDeposit: 7,
GAPInsurance: 8,
LIC: 9,
MechanicalBreakdownProtection: 10,
Mortgage: 11,
NewChecking: 12,
Vehicle: 13
}
}
]
}
});
const testPersonnelGoalsData = () => ({
data: {
departmentId: 1,
year: 2020,
month: 1,
personnel: [
{
employeeId: "1",
fullName: "One, Test E",
role: {
permissions: [{ code: PermissionCode.HAVE_SALES_GOALS }]
}
},
{
employeeId: "3",
fullName: "Three, Test",
role: {
permissions: [{ code: PermissionCode.HAVE_SALES_GOALS }]
}
}
],
goals: {
"1": {
productGoals: {
ATMCard: 0,
CreditCardBalanceTransfer: 0,
CheckCard: 0,
NewChecking: 0,
CreditCard: 0,
DirectDeposit: 0,
GAPInsurance: 0,
LIC: 0,
Mortgage: 0,
MechanicalBreakdownProtection: 0,
Vehicle: 0
}
},
"3": {
productGoals: {
ATMCard: 0,
CreditCardBalanceTransfer: 0,
CheckCard: 0,
NewChecking: 0,
CreditCard: 0,
DirectDeposit: 0,
GAPInsurance: 0,
LIC: 0,
Mortgage: 0,
MechanicalBreakdownProtection: 0,
Vehicle: 0
}
}
}
}
});
@@ -0,0 +1,284 @@
import React, { useEffect, useState } from "react";
import {
getDepartmentGoals,
getPersonnelGoals
} from "../../../actions/GoalActions";
import { IAppState } from "../../../store/Store";
import { useDispatch, useSelector } from "react-redux";
import moment from "moment";
import { IGoalState } from "../../../reducers/GoalReducer";
import AsyncState from "../../../shared/enumerations/AsyncState";
import PersonnelGoalsTable from "./PersonnelGoalsTable";
import { Product2 } from "../../../shared/enumerations/Product";
import {
IGoalChangeRequest,
IPersonnelGoalsInput
} from "../../../shared/models/Goal";
import PersonnelGoalsControls from "./PersonnelGoalsControls";
import GoalService from "../../../shared/services/GoalService";
import { IDepartment } from "../../../shared/models/Department";
import ExportCsvService, {
MonthlyPersonnelGoalsCsvData
} from "../../../shared/services/ExportCsvService";
import TabView from "../../../components/tab-view/TabView";
import PersonnelGoalsDetail from "./PersonnelGoalsDetail";
import Translate from "../../../translate/Translate";
import {
ICustomWeightedDispersalRequest,
IGoalDispersalReceipt,
IGoalDispersalRequest
} from "../../../shared/models/GoalDispersal";
import IAsyncStateContainer from "../../../shared/types/IAsyncStateContainer";
import GoalDispersalService from "./../../../shared/services/GoalDispersalService";
import DispersalFormAlertBar from "../../../components/goal/dispersal/DispersalFormAlertBar";
export interface IGoalsEditorProps {
departments: IDepartment[];
}
const PersonalGoalsTranslate = Translate.Get.containers.goals.personnel;
const PersonnelGoalsEditor: React.FC<IGoalsEditorProps> = (props) => {
const dispatch = useDispatch();
const [input, setInput] = useState<IPersonnelGoalsInput>({
departmentId: props.departments[0].id + "",
year: moment().year(),
month: moment().month() + 1,
isShowAllPersonnelChecked: false
});
const [lastSave, setLastSave] = useState<string>("");
const [pendingChanges, setPendingChanges] = useState<IGoalChangeRequest[]>(
[]
);
const [dispersalReceipt, setDispersalReceipt] = useState<
IAsyncStateContainer<IGoalDispersalReceipt>
>({ state: AsyncState.Inactive });
useEffect(() => {
setPendingChanges([]);
setDispersalReceipt({ state: AsyncState.Inactive });
dispatch(getPersonnelGoals(input));
dispatch(getDepartmentGoals(input));
}, [input, lastSave]);
const goalState = useSelector<IAppState, IGoalState>(
(state) => state.goalState
);
const personnelGoals = goalState.personnelGoals;
const filteredPersonnelGoals = personnelGoals.data
? GoalService.filterPersonnelGoalsByShowAll(
input.isShowAllPersonnelChecked,
personnelGoals.data
)
: personnelGoals.data;
const departmentGoals = goalState.departmentGoals;
const queueChanges = (person: string, type: Product2, value: number) => {
const index = pendingChanges.findIndex(
(element: IGoalChangeRequest) =>
element.product === type &&
element.startDate.year() === input.year &&
element.startDate.month() === input.month - 1 &&
element.personId === person
);
const change = convertToGoalChanges(person, type, value);
const changes = pendingChanges.slice();
if (index >= 0) {
if (isNaN(value)) {
changes.splice(index, 1); // remove it.
} else {
changes.splice(index, 1, change); // replace
}
} else {
if (isNaN(value)) {
return;
}
changes.push(change); // add
}
setPendingChanges(changes);
};
// todo, move to service.
const convertToGoalChanges = (
personId: string,
type: Product2,
value: number
): IGoalChangeRequest => {
let startDate = moment().utc().set("year", input.year);
startDate = startDate.set("month", input.month - 1);
startDate = startDate.clone().startOf("month");
const endDate = startDate.clone();
endDate.add(1, "month");
endDate.subtract(1, "second");
return {
departmentId: parseInt(input.departmentId, 10),
personId: personId,
product: type,
value: value,
startDate: startDate,
endDate: endDate
};
};
const isValidChanges = (): boolean => {
if (pendingChanges && pendingChanges.length > 0) {
return GoalService.isValidPersonnelChanges(
pendingChanges,
filteredPersonnelGoals,
departmentGoals.data
);
}
return false;
};
const saveChanges = () => {
if (isValidChanges()) {
GoalService.savePendingChanges(pendingChanges).then(() => {
setLastSave("Changes Saved " + moment().format("LTS"));
});
}
};
const submitDispersalViaExtrapolation = async (
request: IGoalDispersalRequest
) => {
setDispersalReceipt({ state: AsyncState.Loading });
const res = await GoalDispersalService.DisperseGoalsViaExtrapolation(
request
);
setDispersalReceipt(res);
if (res.state === AsyncState.Success) {
dispatch(getPersonnelGoals(input));
}
};
const submitcustomWeighteDispersal = async (
request: ICustomWeightedDispersalRequest
) => {
setDispersalReceipt({ state: AsyncState.Loading });
const res = await GoalDispersalService.DisperseGoalsByCustomWeights(
request
);
setDispersalReceipt(res);
if (res.state === AsyncState.Success) {
dispatch(getPersonnelGoals(input));
}
};
const exportCsv = () => {
if (filteredPersonnelGoals && departmentGoals.data) {
const personnelGoalsData = filteredPersonnelGoals;
const departmentGoalsData = departmentGoals.data;
const productsWithPersonnelGoals = Product2.Products.filter(
(product) => product.hasPersonnelGoals
);
const deptGoals: MonthlyPersonnelGoalsCsvData = {
rowHeader: "Department Goal",
goals: productsWithPersonnelGoals.reduce(
(perProductGoals, product) => ({
...perProductGoals,
[product.code]:
departmentGoalsData.monthlyGoals[
personnelGoalsData.month - 1
].productGoals[product.code]
}),
{}
)
};
const goals: MonthlyPersonnelGoalsCsvData[] = personnelGoalsData.personnel.map(
(person) => {
return {
rowHeader: person.fullName,
goals: productsWithPersonnelGoals.reduce(
(perProductGoals, product) => ({
...perProductGoals,
[product.code]:
personnelGoalsData.goals[person.employeeId]
.productGoals[product.code]
}),
{}
)
};
}
);
const goalTotals: MonthlyPersonnelGoalsCsvData = {
rowHeader: "Totals",
goals: productsWithPersonnelGoals.reduce(
(perProductGoals, product) => ({
...perProductGoals,
[product.code]: GoalService.sumPersonnelGoalsForProduct(
personnelGoalsData,
product
)
}),
{}
)
};
ExportCsvService.exportMonthlyPersonnelGoalsAsCSV([
deptGoals,
...goals,
goalTotals
]);
}
};
return (
<div>
<DispersalFormAlertBar
goalDispersalReceiptContainer={dispersalReceipt}
/>
<PersonnelGoalsControls
inputForm={input}
onChange={(change) => setInput(change)}
onSave={saveChanges}
onExport={exportCsv}
display={lastSave}
departments={props.departments}
allowSave={isValidChanges()}
dispersalReceiptContainer={dispersalReceipt}
submitDispersalViaExtrapolation={(request) =>
submitDispersalViaExtrapolation(request)
}
submitCustomWeightedDispersal={(request) =>
submitcustomWeighteDispersal(request)
}
/>
{personnelGoals.state == AsyncState.Success &&
personnelGoals.data &&
filteredPersonnelGoals &&
departmentGoals.state == AsyncState.Success &&
departmentGoals.data ? (
<TabView
data-test="tabbed-commission-view"
pages={[
{
header: PersonalGoalsTranslate.tabs.editor,
element: (
<PersonnelGoalsTable
personnelGoals={filteredPersonnelGoals}
departmentGoals={departmentGoals.data}
pendingChanges={pendingChanges}
onChange={queueChanges}
/>
)
},
{
header: PersonalGoalsTranslate.tabs.detail,
element: <PersonnelGoalsDetail input={input} />
}
]}
/>
) : (
<div>Please Wait.... </div>
)}
</div>
);
};
export default PersonnelGoalsEditor;
@@ -0,0 +1,35 @@
import React, { useEffect, useState } from "react";
import PersonnelGoalsEditor from "./PersonnelGoalsEditor";
import { IDepartment } from "../../../shared/models/Department";
import DepartmentService from "../../../shared/services/DepartmentService";
import { Alert } from "reactstrap";
const PersonnelGoalsPage: React.FC = () => {
const [departments, setDepartments] = useState<IDepartment[] | null>(null);
useEffect(() => {
DepartmentService.getAllDepartments(undefined, true)
.then((response) => {
setDepartments(response.data || []);
})
.catch(() => {
setDepartments([]);
});
}, []);
if (departments && departments.length === 0) {
return (
<Alert color="warning" data-test="not-found">
No departments found for your role
</Alert>
);
}
return departments && departments.length > 0 ? (
<PersonnelGoalsEditor departments={departments} />
) : (
<div>Please Wait.... </div>
);
};
export default PersonnelGoalsPage;
@@ -0,0 +1,170 @@
import { shallow } from "enzyme";
import React from "react";
import Product2 from "../../../shared/enumerations/Product";
import {
IDepartmentGoals,
IGoalChangeRequest,
IPersonnelGoals
} from "../../../shared/models/Goal";
import PersonnelGoalsTable, { IProps } from "./PersonnelGoalsTable";
import { IEmployee } from "../../../shared/models/Employee";
const person: IEmployee = {
employeeId: "11771",
adEmail: "[email protected]",
personId: 0,
fullName: "Test McTester",
homeDepartment: "QA",
baseRole: "QA",
departments: [],
role: {
name: "Role1",
description: "NONE",
permissions: [{ code: "VIEW_DASHBOARD", description: "" }],
hasCommissions: true
},
additionalPermissions: [],
assignments: []
};
describe("A table for editing personnel goals", () => {
it("Displays a valid personnel goals edit table", () => {
const personnelGoals: IPersonnelGoals = {
year: 2021,
personnel: [person],
departmentId: 99,
month: 1,
goals: {
"11771": {
productGoals: {}
}
}
};
const departmentGoals: IDepartmentGoals = {
departmentId: 99,
year: 2021,
monthlyGoals: [
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} }
]
};
const pendingChanges: IGoalChangeRequest[] = [];
// eslint-disable-next-line @typescript-eslint/no-empty-function
const onChange = () => {};
let wrapper = shallow(
<PersonnelGoalsTable
personnelGoals={personnelGoals}
departmentGoals={departmentGoals}
onChange={onChange}
pendingChanges={pendingChanges}
/>
);
expect(wrapper.find("[id='personnel-goals-table']").length).toBe(1);
});
describe("Products with personnel goals", () => {
it("Displays titles in the table header for products that have personnel goals", () => {
const wrapper = renderPersonnelGoalsTable();
const expectedProductNames = productWithPersonnelGoals().map(
(product) => product.title
);
const headerCols = wrapper.find("thead > tr > th").slice(1);
expect(headerCols.length).toEqual(
productWithPersonnelGoals().length
);
headerCols.forEach((col) =>
expect(expectedProductNames).toContain(col.text())
);
});
it("Match titles in the department goal row to products that have personnel goals ", () => {
const wrapper = renderPersonnelGoalsTable();
const headerCols = wrapper
.find("thead > tr.alert-info > td")
.slice(1);
const expectedProductCodes = productWithPersonnelGoals().map(
(product) => product.code
);
expect(headerCols.length).toEqual(
productWithPersonnelGoals().length
);
headerCols.forEach((col, index) =>
expect(
col
.prop("title")
?.startsWith(`total_${expectedProductCodes[index]}`)
).toBe(true)
);
});
it("Displays only products with personnel goals in each GoalEditCell", () => {
const wrapper = renderPersonnelGoalsTable();
const goalEditCells = wrapper.find("tbody GoalEditCell");
expect(goalEditCells.length).toEqual(
productWithPersonnelGoals().length
);
goalEditCells.forEach((cell) => {
const goal: { type: Product2 } = cell.prop("goal");
expect(goal.type.hasPersonnelGoals).toBe(true);
});
});
it("Displays only product with personnel goals in each MonthlyProductTotal cell", () => {
const wrapper = renderPersonnelGoalsTable();
const productsInFooter: Product2[] = wrapper
.find("tfoot MonthlyProductTotal")
.map((cell) => cell.prop("product"));
expect(productsInFooter.length).toEqual(
productWithPersonnelGoals().length
);
productsInFooter.forEach((product) =>
expect(product.hasPersonnelGoals).toBe(true)
);
});
});
});
function renderPersonnelGoalsTable(args = {}) {
const personnelGoals: IPersonnelGoals = {
year: 2021,
personnel: [person],
departmentId: 99,
month: 1,
goals: {
"11771": {
productGoals: {}
}
}
};
const departmentGoals: IDepartmentGoals = {
departmentId: 99,
year: 2021,
monthlyGoals: [
{ productGoals: {} },
{ productGoals: {} },
{ productGoals: {} }
]
};
const defaultProps: IProps = {
personnelGoals,
departmentGoals,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange: () => {},
pendingChanges: []
};
const props = { ...defaultProps, ...args };
return shallow(<PersonnelGoalsTable {...props} />);
}
function productWithPersonnelGoals() {
return Product2.Products.filter((product) => product.hasPersonnelGoals);
}
@@ -0,0 +1,152 @@
import React from "react";
import {
IDepartmentGoals,
IGoalChangeRequest,
IPersonnelGoals
} from "../../../shared/models/Goal";
import { Product2 } from "../../../shared/enumerations/Product";
import GoalEditCell from "../GoalEditCell";
import MonthlyProductTotal from "./MonthlyProductTotal";
import GoalService from "../../../shared/services/GoalService";
import UserDisplay from "../../shared/UserDisplay";
export interface IProps {
personnelGoals: IPersonnelGoals;
departmentGoals: IDepartmentGoals;
pendingChanges: IGoalChangeRequest[];
onChange: (label: string, type: Product2, value: number) => void;
}
const PersonnelGoalsTable: React.FC<IProps> = (props: IProps) => {
const onAction = (label: string, type: Product2, value: number) => {
props.onChange(label, type, value);
};
return (
<table
className="table table-striped tablecenterCSS tablecenterheadCSS"
id="personnel-goals-table"
>
<thead className="thead-light">
<tr>
<th scope="col">Personnel</th>
{GoalService.productsWithPersonnelGoals().map((product) => (
<th key={product.code} scope="col" title={product.code}>
{product.title}
</th>
))}
</tr>
<tr className="alert alert-info">
<td>Department Goal</td>
{GoalService.productsWithPersonnelGoals().map((product) => (
<td
key={"total_" + product.code}
scope="col"
title={
"total_" +
product.code +
"_" +
props.personnelGoals.month
}
>
{GoalService.formatMonthlyGoal(
GoalService.getMonthlyGoal(
props.departmentGoals,
props.personnelGoals.month,
product.code
),
product
)}
</td>
))}
</tr>
<tr className="alert alert-secondary">
<td>Totals</td>
{GoalService.productsWithPersonnelGoals().map((product) => (
<td key={"total_" + product.code} scope="col">
<MonthlyProductTotal
personnelGoals={props.personnelGoals}
pendingChanges={props.pendingChanges}
product={product}
departmentTotal={GoalService.getMonthlyGoal(
props.departmentGoals,
props.personnelGoals.month,
product.code
)}
/>
</td>
))}
</tr>
</thead>
<tbody className="form-group">
{props.personnelGoals.personnel.map((person, index: number) => (
<tr key={"personnelGoalsRow_" + index}>
<td
title={person.employeeId + " - " + person.adEmail}
className="tdLabel"
>
<UserDisplay employeeId={person.employeeId} />
</td>
{GoalService.productsWithPersonnelGoals().map(
(product, index2) => (
<td
key={person + "_" + product.code}
scope="col"
title="figure me out"
>
<GoalEditCell
goal={{
year: props.personnelGoals.year,
month: props.personnelGoals.month,
type: product,
value:
props.personnelGoals.goals[
person.employeeId
].productGoals[product.code],
departmentId:
props.personnelGoals
.departmentId,
personId: person.employeeId
}}
tabIndex={index + (index2 + 1) * 100}
requestAction={(
type: Product2,
value: number
) =>
onAction(
person.employeeId,
type,
value
)
}
/>
</td>
)
)}
</tr>
))}
</tbody>
<tfoot>
<tr className="alert alert-secondary">
<td>Totals</td>
{GoalService.productsWithPersonnelGoals().map((product) => (
<td key={"total_" + product.code} scope="col">
<MonthlyProductTotal
personnelGoals={props.personnelGoals}
pendingChanges={props.pendingChanges}
product={product}
departmentTotal={GoalService.getMonthlyGoal(
props.departmentGoals,
props.personnelGoals.month,
product.code
)}
/>
</td>
))}
</tr>
</tfoot>
</table>
);
};
export default PersonnelGoalsTable;
@@ -0,0 +1,37 @@
import React from "react";
import { AutoTable } from "../../components/auto-table/AutoTable";
import Translate from "./../../translate/Translate";
import { IBusinessLine } from "../../shared/models/Settings";
export interface BusinessLineTableProps {
businessLines: IBusinessLine[];
}
const BusinessLineTable: React.FC<BusinessLineTableProps> = (props) => {
const headers = [
Translate.Get.containers.businessLineTable.headers.code,
Translate.Get.containers.businessLineTable.headers.name,
Translate.Get.containers.businessLineTable.headers.orgCode
];
const cells = [
(x: IBusinessLine) => x.code,
(x: IBusinessLine) => x.name,
(x: IBusinessLine) => x.orgCode
];
return (
<div>
<div className="p-2">
<h5>{Translate.Get.containers.businessLineTable.title}</h5>
<AutoTable
className="border rounded"
cells={cells}
headers={headers}
elements={props.businessLines}
/>
</div>
</div>
);
};
export default BusinessLineTable;
@@ -0,0 +1,32 @@
import React from "react";
import { AutoTable } from "../../components/auto-table/AutoTable";
import Translate from "./../../translate/Translate";
export interface OrganizationTableProps {
organizations: string[][];
}
const OrganizationTable: React.FC<OrganizationTableProps> = (props) => {
const headers = [
Translate.Get.containers.organizationTable.headers.orgCode,
Translate.Get.containers.organizationTable.headers.orgName
];
const cells = [(x: any[]) => x[0], (x: any[]) => x[1]];
return (
<div>
<div className="p-2">
<h5>{Translate.Get.containers.organizationTable.title}</h5>
<AutoTable
className="border rounded"
cells={cells}
headers={headers}
elements={props.organizations}
/>
</div>
</div>
);
};
export default OrganizationTable;
@@ -0,0 +1,122 @@
import React, { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { IAppState } from "../../store/Store";
import { RouteComponentProps } from "react-router-dom";
import { flattenRecord } from "../../shared/services/SettingsService";
import Translate from "./../../translate/Translate";
import OrganizationTable from "./OrganizationTable";
import BusinessLineTable from "./BusinessLineTable";
import { IDepartmentState } from "./../../reducers/DepartmentReducer";
import {
getAllDepartments,
updateExistingDepartment,
submitNewDepartment
} from "../../actions/DepartmentActions";
import { getAllUserAssignments } from "../../actions/UserAssignmentActions";
import DepartmentTable from "../../components/department-table/DepartmentTable";
import { useDispatch } from "react-redux";
import RegionTable from "../../components/region/RegionTable";
import { IUserAssignmentState } from "../../reducers/UserAssignmentReducer";
import UserAssignmentService from "../../shared/services/UserAssignmentService";
import { IProfileState } from "../../reducers/ProfileReducer";
import { profileSearch } from "../../actions/ProfileActions";
import { IUserAssignmentDto } from "../../shared/models/UserAssignment";
import AsyncState from "../../shared/enumerations/AsyncState";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
type OrganizationViewProps = RouteComponentProps;
const OrganizationView: React.FC<OrganizationViewProps> = () => {
const dispatch = useDispatch();
const settings = React.useContext(SettingsContext);
// In this case we still load departments instead of using settings because
// the appUser may make modifications. It might be smarter to have the appUser
// reload. They'll almost never 'create' a department.
const departmentState = useSelector<IAppState, IDepartmentState>(
(state) => state.departmentState
);
const userAssignmentState = useSelector<IAppState, IUserAssignmentState>(
(state) => state.userAssignmentState
);
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const [organizations, setOrganizations] = useState<string[][]>([]);
useEffect(() => {
dispatch(getAllDepartments());
dispatch(getAllUserAssignments());
dispatch(profileSearch(null, "RegionalManager"));
}, []);
// TODO, Handle department creation => Department refresh
useEffect(() => {
dispatch(getAllDepartments());
}, [departmentState.submitState.data]);
useEffect(() => {
setOrganizations(flattenRecord(settings?.organizationSet));
}, [settings]);
const [
submitRegionalManagerUpdateState,
setSubmitRegionalManagerUpdateState
] = useState<IAsyncStateContainer<string>>({ state: AsyncState.Inactive });
const regionalManagerAssignments = UserAssignmentService.filterUserAssignmentsByType(
userAssignmentState.userAssignments.data ?? [],
"RegionalManager"
);
const updateRegionalManager = async (
employeeId: string,
regionCode: string
) => {
setSubmitRegionalManagerUpdateState({ state: AsyncState.Loading });
const assignment: IUserAssignmentDto = {
employeeId: employeeId,
key: regionCode
};
const result = await UserAssignmentService.setRegionalManager(
assignment
);
setSubmitRegionalManagerUpdateState(result);
dispatch(getAllUserAssignments());
};
return (
<div>
<h3>{Translate.Get.containers.organizationView.pageTitle}</h3>
<OrganizationTable organizations={organizations} />
<BusinessLineTable businessLines={settings?.businessLines ?? []} />
<RegionTable
assignedManagers={regionalManagerAssignments}
availableManagers={profileState.searchResults.data ?? []}
regions={settings?.regions ?? []}
updateManager={updateRegionalManager}
submitState={submitRegionalManagerUpdateState}
/>
<DepartmentTable
departments={departmentState.departments.data ?? []}
organizationSet={settings?.organizationSet ?? {}}
departmentStateSet={settings?.departmentStateSet ?? {}}
businessLines={settings?.businessLines ?? []}
regions={settings?.regions ?? []}
updateDepartment={(updatedData, id) => {
dispatch(updateExistingDepartment(id, updatedData));
}}
submitNewDepartment={(newDepartment) => {
dispatch(submitNewDepartment(newDepartment));
}}
submitState={departmentState.submitState}
/>
</div>
);
};
export default OrganizationView;
@@ -0,0 +1,45 @@
import React from "react";
import { IEmployee } from "../../shared/models/Employee";
import DepartmentDisplay from "../shared/DepartmentDisplay";
import PayoutProfileForRole from "./PayoutProfileForRole";
import DepartmentService from "../../shared/services/DepartmentService";
import CommissionsForRole from "../commissions-page/CommissionsForRole";
export interface IPayoutProfileProps {
profile: IEmployee;
}
const EligiblePayouts: React.FC<IPayoutProfileProps> = (props) => {
const profile = props.profile;
const activeDepartments = profile.departments.filter((userDept) =>
DepartmentService.isActiveAssignment(userDept)
);
return activeDepartments.length > 0 ? (
<span>
{activeDepartments.map((department, idx) => {
return (
<div
key={idx + "_payoutProfile_" + department.departmentId}
>
<h5>
<DepartmentDisplay
departmentId={department.departmentId}
/>
: You get payouts & commissions for{" "}
{department.role?.name || "Missing"}
</h5>
{/*<CommissionProfileForRole role={profile.role}/>*/}
<PayoutProfileForRole role={department.role} />
<CommissionsForRole role={department.role} />
<hr />
</div>
);
})}
</span>
) : (
<span>No eligible payouts.</span>
);
};
export default EligiblePayouts;
@@ -0,0 +1,40 @@
import React from "react";
import PayoutProfileCard from "../../components/payouts/PayoutProfileCard";
import { IAppRole, IPayoutProfile } from "../../shared/models/Settings";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import { CardDeck } from "reactstrap";
import "./PayoutProfilePage.scss";
export interface IPayoutProfileProps {
role: IAppRole | undefined;
}
const PayoutProfileForRole: React.FC<IPayoutProfileProps> = (props) => {
const settings = React.useContext(SettingsContext);
const payoutProfiles = settings?.payoutProfiles;
let profiles: IPayoutProfile[] = [];
if (payoutProfiles) {
profiles = payoutProfiles.filter((payoutProfile) =>
payoutProfile.roles.find((x) => x.name == props.role?.name)
);
}
return profiles.length > 0 && props.role ? (
<CardDeck className={"FlexCenter"}>
{profiles?.map((payoutProfile, idx) => {
return (
<PayoutProfileCard
profile={payoutProfile}
showRoles
key={"payoutProfileCard_" + idx}
/>
);
})}
</CardDeck>
) : (
<span>No eligible goal based payouts.</span>
);
};
export default PayoutProfileForRole;
@@ -0,0 +1,10 @@
.FittedCard {
width: 30%;
height: auto;
}
.FlexCenter {
display: flex;
align-items: baseline;
justify-content: center;
}
@@ -0,0 +1,37 @@
import React from "react";
import Translate from "./../../translate/Translate";
import PayoutProfileCard from "../../components/payouts/PayoutProfileCard";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import { CardDeck } from "reactstrap";
import "./PayoutProfilePage.scss";
const PayoutProfilePage: React.FC = () => {
const settings = React.useContext(SettingsContext);
const payoutProfiles = settings?.payoutProfiles;
return payoutProfiles ? (
<div>
<h4>{Translate.Get.containers.payoutProfileView.pageTitle}</h4>
<CardDeck className={"FlexCenter"}>
{payoutProfiles
?.sort((a, b) => a.payouts.size)
.map((payoutProfile, idx) => {
return (
<div className={"FittedCard"}>
<PayoutProfileCard
profile={payoutProfile}
showRoles
key={"payoutProfileCard_" + idx}
/>
</div>
);
})}
</CardDeck>
</div>
) : (
<h3>Could not load payout profiles.</h3>
);
};
export default PayoutProfilePage;
@@ -0,0 +1,17 @@
import React from "react";
import { shallow } from "enzyme";
import ExportPayoutPage from "./ExportPayoutPage";
describe("A Component For exporting payouts", () => {
it("Displays the export options", () => {
const wrapper = shallow(<ExportPayoutPage />);
const tableData = wrapper.find("[data-test='export-payout-page']");
expect(tableData.length).toBe(1);
const buttons = wrapper.find("button");
expect(buttons.length).toBe(2);
const selects = wrapper.find("select");
expect(selects.length).toBe(2);
});
});
@@ -0,0 +1,152 @@
import React, { useState } from "react";
import moment from "moment";
import GoalService from "../../shared/services/GoalService";
import Translate from "../../translate/Translate";
import { Alert } from "reactstrap";
import PayoutService from "../../shared/services/PayoutService";
import { IAccountingSummary, IPayoutEntry } from "../../shared/models/Payout";
import AsyncState from "../../shared/enumerations/AsyncState";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import AsyncGuard from "../shared/AsyncGuard";
import PayoutEntryTable from "../../components/payouts/PayoutEntryTable";
import ExportCsvService from "../../shared/services/ExportCsvService";
const ExportPayoutPage: React.FC = () => {
const [month, setMonth] = useState<number>(
moment().add(1, "months").month()
);
const [year, setYear] = useState<number>(moment().year());
const [payouts, setPayouts] = useState<
IAsyncStateContainer<IPayoutEntry[]>
>({
state: AsyncState.Inactive
});
const [accountingReport, setAccountingReport] = useState<
IAsyncStateContainer<IAccountingSummary>
>({
state: AsyncState.Inactive
});
const years = [moment().year() - 2, moment().year() - 1, moment().year()];
const submitForm = async () => {
await fetchPayoutData();
};
const submitAccountingForm = async () => {
setAccountingReport({
state: AsyncState.Loading
});
const date = moment({ year: year, month: month - 1, day: 1 });
const response = await PayoutService.GetAccountingReport(date);
if (response.data) {
setAccountingReport(response);
ExportCsvService.exportAccountingReportCSV(response.data);
} else {
setAccountingReport({
state: AsyncState.NotFound
});
}
};
const fetchPayoutData = async () => {
setPayouts({
state: AsyncState.Loading
});
const response = await PayoutService.GetExportPayouts(year, month);
setPayouts(response);
setAccountingReport({
state: AsyncState.Inactive
});
};
const exportCsv = () => {
if (payouts.data && payouts.data.length > 0) {
ExportCsvService.exportDayforcePayoutCSV(payouts.data);
}
};
return (
<div data-test="export-payout-page">
<Alert color="info">
{Translate.Get.containers.exportPayoutPage.instructions}
</Alert>
<select
className="mx-1"
id="fromMonth"
value={month}
onChange={(event) => {
setMonth(parseInt(event.target.value, 10));
}}
>
{GoalService.Months.map(
(monthStr: string, monthNum: number) => (
<option key={monthNum} value={monthNum + 1}>
{monthStr}
</option>
)
)}
</select>
<select
className="mx-1"
id="fromYear"
value={year}
onChange={(event) => {
setYear(parseInt(event.target.value, 10));
}}
>
{years.map((year: number) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<button
className="btn btn-outline-info btn-sm mx-2"
type="button"
onClick={submitForm}
>
{Translate.Get.containers.exportPayoutPage.exportButton}
</button>
<button
className="btn btn-outline-info btn-sm mx-2"
type="button"
onClick={submitAccountingForm}
>
{
Translate.Get.containers.exportPayoutPage
.exportAccountingButton
}
</button>
<AsyncGuard container={payouts}>
<>
{payouts.data && payouts.data.length > 0 ? (
<button
className="btn btn-secondary btn-sm"
onClick={exportCsv}
>
{" "}
{
Translate.Get.containers.exportPayoutPage
.downloadButton
}
</button>
) : (
<></>
)}
</>
</AsyncGuard>
<br />
<AsyncGuard container={accountingReport}>
<Alert color="info" className="mt-2">
Accounting Report Exported
</Alert>
</AsyncGuard>
<AsyncGuard container={payouts}>
<PayoutEntryTable payoutEntries={payouts.data || []} />
</AsyncGuard>
</div>
);
};
export default ExportPayoutPage;
@@ -0,0 +1,24 @@
import React from "react";
import { shallow } from "enzyme";
import AsyncState from "../../shared/enumerations/AsyncState";
import PayoutPage from "./PayoutPage";
import PayoutService from "../../shared/services/PayoutService";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { IPayoutLineItem } from "../../shared/models/Payout";
describe("A Component For displaying an employee payout", () => {
const payoutServiceMock = jest.spyOn(PayoutService, "getEmployeePayouts");
beforeEach(() => {
payoutServiceMock.mockClear();
});
it("Displays loading wait", () => {
payoutServiceMock.mockResolvedValue({
state: AsyncState.Loading
} as IAsyncStateContainer<IPayoutLineItem[]>);
const wrapper = shallow(<PayoutPage employeeId="11771" />);
expect(wrapper.html()).toBe("Loading...");
});
});
@@ -0,0 +1,86 @@
import React, { useContext, useEffect, useState } from "react";
import PayoutView from "../../components/payouts/PayoutView";
import AsyncState from "../../shared/enumerations/AsyncState";
import moment, { Moment } from "moment";
import { CustomInput, Form, Input } from "reactstrap";
import PayoutService from "../../shared/services/PayoutService";
import { IPayoutLineItem } from "../../shared/models/Payout";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
export interface IEmployeeParam {
employeeId: string;
}
const PayoutPage: React.FC<IEmployeeParam> = (props) => {
const employeeId = props.employeeId;
const [fromDate, setFromDate] = useState<Moment>(moment().startOf("month"));
const [showDetails, setShowDetails] = useState<boolean>(false);
const [lineItems, setLineItems] = useState<
IAsyncStateContainer<IPayoutLineItem[]>
>({
state: AsyncState.Loading
});
const settings = React.useContext(SettingsContext);
useEffect(() => {
setLineItems({
state: AsyncState.Loading
});
const fetchData = async () => {
return await PayoutService.getEmployeePayouts({
employeeId: employeeId,
year: fromDate.year(),
month: fromDate.month() + 1
});
};
fetchData().then((lineItems) => {
setLineItems(lineItems);
});
}, [employeeId, fromDate]);
return lineItems.state === AsyncState.Success && lineItems.data ? (
<>
<div className="d-flex justify-content-end m-2">
Next Incentive Payout Date:{" "}
{moment(settings?.nextBonusPeriod).format("MM/DD/yyyy")}
</div>
<div className="d-flex justify-content-end">
<Form inline noValidate>
Payout details for
<Input
name="AcceptedStateDateTime"
type="month"
bsSize="sm"
required
className="mb-1 ml-2"
value={fromDate.format("YYYY-MM")}
onChange={(event) => {
setFromDate(
moment(event.target.value).startOf("month")
);
}}
/>
<CustomInput
type="checkbox"
id="toggle-all-activity"
checked={showDetails}
onChange={(event) => {
setShowDetails(event.target.checked);
}}
label="Show Details"
className="ml-4 mr-1"
/>
</Form>
</div>
<PayoutView
userPayouts={lineItems.data}
showDetails={showDetails}
/>
</>
) : (
<>Loading...</>
);
};
export default PayoutPage;
@@ -0,0 +1,39 @@
import { shallow } from "enzyme";
import React from "react";
import ProfilePage from "./ProfilePage";
import { IEmployee } from "../../shared/models/Employee";
import ProfileView from "../../components/profile/ProfileView";
jest.mock("react-redux");
describe("A component for displaying appUser profile", () => {
it("Displays a valid profile", () => {
const profile: IEmployee = {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
additionalPermissions: [],
role: {
name: "Role1",
description: "NONE",
permissions: [{ code: "VIEW_DASHBOARD", description: "" }],
hasCommissions: true
},
departments: [],
assignments: []
};
// eslint-disable-next-line @typescript-eslint/no-empty-function
const refreshProfile = () => {};
let wrapper = shallow(
<ProfilePage profile={profile} refreshProfile={refreshProfile} />
);
const found = wrapper.find(ProfileView);
expect(found.length).toBe(1);
});
});
@@ -0,0 +1,208 @@
import React, { useEffect, useState } from "react";
import ProfileView from "../../components/profile/ProfileView";
import { Alert, Col, ListGroup, ListGroupItem, Row } from "reactstrap";
import EmployeePage from "../employee-page/EmployeePage";
import PermissionsPage from "../../components/permission/PermissionsPage";
import PayoutPage from "../payouts/PayoutPage";
import {
IEmployee,
IEmployeeDetailsUpdate
} from "../../shared/models/Employee";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import PermissionGuard from "../shared/PermissionGuard";
import EmployeeStatusGuard from "../shared/EmployeeStatusGuard";
import moment from "moment";
import EligiblePayouts from "../payout-profile/EligiblePayouts";
import EmployeeDetailsPage from "../employee-page/EmployeeDetailsPage";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
interface IProfileParams {
profile: IEmployee;
employeeDetailsUpdateResult: string;
submitStatus: IAsyncStateContainer<string>;
refreshProfile: () => void;
submitEmployeeDetailsUpdate: (update: IEmployeeDetailsUpdate) => void;
}
const ProfilePage: React.FC<IProfileParams> = (props) => {
const [activeSheet, setActiveSheet] = useState<string>("profile");
const settings = React.useContext(SettingsContext);
const profile = props.profile;
const name = profile.fullName || "Unknown";
return (
<Row>
<Col xs="3">
<ListGroup>
<ListGroupItem
active={activeSheet === "profile"}
tag="button"
onClick={() => {
setActiveSheet("profile");
}}
action
>
Profile
</ListGroupItem>
<ListGroupItem
active={activeSheet === "eligibility"}
tag="button"
onClick={() => {
setActiveSheet("eligibility");
}}
action
>
Payout Eligibility
</ListGroupItem>
<ListGroupItem
active={activeSheet === "payouts"}
tag="button"
onClick={() => {
setActiveSheet("payouts");
}}
action
>
Payouts
</ListGroupItem>
<PermissionGuard
requiredPermissions={[
"ASSIGN_ASSOCIATES_TO_DEPARTMENT"
]}
>
<EmployeeStatusGuard employee={profile}>
<ListGroupItem
active={activeSheet === "departments"}
tag="button"
onClick={() => {
setActiveSheet("departments");
}}
action
>
Manage Departments
</ListGroupItem>
</EmployeeStatusGuard>
</PermissionGuard>
<PermissionGuard
requiredPermissions={[
"ASSIGN_PERMISSIONS_TO_ASSOCIATES"
]}
>
<EmployeeStatusGuard employee={profile}>
<ListGroupItem
active={activeSheet === "permissions"}
tag="button"
onClick={() => {
setActiveSheet("permissions");
}}
action
>
Manage Permissions
</ListGroupItem>
</EmployeeStatusGuard>
</PermissionGuard>
<PermissionGuard
requiredPermissions={["MANAGE_EMPLOYEE_DETAILS"]}
>
<EmployeeStatusGuard employee={profile}>
<ListGroupItem
active={activeSheet === "employeeDetails"}
tag="button"
onClick={() => {
setActiveSheet("employeeDetails");
}}
action
>
Manage Employee Details
</ListGroupItem>
</EmployeeStatusGuard>
</PermissionGuard>
</ListGroup>
<br />
</Col>
<Col xs="auto">
<h3>{name}</h3>
{profile.terminated ? (
<Alert color="warning">
Employee Not Active As Of{" "}
{moment(profile.terminated).format("MM/DD/YYYY")}
</Alert>
) : (
""
)}
{(() => {
switch (activeSheet) {
case "departments":
return (
<PermissionGuard
requiredPermissions={[
"ASSIGN_ASSOCIATES_TO_DEPARTMENT"
]}
>
<EmployeePage
employeeId={profile.employeeId}
/>
</PermissionGuard>
);
case "permissions":
return (
<PermissionGuard
requiredPermissions={[
"ASSIGN_PERMISSIONS_TO_ASSOCIATES"
]}
>
<PermissionsPage
additionalPermissions={
profile.additionalPermissions ?? []
}
rolePermissions={
profile.role.permissions
}
allPermissions={
settings?.permissions ?? []
}
refreshProfile={() => {
props.refreshProfile();
}}
employeeId={profile.employeeId}
/>
</PermissionGuard>
);
case "employeeDetails":
return (
<PermissionGuard
requiredPermissions={[
"MANAGE_EMPLOYEE_DETAILS"
]}
>
<EmployeeDetailsPage
profile={profile}
employeeDetailsUpdateResult={
props.employeeDetailsUpdateResult
}
submitStatus={props.submitStatus}
refreshProfile={() => {
props.refreshProfile();
}}
submitEmployeeDetailsUpdate={
props.submitEmployeeDetailsUpdate
}
/>
</PermissionGuard>
);
case "payouts":
return (
<PayoutPage employeeId={profile.employeeId} />
);
case "eligibility":
return <EligiblePayouts profile={profile} />;
case "profile":
default:
return <ProfileView profile={profile} />;
}
})()}
</Col>
</Row>
);
};
export default ProfilePage;
@@ -0,0 +1,44 @@
import React from "react";
import { AutoTable } from "../../components/auto-table/AutoTable";
import Translate from "./../../translate/Translate";
import { useHistory } from "react-router-dom";
import { IEmployeeSummary } from "../../shared/models/Employee";
export interface IProfileTableProps {
profiles: IEmployeeSummary[];
}
const ProfileResultsTable: React.FC<IProfileTableProps> = (props) => {
const history = useHistory();
const headers = [
Translate.Get.containers.profileResultsTable.headers.name,
Translate.Get.containers.profileResultsTable.headers.id,
Translate.Get.containers.profileResultsTable.headers.email
];
const cells = [
(x: IEmployeeSummary) => x.fullName,
(x: IEmployeeSummary) => x.employeeId,
(x: IEmployeeSummary) => x.adEmail
];
return (
<div>
<div className="p-2">
<h5>{Translate.Get.containers.profileResultsTable.title}</h5>
<AutoTable
className="border rounded"
cells={cells}
headers={headers}
elements={props.profiles}
onRowClick={(selection: IEmployeeSummary) => {
history.push("/profile/" + selection.employeeId);
}}
/>
</div>
</div>
);
};
export default ProfileResultsTable;
@@ -0,0 +1,57 @@
import { mount } from "enzyme";
import React from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import * as reactRedux from "react-redux";
import { IProfileState } from "../../reducers/ProfileReducer";
import ProfileSearchPage from "./ProfileSearchPage";
describe("A component for finding employee profiles", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("Displays a blank search form", () => {
const profile: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Role1",
description: "NONE",
permissions: [
{ code: "VIEW_DASHBOARD", description: "" }
],
hasCommissions: true
},
additionalPermissions: [],
departments: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(profile);
let wrapper = mount(<ProfileSearchPage />);
const found = wrapper.find("[data-test='profile-search-page']");
expect(found.length).toBe(1);
expect(dummyDispatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { profileSearch } from "../../actions/ProfileActions";
import { IAppState } from "../../store/Store";
import { IProfileState } from "../../reducers/ProfileReducer";
import AsyncState from "../../shared/enumerations/AsyncState";
import Translate from "../../translate/Translate";
import { FormGroup, Input, Label } from "reactstrap";
import { debounce } from "debounce";
import ProfileResultsTable from "./ProfileResultsTable";
const profileTranslate = Translate.Get.components.profileSearch;
const ProfileSearchPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState<string>("");
const dispatch = useDispatch();
useEffect(() => {
if (searchQuery && searchQuery.length > 1) {
search(searchQuery);
}
}, [searchQuery]);
const search = useCallback(
debounce((query: string) => {
dispatch(profileSearch(query));
}, 500),
[]
);
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const profileResults = profileState.searchResults;
return (
<div data-test="profile-search-page">
<FormGroup>
<Label>{profileTranslate.title}</Label>
<Input
name="AccountNumber"
bsSize="sm"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
}}
type="text"
placeholder={profileTranslate.placeholder}
/>
</FormGroup>
{profileResults.state === AsyncState.Success ? (
<ProfileResultsTable profiles={profileResults?.data || []} />
) : (
<h4>{searchQuery ? profileTranslate.loading : ""}</h4>
)}
</div>
);
};
export default ProfileSearchPage;
@@ -0,0 +1,102 @@
import React, { useEffect, useState } from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import Translate from "../../translate/Translate";
import { useParams } from "react-router-dom";
import ProfilePage from "./ProfilePage";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
import {
IEmployee,
IEmployeeDetailsUpdate,
IEmployeeDetailsUpdateResult
} from "../../shared/models/Employee";
import ProfileDao from "../../shared/dao/ProfileDao";
import { Alert } from "reactstrap";
interface IRouteMatchParams {
employeeId?: string;
}
const profileEditTranslate = Translate.Get.components.profileEdit;
const ProfileSelector: React.FC = () => {
const [profile, setProfile] = useState<IAsyncStateContainer<IEmployee>>({
state: AsyncState.Inactive
});
const [updateFeedback, setUpdateFeedback] = useState<string>("");
const [submitStatus, setUpdatedSubmitStatus] = useState<
IAsyncStateContainer<string>
>({
state: AsyncState.Inactive
});
const { employeeId } = useParams<IRouteMatchParams>();
const refreshUserProfile = () => {
ProfileDao.getProfile(employeeId).then(
(result) => {
setProfile(result);
},
(errorResult) => {
setProfile(errorResult);
}
);
};
const submitCurrentEmployeeDetailsUpdate = async (
update: IEmployeeDetailsUpdate
) => {
setUpdateFeedback(profileEditTranslate.loadingDetailUpdate);
submitStatus.state = AsyncState.Loading;
if (update) {
ProfileDao.submitEmployeeDetailsUpdate(update).then((result) => {
if (result.data) {
setSubmitStatus(result.data);
setUpdateFeedback(result.data.updateMessage);
refreshUserProfile();
}
});
}
};
const setSubmitStatus = async (update: IEmployeeDetailsUpdateResult) => {
if (update.updatedSuccessfully) {
submitStatus.state = AsyncState.Success;
} else {
submitStatus.state = AsyncState.Failure;
}
};
useEffect(() => {
refreshUserProfile();
}, []);
if (profile.state === AsyncState.Success && profile.data)
return (
<ProfilePage
profile={profile.data}
employeeDetailsUpdateResult={updateFeedback}
submitStatus={submitStatus}
refreshProfile={() => refreshUserProfile()}
submitEmployeeDetailsUpdate={(
employeeDetailsUpdate: IEmployeeDetailsUpdate
) => submitCurrentEmployeeDetailsUpdate(employeeDetailsUpdate)}
/>
);
if (profile.state === AsyncState.Failure && profile.data)
return (
<ProfilePage
profile={profile.data}
employeeDetailsUpdateResult={updateFeedback}
submitStatus={submitStatus}
refreshProfile={() => refreshUserProfile()}
submitEmployeeDetailsUpdate={(
employeeDetailsUpdate: IEmployeeDetailsUpdate
) => submitCurrentEmployeeDetailsUpdate(employeeDetailsUpdate)}
/>
);
return <h4>{profileEditTranslate.loading}</h4>;
};
export default ProfileSelector;
@@ -0,0 +1,17 @@
## ProfileSearchPage
This component is used to search for a profile.
## ProfileResultsTable
This component is used to display employee search results.
## ProfileSelector
Responsible for choosing between the current appUser's
profile and the optional route parameter if they were
using the search or a deep link, then show that profile.
## ProfilePage
The actual profile management page.
@@ -0,0 +1,37 @@
import React from "react";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import Translate from "../../translate/Translate";
import { Button, Card, CardDeck } from "reactstrap";
const ReportPage: React.FC = () => {
const settings = React.useContext(SettingsContext);
const reports = settings?.authorizedReports;
return reports ? (
<div>
<h2>{Translate.Get.containers.reportPage.title}</h2>
<CardDeck className={"FlexCenter"}>
<div className={"FittedCard"}>
{reports.map((reportSettings) => {
return (
<Card body outline color="info">
<Button
color="primary"
onClick={() => {
window.open(reportSettings.url);
}}
>
{reportSettings.name}
</Button>
</Card>
);
})}
</div>
</CardDeck>
</div>
) : (
<h3>No Authorized Reports Found</h3>
);
};
export default ReportPage;
@@ -0,0 +1,22 @@
import React from "react";
import { RouteComponentProps } from "react-router-dom";
import Translate from "./../../translate/Translate";
import RoleTable from "./RoleTable";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
type RoleManagementProps = RouteComponentProps;
const RoleManagement: React.FC<RoleManagementProps> = () => {
const settings = React.useContext(SettingsContext);
return (
<div>
<h3>{Translate.Get.containers.roleManagement.pageTitle}</h3>
<RoleTable
permissionSet={settings?.permissions ?? []}
appRoles={settings?.appRoles ?? []}
/>
</div>
);
};
export default RoleManagement;
@@ -0,0 +1,66 @@
import React from "react";
import { AutoTable } from "../../components/auto-table/AutoTable";
import Translate from "./../../translate/Translate";
import { IAppRole, IPermission } from "../../shared/models/Settings";
import PermissionDisplay from "../../components/permission/PermissionDisplay";
export interface RoleTableProps {
appRoles: IAppRole[];
permissionSet: IPermission[];
}
const RoleTable: React.FC<RoleTableProps> = (props) => {
const headers = [
Translate.Get.containers.roleManagement.roleTable.headers.name,
Translate.Get.containers.roleManagement.roleTable.headers.description,
Translate.Get.containers.roleManagement.roleTable.headers.permissions
];
const cells = [
(x: IAppRole) => <b>{x.name}</b>,
(x: IAppRole) => x.description,
(x: IAppRole) => (
<PermissionDisplay permissions={x.permissions}></PermissionDisplay>
)
];
/**
* Sorts by Params Count, then Sorts by Alpha
* Param Count Desc (More Params = Higher on List)
* Alpha Desc (A on top Z on bottom)
*/
const CompareByPermsThenAlpha = (a: IAppRole, b: IAppRole) => {
if (
a.permissions.length - b.permissions.length > 0 ||
a.name < b.name
) {
return -1;
} else if (
a.permissions.length - b.permissions.length < 0 ||
a.name > b.name
) {
return 1;
} else return 0;
};
return (
<div>
<div className="p-2">
<h5>
{Translate.Get.containers.roleManagement.roleTable.title}
</h5>
<div className="border-radius-2 border-1">
<AutoTable
className="border rounded"
cells={cells}
headers={headers}
elements={props.appRoles.sort((a, b) =>
CompareByPermsThenAlpha(a, b)
)}
/>
</div>
</div>
</div>
);
};
export default RoleTable;
@@ -0,0 +1,45 @@
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
getAllRoleMappings,
submitRoleMappingUpdate
} from "../../actions/RoleMappingActions";
import { IRoleMappingState } from "../../reducers/RoleMappingReducer";
import { IAppState } from "../../store/Store";
import RoleMappingTable from "../../components/role-mapping/RoleMappingTable";
import { IRoleMappingChange } from "../../shared/models/RoleMapping";
const RoleMappingPage: React.FC = () => {
const dispatch = useDispatch();
const roleMappingState = useSelector<IAppState, IRoleMappingState>(
(state) => state.roleMappingState
);
const roleMappings = roleMappingState.roleMappings.data ?? [];
const submitCurrentRoleMappingUpdate = async (
update: IRoleMappingChange
) => {
if (roleMappingState.roleMappings.data) {
await dispatch(submitRoleMappingUpdate(update));
await dispatch(getAllRoleMappings());
}
};
useEffect(() => {
dispatch(getAllRoleMappings());
}, []);
return (
<div>
<RoleMappingTable
roleMappings={roleMappings}
submitRoleMappingUpdate={(
roleMappingUpdate: IRoleMappingChange
) => submitCurrentRoleMappingUpdate(roleMappingUpdate)}
/>
</div>
);
};
export default RoleMappingPage;
@@ -0,0 +1,48 @@
import { mount } from "enzyme";
import React from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import AsyncGuard from "./AsyncGuard";
describe("A component for displaying async containers", () => {
it("Displays an async status: Loading", () => {
const container = {
data: [],
state: AsyncState.Loading
};
let wrapper = mount(
<AsyncGuard container={container}>
<>!!ERROR!!</>
</AsyncGuard>
);
expect(wrapper.html()).toBe("Loading...");
});
it("Displays an async custom status: Error", () => {
const container = {
data: [],
state: AsyncState.Failure
};
let wrapper = mount(
<AsyncGuard container={container} error={<h1>OH NO</h1>}>
<>!!ERROR!!</>
</AsyncGuard>
);
expect(wrapper.html()).toBe("<h1>OH NO</h1>");
});
it("Displays an async child on Success", () => {
const container = {
data: [],
state: AsyncState.Success
};
let wrapper = mount(
<AsyncGuard container={container}>
<>YES</>
</AsyncGuard>
);
expect(wrapper.html()).toBe("YES");
});
});
@@ -0,0 +1,35 @@
import React from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import IAsyncStateContainer from "../../shared/types/IAsyncStateContainer";
export interface IAsyncProps {
container: IAsyncStateContainer<any>;
children: JSX.Element;
error?: JSX.Element;
loading?: JSX.Element;
notFound?: JSX.Element;
inactive?: JSX.Element;
}
const AsyncGuard: React.FC<IAsyncProps> = (props) => {
switch (props.container.state) {
case AsyncState.Inactive:
default:
return props.inactive ? props.inactive : <></>;
case AsyncState.Loading:
return props.loading ? props.loading : <>Loading...</>;
case AsyncState.Invalid:
case AsyncState.Failure:
return props.error ? props.error : <>Data failed to load</>;
case AsyncState.NotFound:
return props.notFound ? props.notFound : <>Data was not found</>;
case AsyncState.Success:
return props.container.data ? (
props.children
) : (
<>No Data Returned</>
);
}
};
export default AsyncGuard;
@@ -0,0 +1,25 @@
import { mount } from "enzyme";
import React from "react";
import BusinessLineDisplay from "./BusinessLineDisplay";
import { getMockAppSettings } from "../../shared/testing/mocks/MockAppSettings";
describe("A component for displaying business line", () => {
const useSelectorMock = jest.spyOn(React, "useContext");
beforeEach(() => {
useSelectorMock.mockClear();
});
it("Displays a valid business line name", () => {
useSelectorMock.mockReturnValue(getMockAppSettings());
let wrapper = mount(<BusinessLineDisplay businessLineCode="OPS" />);
expect(wrapper.html()).toBe("Operations");
});
it("Displays an invalid business line name for a code", () => {
useSelectorMock.mockReturnValue(getMockAppSettings());
let wrapper = mount(<BusinessLineDisplay businessLineCode="XYZ" />);
expect(wrapper.html()).toBe("Missing");
});
});
@@ -0,0 +1,21 @@
import React from "react";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
interface IBusinessLineDisplayProps {
businessLineCode: string;
}
const BusinessLineDisplay: React.FC<IBusinessLineDisplayProps> = (props) => {
const settings = React.useContext(SettingsContext);
const businessName = settings?.businessLines.find(
(x) => x.code === props.businessLineCode
);
return props.businessLineCode && businessName ? (
<>{businessName.name}</>
) : (
<>Missing</>
);
};
export default BusinessLineDisplay;
@@ -0,0 +1,58 @@
import { mount } from "enzyme";
import React from "react";
import DepartmentDisplay from "./DepartmentDisplay";
import AsyncState from "../../shared/enumerations/AsyncState";
import { IDepartmentState } from "../../reducers/DepartmentReducer";
import { getMockAppSettings } from "../../shared/testing/mocks/MockAppSettings";
import { getMockDepartments } from "../../shared/testing/mocks/MockDepartments";
describe("A component for displaying departments", () => {
const useContextMock = jest.spyOn(React, "useContext");
beforeEach(() => {
useContextMock.mockClear();
});
it("Displays a valid department", () => {
const departments: IDepartmentState = {
departments: {
data: [
{
id: 4,
departmentId: 5,
departmentName: "bad test",
state: "Active",
businessLineCode: "XY"
},
{
id: 5,
departmentId: 6,
departmentName: "good test",
state: "Active",
businessLineCode: "XY"
}
],
state: AsyncState.Success
},
submitState: {
state: AsyncState.Success
}
};
const settings = getMockAppSettings();
settings.departments = getMockDepartments();
useContextMock.mockReturnValue(settings);
const wrapper = mount(<DepartmentDisplay departmentId={55} />);
const found = wrapper.find('[data-test="department-display-wrapper"]');
expect(found.length).toBe(1);
expect(found.children().first().props().title).toBe("55");
expect(found.children().first().text()).toBe("WAUWATOSA");
});
it("Displays a invalid department", () => {
const wrapper = mount(<DepartmentDisplay departmentId={55} />);
const found = wrapper.find('[data-test="department-display-wrapper"]');
expect(found.length).toBe(1);
expect(found.children().first().text()).toBe("Missing");
});
});
@@ -0,0 +1,47 @@
import React from "react";
import { SettingsContext } from "../../shared/providers/SettingsProvider";
import Configuration from "../../shared/configuration/Configuration";
export enum DepartmentDisplayTargets {
Detail = "detail",
Dashboard = "dashboard"
}
interface IDepartmentDisplayProps {
departmentId: number;
target?: DepartmentDisplayTargets;
nameOverride?: string;
}
const DepartmentDisplay: React.FC<IDepartmentDisplayProps> = (props) => {
const settings = React.useContext(SettingsContext);
const department = settings?.departments.find(
(x) => x.id === props.departmentId
);
let href = Configuration.PrefixAppUrlWithBasename(
Configuration.Get.appUrls.department
).replace(":id", props.departmentId.toString());
if (props.target && props.target === DepartmentDisplayTargets.Dashboard) {
href =
Configuration.PrefixAppUrlWithBasename(
Configuration.Get.appUrls.dashboard
) +
"?dashboard=department&departmentId=" +
props.departmentId;
}
return (
<span data-test="department-display-wrapper">
{props.departmentId && department ? (
<a href={href} title={department.id + ""}>
{props.nameOverride ?? department.departmentName}
</a>
) : (
<>Missing</>
)}
</span>
);
};
export default DepartmentDisplay;
@@ -0,0 +1,17 @@
import React from "react";
import { IEmployee } from "../../shared/models/Employee";
export interface IEmployeeStatusGuardProps {
employee: IEmployee;
children: JSX.Element;
}
const EmployeeStatusGuard: React.FC<IEmployeeStatusGuardProps> = (props) => {
const shouldRenderChildren = () => {
return props.employee && !props.employee.terminated;
};
return shouldRenderChildren() ? props.children : <></>;
};
export default EmployeeStatusGuard;
@@ -0,0 +1,211 @@
import { mount } from "enzyme";
import React from "react";
import AsyncState from "../../shared/enumerations/AsyncState";
import * as reactRedux from "react-redux";
import { IProfileState } from "../../reducers/ProfileReducer";
import PermissionGuard from "./PermissionGuard";
import moment from "moment";
import { UserType } from "../../shared/models/Auditable";
import PermissionCode from "../../shared/enumerations/PermissionCode";
describe("A component for requiring permissions", () => {
const useSelectorMock = jest.spyOn(reactRedux, "useSelector");
const useDispatchMock = jest.spyOn(reactRedux, "useDispatch");
beforeEach(() => {
useSelectorMock.mockClear();
useDispatchMock.mockClear();
});
it("Displays children when role permission present", () => {
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useParams: () => ({
employeeId: "11771"
})
}));
const profile: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: "VIEW_DASHBOARD",
description: "View Dashboard"
}
],
hasCommissions: false
},
additionalPermissions: [],
departments: [
{
id: 1,
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: "VIEW_DASHBOARD",
description: "View Dashboard"
}
],
hasCommissions: false
},
departmentId: 114,
employeeId: "11771",
isHome: true,
note: "asdf",
slidingPayout: 100,
startDate: moment(),
lastUpdated: moment(),
createdByType: UserType.Associate,
createdBy: "asdf",
endDate: undefined,
lastUpdatedByType: UserType.Associate,
created: moment(),
lastUpdatedBy: "asdf"
}
],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(profile);
let wrapper = mount(
<PermissionGuard requiredPermissions={["VIEW_DASHBOARD"]}>
<h1>Authorized</h1>
</PermissionGuard>
);
expect(wrapper.html()).toBe("<h1>Authorized</h1>");
});
it("Displays children when additional permission present", () => {
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useParams: () => ({
employeeId: "11771"
})
}));
const profile: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [],
hasCommissions: false
},
additionalPermissions: [
{
id: 1,
created: moment().utc(),
createdByType: UserType.System,
createdBy: "Tester1",
permission: {
code: "VIEW_DASHBOARD",
description: "View Dashboard"
}
}
],
departments: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(profile);
const wrapper = mount(
<PermissionGuard
requiredPermissions={[PermissionCode.VIEW_DASHBOARD]}
>
<h1>Authorized</h1>
</PermissionGuard>
);
expect(wrapper.html()).toBe("<h1>Authorized</h1>");
});
it("Displays nothing when permission not present", () => {
jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useParams: () => ({
employeeId: "11771"
})
}));
const profile: IProfileState = {
profile: {
data: {
adEmail: "[email protected]",
personId: 11,
employeeId: "11771",
homeDepartment: "HOME",
baseRole: "QA",
fullName: "John Tester",
role: {
name: "Admin",
description: "admin",
permissions: [
{
code: "VIEW_DASHBOARD",
description: "View Dashboard"
}
],
hasCommissions: false
},
additionalPermissions: [],
departments: [],
assignments: []
},
state: AsyncState.Success
},
searchResults: {
state: AsyncState.Success,
data: []
}
};
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue(profile);
let wrapper = mount(
<PermissionGuard requiredPermissions={["ALL_THE_THINGS"]}>
<h1>Authorized</h1>
</PermissionGuard>
);
expect(wrapper.html()).toBe(null);
});
});
@@ -0,0 +1,47 @@
import React from "react";
import { useSelector } from "react-redux";
import { IAppState } from "../../store/Store";
import { IProfileState } from "../../reducers/ProfileReducer";
import AsyncState from "../../shared/enumerations/AsyncState";
import { Alert } from "reactstrap";
import ProfileService from "../../shared/services/ProfileService";
export interface IPermissionProps {
requiredPermissions: string[];
showWarning?: boolean;
children: JSX.Element;
}
const PermissionGuard: React.FC<IPermissionProps> = (props) => {
const profileState = useSelector<IAppState, IProfileState>(
(state) => state.profileState
);
const profile = profileState.profile;
const shouldRenderChildren = () => {
const requiredPermissions = props.requiredPermissions;
if (!requiredPermissions || requiredPermissions.length < 1) {
return true;
}
if (profile.state !== AsyncState.Success || !profile.data) {
return false;
}
const profileData = profile.data;
return ProfileService.hasAnyPermission(
profileData,
requiredPermissions
);
};
if (shouldRenderChildren()) {
return props.children;
}
return props.showWarning ? (
<Alert color="danger">You do not have access.</Alert>
) : (
<></>
);
};
export default PermissionGuard;

Some files were not shown because too many files have changed in this diff Show More