diff --git a/Source/Applications/SystemCenter/SystemCenter.csproj b/Source/Applications/SystemCenter/SystemCenter.csproj index 20e9b3b17..b72a1eebe 100644 --- a/Source/Applications/SystemCenter/SystemCenter.csproj +++ b/Source/Applications/SystemCenter/SystemCenter.csproj @@ -578,7 +578,9 @@ - + + + diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Asset/AssetConnection.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Asset/AssetConnection.tsx index 3ee655c7e..6d78d37ad 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Asset/AssetConnection.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Asset/AssetConnection.tsx @@ -1,4 +1,4 @@ -//****************************************************************************************************** +//****************************************************************************************************** // LocationMeter.tsx - Gbtc // // Copyright © 2020, Grid Protection Alliance. All Rights Reserved. @@ -31,6 +31,7 @@ import { OpenXDA } from '@gpa-gemstone/application-typings'; import { useAppSelector, useAppDispatch } from '../hooks'; import { AssetConnectionTypeSlice } from '../Store/Store'; import { SelectRoles } from '../Store/UserSettings'; +import LocationDrawingsButton from '../CommonComponents/LocationDrawingsButton'; interface AssetConnection { AssetRelationShipTypeID: number, @@ -40,8 +41,7 @@ interface AssetConnection { AssetName: string } -function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number}): JSX.Element{ - +function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number }): JSX.Element { let history = useHistory(); let dispatch = useAppDispatch(); @@ -52,38 +52,41 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number const [selectedTypeID, setSelectedtypeID] = React.useState(0); const [localAssets, setLocalAssets] = React.useState>([]); + const [locations, setLocations] = React.useState([]); + const [isLoadingLocations, setIsLoadingLocations] = React.useState(false); + const [sortKey, setSortKey] = React.useState('AssetKey'); const [ascending, setAscending] = React.useState(true); const [showModal, setShowModal] = React.useState(false); const [status, setStatus] = React.useState<'idle' | 'loading' | 'error'>('idle'); - const actStatus = useAppSelector(AssetConnectionTypeSlice.SearchStatus); const [trigger, setTrigger] = React.useState(0); + const actStatus = useAppSelector(AssetConnectionTypeSlice.SearchStatus); - const [hover, setHover] = React.useState<('Update' | 'Reset' | 'None')>('None'); + const [hover, setHover] = React.useState<('Update' | 'Reset' | 'None' | 'Drawings')>('None'); const roles = useAppSelector(SelectRoles); React.useEffect(() => { - let handle = getAssetConnections(); - return () => { if (handle != null || handle.abort != null) handle.abort();} + const handle = getAssetConnections(); + return () => { if (handle != null && handle.abort != null) handle.abort(); } }, [props.ID, trigger]) React.useEffect(() => { if (props.ID > 0) { let sqlString = `(SELECT AssetRelationshipTypeID FROM AssetRelationshipTypeAssetType LEFT JOIN Asset ON ` - sqlString = sqlString + `Asset.AssetTypeID <> ${props.TypeID} AND Asset.AssetTypeID = AssetRelationshipTypeAssetType.assetTypeID AND ` - sqlString = sqlString + `Asset.ID IN (SELECT AssetID FROM AssetLocation WHERE LocationID IN (Select LocationID FROM AssetLocation WHERE AssetID = ${props.ID})) ` - sqlString = sqlString + `GROUP BY AssetRelationshipTypeAssetType.AssetTypeID, AssetRelationshipTypeAssetType.AssetRelationshipTypeID ` - sqlString = sqlString + `HAVING COUNT(Asset.ID) > 0)` + sqlString = sqlString + `Asset.AssetTypeID <> ${props.TypeID} AND Asset.AssetTypeID = AssetRelationshipTypeAssetType.assetTypeID AND ` + sqlString = sqlString + `Asset.ID IN (SELECT AssetID FROM AssetLocation WHERE LocationID IN (Select LocationID FROM AssetLocation WHERE AssetID = ${props.ID})) ` + sqlString = sqlString + `GROUP BY AssetRelationshipTypeAssetType.AssetTypeID, AssetRelationshipTypeAssetType.AssetRelationshipTypeID ` + sqlString = sqlString + `HAVING COUNT(Asset.ID) > 0)` const filter: Search.IFilter[] = [ { FieldName: 'ID', SearchText: `(SELECT AssetRelationshipTypeID FROM AssetRelationshipTypeAssetType WHERE AssetTypeID = ${props.TypeID})`, Operator: 'IN', Type: 'query', IsPivotColumn: false }, { FieldName: 'ID', SearchText: sqlString, Operator: 'IN', Type: 'query', IsPivotColumn: false } - ] + ] dispatch(AssetConnectionTypeSlice.DBSearch({ filter: filter })) } - }, [props.TypeID]) + }, [props.TypeID]) React.useEffect(() => { if (selectedTypeID == 0) { @@ -97,7 +100,7 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number React.useEffect(() => { let index = assetConnectionTypes.findIndex(t => t.ID == selectedTypeID); - if (index == -1 && assetConnectionTypes.length> 0) + if (index == -1 && assetConnectionTypes.length > 0) setSelectedtypeID(assetConnectionTypes[0].ID) }, [assetConnectionTypes]) @@ -106,7 +109,20 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number if (index == -1 && localAssets.length > 0) setSelectedAssetID(localAssets[0].ID) }, [localAssets]) - + + React.useEffect(() => { + setIsLoadingLocations(true); + const h = $.ajax({ + type: "GET", + url: `${homePath}api/OpenXDA/Asset/${props.ID}/Locations`, + contentType: "application/json; charset=utf-8", + dataType: 'json', + cache: true, + async: true + }).done(data => { setLocations(data); setIsLoadingLocations(false); }); + return () => { if (h!= null && h.abort != null) h.abort(); } + }, [assetConnections]) + function getAssetConnections(): JQuery.jqXHR { setStatus('loading'); return $.ajax({ @@ -171,7 +187,7 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number url: `${homePath}api/OpenXDA/AssetConnection/Add`, contentType: "application/json; charset=utf-8", dataType: 'json', - data: JSON.stringify({ ID: 0, AssetRelationshipTypeID: selectedTypeID, ParentID: props.ID, ChildID: selectedAssetID}), + data: JSON.stringify({ ID: 0, AssetRelationshipTypeID: selectedTypeID, ParentID: props.ID, ChildID: selectedAssetID }), cache: false, async: true }).done(() => { @@ -181,9 +197,8 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number }); } - function handleSelect(item) { - history.push({ pathname: homePath + 'index.cshtml', search: '?name=Asset&AssetID=' + item.row.AssetID}) + history.push({ pathname: homePath + 'index.cshtml', search: '?name=Asset&AssetID=' + item.row.AssetID }) } function hasPermissions(): boolean { @@ -210,7 +225,7 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number - if (status == 'loading' || actStatus == 'loading' ) + if (status == 'loading' || actStatus == 'loading') return
@@ -232,10 +247,16 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number return (
-
-
+
+

Connections:

+
+ +
@@ -304,7 +325,7 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number e.stopPropagation(); if (hasPermissions()) deleteAssetConnection(item); }}>{TrashCan} - } + } >

@@ -356,7 +377,6 @@ function AssetConnectionWindow(props: { Name: string, ID: number, TypeID: number
}
- ); } diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/CommonComponents/LocationDrawingsButton.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/CommonComponents/LocationDrawingsButton.tsx new file mode 100644 index 000000000..67b0463f0 --- /dev/null +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/CommonComponents/LocationDrawingsButton.tsx @@ -0,0 +1,166 @@ +//****************************************************************************************************** +// LocationDrawings.tsx - Gbtc +// +// Copyright © 2024, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 01/06/2024 - Collins Self +// Generated original version of source code. +// +//****************************************************************************************************** +import React from 'react'; +import { BtnDropdown, GenericController, LoadingScreen, Modal, ServerErrorIcon, ToolTip } from '@gpa-gemstone/react-interactive'; +import { OpenXDA } from '@gpa-gemstone/application-typings'; +import { CrossMark } from '@gpa-gemstone/gpa-symbols'; +import LocationDrawingsTable from '../Location/LocationDrawingsTable'; + +const LocationDrawingController = new GenericController(`${homePath}api/LocationDrawing`, "Name", true); +interface LocationDrawingsButtonProps { + Locations: OpenXDA.Types.Location[]; + IsLoadingLocations?: boolean; +} + +type DropDownOption = { + Label: JSX.Element | string, + Callback: () => void, + Disabled: boolean + ToolTipContent: JSX.Element, + ShowToolTip: boolean, + ToolTipLocation: ('top' | 'bottom' | 'left' | 'right'), + Key: string | number +} + +const isValid = (location: OpenXDA.Types.Location, drawingData) => { + let e = ""; + + if (location == null + || (location.Alias === "" + && location.Description === "" + && location.ID === 0 + && location.Latitude == null + && location.LocationKey === "" + && location.Longitude == null + && location.Name === "")) + e = 'No location(s) have been set.'; + else if (drawingData.length == 0) + e = 'No drawing(s) associated with location.'; + return e; +} + +const LocationDrawingsButton: React.FC = (props) => { + const [hover, setHover] = React.useState<'none' | 'drawings'>('none'); + const [pageState, setPageState] = React.useState<"loading" | "error" | "idle">("idle"); + const [selectedLocation, setSelectedLocation] = React.useState(); + const multipleLocations = React.useMemo(() => props.Locations.length > 1, [props.Locations]); + const [showDrawingsModal, setShowDrawingsModal] = React.useState(false); + const [locationOptions, setLocationOptions] = React.useState([]); + + React.useEffect(() => { // Generates the map of errors for each location + setPageState('loading'); + setLocationOptions([]); + + const handles = props.Locations.map((location, i) => { + if (location == null) + return null; + const handle = LocationDrawingController.DBSearch([], 'Name', true, location.ID) + .done((result) => { + const error = isValid(location, result); + const option: DropDownOption = { + Label: location.Name, + Callback: () => handleClickDrawingsModal(location), + Disabled: error != "", + ToolTipContent:

{CrossMark} {error}

, + ShowToolTip: error != "", + ToolTipLocation: "left", + Key: i + }; + setLocationOptions(prev => [...prev, option]); + }) + .fail(() => { throw new Error() }); + return handle; + }).filter(handle => handle != null); + + Promise.all(handles) + .then(() => setPageState('idle'), + () => setPageState('error')); + + return () => handles.forEach(handle => () => { if (handle != null && handle?.abort != null) handle.abort() }); + }, [props.Locations]); + + const handleClickDrawingsModal = (loc?: OpenXDA.Types.Location) => { + if (showDrawingsModal) { + setShowDrawingsModal(false); + return; + } + if (loc == undefined) return; + setSelectedLocation(loc); + setShowDrawingsModal(true); + }; + + return ( + <> + + + {!multipleLocations + ? <> + +

{locationOptions[0]?.ToolTipContent}

+
+ + : handleClickDrawingsModal(props.Locations[0])} + TooltipContent={ +

{CrossMark} {locationOptions[0]?.Disabled}

+ } + ShowToolTip={locationOptions[0]?.ShowToolTip} + Disabled={locationOptions[0]?.Disabled} + BtnClass={'btn-primary'} + Options={locationOptions.slice(1)} + /> + } + setShowDrawingsModal(false)} + ShowCancel={false} + ShowConfirm={false}> +
+
+ +
+
+
+ + ); +}; + +export default LocationDrawingsButton; \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/AddEditDrawingsModal.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/AddEditDrawingsModal.tsx new file mode 100644 index 000000000..2889bd513 --- /dev/null +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/AddEditDrawingsModal.tsx @@ -0,0 +1,121 @@ +//****************************************************************************************************** +// LocationDrawings.tsx - Gbtc +// +// Copyright © 2024, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 11/06/2024 - Collins Self +// Generated original version of source code. +// +//****************************************************************************************************** +import { SystemCenter } from "@gpa-gemstone/application-typings"; +import { Input, Select } from "@gpa-gemstone/react-forms"; +import { Modal } from "@gpa-gemstone/react-interactive"; +import React from "react"; + +interface IProps { + Record: SystemCenter.Types.LocationDrawing, + Setter: (record) => void, + HandleSave: () => void, + Show: boolean, + SetShow: (show: boolean) => void, +} + +const AddEditDrawingsModal = (props: IProps) => { + const [category, setCategory] = React.useState>([]); + + function valid(field: keyof (SystemCenter.Types.LocationDrawing)): boolean { + if (field == 'Name') + return props.Record.Name != null && props.Record.Name.length > 0 && props.Record.Name.length <= 200; + else if (field == 'Link') + return props.Record.Link != null && props.Record.Link.length > 0; + else if (field == 'Number') + return props.Record.Number == null || props.Record.Number.length <= 50; + return true; + } + + function getValueList(listName: string, setter: (value: Array) => void): JQuery.jqXHR> { + let h = $.ajax({ + type: "GET", + url: `${homePath}api/ValueList/Group/${listName}`, + contentType: "application/json; charset=utf-8", + dataType: `json`, + cache: false, + async: true + }); + h.done((dCat: Array) => { + setter(dCat); + + }); + return h; + } + + React.useEffect(() => { + const categoryHandle = getValueList("Category", setCategory); + + return () => { + if (categoryHandle != null && categoryHandle.abort != null) categoryHandle.abort(); + } + }, []) + + return <> + { + props.SetShow(false); + if (conf) props.HandleSave(); + }} + ShowCancel={false} + DisableConfirm={ + !(valid('Name') && + valid('Link') && + valid('Number'))} + ConfirmText={'Save'}> + + Record={props.Record} + Field={'Name'} + Feedback={'A Name of less than 200 characters is required.'} + Valid={valid} + Setter={(r) => props.Setter(r)} /> + + Record={props.Record} + Field={'Link'} + Feedback={'A Link is required.'} + Valid={valid} + Setter={(r) => props.Setter(r)} /> + + Record={props.Record} + Field={'Description'} + Valid={valid} + Setter={(r) => props.Setter(r)} /> + + Record={props.Record} + Field={'Category'} + Options={category.map(item => { return { Value: item.Value, Label: item.AltValue ?? item.Value } })} + Label={'Category'} + Setter={(r) => props.Setter(r)} /> + + Record={props.Record} + Field={'Number'} + Feedback={'Number must be less than 50 characters.'} + Valid={valid} + AllowNull={true} + Setter={(r) => props.Setter(r)} /> + + +} +export default AddEditDrawingsModal; \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/Location.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/Location.tsx index ff98a1194..cfb1cd55f 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/Location.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/Location.tsx @@ -133,7 +133,7 @@ function Location(props: IProps) { {tab === 'meters' ? : null} {tab === 'assets' ? : null} {tab === 'images' ? : null} - {tab === 'drawings' ? : null} + {tab === 'drawings' ? : null} { if (conf) deleteLocation(); setShowDelete(false); }} /> diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawings.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawings.tsx index 6f2a0e8d7..fab37e2b0 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawings.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawings.tsx @@ -22,90 +22,25 @@ // Modified original source code; Incorporated 'Number' and 'Category' fields into // 'Substation Drawings' table. //****************************************************************************************************** - - - - import * as React from 'react'; import * as _ from 'lodash'; -import { Application, OpenXDA, SystemCenter } from '@gpa-gemstone/application-typings'; -import { SystemCenter as SCGlobal } from '../global'; -import { Table, Column, Paging } from '@gpa-gemstone/react-table'; -import { useAppSelector, useAppDispatch } from '../hooks'; -import { Input, Select } from '@gpa-gemstone/react-forms'; -import { Pencil, TrashCan } from '@gpa-gemstone/gpa-symbols'; +import { SystemCenter } from '@gpa-gemstone/application-typings'; +import { GenericController, ToolTip } from '@gpa-gemstone/react-interactive'; +import LocationDrawingsTable from './LocationDrawingsTable'; +import { useAppSelector } from '../hooks'; import { SelectRoles } from '../Store/UserSettings'; -import { ToolTip, GenericController, LoadingScreen, ServerErrorIcon } from '@gpa-gemstone/react-interactive'; -import { current } from '@reduxjs/toolkit'; +import AddEditDrawingsModal from './AddEditDrawingsModal'; -const LocationDrawingsWindow = (props: { Location: OpenXDA.Types.Location }) => { - const LocationDrawingController = new GenericController(`${homePath}api/LocationDrawing`, "Name", true); - const PagingID = 'LocationDrawingPage' - - const [links, setLinks] = React.useState([]); - const [sortKey, setSortKey] = React.useState('Name'); - const [ascending, setAscending] = React.useState(true); +const LocationDrawingsWindow = (props: { LocationID: number }) => { + const roles = useAppSelector(SelectRoles); const emptyRecord: SystemCenter.Types.LocationDrawing = { ID: 0, LocationID: 0, Name: '', Link: '', Description: '', Number: '', Category: '' }; const [record, setRecord] = React.useState(emptyRecord); - const [category, setCategory] = React.useState>([]); const [hover, setHover] = React.useState<('Update' | 'Reset' | 'None')>('None'); - const roles = useAppSelector(SelectRoles); - - const [page, setPage] = React.useState(0); - const [pageInfo, setPageInfo] = React.useState<{ RecordsPerPage: number, NumberOfPages: number, TotalRecords: number }>({ RecordsPerPage: 0, NumberOfPages: 0, TotalRecords: 0 }); - const [pageState, setPageState] = React.useState<'error' | 'idle' | 'loading'>('idle'); - - React.useEffect(() => { - const storedInfo = JSON.parse(localStorage.getItem(PagingID) as string); - if (storedInfo != null) setPage(storedInfo); - }, []); - - React.useEffect(() => { - localStorage.setItem(PagingID, JSON.stringify(page)); - }, [page]); - - React.useEffect(() => { - const handle = fetchDrawings(sortKey, ascending, page, props.Location.ID); - return () => { if (handle != null && handle?.abort != null) handle.abort(); } - }, [sortKey, ascending, page, props.Location.ID]); - - const fetchDrawings = (sortKey: keyof SystemCenter.Types.LocationDrawing, ascending: boolean, page: number, locationID: number) => { - setPageState('loading'); - const handle = LocationDrawingController.PagedSearch([], sortKey, ascending, page, locationID) - .done((result) => { - setLinks(JSON.parse(result.Data as unknown as string)); - if (result.NumberOfPages === 0) result.NumberOfPages = 1; - setPageInfo(result); - setPageState('idle'); - }) - .fail(() => setPageState('error')); - return handle; - } - - React.useEffect(() => { - let categoryHandle = getValueList("Category", setCategory); - - return () => { - if (categoryHandle != null && categoryHandle.abort != null) categoryHandle.abort(); - } - }, []) - - function getValueList(listName: string, setter: (value: Array) => void): JQuery.jqXHR> { - let h = $.ajax({ - type: "GET", - url: `${homePath}api/ValueList/Group/${listName}`, - contentType: "application/json; charset=utf-8", - dataType: `json`, - cache: false, - async: true - }); - h.done((dCat: Array) => { - setter(dCat); - - }); - return h; - } + const [showModal, setShowModal] = React.useState(false); + const [editMode, setEditMode] = React.useState(false); + const [updateTable, setUpdateTable] = React.useState(0); + const LocationDrawingController = new GenericController(`${homePath}api/LocationDrawing`, "Name", true); function hasPermissions(): boolean { if (roles.indexOf('Administrator') < 0 && roles.indexOf('Engineer') < 0) @@ -113,39 +48,16 @@ const LocationDrawingsWindow = (props: { Location: OpenXDA.Types.Location }) => return true; } - function valid(field: keyof (SystemCenter.Types.LocationDrawing)): boolean { - if (field == 'Name') - return record.Name != null && record.Name.length > 0 && record.Name.length <= 200; - else if (field == 'Link') - return record.Link != null && record.Link.length > 0; - else if (field == 'Number') - return record.Number == null || record.Number.length <=50; - return true; - } - const handleSave = () => { - setPageState('loading'); - LocationDrawingController.DBAction('PATCH', record) - .then(() => { - fetchDrawings(sortKey, ascending, page, props.Location.ID); - }) - .catch(() => { - setPageState('error'); - }); - }; - - const handleDelete = (item: SystemCenter.Types.LocationDrawing) => { - setPageState('loading'); - LocationDrawingController.DBAction('DELETE', item) - .then(() => { - fetchDrawings(sortKey, ascending, page, props.Location.ID); - }) - .catch(() => { - setPageState('error'); - }); + setShowModal(false); + setRecord(record); + editMode ? LocationDrawingController.DBAction('PATCH', record) + .done(() => {setUpdateTable(updateTable + 1)}) + : LocationDrawingController.DBAction('POST', record) + .done(() => {setUpdateTable(updateTable + 1)}); }; - return ( + return (<>
@@ -155,128 +67,44 @@ const LocationDrawingsWindow = (props: { Location: OpenXDA.Types.Location }) =>
-
- - TableClass="table table-hover" - Data={links} - SortKey={sortKey} - Ascending={ascending} - OnSort={(d) => { - if (d.colKey === 'EditDelete') - return; - if (d.colKey == sortKey) - setAscending(!ascending); - else { - setAscending(true); - setSortKey(d.colKey as keyof SystemCenter.Types.LocationDrawing); - } - }} - TableStyle={{ padding: 0, width: '100%', tableLayout: 'fixed', display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }} - TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - TbodyStyle={{ display: 'block', width: '100%', overflowY: 'auto', flex: 1 }} - RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - Selected={(item) => false} - KeySelector={(item) => item.ID} - > - - Key={'Name'} - AllowSort={true} - Field={'Name'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Name - - - Key={'Link'} - AllowSort={true} - Field={'Link'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - Content={({ item, key }) => {item[key]} } - > Link - - - Key={'Description'} - AllowSort={true} - Field={'Description'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Description - - - Key={'Number'} - AllowSort={true} - Field={'Number'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Number - - - Key={'Category'} - AllowSort={true} - Field={'Category'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Category - - - Key={'EditDelete'} - AllowSort={false} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - Content={({ item }) => - - - - - } - >

- - - - -
-
- setPage(p - 1)} /> -
-
-
+ { + setRecord(record); + setEditMode(true); + setShowModal(true); + }} + RefreshDrawings={updateTable} + />
- +
- + +

Your role does not have permission. Please contact your Administrator if you believe this to be in error.

-
- - ); - + ); } export default LocationDrawingsWindow; \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawingsTable.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawingsTable.tsx new file mode 100644 index 000000000..35c1a8223 --- /dev/null +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Location/LocationDrawingsTable.tsx @@ -0,0 +1,188 @@ +//****************************************************************************************************** +// LocationDrawings.tsx - Gbtc +// +// Copyright © 2024, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 11/06/2024 - Collins Self +// Generated original version of source code. +// +//****************************************************************************************************** +import { SystemCenter } from "@gpa-gemstone/application-typings"; +import { Pencil, TrashCan } from "@gpa-gemstone/gpa-symbols"; +import { GenericController, LoadingScreen, ServerErrorIcon } from "@gpa-gemstone/react-interactive"; +import { Column, Table, Paging } from "@gpa-gemstone/react-table"; +import React from "react"; +import { useAppSelector } from "../hooks"; +import { SelectRoles } from "../Store/UserSettings"; + +interface IProps { + LocationID: number, + Edit?: (record: SystemCenter.Types.LocationDrawing) => void, + /** + * @param RefreshDrawings Counter that triggers a fetchDrawings function + */ + RefreshDrawings: number +} + +const LocationDrawingsTable = (props: IProps) => { + const [links, setLinks] = React.useState([]); + const [sortKey, setSortKey] = React.useState('Name'); + const [ascending, setAscending] = React.useState(true); + const [pageInfo, setPageInfo] = React.useState<{ RecordsPerPage: number, NumberOfPages: number, TotalRecords: number }>({ RecordsPerPage: 0, NumberOfPages: 0, TotalRecords: 0 }); + const [pageState, setPageState] = React.useState<'error' | 'idle' | 'loading'>('idle'); + const [page, setPage] = React.useState(0); + + const roles = useAppSelector(SelectRoles); + const LocationDrawingController = new GenericController(`${homePath}api/LocationDrawing`, "Name", true); + const PagingID = 'LocationDrawingPage' + + function hasPermissions(): boolean { + if (roles.indexOf('Administrator') < 0 && roles.indexOf('Engineer') < 0) + return false; + return true; + } + + const fetchDrawings = (sortKey: keyof SystemCenter.Types.LocationDrawing, ascending: boolean, page: number, locationID: number) => { + setPageState('loading'); + const handle = LocationDrawingController.PagedSearch([], sortKey, ascending, page, locationID) + .done((result) => { + setLinks(JSON.parse(result.Data as unknown as string)); + if (result.NumberOfPages === 0) result.NumberOfPages = 1; + setPageInfo({RecordsPerPage: result.RecordsPerPage, + NumberOfPages: result.NumberOfPages, + TotalRecords: result.TotalRecords}); + setPageState('idle'); + }) + .fail(() => setPageState('error')); + return handle; + } + + const handleDelete = (item: SystemCenter.Types.LocationDrawing) => { + setPageState('loading'); + LocationDrawingController.DBAction('DELETE', item) + .then(() => fetchDrawings(sortKey, ascending, page, props.LocationID), + () => setPageState('error')) + }; + + React.useEffect(() => { + const storedInfo = JSON.parse(localStorage.getItem(PagingID) as string); + if (storedInfo != null) setPage(storedInfo); + }, []); + + React.useEffect(() => { + localStorage.setItem(PagingID, JSON.stringify(page)); + }, [page]); + + React.useEffect(() => { + const handle = fetchDrawings(sortKey, ascending, page, props.LocationID); + return () => { if (handle != null && handle?.abort != null) handle.abort(); } + }, [sortKey, ascending, page, props.LocationID, props.RefreshDrawings]); + + return <> +
+ + TableClass="table table-hover" + Data={links} + SortKey={sortKey} + Ascending={ascending} + OnSort={(d) => { + if (d.colKey === 'EditDelete') + return; + if (d.colKey == sortKey) + setAscending(!ascending); + else { + setAscending(true); + setSortKey(d.colKey as keyof SystemCenter.Types.LocationDrawing); + } + }} + TableStyle={{ padding: 0, width: '100%', tableLayout: 'fixed', display: 'flex', flexDirection: 'column', overflow: 'hidden', flex: 1 }} + TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} + TbodyStyle={{ display: 'block', width: '100%', overflowY: 'auto', flex: 1 }} + RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} + Selected={(item) => false} + KeySelector={(item) => item.ID} + > + + Key={'Name'} + AllowSort={true} + Field={'Name'} + HeaderStyle={{ width: '20%' }} + RowStyle={{ width: '20%' }} + > Name + + + Key={'Link'} + AllowSort={true} + Field={'Link'} + HeaderStyle={{ width: '20%' }} + RowStyle={{ width: '20%' }} + Content={({ item, key }) => {item[key]}} + > Link + + + Key={'Number'} + AllowSort={true} + Field={'Number'} + HeaderStyle={{ width: '10%' }} + RowStyle={{ width: '10%' }} + > Number + + + Key={'Category'} + AllowSort={true} + Field={'Category'} + HeaderStyle={{ width: '10%' }} + RowStyle={{ width: '10%' }} + > Category + + + Key={'Description'} + AllowSort={true} + Field={'Description'} + HeaderStyle={{ width: 'auto' }} + RowStyle={{ width: 'auto' }} + > Description + + {props.Edit ? + + Key={'EditDelete'} + AllowSort={false} + HeaderStyle={{ width: '10%' }} + RowStyle={{ width: '10%' }} + Content={({ item }) => + + + + + } + >

+ + : null} + + + +
+
+ setPage(p - 1)} /> +
+
+
+ +} + +export default LocationDrawingsTable; \ No newline at end of file diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/MeterLocation.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/MeterLocation.tsx index 6298b5456..f02e2b446 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/MeterLocation.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/MeterLocation.tsx @@ -168,13 +168,17 @@ const LocationWindow = (props: IProps) => {
- { setLocation(loc); setHasChanged(true); }} UpdateMeter={(m) => { setHasChanged(props.Meter.LocationID != (m.LocationID != null ? parseInt(m.LocationID.toString()) : 0)); setMeter({ ...m, LocationID: (m.LocationID != null ? parseInt(m.LocationID.toString()) : 0) }); }} DisableLocation={location.ID > 0} + LocationStatus={locationStatus} />
diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/LocationDrawings.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/LocationDrawings.tsx deleted file mode 100644 index 2b5752ec3..000000000 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/LocationDrawings.tsx +++ /dev/null @@ -1,129 +0,0 @@ -//****************************************************************************************************** -// LocationDrawings.tsx - Gbtc -// -// Copyright © 2023, Grid Protection Alliance. All Rights Reserved. -// -// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See -// the NOTICE file distributed with this work for additional information regarding copyright ownership. -// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this -// file except in compliance with the License. You may obtain a copy of the License at: -// -// http://opensource.org/licenses/MIT -// -// Unless agreed to in writing, the subject software distributed under the License is distributed on an -// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the -// License for the specific language governing permissions and limitations. -// -// Code Modification History: -// ---------------------------------------------------------------------------------------------------- -// 08/24/2023 - Parker Dinsdale -// Generated original version of source code. -// -//****************************************************************************************************** - - -import * as React from 'react'; -import { SystemCenter } from '@gpa-gemstone/application-typings' -import { LocationDrawingSlice } from '../../Store/Store'; -import { Modal, ToolTip } from '@gpa-gemstone/react-interactive'; -import { Table, Column } from '@gpa-gemstone/react-table'; -import { useAppDispatch, useAppSelector } from '../../hooks'; -import { CreateGuid } from '@gpa-gemstone/helper-functions'; - -interface IProps { - LocationID: number | null -} - -const LocationDrawings = (props: IProps) => { - const dispatch = useAppDispatch(); - const guid = React.useRef(CreateGuid()); - - const drawingData = useAppSelector(LocationDrawingSlice.Data); - const drawingStatus = useAppSelector(LocationDrawingSlice.Status); - const drawingParentID = useAppSelector(LocationDrawingSlice.ParentID); - const drawingSortKey = useAppSelector(LocationDrawingSlice.SortField); - const drawingAscending = useAppSelector(LocationDrawingSlice.Ascending); - - const [showDrawings, setShowDrawings] = React.useState(false); - const [hover, setHover] = React.useState<'none' | 'drawings'>('none'); - - React.useEffect(() => { - if (drawingStatus == 'unintiated' || drawingStatus == 'changed' || drawingParentID != props.LocationID) - dispatch(LocationDrawingSlice.Fetch(props.LocationID)); - }, [drawingStatus, drawingParentID, props.LocationID]); - - return ( -
- - - setShowDrawings(false)} ShowCancel={false} ConfirmText={'Done'}> -
-
- - TableClass="table table-hover" - Data={drawingData} - SortKey={drawingSortKey} - Ascending={drawingAscending} - OnSort={(d) => { - dispatch(LocationDrawingSlice.Sort({ SortField: d.colField, Ascending: d.ascending })); - }} - OnClick={(d) => window.open(d.row.Link, '_blank')} - TheadStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - TbodyStyle={{ display: 'block', overflowY: 'scroll', maxHeight: '400px', width: '100%' }} - RowStyle={{ fontSize: 'smaller', display: 'table', tableLayout: 'fixed', width: '100%' }} - Selected={(item) => false} - KeySelector={(item) => item.ID} - > - - Key={'Name'} - AllowSort={true} - Field={'Name'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Name - - - Key={'Description'} - AllowSort={true} - Field={'Description'} - HeaderStyle={{ width: 'auto' }} - RowStyle={{ width: 'auto' }} - > Description - - - Key={'Number'} - AllowSort={true} - Field={'Number'} - HeaderStyle={{ width: '15%' }} - RowStyle={{ width: '15%' }} - > Number - - - Key={'Category'} - AllowSort={true} - Field={'Category'} - HeaderStyle={{ width: '15%' }} - RowStyle={{ width: '15%' }} - > Category - - -
-
-
- - -

No drawings associated with this substation.

-
-
- ) -} - -export default LocationDrawings; diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/MeterLocationProperties.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/MeterLocationProperties.tsx index c331cfa1d..9bb51b0f9 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/MeterLocationProperties.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/Meter/PropertyUI/MeterLocationProperties.tsx @@ -29,10 +29,10 @@ import { Input, TextArea } from '@gpa-gemstone/react-forms'; import { AssetAttributes } from '../../AssetAttribute/Asset'; import { DefaultSelects } from '@gpa-gemstone/common-pages'; import { ByLocationSlice } from '../../Store/Store'; -import LocationDrawings from './LocationDrawings'; import { useAppSelector } from '../../hooks'; import { SelectRoles } from '../../Store/UserSettings'; import { ToolTip } from '@gpa-gemstone/react-interactive'; +import LocationDrawingsButton from '../../CommonComponents/LocationDrawingsButton'; import { Column } from '@gpa-gemstone/react-table'; declare var homePath: string; @@ -43,6 +43,7 @@ interface IProps { Locationlist: OpenXDA.Types.Location[], Location: OpenXDA.Types.Location, SetLocation: (loc: OpenXDA.Types.Location) => void, + LocationStatus: string, DisableLocation: boolean } @@ -50,7 +51,9 @@ const MeterLocationProperties = (props: IProps) => { const [validKey, setValidKey] = React.useState(true); const [showStationSelector, setShowStationSelector] = React.useState(false); const [hover, setHover] = React.useState<('submit' | 'clear' | 'none')>('none'); + const roles = useAppSelector(SelectRoles); + const locationArray = React.useMemo(() => [props.Location], [props.Location]); React.useEffect(() => { const key = props.Location.LocationKey; @@ -145,11 +148,11 @@ const MeterLocationProperties = (props: IProps) => {

Your role does not have permission. Please contact your Administrator if you believe this to be in error.

- - Record={props.Location} Field={'LocationKey'} + + Record={props.Location} Field={'LocationKey'} Label={'Key'} Feedback={'A unique key of less than 50 characters is required.'} - Valid={valid} + Valid={valid} Setter={(loc) => props.SetLocation(loc)} Disabled={!hasPermissions() || props.DisableLocation} /> Record={props.Location} Field={'Name'} @@ -165,25 +168,28 @@ const MeterLocationProperties = (props: IProps) => {
- +
- Record={props.Location} - Field={'Alias'} Label={'Alias'} Feedback={'Alias must be less than 200 characters.'} + Record={props.Location} + Field={'Alias'} Label={'Alias'} Feedback={'Alias must be less than 200 characters.'} Valid={valid} Setter={(loc) => props.SetLocation(loc)} Disabled={!hasPermissions() || props.DisableLocation} /> - Record={props.Location} - Field={'Latitude'} Label={'Latitude'} - Feedback={'A numeric Latitude value between -180 and 180 is required.'} + Record={props.Location} + Field={'Latitude'} Label={'Latitude'} + Feedback={'A numeric Latitude value between -180 and 180 is required.'} Valid={valid} Setter={(loc) => props.SetLocation(loc)} Disabled={!hasPermissions() || props.DisableLocation} /> - Record={props.Location} Field={'Longitude'} Label={'Longitude'} - Feedback={'A numeric Longitude value between -180 and 180 is required.'} + Record={props.Location} Field={'Longitude'} Label={'Longitude'} + Feedback={'A numeric Longitude value between -180 and 180 is required.'} Valid={valid} Setter={(loc) => props.SetLocation(loc)} Disabled={!hasPermissions() || props.DisableLocation} /> - Rows={3} Record={props.Location} Field={'Description'} Label={'Description'} + Rows={3} Record={props.Location} Field={'Description'} Label={'Description'} Valid={valid} Setter={(loc) => props.SetLocation(loc)} Disabled={!hasPermissions() || props.DisableLocation} />
- { diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/AssetPage.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/AssetPage.tsx index 874f68247..74e06ebfb 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/AssetPage.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/AssetPage.tsx @@ -40,8 +40,6 @@ import DERAttributes from '../AssetAttribute/DER'; import AssetSelect from '../Asset/AssetSelect'; import { ReactIcons } from '@gpa-gemstone/gpa-symbols'; import { getAssetWithAdditionalFields } from '../../../TS/Services/Asset'; -import LocationDrawings from '../Meter/PropertyUI/LocationDrawings'; -import { GetNodeSize } from '@gpa-gemstone/helper-functions'; import { Table, Column } from '@gpa-gemstone/react-table'; import GenerationAttributes from '../AssetAttribute/Generation'; import StationAuxAttributes from '../AssetAttribute/StationAux'; @@ -106,6 +104,7 @@ export default function AssetPage(props: IProps) { channelsWorking.filter(ch => (ch.Asset === (newEdit === 'Edit' ? editAssetKey : tempKey)) || (ch.Asset === "")) , [channelsWorking, editAssetKey, newEdit]); + const assetData = React.useMemo(() => { const u = _.cloneDeep(props.Assets); if (sortKey === 'Channels') @@ -355,7 +354,7 @@ export default function AssetPage(props: IProps) { } ) - } + }
@@ -511,16 +510,6 @@ export default function AssetPage(props: IProps) { props.UpdateChannels(channels); props.UpdateAssetConnections(assetConnections); }}> -
  • -
    - Actions: -
    -
    - -
    -
    -
    -
  • (chan.Asset === tempKey) ? ({ ...chan, Asset: record.AssetKey }) : chan); record.Channels = channelsWithNewKey - .filter(chan => chan.Asset === record.AssetKey); ; + .filter(chan => chan.Asset === record.AssetKey); list.push(record); props.UpdateChannels(channelsWithNewKey); } @@ -563,7 +552,7 @@ export default function AssetPage(props: IProps) { >
    -
    +
    { if (m.LocationID != 0 && m.LocationID != null) getDifferentMeterLocation(m.LocationID) @@ -96,8 +99,10 @@ export default function LocationPage(props: IProps) { Description: '', }); }} - Locationlist={locations != null ? locations : []} DisableLocation={props.LocationInfo.ID != 0} /> - + Locationlist={locations != null ? locations : []} + DisableLocation={props.LocationInfo.ID != 0} + LocationStatus={lStatus} + /> ); } diff --git a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/NewMeterWizard.tsx b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/NewMeterWizard.tsx index 8ecc8622b..49a986ad9 100644 --- a/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/NewMeterWizard.tsx +++ b/Source/Applications/SystemCenter/wwwroot/Scripts/TSX/SystemCenter/NewMeterWizard/NewMeterWizard.tsx @@ -24,7 +24,7 @@ import * as React from 'react'; import * as _ from 'lodash'; import { Application, OpenXDA } from '@gpa-gemstone/application-typings'; -import { LoadingScreen, ServerErrorIcon, ToolTip, Warning, Modal, ProgressBar } from '@gpa-gemstone/react-interactive'; +import { LoadingScreen, ServerErrorIcon, ToolTip, Warning, Modal, ProgressBar, GenericController } from '@gpa-gemstone/react-interactive'; import { useAppDispatch, useAppSelector } from '../hooks'; import { CrossMark, Warning as WarningSymbol } from '@gpa-gemstone/gpa-symbols'; import { SelectMeterStatus, FetchMeter } from '../Store/MeterSlice'; @@ -41,7 +41,7 @@ import AdditionalFieldsWindow from '../CommonComponents/AdditionalFieldsWindow'; import MultipleAssetsPage from './MultipleAssetsPage'; import CustomerAssetGroupPage from './CustomerAssetGroupPage'; import LineSegmentWindow from '../AssetAttribute/LineSegmentWindow'; -import LocationDrawings from '../Meter/PropertyUI/LocationDrawings'; +import LocationDrawingsButton from '../CommonComponents/LocationDrawingsButton'; // Define Step Numbers const generalStep: number = 1; @@ -94,10 +94,12 @@ export default function NewMeterWizard(props: {IsEngineer: boolean}) { // Wizard Page Control const [error, setError] = React.useState([]); const [warning, setWarning] = React.useState([]); - const [hover, setHover] = React.useState<'None' | 'Next' | 'Prev'>('None'); + const [hover, setHover] = React.useState<'None' | 'Next' | 'Prev' | 'Drawings'>('None'); const [showSubmit, setShowSubmit] = React.useState(false); const [status, setStatus] = React.useState('unintiated'); + const locationInfoArray = React.useMemo(() => [locationInfo], [locationInfo]); + React.useEffect(() => { if (mStatus === 'unintiated' || mStatus === 'changed') dispatch(FetchMeter()); @@ -403,8 +405,13 @@ export default function NewMeterWizard(props: {IsEngineer: boolean}) { return

    Number of Trend Channels: {channels.reduce((p, c) => c.Trend ? (p + 1) : p, 0)}

    ; - else if (currentStep === assetStep) - return + else if (currentStep === assetStep + || currentStep === connectionStep) + return ( + + ) else if (currentStep >= additionalFieldMeterStep) { return (
    @@ -507,11 +514,11 @@ export default function NewMeterWizard(props: {IsEngineer: boolean}) {
    -
    +

    {header}

    -
    +
    {secondaryHeader}