diff --git a/.vscode/launch.json b/.vscode/launch.json index d1478c8b4..86e52d266 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -50,4 +50,4 @@ "stopAll": true } ] -} \ No newline at end of file +} diff --git a/src/CareTogether.Api/Controllers/ConfigurationController.cs b/src/CareTogether.Api/Controllers/ConfigurationController.cs index 0768ec570..8f8bc1791 100644 --- a/src/CareTogether.Api/Controllers/ConfigurationController.cs +++ b/src/CareTogether.Api/Controllers/ConfigurationController.cs @@ -432,6 +432,25 @@ Guid locationId return Ok(result); } + [HttpPut("/api/{organizationId:guid}/{locationId:guid}/[controller]/policy")] +public async Task> PutEffectiveLocationPolicy( + Guid organizationId, + Guid locationId, + [FromBody] EffectiveLocationPolicy policy +) +{ + if (!User.IsInRole(SystemConstants.ORGANIZATION_ADMINISTRATOR)) + return Forbid(); + + var result = await policiesResource.UpsertEffectiveLocationPolicyAsync( + organizationId, + locationId, + policy + ); + + return Ok(result); +} + [HttpGet("/api/{organizationId:guid}/{locationId:guid}/[controller]/flags")] public async Task> GetLocationFlags(Guid organizationId) { diff --git a/src/CareTogether.Core/Resources/Policies/PoliciesResource.cs b/src/CareTogether.Core/Resources/Policies/PoliciesResource.cs index d0822bfed..09aa494da 100644 --- a/src/CareTogether.Core/Resources/Policies/PoliciesResource.cs +++ b/src/CareTogether.Core/Resources/Policies/PoliciesResource.cs @@ -218,9 +218,8 @@ af with .ToImmutableList(), }, }; - - return effectivePolicy; - } + return effectivePolicy; +} public async Task GetOrganizationSecretsAsync(Guid organizationId) { diff --git a/src/caretogether-pwa/package-lock.json b/src/caretogether-pwa/package-lock.json index 5ccfa1164..3cadd2b85 100644 --- a/src/caretogether-pwa/package-lock.json +++ b/src/caretogether-pwa/package-lock.json @@ -28,6 +28,7 @@ "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.5.2", "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "dexie": "^3.2.7", "dexie-react-hooks": "^1.1.7", "posthog-js": "^1.260.1", @@ -2945,6 +2946,11 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/src/caretogether-pwa/package.json b/src/caretogether-pwa/package.json index 01702a930..c05951356 100644 --- a/src/caretogether-pwa/package.json +++ b/src/caretogether-pwa/package.json @@ -24,6 +24,7 @@ "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.5.2", "date-fns": "^3.6.0", + "dayjs": "^1.11.13", "dexie": "^3.2.7", "dexie-react-hooks": "^1.1.7", "posthog-js": "^1.260.1", diff --git a/src/caretogether-pwa/src/GeneratedClient.ts b/src/caretogether-pwa/src/GeneratedClient.ts index b02df1337..4553a05d3 100644 --- a/src/caretogether-pwa/src/GeneratedClient.ts +++ b/src/caretogether-pwa/src/GeneratedClient.ts @@ -282,6 +282,50 @@ export class ConfigurationClient { return Promise.resolve(null as any); } + putEffectiveLocationPolicy(organizationId: string, locationId: string, policy: EffectiveLocationPolicy): Promise { + let url_ = this.baseUrl + "/api/{organizationId}/{locationId}/Configuration/policy"; + if (organizationId === undefined || organizationId === null) + throw new Error("The parameter 'organizationId' must be defined."); + url_ = url_.replace("{organizationId}", encodeURIComponent("" + organizationId)); + if (locationId === undefined || locationId === null) + throw new Error("The parameter 'locationId' must be defined."); + url_ = url_.replace("{locationId}", encodeURIComponent("" + locationId)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(policy); + + let options_: RequestInit = { + body: content_, + method: "PUT", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processPutEffectiveLocationPolicy(_response); + }); + } + + protected processPutEffectiveLocationPolicy(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + let resultData200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver); + result200 = EffectiveLocationPolicy.fromJS(resultData200); + return result200; + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + getLocationFlags(organizationId: string, locationId: string): Promise { let url_ = this.baseUrl + "/api/{organizationId}/{locationId}/Configuration/flags"; if (organizationId === undefined || organizationId === null) diff --git a/src/caretogether-pwa/src/Model/ConfigurationModel.ts b/src/caretogether-pwa/src/Model/ConfigurationModel.ts index b4566cc4b..8df6a978c 100644 --- a/src/caretogether-pwa/src/Model/ConfigurationModel.ts +++ b/src/caretogether-pwa/src/Model/ConfigurationModel.ts @@ -3,6 +3,7 @@ import { OrganizationConfiguration, RequirementStage, VolunteerFamilyRequirementScope, + EffectiveLocationPolicy, } from '../GeneratedClient'; import { useLoadable } from '../Hooks/useLoadable'; import { api } from '../Api/Api'; @@ -15,6 +16,12 @@ export const organizationConfigurationEdited = default: null, }); +export const effectiveLocationPolicyEdited = + atom({ + key: 'effectiveLocationPolicyEdited', + default: null, + }); + export type ExtendedOrganizationConfiguration = OrganizationConfiguration & { availableTimeZones?: string[]; ethnicities?: string[]; @@ -70,12 +77,13 @@ export const adultFamilyRelationshipsData = selector({ export const policyData = selector({ key: 'policyData', get: async ({ get }) => { + const edited = get(effectiveLocationPolicyEdited); + if (edited) return edited; const { organizationId, locationId } = get(selectedLocationContextState); - const dataResponse = await api.configuration.getEffectiveLocationPolicy( + return await api.configuration.getEffectiveLocationPolicy( organizationId, locationId ); - return dataResponse; }, }); diff --git a/src/caretogether-pwa/src/Settings/Locations/LocationEdit.tsx b/src/caretogether-pwa/src/Settings/Locations/LocationEdit.tsx index aea195c11..8837f9203 100644 --- a/src/caretogether-pwa/src/Settings/Locations/LocationEdit.tsx +++ b/src/caretogether-pwa/src/Settings/Locations/LocationEdit.tsx @@ -8,7 +8,10 @@ import { } from '@mui/material'; import { useState, useEffect, useMemo } from 'react'; import { useLoadable } from '../../Hooks/useLoadable'; -import { organizationConfigurationQuery } from '../../Model/ConfigurationModel'; +import { + organizationConfigurationQuery, + policyData, +} from '../../Model/ConfigurationModel'; import { ProgressBackdrop } from '../../Shell/ProgressBackdrop'; import { useScreenTitle } from '../../Shell/ShellScreenTitle'; import { useRecoilValue } from 'recoil'; @@ -41,6 +44,11 @@ export function LocationEdit() { (location) => location.id === editingLocationId ); + const effectiveLocationPolicy = useRecoilValue(policyData); + + // TODO: Use this to implement the other tabs + console.log({ effectiveLocationPolicy }); + useScreenTitle(`Editing ${location?.name} configuration`); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); @@ -221,6 +229,12 @@ export function LocationEdit() { )} + {activeTab === 'actions' && ( + + + + )} + {activeTab === 'accessLevels' && ( diff --git a/src/caretogether-pwa/src/Settings/Locations/Tabs/ActionDefinitions.tsx b/src/caretogether-pwa/src/Settings/Locations/Tabs/ActionDefinitions.tsx index 8b809971e..a27d720d0 100644 --- a/src/caretogether-pwa/src/Settings/Locations/Tabs/ActionDefinitions.tsx +++ b/src/caretogether-pwa/src/Settings/Locations/Tabs/ActionDefinitions.tsx @@ -1,5 +1,171 @@ -import { Typography } from '@mui/material'; +import { + Box, + Button, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, + Paper, + Link, + TablePagination, + Drawer, + InputAdornment, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useState, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { policyData } from '../../../Model/ConfigurationModel'; +import { AddActionDefinition } from './AddActionDefinition'; + +const requirementLabel: Record = { + 0: 'None', + 1: 'Allowed', + 2: 'Required', +}; + +function truncate(text?: string | null, length = 40) { + if (!text) return '-'; + return text.length <= length ? text : text.slice(0, length) + '…'; +} + +function formatValidity(value?: string | null) { + if (!value) return '-'; + const [days] = value.split('.'); + return `${days} days`; +} export default function ActionDefinitions() { - return Action Definitions; + const effectiveLocationPolicy = useRecoilValue(policyData); + const actionDefinitions = effectiveLocationPolicy?.actionDefinitions; + + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(0); + const rowsPerPage = 5; + const [openDrawer, setOpenDrawer] = useState(false); + + const entries = Object.entries(actionDefinitions ?? {}); + + const filtered = useMemo( + () => + entries.filter(([name]) => + name.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [entries, searchTerm] + ); + + const paginated = filtered.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage + ); + + if (!actionDefinitions) { + return No action definitions found.; + } + + return ( + + + Action Definitions + + + + + + ), + }} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> + + + + + + Name + Document Link + Note + Instructions + Info Link + Validity + + + + + {paginated.map(([name, def]) => ( + + + {name} + + {def.alternateNames && def.alternateNames.length > 0 && ( + + {def.alternateNames.join(', ')} + + )} + + + {requirementLabel[def.documentLink]} + {requirementLabel[def.noteEntry]} + {truncate(def.instructions)} + + {def.infoLink ? ( + + {def.infoLink} + + ) : ( + '-' + )} + + {formatValidity(def.validity)} + + ))} + +
+
+ + setPage(newPage)} + rowsPerPage={rowsPerPage} + rowsPerPageOptions={[]} + /> + + + + + + setOpenDrawer(false)} + > + + setOpenDrawer(false)} /> + + +
+ ); } diff --git a/src/caretogether-pwa/src/Settings/Locations/Tabs/AddActionDefinition.tsx b/src/caretogether-pwa/src/Settings/Locations/Tabs/AddActionDefinition.tsx new file mode 100644 index 000000000..c0b4c5fc0 --- /dev/null +++ b/src/caretogether-pwa/src/Settings/Locations/Tabs/AddActionDefinition.tsx @@ -0,0 +1,215 @@ +import { Button, TextField, Typography, Stack, MenuItem } from '@mui/material'; +import { useForm, Controller } from 'react-hook-form'; +import { useRecoilValue } from 'recoil'; +import { selectedLocationContextState } from '../../../Model/Data'; +import { api } from '../../../Api/Api'; +import { + EffectiveLocationPolicy, + ActionRequirement, +} from '../../../GeneratedClient'; +import { useSetRecoilState } from 'recoil'; +import { effectiveLocationPolicyEdited } from '../../../Model/ConfigurationModel'; + +interface DrawerProps { + onClose: () => void; +} + +interface AddActionDefinitionFormValues { + name: string; + alternateNames: string; + instructions: string; + infoLink: string; + documentRequirement: 0 | 1 | 2; + noteRequirement: 0 | 1 | 2; + validityInDays: number | null; +} + +const requirementOptions = [ + { label: 'None', value: 0 }, + { label: 'Allowed', value: 1 }, + { label: 'Required', value: 2 }, +]; + +export function AddActionDefinition({ onClose }: DrawerProps) { + const { organizationId, locationId } = useRecoilValue( + selectedLocationContextState + ); + + const setEditedPolicy = useSetRecoilState(effectiveLocationPolicyEdited); + + const { + handleSubmit, + control, + formState: { errors, isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + name: '', + alternateNames: '', + instructions: '', + infoLink: '', + documentRequirement: 0, + noteRequirement: 0, + validityInDays: null, + }, + }); + + const convertToBackend = (data: AddActionDefinitionFormValues) => { + const parsedAlternateNames = data.alternateNames + ? data.alternateNames.split(',').map((x) => x.trim()) + : []; + + const validity = + data.validityInDays && data.validityInDays > 0 + ? `${data.validityInDays}.00:00:00` + : undefined; + + return { + actionName: data.name, + alternateNames: parsedAlternateNames, + instructions: data.instructions || undefined, + infoLink: data.infoLink || undefined, + documentLink: data.documentRequirement, + noteEntry: data.noteRequirement, + validity, + canView: undefined, + canEdit: undefined, + }; + }; + + const onSubmit = async (values: AddActionDefinitionFormValues) => { + const newAction = convertToBackend(values); + + const currentPolicy = await api.configuration.getEffectiveLocationPolicy( + organizationId, + locationId + ); + + const updatedPolicy = new EffectiveLocationPolicy({ + ...currentPolicy, + actionDefinitions: { + ...currentPolicy.actionDefinitions, + [newAction.actionName]: new ActionRequirement(newAction), + }, + }); + + await api.configuration.putEffectiveLocationPolicy( + organizationId, + locationId, + updatedPolicy as unknown as EffectiveLocationPolicy + ); + + setEditedPolicy(updatedPolicy); + + onClose(); + }; + + return ( +
+ + Add New Action Definition + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + {requirementOptions.map((opt) => ( + + {opt.label} + + ))} + + )} + /> + + ( + + {requirementOptions.map((opt) => ( + + {opt.label} + + ))} + + )} + /> + + ( + + )} + /> + + + + + + +
+ ); +} diff --git a/swagger.json b/swagger.json index 920eee18d..5d9fe6f8d 100644 --- a/swagger.json +++ b/swagger.json @@ -278,6 +278,58 @@ } } } + }, + "put": { + "tags": [ + "Configuration" + ], + "operationId": "Configuration_PutEffectiveLocationPolicy", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "guid" + }, + "x-position": 1 + }, + { + "name": "locationId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "guid" + }, + "x-position": 2 + } + ], + "requestBody": { + "x-name": "policy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EffectiveLocationPolicy" + } + } + }, + "required": true, + "x-position": 3 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EffectiveLocationPolicy" + } + } + } + } + } } }, "/api/{organizationId}/{locationId}/Configuration/flags": { diff --git a/test/CareTogether.TestData/TestDataProvider.cs b/test/CareTogether.TestData/TestDataProvider.cs index 274833143..33476aa6d 100644 --- a/test/CareTogether.TestData/TestDataProvider.cs +++ b/test/CareTogether.TestData/TestDataProvider.cs @@ -2300,7 +2300,7 @@ .. new[] OrganizationRoles: [SystemConstants.ORGANIZATION_ADMINISTRATOR], ApprovalRoles: [] ), - ] + ] ), new LocationConfiguration( guid3,