diff --git a/e2e/test/api/sap.test.ts b/e2e/test/api/sap.test.ts index c87e2980..1d8b7e3d 100644 --- a/e2e/test/api/sap.test.ts +++ b/e2e/test/api/sap.test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import { test } from '@utils/fixtures.js'; -test.describe.only('Project endpoints', () => { +test.describe('Project endpoints', () => { test('SAP project import without WBS', async ({ devSession }) => { const res = await devSession.session.client.sap.getSapProject.mutate({ projectId: 'A1111_00000', diff --git a/e2e/test/project.test.ts b/e2e/test/project.test.ts index dd9b221a..b43124d9 100644 --- a/e2e/test/project.test.ts +++ b/e2e/test/project.test.ts @@ -70,7 +70,7 @@ async function createProject( await page.getByLabel('Lautakunta *', { exact: true }).click(); await page.getByRole('option', { name: 'Yhdyskuntalautakunta' }).click(); - await page.getByRole('button', { name: 'Lisää hanke' }).click(); + await page.getByRole('button', { name: 'Tallenna' }).click(); // URL should include the newly created project ID, parse it from the URL await expect(page).toHaveURL(/https:\/\/localhost:1443\/investointihanke\/[0-9a-f-]+/); @@ -94,6 +94,7 @@ async function deleteProject(page: Page, projectId: string) { await page.goto(`https://localhost:1443/investointihanke/${projectId}`); // Delete the project + await page.getByRole('button', { name: 'Muokkaa hanketta' }).click(); await page.getByRole('button', { name: 'Poista hanke' }).click(); await page.getByRole('button', { name: 'Poista' }).click(); await expect(page).toHaveURL('https://localhost:1443/kartta/hankkeet'); diff --git a/frontend/src/assets/detailplanClusterPoint.svg b/frontend/src/assets/detailplanClusterPoint.svg index 293848e7..ac61418f 100644 --- a/frontend/src/assets/detailplanClusterPoint.svg +++ b/frontend/src/assets/detailplanClusterPoint.svg @@ -1,23 +1,53 @@ - + - - + + - - - - + + + + - - - + + + - + diff --git a/frontend/src/assets/investmentClusterPoint.svg b/frontend/src/assets/investmentClusterPoint.svg index 0cfc57e1..0eeb46f3 100644 --- a/frontend/src/assets/investmentClusterPoint.svg +++ b/frontend/src/assets/investmentClusterPoint.svg @@ -1,23 +1,53 @@ - + - - + + - - - - + + + + - - - + + + - + diff --git a/frontend/src/assets/maintenanceClusterPoint.svg b/frontend/src/assets/maintenanceClusterPoint.svg index 443b5ba0..7ff5254c 100644 --- a/frontend/src/assets/maintenanceClusterPoint.svg +++ b/frontend/src/assets/maintenanceClusterPoint.svg @@ -1,23 +1,53 @@ - + - - + + - - - - + + + + - - - + + + - + diff --git a/frontend/src/assets/projectClusterPoint.svg b/frontend/src/assets/projectClusterPoint.svg index 871ded7f..04db2840 100644 --- a/frontend/src/assets/projectClusterPoint.svg +++ b/frontend/src/assets/projectClusterPoint.svg @@ -1,23 +1,53 @@ - + - - + + - - - - + + + + - - - + + + - + diff --git a/frontend/src/components/Map/ColorPatternSelect.tsx b/frontend/src/components/Map/ColorPatternSelect.tsx index 9ca3a3c2..737854f8 100644 --- a/frontend/src/components/Map/ColorPatternSelect.tsx +++ b/frontend/src/components/Map/ColorPatternSelect.tsx @@ -28,7 +28,7 @@ export function ColorPatternSelect() { height: 40px; position: absolute; top: 1rem; - right: 0.5rem; + left: 0.5rem; z-index: 202; `} onChange={handleChange} diff --git a/frontend/src/components/Map/DrawConfirmButtons.tsx b/frontend/src/components/Map/DrawConfirmButtons.tsx deleted file mode 100644 index 0dfc148a..00000000 --- a/frontend/src/components/Map/DrawConfirmButtons.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Close, Save } from '@mui/icons-material'; -import { Box, Button, css } from '@mui/material'; - -import { useTranslations } from '@frontend/stores/lang'; - -interface Props { - onSaveClick: () => void; - onUndoClick: () => void; -} - -export function DrawConfirmButtons({ onSaveClick, onUndoClick }: Props) { - const tr = useTranslations(); - - return ( - - - - - ); -} diff --git a/frontend/src/components/Map/DrawerContainer.tsx b/frontend/src/components/Map/DrawerContainer.tsx index 304e6925..3a2f0c86 100644 --- a/frontend/src/components/Map/DrawerContainer.tsx +++ b/frontend/src/components/Map/DrawerContainer.tsx @@ -39,6 +39,7 @@ export function DrawerContainer(props: Props) { position: absolute; top: 0; bottom: 0; + right: 2rem; display: flex; align-items: flex-end; gap: 1rem; diff --git a/frontend/src/components/Map/LayerDrawer.tsx b/frontend/src/components/Map/LayerDrawer.tsx index d9f7317d..12f7024b 100644 --- a/frontend/src/components/Map/LayerDrawer.tsx +++ b/frontend/src/components/Map/LayerDrawer.tsx @@ -21,7 +21,7 @@ import { mapOptions } from './mapOptions'; const containerStyles = css` width: 280px; position: absolute; - left: 0; + right: -2rem; top: 0; bottom: 0; display: flex; @@ -29,9 +29,9 @@ const containerStyles = css` flex-direction: column; overflow-y: auto; transition: opacity 0.15s ease-in-out; - box-shadow: 2px 1px 4px #9c9c9c; - border-top-right-radius: 2px 2px; - border-bottom-right-radius: 2px 2px; + box-shadow: -2px 1px 4px #9c9c9c; + border-top-left-radius: 2px 2px; + border-bottom-left-radius: 2px 2px; `; const drawerStyles = css` @@ -206,7 +206,10 @@ export function LayerDrawer({ ))} - + toggleButtonStyle?.(theme, isOpen)} disableTouchRipple diff --git a/frontend/src/components/Map/Legend.tsx b/frontend/src/components/Map/Legend.tsx index fdba4be3..db8a2c26 100644 --- a/frontend/src/components/Map/Legend.tsx +++ b/frontend/src/components/Map/Legend.tsx @@ -114,13 +114,10 @@ export function Legend({ flex-direction: column; position: absolute; bottom: 0; - left: 0; - padding: 0.25rem 0.5rem calc(42px + 2rem) 1rem; + right: -2rem; + padding: 0.25rem 1rem calc(42px + 2rem) 1rem; background-color: white; - box-shadow: - 0, - 0px 5px 8px 0px rgba(0, 0, 0, 0.14), - 0; + box-shadow: -5px 0px 8px 0px rgba(0, 0, 0, 0.14); `} > ({ css={(theme) => css` position: absolute; top: 1rem; - left: 3rem; + right: 2.5rem; padding: 0; z-index: 202; :hover .MuiSvgIcon-root .icon-path { diff --git a/frontend/src/components/Map/MapToolbar.tsx b/frontend/src/components/Map/MapToolbar.tsx index 45ac00f0..73e2fd3b 100644 --- a/frontend/src/components/Map/MapToolbar.tsx +++ b/frontend/src/components/Map/MapToolbar.tsx @@ -25,9 +25,9 @@ const toolsContainerStyle = css` width: 48px; top: 0; bottom: 0; - right: 0; + left: 0; background: #eee; - border-left: 1px solid #aaa; + border-right: 1px solid #aaa; opacity: 1; z-index: 200; `; @@ -140,7 +140,7 @@ export function MapToolbar(props: Props) { if (props.toolsHidden?.includes(tool.type)) return null; return ( void; + onUndo?: ( + drawSource: VectorSource>, + selectionSource: VectorSource>, + ) => void; drawStyle: Style | Style[]; toolsHidden?: ToolType[]; editable: boolean; @@ -98,9 +105,10 @@ interface Props { withColorPatternSelect?: boolean; } -export function MapWrapper( - props: Props, -) { +export const MapWrapper = forwardRef(function MapWrapper< + TProject extends ProjectData, + TProjectObject extends ProjectObjectData, +>(props: Props, ref: React.Ref<{ handleUndoDraw: () => void }>) { const [zoom, setZoom] = useState(mapOptions.tre.defaultZoom); const [viewExtent, setViewExtent] = useState(mapOptions.tre.extent); @@ -117,13 +125,8 @@ export function MapWrapper(null); - const [projection] = useState(() => - getMapProjection( - mapOptions.projection.code, - mapOptions.projection.extent, - mapOptions.projection.units, - ), - ); + const projection = useAtomValue(mapProjectionAtom); + const [baseLayerId] = useAtom(baseLayerIdAtom); const selectedWFSLayers = useAtomValue(selectedWFSLayersAtom); const selectedItemLayers = useAtomValue(selectedItemLayersAtom); @@ -154,21 +157,33 @@ export function MapWrapper(null); const [interactions, setInteractions] = useState(null); const selectionLayer = useMemo(() => createSelectionLayer(selectionSource), []); const freezeMapHeight = useAtomValue(freezeMapHeightAtom); + const [dirtyViews, setDirtyViews] = useAtom(dirtyViewsAtom); + useNavigationBlocker(dirtyViews.map, 'map'); useEffect(() => { - return () => resetInfoBox(); + return () => { + resetInfoBox(); + setDirtyViews((prev) => ({ ...prev, map: false })); + }; }, []); const drawSource = useMemo(() => props.drawSource ?? new VectorSource({ wrapX: false }), []); + useImperativeHandle( + ref, + () => ({ + handleUndoDraw, + handleSave: handleDrawSave, + }), + [drawSource, selectionSource], + ); + function resetSelectInteractions() { resetInfoBox(); setInteractions([registerProjectSelectInteraction]); @@ -204,7 +219,9 @@ export function MapWrapper createModifyInteraction({ source: selectionSource, - onModifyEnd: () => setDirty(true), + onModifyEnd: () => { + setDirtyViews((prev) => ({ ...prev, map: true })); + }, }), [], ); @@ -219,10 +236,6 @@ export function MapWrapper feature.getGeometry()?.getType() === geometryType); } - /* function savedFeaturesSelected() { - return selectionSource.getFeatures().some((feature) => ); - } */ - const drawLayer = useMemo(() => createDrawLayer(drawSource, props.drawOptions?.drawStyle), []); const registerDrawInteraction = useMemo( @@ -233,7 +246,7 @@ export function MapWrapper { - setDirty(true); + setDirtyViews((prev) => ({ ...prev, map: true })); }, drawType: selectedTool === 'newPointFeature' ? 'Point' : 'Polygon', }), @@ -302,7 +315,7 @@ export function MapWrapper ({ ...prev, map: true })); } useEffect(() => { @@ -329,7 +342,7 @@ export function MapWrapper ({ ...prev, map: true })); setFeatureSelector((prev) => ({ features: deleteSelectedFeatures(drawSource, selectionSource), pos: prev.pos, @@ -369,6 +382,27 @@ export function MapWrapper ({ + features: deleteSelectedFeatures(drawSource, selectionSource), + pos: prev.pos, + })); + setSelectedTool(null); + setDirtyViews((prev) => ({ ...prev, map: false })); + addFeaturesFromGeoJson(drawSource, props.drawOptions?.geoJson); + } + + function handleDrawSave() { + selectionSource.clear(); + setFeatureSelector(RESET); + setSelectedTool(null); + setDirtyViews((prev) => ({ ...prev, map: false })); + return getGeoJSONFeaturesString( + drawSource.getFeatures(), + projection?.getCode() ?? mapOptions.projection.code, + ); + } + return (
)} @@ -466,30 +500,6 @@ export function MapWrapper - - {dirty && ( - { - selectionSource.clear(); - setFeatureSelector(RESET); - setDirty(false); - props.drawOptions?.onFeaturesSaved?.( - getGeoJSONFeaturesString( - drawSource.getFeatures(), - projection?.getCode() ?? mapOptions.projection.code, - ), - ); - }} - onUndoClick={() => { - setFeatureSelector((prev) => ({ - features: deleteSelectedFeatures(drawSource, selectionSource), - pos: prev.pos, - })); - setDirty(false); - addFeaturesFromGeoJson(drawSource, props.drawOptions?.geoJson); - }} - /> - )} {props.drawOptions?.editable && (
); -} +}); diff --git a/frontend/src/components/NavigationBlocker.tsx b/frontend/src/components/NavigationBlocker.tsx index 74cecce6..fee6fa72 100644 --- a/frontend/src/components/NavigationBlocker.tsx +++ b/frontend/src/components/NavigationBlocker.tsx @@ -9,7 +9,7 @@ interface Props { status: BlockerStatus; } -const splitViewForms = ['investmentForm', 'detailplanForm', 'projectObjectForm']; +const splitViewForms = ['investmentForm', 'maintenanceForm', 'detailplanForm', 'projectObjectForm']; export function NavigationBlocker({ status }: Props) { const tr = useTranslations(); diff --git a/frontend/src/stores/map.ts b/frontend/src/stores/map.ts index 3314954b..b444f979 100644 --- a/frontend/src/stores/map.ts +++ b/frontend/src/stores/map.ts @@ -6,7 +6,8 @@ import VectorLayer from 'ol/layer/Vector'; import { Pixel } from 'ol/pixel'; import VectorSource from 'ol/source/Vector'; -import { getFeatureItemIds } from '@frontend/components/Map/mapFunctions'; +import { getFeatureItemIds, getMapProjection } from '@frontend/components/Map/mapFunctions'; +import { mapOptions } from '@frontend/components/Map/mapOptions'; import { PROJECT_OBJECT_STYLE, ProjectColorCodes, @@ -30,6 +31,14 @@ const defaultFeatureSelectorState: FeatureSelector = { export const featureSelectorAtom = atomWithReset(defaultFeatureSelectorState); +export const mapProjectionAtom = atom( + getMapProjection( + mapOptions.projection.code, + mapOptions.projection.extent, + mapOptions.projection.units, + ), +); + export type VectorLayerKey = | 'kaupunginosat' | 'kiinteistot' diff --git a/frontend/src/stores/navigationBlocker.tsx b/frontend/src/stores/navigationBlocker.tsx index bc49456a..3b85845a 100644 --- a/frontend/src/stores/navigationBlocker.tsx +++ b/frontend/src/stores/navigationBlocker.tsx @@ -13,9 +13,13 @@ export interface BlockerStatus { export const blockerStatusAtom = atom(defaultStatus); -export function useNavigationBlocker(isDirty: boolean, identifier: string) { +export function useNavigationBlocker(isDirty: boolean, identifier: string, callBack?: () => void) { const [, editBlockerStatus] = useAtom(blockerStatusAtom); + useEffect(() => { + return () => callBack?.(); + }, []); + useEffect(() => { if (typeof isDirty === 'boolean') { editBlockerStatus((prev) => ({ diff --git a/frontend/src/stores/projectView.ts b/frontend/src/stores/projectView.ts new file mode 100644 index 00000000..c38cbc79 --- /dev/null +++ b/frontend/src/stores/projectView.ts @@ -0,0 +1,15 @@ +import { atom } from 'jotai'; +import { atomWithReset } from 'jotai/utils'; + +export type ModifiableField = 'form' | 'map' | 'finances' | 'permissions'; + +const defaultDirtyViews: Record = { + form: false, + map: false, + finances: false, + permissions: false, +}; + +export const projectEditingAtom = atom(false); + +export const dirtyViewsAtom = atomWithReset(defaultDirtyViews); diff --git a/frontend/src/views/DetailplanProject/DetailplanProject.tsx b/frontend/src/views/DetailplanProject/DetailplanProject.tsx index 4d59523e..4856265f 100644 --- a/frontend/src/views/DetailplanProject/DetailplanProject.tsx +++ b/frontend/src/views/DetailplanProject/DetailplanProject.tsx @@ -1,8 +1,8 @@ -import { AccountTree, KeyTwoTone, Mail, Map, Undo } from '@mui/icons-material'; -import { Box, Breadcrumbs, Button, Chip, Paper, Tab, Tabs, Typography, css } from '@mui/material'; +import { AccountTree, KeyTwoTone, Mail, Map } from '@mui/icons-material'; +import { Box, Breadcrumbs, Chip, Paper, Tab, Tabs, Typography, css } from '@mui/material'; import { useAtomValue } from 'jotai'; import { ReactElement } from 'react'; -import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Link, useParams, useSearchParams } from 'react-router-dom'; import { trpc } from '@frontend/client'; import { ErrorPage } from '@frontend/components/ErrorPage'; @@ -10,7 +10,7 @@ import { MapWrapper } from '@frontend/components/Map/MapWrapper'; import { getProjectAreaStyle } from '@frontend/components/Map/styles'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; -import { DeleteProjectDialog } from '@frontend/views/Project/DeleteProjectDialog'; +import { projectEditingAtom } from '@frontend/stores/projectView'; import { ProjectRelations } from '@frontend/views/Project/ProjectRelations'; import { TranslationKey } from '@shared/language'; @@ -22,6 +22,7 @@ import { } from '@shared/schema/userPermissions'; import { ProjectPermissions } from '../Project/ProjectPermissions'; +import { ProjectViewWrapper } from '../Project/ProjectViewWrapper'; import { DetailplanProjectForm } from './DetailplanProjectForm'; import { DetailplanProjectNotification } from './DetailplanProjectNotification'; @@ -86,10 +87,11 @@ function getTabs(projectId: string) { export function DetailplanProject() { const routeParams = useParams() as { projectId: string }; const [searchParams] = useSearchParams(); - const navigate = useNavigate(); + const tabView = searchParams.get('tab') || 'default'; const projectId = routeParams?.projectId; const user = useAtomValue(asyncUserAtom); + const editing = useAtomValue(projectEditingAtom); const project = trpc.detailplanProject.get.useQuery( { projectId }, @@ -110,6 +112,10 @@ export function DetailplanProject() { const tabIndex = tabs.findIndex((tab) => tab.tabView === tabView); + function handleFormCancel(formRef: React.RefObject<{ onCancel: () => void }>) { + formRef.current?.onCancel(); + } + if (projectId && project.isLoading) { return {tr('loading')}; } @@ -126,119 +132,101 @@ export function DetailplanProject() { } return ( - - - - {project.data ? ( - - ) : ( - - )} - - - {!projectId && ( - - )} - - -
- - - {project.data && ( - - )} - - - handleFormCancel(formRef)} + renderHeaderContent={() => ( + - + {project.data ? ( + + ) : ( + + )} + + + )} + renderMainContent={(tabRefs) => ( +
+ + + + + - {tabs.map((tab) => ( - - ))} - - - {tabView === 'default' && ( - - - - )} - - {tabView !== 'default' && ( - - {tabView === 'sidoshankkeet' && ( - - )} - {tabView === 'tiedotus' && ( - + {tabs.map((tab) => ( + - )} - {tabView === 'luvitus' && ( - + + {tabView === 'default' && ( + + - )} - - )} - -
- + + )} + + {tabView !== 'default' && ( + + {tabView === 'sidoshankkeet' && ( + + )} + {tabView === 'tiedotus' && ( + + )} + {tabView === 'luvitus' && ( + + )} + + )} +
+
+ )} + /> ); } diff --git a/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx b/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx index f3323bbf..f2132887 100644 --- a/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx +++ b/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AddCircle, Edit, HourglassFullTwoTone, Save, Undo } from '@mui/icons-material'; -import { Box, Button, TextField, Typography } from '@mui/material'; +import { AddCircle } from '@mui/icons-material'; +import { Button, TextField, Typography } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { useAtomValue } from 'jotai'; -import { useEffect, useMemo, useState } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { FormProvider, ResolverOptions, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -19,6 +19,7 @@ import { useNotifications } from '@frontend/services/notification'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom, projectEditingAtom } from '@frontend/stores/projectView'; import { getRequiredFields } from '@frontend/utils/form'; import { mergeErrors } from '@shared/formerror'; @@ -27,7 +28,7 @@ import { DetailplanProject, detailplanProjectSchema, } from '@shared/schema/project/detailplan'; -import { hasWritePermission, isAdmin, ownsProject } from '@shared/schema/userPermissions'; +import { isAdmin, ownsProject } from '@shared/schema/userPermissions'; import { ProjectOwnerChangeDialog } from '../Project/ProjectOwnerChangeDialog'; @@ -47,17 +48,34 @@ const readonlyFieldProps = { InputProps: { readOnly: true }, } as const; -export function DetailplanProjectForm(props: Readonly) { +export const DetailplanProjectForm = forwardRef(function DetailplanProjectForm( + props: Readonly, + ref, +) { + useImperativeHandle( + ref, + () => ({ + onSave: () => { + form.handleSubmit(onSubmit)(); + }, + onCancel: () => { + form.reset(); + }, + }), + [], + ); + const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [editing, setEditing] = useState(!props.project); const currentUser = useAtomValue(asyncUserAtom); const [nextDetailplanId, setNextDetailplanId] = useState(null); const [ownerChangeDialogOpen, setOwnerChangeDialogOpen] = useState(false); const [keepOwnerRights, setKeepOwnerRights] = useState(false); const [displayInvalidSAPIdDialog, setDisplayInvalidSAPIdDialog] = useState(false); + const setDirtyViews = useSetAtom(dirtyViewsAtom); + const editing = useAtomValue(projectEditingAtom); const readonlyProps = useMemo(() => { if (editing) { @@ -162,7 +180,6 @@ export function DetailplanProjectForm(props: Readonly) { { input: { projectId: data.projectId } }, ], }); - setEditing(false); form.reset(data); } notify({ @@ -185,6 +202,17 @@ export function DetailplanProjectForm(props: Readonly) { } }, [form.formState.isSubmitSuccessful, form.reset]); + useEffect(() => { + if (!props.project) { + setDirtyViews((prev) => ({ ...prev, form: form.formState.isValid })); + } else { + setDirtyViews((prev) => ({ + ...prev, + form: form.formState.isValid && form.formState.isDirty, + })); + } + }, [props.project, form.formState.isValid, form.formState.isDirty]); + const ownerWatch = form.watch('owner'); const onSubmit = async (data: DetailplanProject | DbDetailplanProject) => { @@ -212,41 +240,7 @@ export function DetailplanProjectForm(props: Readonly) { {!props.project && ( {tr('newDetailplanProject.formTitle')} )} - {props.project && ( - - {tr('projectForm.formTitle')} - {!form.formState.isDirty && !editing ? ( - - ) : ( - - )} - - )} + {props.project && {tr('projectForm.formTitle')}}
formField="projectName" @@ -520,21 +514,6 @@ export function DetailplanProjectForm(props: Readonly) { {tr('newProject.createBtnLabel')} )} - - {props.project && editing && ( - - )} ) { /> ); -} +}); diff --git a/frontend/src/views/MaintenanceProject/MaintenanceProject.tsx b/frontend/src/views/MaintenanceProject/MaintenanceProject.tsx index a2689d9f..2297b247 100644 --- a/frontend/src/views/MaintenanceProject/MaintenanceProject.tsx +++ b/frontend/src/views/MaintenanceProject/MaintenanceProject.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/react'; -import { Euro, KeyTwoTone, ListAlt, Map, Undo } from '@mui/icons-material'; -import { Alert, Box, Breadcrumbs, Button, Chip, Paper, Tab, Tabs, Typography } from '@mui/material'; +import { Euro, KeyTwoTone, ListAlt, Map } from '@mui/icons-material'; +import { Box, Breadcrumbs, Chip, Paper, Tab, Tabs, Typography } from '@mui/material'; import { useAtomValue } from 'jotai'; import VectorSource from 'ol/source/Vector'; import { useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router'; +import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom'; @@ -15,15 +15,19 @@ import { DRAW_LAYER_Z_INDEX, addFeaturesFromGeoJson, featuresFromGeoJSON, + getGeoJSONFeaturesString, } from '@frontend/components/Map/mapInteractions'; -import { treMunicipalityGeometry } from '@frontend/components/Map/mapOptions'; +import { mapOptions, treMunicipalityGeometry } from '@frontend/components/Map/mapOptions'; import { getProjectAreaStyle } from '@frontend/components/Map/styles'; -import { useNotifications } from '@frontend/services/notification'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; -import { getProjectMunicipalityLayer, getProjectObjectsLayer } from '@frontend/stores/map'; +import { + getProjectMunicipalityLayer, + getProjectObjectsLayer, + mapProjectionAtom, +} from '@frontend/stores/map'; +import { projectEditingAtom } from '@frontend/stores/projectView'; import { MaintenanceProjectForm } from '@frontend/views/MaintenanceProject/MaintenanceProjectForm'; -import { DeleteProjectDialog } from '@frontend/views/Project/DeleteProjectDialog'; import { ProjectAreaSelectorForm } from '@frontend/views/Project/ProjectAreaSelectorForm'; import { ProjectFinances } from '@frontend/views/Project/ProjectFinances'; import { ProjectPermissions } from '@frontend/views/Project/ProjectPermissions'; @@ -36,6 +40,8 @@ import { ownsProject, } from '@shared/schema/userPermissions'; +import { ProjectViewWrapper } from '../Project/ProjectViewWrapper'; + const pageContentStyle = css` display: grid; grid-template-columns: minmax(384px, 1fr) minmax(512px, 2fr); @@ -90,10 +96,11 @@ function getTabs(projectId: string) { export function MaintenanceProject() { const routeParams = useParams() as { projectId: string }; const [searchParams] = useSearchParams(); - const navigate = useNavigate(); const tabView = searchParams.get('tab') || 'default'; const user = useAtomValue(asyncUserAtom); + const editing = useAtomValue(projectEditingAtom); + const mapProjection = useAtomValue(mapProjectionAtom); const projectId = routeParams?.projectId; const project = trpc.maintenanceProject.get.useQuery( { projectId }, @@ -102,7 +109,6 @@ export function MaintenanceProject() { const [coversMunicipality, setCoversMunicipality] = useState( project.data?.coversMunicipality ?? false, ); - const [formsEditing, setFormsEditing] = useState(false); const userCanModify = Boolean( project.data && @@ -115,26 +121,7 @@ export function MaintenanceProject() { ); const tabIndex = tabs.findIndex((tab) => tab.tabView === tabView); - const [geom, setGeom] = useState(null); - const tr = useTranslations(); - const notify = useNotifications(); - const geometryUpdate = trpc.project.updateGeometry.useMutation({ - onSuccess: () => { - project.refetch(); - notify({ - severity: 'success', - title: tr('project.notifyGeometryUpdateTitle'), - duration: 5000, - }); - }, - onError: () => { - notify({ - severity: 'error', - title: tr('project.notifyGeometryUpdateFailedTitle'), - }); - }, - }); const projectObjects = trpc.projectObject.getByProjectId.useQuery( { projectId }, @@ -185,15 +172,18 @@ export function MaintenanceProject() { } }, [project.data]); - useEffect(() => { - setFormsEditing(!projectId); - }, [projectId]); - function mapIsEditable() { if (coversMunicipality) return false; return !projectId || userCanModify; } + function handleFormCancel(formRef: React.RefObject<{ onCancel: () => void }>) { + if (formRef.current?.onCancel) { + formRef.current?.onCancel(); + addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); + } + } + if (projectId && project.isLoading) { return {tr('loading')}; } @@ -210,178 +200,155 @@ export function MaintenanceProject() { } return ( - - - - {project.data ? ( - - ) : ( - - )} - - {tr('maintenanceProject.disclaimer')} - {!projectId && ( - - )} - - -
- - { - addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); - }} - /> - {project.data && ( - - )} - - - handleFormCancel(formRef)} + renderHeaderContent={() => ( + - {tabs.length > 0 && ( - - {tabs.map((tab) => ( - - ))} - - )} + + {project.data ? ( + + ) : ( + + )} + + + )} + renderMainContent={(tabRefs) => ( +
+ + { + return getGeoJSONFeaturesString( + drawSource.getFeatures(), + mapProjection?.getCode() ?? mapOptions.projection.code, + ); + }} + onCancel={() => { + addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); + }} + /> + - {tabView === 'default' && ( - - {(!projectId || formsEditing) && ( - { - if (isChecked) { - setGeom(null); - } + + {tabs.length > 0 && ( + + {tabs.map((tab) => ( + + ))} + + )} - setCoversMunicipality(isChecked); + {tabView === 'default' && ( + + {(!projectId || editing) && ( + { + setCoversMunicipality(isChecked); + }} + /> + )} + ({ + ...obj, + project: { + projectId: projectId, + projectName: project.data?.projectName ?? '', + projectType: 'maintenanceProject', + coversMunicipality: project.data?.coversMunicipality ?? false, + }, + })) ?? [] + } + interactiveLayers={['projectObjects']} /> - )} - { - if (!project.data || coversMunicipality !== project.data.coversMunicipality) { - setGeom(features); - } else { - geometryUpdate.mutate({ projectId, features }); - } - }, - }} - fitExtent="geoJson" - vectorLayers={[ - ...(coversMunicipality ? [municipalityGeometryLayer] : []), - projectObjectsLayer, - ]} - projectObjects={ - projectObjects.data?.map((obj) => ({ - ...obj, - project: { - projectId: projectId, - projectName: project.data?.projectName ?? '', - projectType: 'maintenanceProject', - coversMunicipality: project.data?.coversMunicipality ?? false, - }, - })) ?? [] - } - interactiveLayers={['projectObjects']} - /> - - )} + + )} - {tabView !== 'default' && ( - - {tabView === 'talous' && ( - { - document.dispatchEvent(new Event('budgetUpdated')); - }} - editable={userCanModify} - project={{ type: 'maintenanceProject', data: project.data }} - writableFields={['estimate']} - /> - )} - {tabView === 'kohteet' && ( - - )} - {tabView === 'luvitus' && ( - - )} - - )} - -
- + {tabView !== 'default' && ( + + {tabView === 'talous' && ( + { + document.dispatchEvent(new Event('budgetUpdated')); + }} + editable={userCanModify} + project={{ type: 'maintenanceProject', data: project.data }} + writableFields={['estimate']} + /> + )} + {tabView === 'kohteet' && ( + + )} + {tabView === 'luvitus' && ( + + )} + + )} +
+
+ )} + /> ); } diff --git a/frontend/src/views/MaintenanceProject/MaintenanceProjectForm.tsx b/frontend/src/views/MaintenanceProject/MaintenanceProjectForm.tsx index 6a1a86f6..483df659 100644 --- a/frontend/src/views/MaintenanceProject/MaintenanceProjectForm.tsx +++ b/frontend/src/views/MaintenanceProject/MaintenanceProjectForm.tsx @@ -1,11 +1,10 @@ import { css } from '@emotion/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AddCircle, Edit, HourglassFullTwoTone, Save, Undo } from '@mui/icons-material'; -import { Alert, Box, Button, TextField, Typography } from '@mui/material'; +import { Box, TextField, Typography } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { useAtomValue } from 'jotai'; -import { useEffect, useMemo, useState } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { FormProvider, ResolverOptions, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -20,6 +19,7 @@ import { useNotifications } from '@frontend/services/notification'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom, projectEditingAtom } from '@frontend/stores/projectView'; import { getRequiredFields } from '@frontend/utils/form'; import { ProjectOwnerChangeDialog } from '@frontend/views/Project/ProjectOwnerChangeDialog'; @@ -29,7 +29,7 @@ import { MaintenanceProject, maintenanceProjectSchema, } from '@shared/schema/project/maintenance'; -import { hasWritePermission, isAdmin, ownsProject } from '@shared/schema/userPermissions'; +import { isAdmin, ownsProject } from '@shared/schema/userPermissions'; const newProjectFormStyle = css` display: grid; @@ -38,25 +38,44 @@ const newProjectFormStyle = css` interface MaintenanceProjectFormProps { project?: DbMaintenanceProject | null; - geom: string | null; coversMunicipality: boolean; setCoversMunicipality: React.Dispatch>; - editing: boolean; - setEditing: React.Dispatch>; onCancel?: () => void; + getDrawGeometry: () => string; } -export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { - const { coversMunicipality, setCoversMunicipality, editing, setEditing } = props; +export const MaintenanceProjectForm = forwardRef(function MaintenanceProjectForm( + props: MaintenanceProjectFormProps, + ref, +) { + const { coversMunicipality, setCoversMunicipality } = props; + + useImperativeHandle( + ref, + () => ({ + onSave: (geom?: string) => { + form.handleSubmit((data) => onSubmit(data, geom))(); + }, + onCancel: () => { + form.reset(); + externalForm.reset(); + setCoversMunicipality(form.getValues('coversMunicipality')); + }, + }), + [coversMunicipality], + ); + const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); const navigate = useNavigate(); const currentUser = useAtomValue(asyncUserAtom); + const setDirtyViews = useSetAtom(dirtyViewsAtom); const [ownerChangeDialogOpen, setOwnerChangeDialogOpen] = useState(false); const [keepOwnerRights, setKeepOwnerRights] = useState(false); const [displayInvalidSAPIdDialog, setDisplayInvalidSAPIdDialog] = useState(false); + const [editing, setEditing] = useAtom(projectEditingAtom); const { user, sap } = trpc.useUtils(); const readonlyProps = useMemo(() => { @@ -153,6 +172,22 @@ export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { const ownerWatch = form.watch('owner'); const endDateWatch = form.watch('endDate'); + useEffect(() => { + if (!props.project) { + setDirtyViews((prev) => ({ ...prev, form: form.formState.isValid })); + } else { + setDirtyViews((prev) => ({ + ...prev, + form: !submitDisabled(), + })); + } + }, [ + props.project, + form.formState.isValid, + form.formState.isDirty, + externalForm.formState.isDirty, + ]); + useEffect(() => { form.reset( props.project @@ -183,7 +218,6 @@ export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { ], }); - setEditing(false); form.reset(data); externalForm.reset({ coversMunicipality: data.coversMunicipality }); } @@ -207,7 +241,7 @@ export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { } }, [form.formState.isSubmitSuccessful, form.reset, displayInvalidSAPIdDialog]); - const onSubmit = async (data: MaintenanceProject | DbMaintenanceProject) => { + const onSubmit = async (data: MaintenanceProject | DbMaintenanceProject, geom?: string) => { let validOrEmptySAPId; try { validOrEmptySAPId = data.sapProjectId @@ -223,7 +257,7 @@ export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { return; } projectUpsert.mutate({ - project: { ...data, geom: props.geom, coversMunicipality: coversMunicipality }, + project: { ...data, geom: geom ?? null, coversMunicipality: coversMunicipality }, keepOwnerRights, }); }; @@ -241,45 +275,8 @@ export function MaintenanceProjectForm(props: MaintenanceProjectFormProps) { {!props.project && ( {tr('newMaintenanceProject.formTitle')} )} - {props.project && ( - - {tr('projectForm.formTitle')} - {!form.formState.isDirty && !editing ? ( - - ) : ( - - )} - - )} -
+ {props.project && {tr('projectForm.formTitle')}} + )} /> - - {!props.project && ( - <> - {!coversMunicipality && (!props.geom || props.geom === '[]') && ( - - {tr('newProject.infoNoGeom')} - - )} - - - )} - - {props.project && editing && ( - - )} { form.reset(undefined, { keepValues: true }); setDisplayInvalidSAPIdDialog(false); + setEditing(true); }} /> ); -} +}); diff --git a/frontend/src/views/Manual/fi.md b/frontend/src/views/Manual/fi.md index ccdfe7c1..8781c549 100644 --- a/frontend/src/views/Manual/fi.md +++ b/frontend/src/views/Manual/fi.md @@ -57,39 +57,48 @@ - [Yrityksien ja heidän yhteyshenkilöiden hallinta](#yrityksien-ja-heidän-yhteyshenkilöiden-hallinta) # Yleistä + Hanna on Tampereen kaupungin maankäytön suunnittelun ja toteuttamisen hanketietojärjestelmä. Se tarjoaa mahdollisuuden seuraavaan: + - Investointiohjelman laadinta - Hankkeiden taloustoteuman seuranta - Asemakaavahankkeen perustaminen -Hannan käyttöä laajennetaan käyttäjäryhmä kerrallaan. Kehitys on aloitettu vuonna 2022. Kehittäjänä ja ylläpitäjänä toimii Ubigu Oy. +Hannan käyttöä laajennetaan käyttäjäryhmä kerrallaan. Kehitys on aloitettu vuonna 2022. Kehittäjänä ja ylläpitäjänä toimii Ubigu Oy. # Järjestelmän tuki + Lähitukea tarjoaa Jaana Turunen ([jaana.turunen@tampere.fi](mailto:jaana.turunen@tampere.fi)). Jaana vastaa myös käyttäjien luvittamisesta sovelluksen käyttöön. Hannalla on myös oma Teams -ryhmä, jossa järjestelmän käytöstä ja kehityksestä voi keskustella muiden käyttäjien ja kehittäjien kesken. -Virhetilanteista ja bugeista viestiä voi lähettää myös suoraan kehittäjille osoitteeseen [tuki@ubigu.fi](mailto:tuki@ubigu.fi). +Virhetilanteista ja bugeista viestiä voi lähettää myös suoraan kehittäjille osoitteeseen [tuki@ubigu.fi](mailto:tuki@ubigu.fi). # Testi- ja tuotantojärjestelmä + Hannasta on olemassa kaksi eri järjestelmää. Testijärjestelmä toimii osoitteessa [tre-hanna-test.azurewebsites.net](https://tre-hanna-test.azurewebsites.net/). Siellä käyttäjät voivat kokeilla Hannan toiminnallisuuksia ilman huolta järjestelmän rikkoutumisesta tai tuotantokäytön häiriintymisestä. Varsinainen käyttö tapahtuu tuotantojärjestelmässä osoitteessa [hanna.tampere.fi](https://hanna.tampere.fi). Huomioi, että testijärjestelmään luodut tiedot voivat aikanaan poistua, ja se on tarkoitettu ainoastaan testikäyttöön. Varsinainen, säilytettävä, ja myös varmuuskopioitava, hanketieto tulee kirjata tuotantojärjestelmään. Järjestelmiin luvittaminen tapahtuu erikseen, eli esimerkiksi tuotantojärjestelmään pääsevä käyttäjä ei automaattisesti pääse myös testijärjestelmään. # Integraatiot muihin järjestelmiin -Tässä kappaleessa on listattu kaupungin muut tietojärjestelmät, joiden kanssa Hanna omaa jonkinasteisen integraation. + +Tässä kappaleessa on listattu kaupungin muut tietojärjestelmät, joiden kanssa Hanna omaa jonkinasteisen integraation. ## SAP -Hanna lukee SAP:sta projektien tietoja sekä niiden tositteita. Toistaiseksi tietojen luku tapahtuu yksisuuntaisesti, eli SAP ei vastavuoroisesti hae tietoa Hannasta tai ota kantaa Hannan hankkeisiin. Hannasta käsin ei myöskään toistaiseksi ole mahdollista päivittää tietoja suoraan SAP:iin. Hanna hakee kaikki SAP:n projektit ja niiden tositteet kerran vuorokaudessa yöaikaan ja tallentaa ne omaan tietokantaansa, josta ne esitetään käyttöliittymässä. SAP:iin toteutetut muutokset ilmenevät näin olen Hannassa viiveellä. + +Hanna lukee SAP:sta projektien tietoja sekä niiden tositteita. Toistaiseksi tietojen luku tapahtuu yksisuuntaisesti, eli SAP ei vastavuoroisesti hae tietoa Hannasta tai ota kantaa Hannan hankkeisiin. Hannasta käsin ei myöskään toistaiseksi ole mahdollista päivittää tietoja suoraan SAP:iin. Hanna hakee kaikki SAP:n projektit ja niiden tositteet kerran vuorokaudessa yöaikaan ja tallentaa ne omaan tietokantaansa, josta ne esitetään käyttöliittymässä. SAP:iin toteutetut muutokset ilmenevät näin olen Hannassa viiveellä. Projektien ja tositteiden haku SAP:sta on rajattu seuraaviin yrityksiin. + - 1110 (KAPA) - 1350 (KITIA) - 1540 (ELOSA) ## Geoserver -Paikkatietojen osalta Hanna hyödyntää kaupungin olemassaolevia aineistoja ja rajapintoja. Geoserveriltä haetaan erilaisia taustakartta-aineistoja (opaskartta, asemakaava, virastokartta...), rekisterikohteita (kiinteistöt, kadut...) sekä aluerajauksia (kaupunginosat). Saatavilla olevia aineistoja on mahdollista lisätä tarpeen mukaan. Toistaiseksi Hannassa piirretyt hanke- ja kohdealueet eivät ole tarjolla kaupungin Geoserverillä. + +Paikkatietojen osalta Hanna hyödyntää kaupungin olemassaolevia aineistoja ja rajapintoja. Geoserveriltä haetaan erilaisia taustakartta-aineistoja (opaskartta, asemakaava, virastokartta...), rekisterikohteita (kiinteistöt, kadut...) sekä aluerajauksia (kaupunginosat). Saatavilla olevia aineistoja on mahdollista lisätä tarpeen mukaan. Toistaiseksi Hannassa piirretyt hanke- ja kohdealueet eivät ole tarjolla kaupungin Geoserverillä. ## Hankkeen perustamisen ja ostotilauksen pyyntölomake -Hannan navigointipalkin oikeasta laidasta löytyy painike, joka ohjaa käyttäjän kaupungin e-lomakkeelle, jota kautta voi pyytää SAP-projektin perustamista ja ostotilauksen tekemistä talousyksiköltä. + +Hannan navigointipalkin oikeasta laidasta löytyy painike, joka ohjaa käyttäjän kaupungin e-lomakkeelle, jota kautta voi pyytää SAP-projektin perustamista ja ostotilauksen tekemistä talousyksiköltä. # Karttasivu @@ -101,19 +110,22 @@ Vasemmassa yläkulmassa voit valita tarkasteltavaksi joko hankkeet tai investoin Hakutuloksissa hankkeille kerrotaan niiden nimi, tyyppi ja toteutusväli. Lisäksi asemakaavahankkeille kerrotaan suluissa niiden kaavanumero. Kattavimmin hankkeen tiedot löytyvät kuitenkin niiden omilta [hankesivuilta](#hankesivu). Kohteille taas esitetään nimi, niiden hankkeen nimi, toteutusväli sekä kohteen laji (suunnittelu/rakentaminen). Lisäksi kohteet on ryhmitelty hakutuloksiin hankkeittain. Kattavin tieto hankkeista ja niiden kohteista löytyy kuitenkin itse hankesivulta. Sieltä käsin tapahtuu tietojen kirjaus, muokkaus, poistaminen ja käyttöoikeuksien muutokset. Hankesivulta käsin käyttäjä pääsee navigoimaan myös kohteille. -Hankkeet esitetään kartalla vihreinä ja kohteet sinisinä. Toistaiseksi käyttäjä ei pääse vaikuttamaan symbologiaan. +Hankkeet esitetään kartalla vihreinä ja kohteet sinisinä. Toistaiseksi käyttäjä ei pääse vaikuttamaan symbologiaan. -Käyttäjä voi klikata kartalla näkyviä hankkeita ja kohteita, jolloin niiden tiedot, ja mahdollisuus siirtyä hanke- tai kohdesivulle tulee tarjolle ponnahdusikkunaan. Jos klikkaus kohdistuu useampaan objektiin, niiden välillä voi selata nuolinäppäimillä. Karttavalinta korostetaan keltaisella. Huomioi, että valinta voi poistua, jos muokkaat hakusuodattimia. Toistaiseksi rajapinnoilta haettavien aineistojen (esim. rakennukset) tietoja ei pysty selaamaan kartalla. +Käyttäjä voi klikata kartalla näkyviä hankkeita ja kohteita, jolloin niiden tiedot, ja mahdollisuus siirtyä hanke- tai kohdesivulle tulee tarjolle ponnahdusikkunaan. Jos klikkaus kohdistuu useampaan objektiin, niiden välillä voi selata nuolinäppäimillä. Karttavalinta korostetaan keltaisella. Huomioi, että valinta voi poistua, jos muokkaat hakusuodattimia. Toistaiseksi rajapinnoilta haettavien aineistojen (esim. rakennukset) tietoja ei pysty selaamaan kartalla. ![karttavalinta](/images/karttavalinta.png) ## Uuden hankkeen perustaminen -Uusi hanke perustetaan etusivulta löytyvästä _Luo uusi hanke_ -painikkeesta. Painaessaan sitä käyttäjä valitsee ensin, minkä hanketyypin mukaisen hankkeen hän haluaa perustaa. Vain ne hanketyypit, joiden perustamiseen käyttäjällä on oikeus, listataan (lue lisää [käyttöoikeuksista](#käyttöoikeudet)). Valinta on olennainen, sillä hanketyypeillä on erilaiset tietoskeemat. Tämän jälkeen käyttäjä ohjataan tyhjälle hankesivulle, jossa hankkeen tiedot pääsee täyttämään. -Uusia kohteita voi perustaa karttasivun kohteet-välilehdeltä, hankesivuilta ja investointiohjemoinnin näkymästä käsin. +Uusi hanke perustetaan etusivulta löytyvästä _Luo uusi hanke_ -painikkeesta. Painaessaan sitä käyttäjä valitsee ensin, minkä hanketyypin mukaisen hankkeen hän haluaa perustaa. Vain ne hanketyypit, joiden perustamiseen käyttäjällä on oikeus, listataan (lue lisää [käyttöoikeuksista](#käyttöoikeudet)). Valinta on olennainen, sillä hanketyypeillä on erilaiset tietoskeemat. Tämän jälkeen käyttäjä ohjataan tyhjälle hankesivulle, jossa hankkeen tiedot pääsee täyttämään. + +Uusia kohteita voi perustaa karttasivun kohteet-välilehdeltä, hankesivuilta ja investointiohjemoinnin näkymästä käsin. ## Hankkeiden hakeminen + Hankkeita voi hakea seuraavilla ehdoilla: + - Vapaa tekstihaku, joka nimeen, kuvaukseen sekä kaavanumeroon, jos kyseessä on asemakaavahanke - Hakuaikaväli (tarkistaa, leikkaako asetettu aikaväli hankkeen alku- ja loppupäivämäärän väliä) - Elinkaaren tila @@ -122,10 +134,12 @@ Hankkeita voi hakea seuraavilla ehdoilla: Jos hakuehtoja on useampia, niiden kaikkien pitää toteutua, jotta hanke ilmestyy hakutuloksiin (`JA`-operaattori).Lisäksi, jos käyttäjä valitsee haussa jommankumman hanketyypeistä, ilmestyy hakuosuuden yhteyteen valinta _näytä lisää hakuehtoja_. Sen takaa avautuvasta osiosta käyttäjä voi vielä tarkentaa hakua hanketyypille ominaisilla tiedoilla. Asemakaavahankkeita koskevaa hakua voi esimerkiksi tarkentaa valitsemalla sopivan `suunnittelualueen` tai `valmistelijan`. -Myös itse karttaikkuna toimii suodattimena. Hakutuloksiin tulevat oletuksena listatuksi myös hankkeet, joilta puuttuu aluerajaus, jonka osoittaminen on vapaavalintaista. Käyttäjä voi painaa painiketta _sisällytä vain hankkeet alueilla_, jolloin hakutuloslista poistuu alueettomat hankkeet. +Myös itse karttaikkuna toimii suodattimena. Hakutuloksiin tulevat oletuksena listatuksi myös hankkeet, joilta puuttuu aluerajaus, jonka osoittaminen on vapaavalintaista. Käyttäjä voi painaa painiketta _sisällytä vain hankkeet alueilla_, jolloin hakutuloslista poistuu alueettomat hankkeet. ## Kohteiden hakeminen + Kohteita voi hakea seuraavilla ehdoilla: + - Vapaa haku, joka kohdistuu kohteen nimeen ja kuvaukseen - Hakuaikaväli (tarkistaa, leikkaako asetettu aikaväli kohteen alku- ja loppupäivämäärän väliä) - Kohteen laji (suunnittelu/rakentaminen) @@ -136,98 +150,117 @@ Kohteita voi hakea seuraavilla ehdoilla: - Rakennuttaja - Suunnitteluttaja -Kuten hankkeitakin hakiessa, jos hakuehtoja on useampia, niiden kaikkien pitää toteutua, jotta kohde ilmestyy hakutuloksiin (`JA`-operaattori). +Kuten hankkeitakin hakiessa, jos hakuehtoja on useampia, niiden kaikkien pitää toteutua, jotta kohde ilmestyy hakutuloksiin (`JA`-operaattori). -Myös itse karttaikkuna toimii suodattimena. Hakutuloksiin tulevat oletuksena listatuksi myös kohteet, joilta puuttuu aluerajaus, jonka osoittaminen on vapaavalintaista. Käyttäjä voi painaa painiketta _sisällytä vain kohteet alueilla_, jolloin hakutuloslista poistuu alueettomat kohteet. +Myös itse karttaikkuna toimii suodattimena. Hakutuloksiin tulevat oletuksena listatuksi myös kohteet, joilta puuttuu aluerajaus, jonka osoittaminen on vapaavalintaista. Käyttäjä voi painaa painiketta _sisällytä vain kohteet alueilla_, jolloin hakutuloslista poistuu alueettomat kohteet. ## Kartan toiminnot + Karttanäkymää voi liikuttaa raahamalla sitä hiirellä. Hiiren rullaa voi hyödyntää lähentämiseen ja loitontamiseen. Vastaavat painikkeet löytyvät karttaikkunan vasemmasta yläkulmasta. Sieltä löytyy myös painike _Palauta zoomaus_, joka asettaa karttanäkymän sen oletusrajaukseen. Karttanäkymään on valittavissa seuraavat taustakartat vasemmasta alakulmasta löytyvästä painikkeesta: + - Virastokartta - Opaskartta - Kantakartta - Ilmakuva - Ajantasa-asemakaava -Lisäksi samaisesta valikosta on valittavissa seuraavat vektorimuotoiset paikkatietotasot: +Lisäksi samaisesta valikosta on valittavissa seuraavat vektorimuotoiset paikkatietotasot: + - Kiinteistöt - Rakennukset - Kadut - Kevyen liikenteen väylät - Kaupunginosat -Toistaiseksi käyttäjät eivät pysty vaikuttamaan näiden tasojen esitystyyliin. +Toistaiseksi käyttäjät eivät pysty vaikuttamaan näiden tasojen esitystyyliin. ## Tietojen vienti taulukkotiedostoon -Hankkeet ja/tai niiden kohteet on mahdollista viedä Excel -taulukkotiedostoon valitsemalla hakutulososion yhteydestä löytyvä _Lataa raportti_ -painike. Tiedostoon viedään sillä hetkellä aktiivisen haun palauttamat hankkeet/kohteet. Jos hanketyyppejä on useita, viedään ne tiedostossa omille välilehdilleen. Investointihankkeista viedään taulukkotiedostoon lisäksi niiden kohteet, ja tiedot on rivitetty kohteiden mukaan. Esimerkiksi hanke, jolle on kirjattu kahdeksan kohdetta, ilmenee taulukkotiedostossa kahdeksalla rivillä. + +Hankkeet ja/tai niiden kohteet on mahdollista viedä Excel -taulukkotiedostoon valitsemalla hakutulososion yhteydestä löytyvä _Lataa raportti_ -painike. Tiedostoon viedään sillä hetkellä aktiivisen haun palauttamat hankkeet/kohteet. Jos hanketyyppejä on useita, viedään ne tiedostossa omille välilehdilleen. Investointihankkeista viedään taulukkotiedostoon lisäksi niiden kohteet, ja tiedot on rivitetty kohteiden mukaan. Esimerkiksi hanke, jolle on kirjattu kahdeksan kohdetta, ilmenee taulukkotiedostossa kahdeksalla rivillä. Toistaiseksi asemakaavahankkeista viedään taulukkotiedostoon vain osa tietokentistä perustuen mallina käytettyyn asemakaavaluetteloon. # Käyttöoikeudet ## Käyttäjätyypit -Hannaan luvitetut käyttäjät jakautuvat perus- ja pääkäyttäjiin. Hanna tunnistaa automaattisesti kirjautumisen yhteydessä, kumpaan ryhmään käyttäjä kuuluu. Pääkäyttäjillä on Hannan käyttöön laajimmat mahdolliset oikeudet, ja he pystyvät muokkaamaan kaikkia hankkeita, poistamaan niitä ja vaihtamaan niiden omistajia. Peruskäyttäjien käyttöoikeudet on kuvattu tarkemmin alla. -Jos sinulla on tarve vaihtaa toiseen käyttäjätyyppiin, ole yhteydessä Jaana Turuseen (jaana.turunen@tampere.fi). +Hannaan luvitetut käyttäjät jakautuvat perus- ja pääkäyttäjiin. Hanna tunnistaa automaattisesti kirjautumisen yhteydessä, kumpaan ryhmään käyttäjä kuuluu. Pääkäyttäjillä on Hannan käyttöön laajimmat mahdolliset oikeudet, ja he pystyvät muokkaamaan kaikkia hankkeita, poistamaan niitä ja vaihtamaan niiden omistajia. Peruskäyttäjien käyttöoikeudet on kuvattu tarkemmin alla. + +Jos sinulla on tarve vaihtaa toiseen käyttäjätyyppiin, ole yhteydessä Jaana Turuseen (jaana.turunen@tampere.fi). ## Pääkäyttäjän luvitusnäkymä + Pääkäyttäjille on luotu oma näkymänsä, joka ei ole tarjolla peruskäyttäjille. Näkymästä käsin pääkäyttäjät voivat muokata seuraavia peruskäyttäjien oikeuksia: + - Oikeus perustaa investointihanke - Oikeus perustaa asemakaavahanke - Oikeus muokata investointihankkeen talousarvioita ja käyttösuunnitelman muutosta -Pääkäyttäjä ei voi poistaa toisen pääkäyttäjän oikeuksia, vaan ne luetaan aina Tampereen Microsoft Entra ID:stä. Lisätessään tai poistaessaan oikeuksia kyseisten käyttäjien istunto päivitetään, ja muuttuneet käyttöoikeudet tulevat voimaan heti. +Pääkäyttäjä ei voi poistaa toisen pääkäyttäjän oikeuksia, vaan ne luetaan aina Tampereen Microsoft Entra ID:stä. Lisätessään tai poistaessaan oikeuksia kyseisten käyttäjien istunto päivitetään, ja muuttuneet käyttöoikeudet tulevat voimaan heti. ![Pääkäyttäjän luvitusnäkymä](/images/paakayttajan_luvitusnakyma.png)
_Pääkäyttäjän luvitusnäkymä näyttää tältä. Peruskäyttäjiltä kyseinen sivu puuttuu kokonaan. Muut pääkäyttäjät ilmenevät harmaina, eikä heidän oikeuksiaan pääse muokkaamaan._ ## Lukuoikeus -Jokaisella Hannaan pääsevällä käyttäjällä on oikeus lukea koko hankejoukkoa, joka Hannaan on avattu. Tämä koskee myös SAP:n rajapinnan yli haettuja talous- ja projektitietoja (huomioi erityisesti [SAP-raportit -näkymä](#sap-raportit-näkymä)). Toistaiseksi Hanna-sovelluksen käyttöön on luvitettu vain Tampereen kaupunkiorganisaatioon kuuluvia henkilöitä. + +Jokaisella Hannaan pääsevällä käyttäjällä on oikeus lukea koko hankejoukkoa, joka Hannaan on avattu. Tämä koskee myös SAP:n rajapinnan yli haettuja talous- ja projektitietoja (huomioi erityisesti [SAP-raportit -näkymä](#sap-raportit-näkymä)). Toistaiseksi Hanna-sovelluksen käyttöön on luvitettu vain Tampereen kaupunkiorganisaatioon kuuluvia henkilöitä. ## Hankkeen perustamisoikeus -Pääkäyttäjät sekä heidän yksilöimänsä käyttäjät voivat perustaa Hannassa uusia hankkeita. Pääkäyttäjä yksilöi perustamisoikeuden hanketyypin tarkkuudella. Näin esimerkiksi voidaan sallia käyttäjälle perustaa asemakaavahankkeita, mutta estää investointihankkeiden perustaminen joko vahingossa tai epätarkoituksenmukaisesti. Jos peruskäyttäjä ei omaa oikeutta perustaa mitään hankkeita, esitetään karttanäkymän oikeassa ylälaidassa näkyvä `Luo uusi hanke` -painike harmaana. + +Pääkäyttäjät sekä heidän yksilöimänsä käyttäjät voivat perustaa Hannassa uusia hankkeita. Pääkäyttäjä yksilöi perustamisoikeuden hanketyypin tarkkuudella. Näin esimerkiksi voidaan sallia käyttäjälle perustaa asemakaavahankkeita, mutta estää investointihankkeiden perustaminen joko vahingossa tai epätarkoituksenmukaisesti. Jos peruskäyttäjä ei omaa oikeutta perustaa mitään hankkeita, esitetään karttanäkymän oikeassa ylälaidassa näkyvä `Luo uusi hanke` -painike harmaana. ## Oikeus muokata hankkeen tietoja -Hankkeiden muokkausoikeus on hankkeen omistajalla, hänen osoittamillaan muilla peruskäyttäjillä sekä Hannan pääkäyttäjillä. Hankkeen muokkausoikeudet eivät oikeuta muokkaamaan hankkeen käyttöoikeuksia, vaan niiden muokkaaminen on rajattu yksinään hankkeen omistajalle (sekä pääkäyttäjille). Muokkausoikeudet periytyvät myös hankkeen kohteille ja vaiheille, ja se koskettaa myös niiden luontia ja poistamista. Talousarvioiden ja käyttösuunnitelman muutoksen muokkaamiseen tarvitaan lisäksi erillinen oikeus pääkäyttäjältä. + +Hankkeiden muokkausoikeus on hankkeen omistajalla, hänen osoittamillaan muilla peruskäyttäjillä sekä Hannan pääkäyttäjillä. Hankkeen muokkausoikeudet eivät oikeuta muokkaamaan hankkeen käyttöoikeuksia, vaan niiden muokkaaminen on rajattu yksinään hankkeen omistajalle (sekä pääkäyttäjille). Muokkausoikeudet periytyvät myös hankkeen kohteille ja vaiheille, ja se koskettaa myös niiden luontia ja poistamista. Talousarvioiden ja käyttösuunnitelman muutoksen muokkaamiseen tarvitaan lisäksi erillinen oikeus pääkäyttäjältä. ## Oikeus poistaa hanke -Vain hankkeen omistaja ja pääkäyttäjä voivat poistaa hankkeen. Huomioi, että hankkeen poistaminen tarkoittaa myös sen kohteiden ja vaiheiden poistamista. + +Vain hankkeen omistaja ja pääkäyttäjä voivat poistaa hankkeen. Huomioi, että hankkeen poistaminen tarkoittaa myös sen kohteiden ja vaiheiden poistamista. ## Hankkeen omistajan vaihtaminen -Hankkeen omistaja voi luopua omistajuudestaan ja osoittaa sen toiselle käyttäjälle niin halutessaan. Ennen vaihtoa Hanna kysyy häneltä varmistuksen vaihtopäätöksestä ja sen, halutaanko vanhalle omistajalle jättää vielä muokkausoikeus hankkeeseen. Ongelmatilanteiden ilmetessä pääkäyttäjä voi aina vaihtaa hankkeen omistajaa. + +Hankkeen omistaja voi luopua omistajuudestaan ja osoittaa sen toiselle käyttäjälle niin halutessaan. Ennen vaihtoa Hanna kysyy häneltä varmistuksen vaihtopäätöksestä ja sen, halutaanko vanhalle omistajalle jättää vielä muokkausoikeus hankkeeseen. Ongelmatilanteiden ilmetessä pääkäyttäjä voi aina vaihtaa hankkeen omistajaa. ## Oikeus muokata talousarvioita ja käyttösuunnitelman muutoksia -Muokatakseen investointihankkeiden talousarvioita sekä sen kohteiden käyttösuunnitelman muutosta (KSM), peruskäyttäjä tarvitsee siihen erikseen luvan pääkäyttäjältä. Tämä oikeus on universaali, eli se tulee annetuksi kerralla koko Hannan hankejoukolle, niiden kohteille ja vaiheille. Käyttäjä, joka on luvitettu muokkaamaan talousarvioita ja KSM:ää, ei tarvitse erikseen hankkeen omistajalta muokkausoikeutta hankkeeseen muokatakseen kyseisiä arvoja. Hän tarvitsee ne kuitenkin muokatakseen hankkeen muita tietoja (myös ennuste). + +Muokatakseen investointihankkeiden talousarvioita sekä sen kohteiden käyttösuunnitelman muutosta (KSM), peruskäyttäjä tarvitsee siihen erikseen luvan pääkäyttäjältä. Tämä oikeus on universaali, eli se tulee annetuksi kerralla koko Hannan hankejoukolle, niiden kohteille ja vaiheille. Käyttäjä, joka on luvitettu muokkaamaan talousarvioita ja KSM:ää, ei tarvitse erikseen hankkeen omistajalta muokkausoikeutta hankkeeseen muokatakseen kyseisiä arvoja. Hän tarvitsee ne kuitenkin muokatakseen hankkeen muita tietoja (myös ennuste). # Hankesivu -Hankesivu on hankkeen koko tietosisällön yhteenkokoava paikka. + +Hankesivu on hankkeen koko tietosisällön yhteenkokoava paikka. ## Hankkeen tietojen syöttö ja muokkaaminen -Perustaakseen uuden hankkeen käyttäjän on täytettävä sen tietoihin vähintään pakolliset kentät. Pakolliset tietokentät on merkitty käyttöliittymässä tähtikuviolla ("*"). Investointihankkeen tarkempi tietosisältö ja sen merkitys on kuvattu [täällä](#investointihankkeen-tietosisältö) ja asemakaavahankkeen tietosisälltö [täällä](#asemakaavahanke). -Hankkeen perustamisen jälkeen sen tietoja voi edelleen muokata valitsemalla hankkeen sivulla painamalla Muokkaa-painiketta tietolomakkeen oikeassa yläkulmassa. Muokattujenkin tietojen tulee sisältää aina vähintään pakolliset tietosisällöt, jotta muokkausten tallentaminen on mahdollista. Jokainen muokkaus luo Hannan tietokantaan uuden version hankkeesta. Sen osana tallentuu tieto siitä, kuka muokkauksen on tehnyt, milloin se on toteutunut ja mitä tarkalleen on muokattu. Käyttäjät eivät toistaiseksi pysty palauttamaan käyttöliittymästä käsin hankkeen aiempia versioita, mutta Hannan kehittäjät pystyvät siihen tarvittaessa. Toistaiseksi hankkeen historia- ja versiotietoja ei esitetä käyttöliittymässä. +Perustaakseen uuden hankkeen käyttäjän on täytettävä sen tietoihin vähintään pakolliset kentät. Pakolliset tietokentät on merkitty käyttöliittymässä tähtikuviolla ("\*"). Investointihankkeen tarkempi tietosisältö ja sen merkitys on kuvattu [täällä](#investointihankkeen-tietosisältö) ja asemakaavahankkeen tietosisälltö [täällä](#asemakaavahanke). + +Hankkeen perustamisen jälkeen sen tietoja voi edelleen muokata valitsemalla hankkeen sivulla painamalla Muokkaa-painiketta tietolomakkeen oikeassa yläkulmassa. Muokattujenkin tietojen tulee sisältää aina vähintään pakolliset tietosisällöt, jotta muokkausten tallentaminen on mahdollista. Jokainen muokkaus luo Hannan tietokantaan uuden version hankkeesta. Sen osana tallentuu tieto siitä, kuka muokkauksen on tehnyt, milloin se on toteutunut ja mitä tarkalleen on muokattu. Käyttäjät eivät toistaiseksi pysty palauttamaan käyttöliittymästä käsin hankkeen aiempia versioita, mutta Hannan kehittäjät pystyvät siihen tarvittaessa. Toistaiseksi hankkeen historia- ja versiotietoja ei esitetä käyttöliittymässä. Käyttäjän perustettua hankkeen se saa uniikin tunnisteen. Tämä tunniste on nähtävissä internetselaimen osoitekentässä käyttäjän avatessa hankkeen sivun. Hankkeen jakaminen toiselle Hannaan luvitellu käyttäjälle on mahdollista kopioimalla selaimen osoitekentän linkki ja jakamalla se valitsemallaan tavalla. Hankesivulta löytyy lisäksi hankekohtaiset toiminnot. Investointihankkeiden osalta tämä käsittää seuraavat: + - Kohteiden perustamisen ja niiden tietojen selaaminen (lue lisää kohteista [täällä](#kohteet)) - Hankkeen talouden hallinta ([lue lisää](#talous)) - Sidoshankkeiden hallinnointi ([lue lisää](#hankkeiden-liittyminen-toisiinsa)) - Aluerajauksen piirto Asemakaavahankkeiden osalta hankesivulta löytyvät seuraavat toiminnot: + - Talousyksikön tiedottaminen uudesta hankkeesta tai muutoksista hankkeen tiedoissa [lue lisää](#asemakaavahankkeesta-tiedottaminen)) - Sidoshankkeiden osoittaminen ([lue lisää](#hankkeiden-liittyminen-toisiinsa)) ## Hankkeen poistaminen -Hankkeen poistaminen on mahdollista vain sen omistajan ja pääkäyttäjän toimesta (lisää käyttöoikeuksista [täällä](#käyttöoikeudet)). + +Hankkeen poistaminen on mahdollista vain sen omistajan ja pääkäyttäjän toimesta (lisää käyttöoikeuksista [täällä](#käyttöoikeudet)). Perustetun hankkeen voi poistaa hankesivulta käsin valitsemalla Poista hanke tietolomakkeen alaosasta. Ennen poistamista Hanna vielä kysyy varmistuksen käyttäjältä. Poistamisen jälkeen hanketta ei enää pysty palauttamaan käyttöliittymästä käsin. Hankkeen tiedot kuitenkin tosiasiallisesti arkistoituvat Hannan tietokantaan, josta käsin Hannan kehittäjät voivat tarpeen mukaan palauttaa hankkeen. Investointihankkeiden poistamisen osalta on tärkeää huomioida se, että samalla poistuvat hankkeelle mahdollisesti kirjatut kohteet ja tehtävät. ## Aluerajauksen piirto + Investointihankkeelle voi halutessaan piirtää aluerajauksen. Aluerajauksen saa piirrettyä karttanäkymän oikeasta laidasta löytyviä toimintoja hyödyntämällä. Toiminnot ja niiden kuvaukset on listattu alle. Hankkeen aluerajaus on aina aluemuotoinen, ja se voi muodostua monesta erillisestä alueesta (multipolygon). - **Luo alue:** Valitsemalla painikkeen ja viemällä kursorin kartalle pääset aloittamaan alueen piirron. Jokainen hiiren vasemman painallus luo aluerajaukseen yhden solmupisteen. Jotta piirretty alue olisi eheä, täytyy käyttäjän luoda vähintään kolme solmupistettä. Voit viimeistellä piirtämäsi alueen luomalla viimeisen solmupisteen kaksoispainalluksella, jolloin alue ilmestyy kartalle. Huomioi kuitenkin, että piirretty alue ei tallennu automaattisesti. @@ -239,6 +272,7 @@ Investointihankkeelle voi halutessaan piirtää aluerajauksen. Aluerajauksen saa - **Tallenna muutokset:** Painiketta painamalla käyttäjän tekemät muutokset tallennetaan. Piirtämisen tukena voi hyödyntää seuraavia kaupungin paikkatietoaineistoja: + - Kiinteistöt - Rakennukset - Kadut @@ -247,60 +281,65 @@ Piirtämisen tukena voi hyödyntää seuraavia kaupungin paikkatietoaineistoja: Toistaiseksi Hannaan luotuja aluerajauksia ei tarjota rajapinnan yli toisille käyttäjille. -Hankkeiden aluerajauksien karttaväri on vihreä. +Hankkeiden aluerajauksien karttaväri on vihreä. ![Hankesivulta löytyvä karttanäkymä](/images/hankesivun_karttanakyma_toiminnot_nimetty.png)
_Hankesivulta löytyvä karttanäkymä. Oikealla laidassa näkyvissä yllä kuvatut toiminnot._ ## Hankkeiden liittyminen toisiinsa + Hankkeet ovat harvoin täysin itsenäisiä kokonaisuuksia, vaan ovat pikemminkin osa ketjua. Tällainen ketju muodostuu esimerkiksi silloin, kun pitkän aikavälin maankäytön suunnittelusta (PALM) siirrytään kaavoittamaan, sen jälkeen rakentamaan investointeja ja lopuksi ylläpitämään rakennettua infrastruktuuria. Myöhemmin esimerkiksi asemakaavaan saatetaan kohdistaa muutoksia tai kertaalleen rakennettua infraa saneerata tai parantaa sen toimivuutta muuuten. Tämän ketjun hahmottamiseksi hankkeiden välille voi osoittaa linkin kolmella tavalla: - alisteisesti (alahanke) - ylisteisesti (ylähanke) - rinnakkaisesti (rinnakkaishanke) -Voit osoittaa Hannan hankkeelle sidoshankkeen omasta valikostaan. Vaihtoehtoina ovat Hannassa perustetut hankkeet. +Voit osoittaa Hannan hankkeelle sidoshankkeen omasta valikostaan. Vaihtoehtoina ovat Hannassa perustetut hankkeet. ![sidoshankkeet hankesivulla](/images/sidoshankkeet.png)
_Kuvassa hankkeelle on osoitettu yksi alahanke._ # Hanketyypit + Hannan hankkeet jakautuvat kahteen eri tyyppiin, jotka ovat investointihanke ja asemakaavahanke. Niiden tarkempi tietosisältö ja piirteet on kuvattu alla. ## Asemakaavahanke ### Yleistä + Uudet asemakaavahankkeet perustetaan Hannassa. Järjestelmä osoittaa asemakaavalle automaattisesti kaavanumeron. Asemakaavahankkeet voivat olla tyypiltään uusia asemakaavoja, asemakaavamuutoksia tai yleissuunnitelmia. Niistä kaikki saavat aina oman kaavanumeronsa. Asemakaavahankkeet ovat myös samalla investointihankkeita, mutta johtuen niiden poikkeavasta tietosisällöstä, ne on irroitettu omaksi hanketyypikseen. ### Asemakaavahankkeen tietosisältö -| Tietokenttä | Kuvaus | Pakollinen tieto | -| --- | --- | --- | -| Hankkeen nimi | Asemakaavahankkeen annettava nimi. | Kyllä | -| Kuvaus | Vapaamuotoinen sanallinen kuvaus hankkeesta. | Kyllä | -| Alkuajankohta | Ajankohta jolloin hankkeen toteutus alkaa. | Kyllä | -| Loppuajankohta | Ajankohta jolloin hanke päättyy. Loppuajankohdan täytyy sijaita alkuajankohdan jälkeen. | Kyllä | -| Omistaja | Hankkeen omistajalla viitataan käyttäjään, joka omistaa hankkeen. Omistaja on lähtökohtaisesti hankkeen perustanut käyttäjä. Sitä voi kuitenkin vaihtaa valitsemalla arvoksi toisen käyttäjän. | Kyllä | -| Valmistelija | Asemakaavahankkeen valmistelusta vastaava henkilö. | Kyllä | -| Elinkaaren tila | Hankkeella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Hanke saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Elinkaaritilojen ja esimerkiksi hankkeelle annettujen alku- ja loppuajankohtien välillä ei ole automaatiota, vaan ne toimivat toisistaan irrallisesti. | Kyllä | -| Alue/kaupunginosa | Alue tai kaupunginosa, jota asemakaavahanke koskee. | Kyllä | -| Kortteli/tontti | Kortteli tai tontti, jota asemakaavahanke koskee. | Kyllä | -| Osoitteet | Osoite tai osoitteet, missä asemakaavahanke sijaitsee | Kyllä | -| Diaarinumero | Asemakaavahankkeelle annettu diaarinumero Selma -sovelluksessa. | Kyllä | -| Diaaripäivämäärä | Päivämäärä, jona kirjaamo on avannut asemakaavahankkeelle diaarin Selma-tietojärjestelmässä. | Ei | -| Kaavanumero | Kaikki Hannassa perustetut asemakaavahankkeet saavat automaattisesti Hannan generoimana kaavanumeron. Käyttäjä ei voi muokata itse kaavanumeroa. Kaavanumero lukittuu hankkeen tallennushetkellä. | Kyllä | -| SAP-projektin ID | Mikäli asemakaavahanke on perustettu myös SAP-tietojärjestelmään, voi sen SAP-projektin ID:n kertoa Hannan hankkeelle taloustoteuman seuraamiseksi. Ole tarkkana, että annat arvoksi SAP:n projektin tunnisteen, etkä esimerkiksi rakenneosan tunnusta. Hanna validoi annetun tunnisteen ja kertoo käyttäjälle sen onnistumisesta. | Ei | -| Kaavahanketyyppi | Arvo valitaan joukosta Asemakaava, Asemakaavamuutos ja Yleissuunnitelma. Kaikki kolme saavat perustamisen yhteydessä oman uniikin kaavanumeron. | Ei | -| Suunnittelualue | Asemakaavahankkeet yksilöidään yhdelle neljästä eri suunnittelualueesta, jotka ovat Keskusta, Länsi, Itä ja Etelä. | Ei | -| Tekninen suunnittelija | Asemakaavahankkeelle osoitettu teknisestä avusta vastaava henkilö. | Ei | -| Aloitepäivämäärä | Asemakaavan aloitteeseen kirjattu päivämäärä. | Ei | -| Hakijan nimi | Kaavaa hakevan tahon nimi. | Ei | -| Hakijan osoite | Kaavaa hakevan tahon osoite. | Ei | -| Hakijan tavoitteet | Kaavaa hakevan tahon tavoitteet vapaamuotoisesti kuvattuna. | Ei | -| Lisätiedot | Kaavahakemukseen tai -hakijaan liittyvät lisätiedot | Ei | +| Tietokenttä | Kuvaus | Pakollinen tieto | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Hankkeen nimi | Asemakaavahankkeen annettava nimi. | Kyllä | +| Kuvaus | Vapaamuotoinen sanallinen kuvaus hankkeesta. | Kyllä | +| Alkuajankohta | Ajankohta jolloin hankkeen toteutus alkaa. | Kyllä | +| Loppuajankohta | Ajankohta jolloin hanke päättyy. Loppuajankohdan täytyy sijaita alkuajankohdan jälkeen. | Kyllä | +| Omistaja | Hankkeen omistajalla viitataan käyttäjään, joka omistaa hankkeen. Omistaja on lähtökohtaisesti hankkeen perustanut käyttäjä. Sitä voi kuitenkin vaihtaa valitsemalla arvoksi toisen käyttäjän. | Kyllä | +| Valmistelija | Asemakaavahankkeen valmistelusta vastaava henkilö. | Kyllä | +| Elinkaaren tila | Hankkeella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Hanke saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Elinkaaritilojen ja esimerkiksi hankkeelle annettujen alku- ja loppuajankohtien välillä ei ole automaatiota, vaan ne toimivat toisistaan irrallisesti. | Kyllä | +| Alue/kaupunginosa | Alue tai kaupunginosa, jota asemakaavahanke koskee. | Kyllä | +| Kortteli/tontti | Kortteli tai tontti, jota asemakaavahanke koskee. | Kyllä | +| Osoitteet | Osoite tai osoitteet, missä asemakaavahanke sijaitsee | Kyllä | +| Diaarinumero | Asemakaavahankkeelle annettu diaarinumero Selma -sovelluksessa. | Kyllä | +| Diaaripäivämäärä | Päivämäärä, jona kirjaamo on avannut asemakaavahankkeelle diaarin Selma-tietojärjestelmässä. | Ei | +| Kaavanumero | Kaikki Hannassa perustetut asemakaavahankkeet saavat automaattisesti Hannan generoimana kaavanumeron. Käyttäjä ei voi muokata itse kaavanumeroa. Kaavanumero lukittuu hankkeen tallennushetkellä. | Kyllä | +| SAP-projektin ID | Mikäli asemakaavahanke on perustettu myös SAP-tietojärjestelmään, voi sen SAP-projektin ID:n kertoa Hannan hankkeelle taloustoteuman seuraamiseksi. Ole tarkkana, että annat arvoksi SAP:n projektin tunnisteen, etkä esimerkiksi rakenneosan tunnusta. Hanna validoi annetun tunnisteen ja kertoo käyttäjälle sen onnistumisesta. | Ei | +| Kaavahanketyyppi | Arvo valitaan joukosta Asemakaava, Asemakaavamuutos ja Yleissuunnitelma. Kaikki kolme saavat perustamisen yhteydessä oman uniikin kaavanumeron. | Ei | +| Suunnittelualue | Asemakaavahankkeet yksilöidään yhdelle neljästä eri suunnittelualueesta, jotka ovat Keskusta, Länsi, Itä ja Etelä. | Ei | +| Tekninen suunnittelija | Asemakaavahankkeelle osoitettu teknisestä avusta vastaava henkilö. | Ei | +| Aloitepäivämäärä | Asemakaavan aloitteeseen kirjattu päivämäärä. | Ei | +| Hakijan nimi | Kaavaa hakevan tahon nimi. | Ei | +| Hakijan osoite | Kaavaa hakevan tahon osoite. | Ei | +| Hakijan tavoitteet | Kaavaa hakevan tahon tavoitteet vapaamuotoisesti kuvattuna. | Ei | +| Lisätiedot | Kaavahakemukseen tai -hakijaan liittyvät lisätiedot | Ei | ### Asemakaavahankkeesta tiedottaminen -Asemakaavahankkeen perustamisen jälkeen käyttäjä voi lähettää siitä tiedotteen haluamiinsa sähköpostiosoitteisiin tiedotus- välilehdellä Toiminnallisuus tähtää asemakaavahankkeen perustamiseen SAP:ssa, joten seuraavia sähköpostiosoitteita tarjotaan oletusarvoisesti. Käyttäjä voi kuitenkin ottaa ne pois lähetettävien listalta niin halutessaan. + +Asemakaavahankkeen perustamisen jälkeen käyttäjä voi lähettää siitä tiedotteen haluamiinsa sähköpostiosoitteisiin tiedotus- välilehdellä Toiminnallisuus tähtää asemakaavahankkeen perustamiseen SAP:ssa, joten seuraavia sähköpostiosoitteita tarjotaan oletusarvoisesti. Käyttäjä voi kuitenkin ottaa ne pois lähetettävien listalta niin halutessaan. + - kapa_talous@tampere.fi - kapakaava@tampere.fi @@ -321,108 +360,120 @@ Myös Hannan testijärjestelmästä käsin voi lähettää tiedotteita. Tällöi ## Investointihanke ### Yleistä -Investointihanke on hanke, jolla kasvatetaan Tampereen kaupungin omaisuuden arvoa. Siihen käytetty raha on investointirahaa (vrt. käyttötalous) ja käytettävissä olevan rahan määrää ohjaavat eri lautakuntien vuosisuunnitelmat vuositasolla. Investointihankkeita on monenlaisia, minkä myötä tämän hanketyypin on tarkoitus olla yleiskäyttöinen. Asemakaavahankkeet ovat Hannassa oma hanketyyppinsä huolimatta siitä, että nekin ovat määritelmän mukaisesti yhtä lailla investointihankkeita. + +Investointihanke on hanke, jolla kasvatetaan Tampereen kaupungin omaisuuden arvoa. Siihen käytetty raha on investointirahaa (vrt. käyttötalous) ja käytettävissä olevan rahan määrää ohjaavat eri lautakuntien vuosisuunnitelmat vuositasolla. Investointihankkeita on monenlaisia, minkä myötä tämän hanketyypin on tarkoitus olla yleiskäyttöinen. Asemakaavahankkeet ovat Hannassa oma hanketyyppinsä huolimatta siitä, että nekin ovat määritelmän mukaisesti yhtä lailla investointihankkeita. Hannassa investointihankkeella on asemakaavahankkeesta poiketen laajempi tietomalli, joka pitää sisällään myös mahdollisuuden kirjata kohteita ja kohteille (työ-)vaiheita. Alla on kuvattu näiden kolmen elementin tietosisältö. ### Investointihankkeen tietosisältö -| Tietokenttä | Kuvaus | Pakollinen tieto | -| --- | --- | --- | -| Hankkeen nimi | Investointihankkeelle annettu vapaamuotoinen nimi. | Kyllä | -| Kuvaus | Vapaamuotoinen sanallinen kuvaus hankkeesta. | Kyllä | -| Alkuajankohta | Ajankohta jolloin hankkeen toteutus alkaa. | Kyllä | -| Loppuajankohta | Ajankohta jolloin hanke päättyy. Loppuajankohdan täytyy sijaita alkuajankohdan jälkeen. | Kyllä | -| Omistaja | Hankkeen omistajalla viitataan käyttäjään, joka omistaa hankkeen. Omistaja on lähtökohtaisesti hankkeen perustanut käyttäjä. Sitä voi kuitenkin vaihtaa valitsemalla arvoksi toisen käyttäjän. Omistajalla on oikeus poistaa hanke ja osoittaa siihen muokkausoikeus | Kyllä | -| Elinkaaren tila | Arvo valitaan seuraavista joukosta: Aloittamatta, Käynnissä, Valmis, Odottaa. Hanke saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. | Kyllä | -| Lautakunta | Hankkeelle voi valita yhden seuraavista lautakunnista: Yhdyskuntalautakunta, Elinvoima- ja osaamislautakunta, Asunto- ja kiinteistölautakunta, Joukkoliikennelautakunta. Valitsemalla lautakunta osoitetaan hankkeelle se, kenen vuosisuunnitelmasta hankkeen toteutus saa varansa. | Kyllä | -| SAP-projektin ID | Mikäli investointihanke on perustettu myös SAP-tietojärjestelmään, voi sen SAP-projektin ID:n kertoa Hannan hankkeelle taloustoteuman seuraamiseksi. Ole tarkkana, että annat arvoksi SAP:n projektin tunnisteen, etkä esimerkiksi rakenneosan tunnusta. Hanna validoi annetun tunnisteen ja kertoo käyttäjälle sen onnistumisesta. | Ei | +| Tietokenttä | Kuvaus | Pakollinen tieto | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Hankkeen nimi | Investointihankkeelle annettu vapaamuotoinen nimi. | Kyllä | +| Kuvaus | Vapaamuotoinen sanallinen kuvaus hankkeesta. | Kyllä | +| Alkuajankohta | Ajankohta jolloin hankkeen toteutus alkaa. | Kyllä | +| Loppuajankohta | Ajankohta jolloin hanke päättyy. Loppuajankohdan täytyy sijaita alkuajankohdan jälkeen. | Kyllä | +| Omistaja | Hankkeen omistajalla viitataan käyttäjään, joka omistaa hankkeen. Omistaja on lähtökohtaisesti hankkeen perustanut käyttäjä. Sitä voi kuitenkin vaihtaa valitsemalla arvoksi toisen käyttäjän. Omistajalla on oikeus poistaa hanke ja osoittaa siihen muokkausoikeus | Kyllä | +| Elinkaaren tila | Arvo valitaan seuraavista joukosta: Aloittamatta, Käynnissä, Valmis, Odottaa. Hanke saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. | Kyllä | +| Lautakunta | Hankkeelle voi valita yhden seuraavista lautakunnista: Yhdyskuntalautakunta, Elinvoima- ja osaamislautakunta, Asunto- ja kiinteistölautakunta, Joukkoliikennelautakunta. Valitsemalla lautakunta osoitetaan hankkeelle se, kenen vuosisuunnitelmasta hankkeen toteutus saa varansa. | Kyllä | +| SAP-projektin ID | Mikäli investointihanke on perustettu myös SAP-tietojärjestelmään, voi sen SAP-projektin ID:n kertoa Hannan hankkeelle taloustoteuman seuraamiseksi. Ole tarkkana, että annat arvoksi SAP:n projektin tunnisteen, etkä esimerkiksi rakenneosan tunnusta. Hanna validoi annetun tunnisteen ja kertoo käyttäjälle sen onnistumisesta. | Ei | ### Investointikohteet -Kohde on investointihankkeen sisäinen olemassa oleva tai suunnitteilla oleva fyysinen rakennelma, jolla on tunnistettu käyttötarkoitus. Kyseessä on usein rekisterikohde, ja kohde voikin olla esimerkiksi väylä, rakennus, aukio, viheralue tai taitorakenne. Hankkeelle osoitetut resurssit, kuten raha, aika, alue ja toimijat eivät yleensä kohdistu koko hankkeelle tasan, ja kohteiden pääasiallinen tarkoitus onkin tarkentaa hankkeen sisältämien toimenpiteiden ja tavoitteiden kohdistumista. Investointihanke voi esimerkiksi viitata kokonaisen kaava-alueen rakentamiseen. + +Kohde on investointihankkeen sisäinen olemassa oleva tai suunnitteilla oleva fyysinen rakennelma, jolla on tunnistettu käyttötarkoitus. Kyseessä on usein rekisterikohde, ja kohde voikin olla esimerkiksi väylä, rakennus, aukio, viheralue tai taitorakenne. Hankkeelle osoitetut resurssit, kuten raha, aika, alue ja toimijat eivät yleensä kohdistu koko hankkeelle tasan, ja kohteiden pääasiallinen tarkoitus onkin tarkentaa hankkeen sisältämien toimenpiteiden ja tavoitteiden kohdistumista. Investointihanke voi esimerkiksi viitata kokonaisen kaava-alueen rakentamiseen. Kohteita voi kirjata vain investointihankkeelle. Niiden kirjaaminen ei ole pakollista, eikä niiden lukumäärää hankkeella ole rajoitettu. -Hankkeen sisältämät kohteet on kirjattu hankesivun kohteet -välilehdelle. Sieltä käsin käyttäjä voi kirjata hankkeelle myös uusia kohteita valitsemalla _Luo uusi kohde_ -painikkeen. Valitsemalla kohteen käyttäjä siirtyy kohdesivulle, joka muistuttaa hankesivua, mutta kuvaa hankkeen sijasta sen kohteen. +Hankkeen sisältämät kohteet on kirjattu hankesivun kohteet -välilehdelle. Sieltä käsin käyttäjä voi kirjata hankkeelle myös uusia kohteita valitsemalla _Luo uusi kohde_ -painikkeen. Valitsemalla kohteen käyttäjä siirtyy kohdesivulle, joka muistuttaa hankesivua, mutta kuvaa hankkeen sijasta sen kohteen. -Tällä hetkellä toimintatapana on, että suunnittelulle ja rakentamiselle avataan omat kohteensa. +Tällä hetkellä toimintatapana on, että suunnittelulle ja rakentamiselle avataan omat kohteensa. -Kohteen toteutusväli ei saa sijaita hankkeen toteutusvälin ulkopuolella. Jos käyttäjä yrittää avata, tai muokata olemassaolevaa kohdetta niin, että näin on käymässä, Hanna pyytää muokkaamaan toteutusväliä, ja vaihtoehtoisesti tarjoaa mahdollisuutta laventaa hankkeen toteutusväliä. Vastavuoroisesti käyttäjän ei anneta kaventaa hankkeen toteutusväliä, jos se tarkoittaisi sitä, että jokin sen kohteista jäisi sen toteutusvälin ulkopuolelle. +Kohteen toteutusväli ei saa sijaita hankkeen toteutusvälin ulkopuolella. Jos käyttäjä yrittää avata, tai muokata olemassaolevaa kohdetta niin, että näin on käymässä, Hanna pyytää muokkaamaan toteutusväliä, ja vaihtoehtoisesti tarjoaa mahdollisuutta laventaa hankkeen toteutusväliä. Vastavuoroisesti käyttäjän ei anneta kaventaa hankkeen toteutusväliä, jos se tarkoittaisi sitä, että jokin sen kohteista jäisi sen toteutusvälin ulkopuolelle. #### Investointikohteen tietosisältö -| Tietokenttä | Kuvaus | Pakollinen tieto | -| --- | --- | --- | -| Nimi | Kohteelle annettu nimi. Nimi ei saa olla sama hankkeen kanssa. | Kyllä | -| Kuvaus | Vapaamuotoinen sanallinen kuvaus kohteesta. | Kyllä | -| Suunnitteluttaja | Kohteen pääsuunnittelija. Valinta kohdistuu Tampereen sisäisiin henkilöihin. | Ei | -| Rakennuttaja | Kohteen päärakennuttaja. Valinta kohdistuu Tampereen sisäisiin henkilöihin. | Ei | -| Kohteen laji | Yksilöi, onko kohteessa kyse suunnitelusta vai rakentamisesta. Arvo valitaan alasvetovalikosta. | Kyllä | -| Alkuajankohta | Ajankohta jolloin kohteen toteutus alkaa. | Kyllä | -| Loppuajankohta | Ajankohta jolloin kohteen toteutus päättyy. | Kyllä | -| Elinkaaritila | Kohteella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Kohde saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Hankkeen ja sen kohteiden elinkaaritilojen hallinta perustuu toistaiseksi manuaaliseen kirjaamiseen. | Kyllä | -| Kohteen tyyppi | Kohteen tyyppi kertoo, onko kyse uudesta rakentamisesta vai olemassaolevan kohteen muokkaamisesta. Kohteelle valitaan yksi tyyppi arvojoukosta: Uudisrakentaminen, Peruskorjaaminen, Toimivuuden parantaminen. | Kyllä | -| Omaisuusluokka | Omaisuusluokka määrittelee poistoajan, jonka mukaan käytetty investointi poistuu taseesta. Arvo valitaan alasvetovalikosta valmiista koodistosta. | Kyllä | -| Toiminnallinen käyttötarkoitus | Toiminnallinen käyttötarkoitus viittaa kohteen käyttötarkoitukseen valmistuessaan. Arvo valitaan alasvetovalikosta valmiista koodistosta. | Kyllä | -| SAP-rakenneosa | Jos kohteelle löytyy sitä vastaava rakenneosa SAP:n projektista, voi käyttäjä osoittaa sen valitsemalla valikosta sopivan arvon. Tämän ehtona on se, että hankkeelle on osoitettu SAP-projektin ID. Tämä mahdollistaa kohteen taloustoteuman seurannan. | Ei | +| Tietokenttä | Kuvaus | Pakollinen tieto | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| Nimi | Kohteelle annettu nimi. Nimi ei saa olla sama hankkeen kanssa. | Kyllä | +| Kuvaus | Vapaamuotoinen sanallinen kuvaus kohteesta. | Kyllä | +| Suunnitteluttaja | Kohteen pääsuunnittelija. Valinta kohdistuu Tampereen sisäisiin henkilöihin. | Ei | +| Rakennuttaja | Kohteen päärakennuttaja. Valinta kohdistuu Tampereen sisäisiin henkilöihin. | Ei | +| Kohteen laji | Yksilöi, onko kohteessa kyse suunnitelusta vai rakentamisesta. Arvo valitaan alasvetovalikosta. | Kyllä | +| Alkuajankohta | Ajankohta jolloin kohteen toteutus alkaa. | Kyllä | +| Loppuajankohta | Ajankohta jolloin kohteen toteutus päättyy. | Kyllä | +| Elinkaaritila | Kohteella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Kohde saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Hankkeen ja sen kohteiden elinkaaritilojen hallinta perustuu toistaiseksi manuaaliseen kirjaamiseen. | Kyllä | +| Kohteen tyyppi | Kohteen tyyppi kertoo, onko kyse uudesta rakentamisesta vai olemassaolevan kohteen muokkaamisesta. Kohteelle valitaan yksi tyyppi arvojoukosta: Uudisrakentaminen, Peruskorjaaminen, Toimivuuden parantaminen. | Kyllä | +| Omaisuusluokka | Omaisuusluokka määrittelee poistoajan, jonka mukaan käytetty investointi poistuu taseesta. Arvo valitaan alasvetovalikosta valmiista koodistosta. | Kyllä | +| Toiminnallinen käyttötarkoitus | Toiminnallinen käyttötarkoitus viittaa kohteen käyttötarkoitukseen valmistuessaan. Arvo valitaan alasvetovalikosta valmiista koodistosta. | Kyllä | +| SAP-rakenneosa | Jos kohteelle löytyy sitä vastaava rakenneosa SAP:n projektista, voi käyttäjä osoittaa sen valitsemalla valikosta sopivan arvon. Tämän ehtona on se, että hankkeelle on osoitettu SAP-projektin ID. Tämä mahdollistaa kohteen taloustoteuman seurannan. | Ei | #### Kohteen toimijat -Kohteelle voi lisäksi osoittaa toimijoita. Toimija koostuu henkilön ja roolin yhdistelmästä (esimerkiksi Iiro Iironen - urakoitsijan edustaja). Toistaiseksi tarjolla on seuraavat roolit. Rooli listaa täydennetään tarpeen mukaan. + +Kohteelle voi lisäksi osoittaa toimijoita. Toimija koostuu henkilön ja roolin yhdistelmästä (esimerkiksi Iiro Iironen - urakoitsijan edustaja). Toistaiseksi tarjolla on seuraavat roolit. Rooli listaa täydennetään tarpeen mukaan. + - Turvallisuuskoordinaattori - Vastaava työnjohtaja - Valvoja - Suunnittelun edustaja - Urakoitsijan edustaja -Toimijoiden yksilöiminen kohteelle ei ole pakollista. Yhteen rooliin on mahdollista osoittaa useita henkilöitä. Henkilöt voivat olla sisäisiä henkilöitä, toisin sanoen kaupungin palveluksessa, tai ulkoisia henkilöitä, kuten esimerkiksi konsultteja. Sisäisten henkilöiden lista juontuu Hannan tuntemista käyttäjistä, eli käyttäjistä, jotka ovat luvitettu Hannaan ja kirjautuneet ainakin kerran. Ulkoisia henkilöitä voi hallita ja lisätä [hallintapaneelista](#yrityksien-ja-heidän-yhteyshenkilöiden-hallinta) käsin kuka tahansa. +Toimijoiden yksilöiminen kohteelle ei ole pakollista. Yhteen rooliin on mahdollista osoittaa useita henkilöitä. Henkilöt voivat olla sisäisiä henkilöitä, toisin sanoen kaupungin palveluksessa, tai ulkoisia henkilöitä, kuten esimerkiksi konsultteja. Sisäisten henkilöiden lista juontuu Hannan tuntemista käyttäjistä, eli käyttäjistä, jotka ovat luvitettu Hannaan ja kirjautuneet ainakin kerran. Ulkoisia henkilöitä voi hallita ja lisätä [hallintapaneelista](#yrityksien-ja-heidän-yhteyshenkilöiden-hallinta) käsin kuka tahansa. ![Toimijat_kohteella](/images/toimijat_kohteella.png) _Yllä olevassa kuvassa on esitetty kohteelle valitut toimijat. Valvojaksi on valittu useampi henkilö._ #### Vaiheet + Vaihe on kohteeseen kohdistuva työvaihe, josta syntyy jokin konkreettinen tulos ja samalla kustannus. Vaiheella ei ole hankkeesta ja kohteesta poiketen omaa sijaintia. Vaiheen tuloksena voi olla esimerkiksi uusi tai korjattu rakennus tai muu rakennelma, asiakirja, mittaustulos tai ylläpidon toimi. Vaiheen määrityksessä auttaa pitkä koodisto, jonka arvot vastaavat SAP-järjestelmän vastaavaa koodistoa. Alla olevassa taulukossa on kuvattu vaiheen tietosisältö. -| Tietokenttä | Kuvaus | Pakollinen tieto | -| --- | --- | --- | -| Nimi | Vaiheelle annettu nimi. Nimen täytyy olla uniikki. Nimi ei saa olla sama hankkeen tai kohteen kanssa. | Kyllä | -| Kuvaus | Vapaamuotoinen sanallinen kuvaus vaiheesta. | Kyllä | -| Elinkaaren tila | Vaiheella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Tehtävä saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Hankkeen, kohteiden ja tehtävien välinen elinkaaritilojen hallinta perustuu toistaiseksi manuaaliseen kirjaamiseen. | Kyllä | -| Vaihe | Vaiheen koodi osoittaa tarkemmin, millaisesta toimennpiteestä on kyse. Se osoitetaan laajasta koodistosta, josta löytyy suunnitteluun (2-alkuiset), rakentamiseen (3-alkuiset) ja ylläpitoon (4-alkuiset) liittyviä toimenpiteitä. Toistaiseksi Hannassa ei ole rajoitettu vaiheen tyyppivalintaa hankkeen tai kohteen tyypin mukaan. Vaihekoodit vastaavat SAP -järjestelmästä löytyviä koodeja. | Kyllä | -| Alkuajankohta | Ajankohta jolloin vaiheen toteutus alkaa. | Kyllä | -| Loppuajankohta | Ajankohta jolloin vaiheen toteutus päättyy. | Kyllä | +| Tietokenttä | Kuvaus | Pakollinen tieto | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Nimi | Vaiheelle annettu nimi. Nimen täytyy olla uniikki. Nimi ei saa olla sama hankkeen tai kohteen kanssa. | Kyllä | +| Kuvaus | Vapaamuotoinen sanallinen kuvaus vaiheesta. | Kyllä | +| Elinkaaren tila | Vaiheella on arvona kerrallaan aina vain yksi seuraavista elinkaaritiloista: Aloittamatta, Käynnissä, Valmis, Odottaa. Tehtävä saa perustamisen hetkellä elinkaaritilakseen automaattisesti Aloittamatta. Hankkeen, kohteiden ja tehtävien välinen elinkaaritilojen hallinta perustuu toistaiseksi manuaaliseen kirjaamiseen. | Kyllä | +| Vaihe | Vaiheen koodi osoittaa tarkemmin, millaisesta toimennpiteestä on kyse. Se osoitetaan laajasta koodistosta, josta löytyy suunnitteluun (2-alkuiset), rakentamiseen (3-alkuiset) ja ylläpitoon (4-alkuiset) liittyviä toimenpiteitä. Toistaiseksi Hannassa ei ole rajoitettu vaiheen tyyppivalintaa hankkeen tai kohteen tyypin mukaan. Vaihekoodit vastaavat SAP -järjestelmästä löytyviä koodeja. | Kyllä | +| Alkuajankohta | Ajankohta jolloin vaiheen toteutus alkaa. | Kyllä | +| Loppuajankohta | Ajankohta jolloin vaiheen toteutus päättyy. | Kyllä | ### Investointihankkeen talous -Hankesivun talousvälilehdeltä käsin hankkeelle on mahdollista kirjata vuosikohtainen talousarvio, ennuste ja käyttösuunnitelman muutos. Lisäksi samaan näkymään luetaan SAP:sta toteuma, jos sellainen on tarjolla. Talous-välilehdelle näkyvien vuosikohtaisten rivien lukumäärä johdetaan automaattisesti hankkeelle annetusta toteutusvälistä (alku- ja loppuajankohta). Luvut annetaan aina euroina. Kirjaaminen on mahdollista kahden desimaalin tarkkuudella. -Huomioi, että vielä toistaiseksi hankkeelle, sen kohteille ja kohteen vaiheille kirjattavat talousluvut eivät ole toisistaan riippuvaisia, vaan keskenään irrallisia tietokenttiä. +Hankesivun talousvälilehdeltä käsin hankkeelle on mahdollista kirjata vuosikohtainen talousarvio, ennuste ja käyttösuunnitelman muutos. Lisäksi samaan näkymään luetaan SAP:sta toteuma, jos sellainen on tarjolla. Talous-välilehdelle näkyvien vuosikohtaisten rivien lukumäärä johdetaan automaattisesti hankkeelle annetusta toteutusvälistä (alku- ja loppuajankohta). Luvut annetaan aina euroina. Kirjaaminen on mahdollista kahden desimaalin tarkkuudella. + +Huomioi, että vielä toistaiseksi hankkeelle, sen kohteille ja kohteen vaiheille kirjattavat talousluvut eivät ole toisistaan riippuvaisia, vaan keskenään irrallisia tietokenttiä. -Talousosioon kirjattujen lukujen katsotaan kohdistuvan aina hankkeelle kirjattuun lautakuntaan. +Talousosioon kirjattujen lukujen katsotaan kohdistuvan aina hankkeelle kirjattuun lautakuntaan. #### Talousarvio -Talousarvio on käyttäjän arvio ja päättäjille esitettävä kustannus hankkeen, kohteen tai vaiheen toteuttamisesta. Toistaiseksi Hannalle ei ole keinoa eritellä sitä, onko talousarvioksi kirjattu luku saanut hyväksynnän vaadittavilta tahoilta, vai yksinään käyttäjän arvio kustannuksesta. Talousarvion voi tällä hetkellä kirjata sekä hankkeille, sen kohteille sekä kohteiden vaiheille. Niille kirjattavat arvot ovat kuitenkin toisistaan irrallisia, eli toistaiseksi jää käyttäjän vastuulle jakaa raha loogisesti osana rakennetta. + +Talousarvio on käyttäjän arvio ja päättäjille esitettävä kustannus hankkeen, kohteen tai vaiheen toteuttamisesta. Toistaiseksi Hannalle ei ole keinoa eritellä sitä, onko talousarvioksi kirjattu luku saanut hyväksynnän vaadittavilta tahoilta, vai yksinään käyttäjän arvio kustannuksesta. Talousarvion voi tällä hetkellä kirjata sekä hankkeille, sen kohteille sekä kohteiden vaiheille. Niille kirjattavat arvot ovat kuitenkin toisistaan irrallisia, eli toistaiseksi jää käyttäjän vastuulle jakaa raha loogisesti osana rakennetta. #### Toteuma -Hankkeille, ja myös investointihankkeiden kohteille, joille on ilmoitettu niille luotu SAP-projekti (tai kohteen tapauksessa rakenneosa), ilmoitetaan kustannusarvion rinnalla niiden toteuma. Toteuma ilmoitetaan vuositasolla, kuten kustannusarviokin. Toteuman näkeminen mahdollistaa hankkeiden taloudellisen seurannan sekä reagoinnin mahdollisiin poikkeamiin, kuten budjetin ylityksiin. Toteumaa ei voi muokata Hannasta käsin. Toteuma haetaan suoraan SAP:iin kirjatuista tositteista summaamalla niiden luvut vuosikohtaisesti. Toteuma haetaan hankkeelle, kohteille ja vaiheille. Hanna tuntee toteuman tositteen tarkkuudella. Yksittäisiä tositteita ei kuitenkaan eritellä käyttöliittymään. + +Hankkeille, ja myös investointihankkeiden kohteille, joille on ilmoitettu niille luotu SAP-projekti (tai kohteen tapauksessa rakenneosa), ilmoitetaan kustannusarvion rinnalla niiden toteuma. Toteuma ilmoitetaan vuositasolla, kuten kustannusarviokin. Toteuman näkeminen mahdollistaa hankkeiden taloudellisen seurannan sekä reagoinnin mahdollisiin poikkeamiin, kuten budjetin ylityksiin. Toteumaa ei voi muokata Hannasta käsin. Toteuma haetaan suoraan SAP:iin kirjatuista tositteista summaamalla niiden luvut vuosikohtaisesti. Toteuma haetaan hankkeelle, kohteille ja vaiheille. Hanna tuntee toteuman tositteen tarkkuudella. Yksittäisiä tositteita ei kuitenkaan eritellä käyttöliittymään. #### Ennuste -Ennusteella viitataan hankkeen läheisesti tuntevan tahon arvioon siitä, miten sille miten hankkeen talousarvio kestää tarkastelun toteumaa vasten. Ennusteen kirjaaminen on tapa viestiä budjetin (talousarvion) alittumisesta tai ylittymisestä. Budjetin ylitys kirjataan positiivisena, eli esimerkiksi sadan tuhannen euron ylitys kirjataan lukuna 100 000. Budjetin alitus kirjataan taas negatiivisena numerona, esimerkiksi -100 000. Hannan käyttöliittymä värittää budjetin ylitykset punaisella värillä ja alitukset sinisellä värillä. -Ennusteet kirjataan hankkeen kohteille. Hankesivun talousvälilehdellä on vain luettavissa sen kohteilta summattut ennusteluvut vuosittain. +Ennusteella viitataan hankkeen läheisesti tuntevan tahon arvioon siitä, miten sille miten hankkeen talousarvio kestää tarkastelun toteumaa vasten. Ennusteen kirjaaminen on tapa viestiä budjetin (talousarvion) alittumisesta tai ylittymisestä. Budjetin ylitys kirjataan positiivisena, eli esimerkiksi sadan tuhannen euron ylitys kirjataan lukuna 100 000. Budjetin alitus kirjataan taas negatiivisena numerona, esimerkiksi -100 000. Hannan käyttöliittymä värittää budjetin ylitykset punaisella värillä ja alitukset sinisellä värillä. + +Ennusteet kirjataan hankkeen kohteille. Hankesivun talousvälilehdellä on vain luettavissa sen kohteilta summattut ennusteluvut vuosittain. #### Käyttösuunnitelman muutos + Mikäli hankkeeseen suunnitellun talousarvion käyttö muuttuu merkittävästi vuoden aikana, voi hankkeen taloustietoihin kirjata käyttösuunnitelman muutoksen. Tämä voi mahdollistaa esimerkiksi lisärahan osoittamisen hankkeelle tai siitä käyttämättä jäävien rahojen ohjaamisen osaksi toisen hankkeen talousarviota. Käyttösuunnitelman muutos on aina positiivinen luku. -KSM kirjataan hankkeen kohteille. Hankesivun talousvälilehdellä on vain luettavissa sen kohteilta summatut KSM-luvut vuosittain. +KSM kirjataan hankkeen kohteille. Hankesivun talousvälilehdellä on vain luettavissa sen kohteilta summatut KSM-luvut vuosittain. # Investointiohjelmointinäkymä ![Investointiohjelmointi](/images/investointiohjelmointi_v2.png) ## Tietosisältö ja tietojen muokkaaminen -Investointiohjelmointinäkymä on nimensä mukaisesti tarkoitettu vuosikohtaisen investointiohjelmoinnin rakentamiseen, sen seuraamiseen ja hallinnointiin. Kyseinen näkymä muodostuu taulukosta, joka listaa investointihankkeiden **kohteita**. Näkymään voi siirtyä päänavigointipalkista käsin (1). -Taulukossa (8) jokaiselle kohteelle on kerrottu seuraavat tiedot, mikäli ne ovat olemassa: +Investointiohjelmointinäkymä on nimensä mukaisesti tarkoitettu vuosikohtaisen investointiohjelmoinnin rakentamiseen, sen seuraamiseen ja hallinnointiin. Kyseinen näkymä muodostuu taulukosta, joka listaa investointihankkeiden **kohteita**. Näkymään voi siirtyä päänavigointipalkista käsin (1). + +Taulukossa (8) jokaiselle kohteelle on kerrottu seuraavat tiedot, mikäli ne ovat olemassa: + - Hanke, johon kohde kuuluu - Kohteen nimi - (Elinkaari-)Tila @@ -436,38 +487,40 @@ Taulukossa (8) jokaiselle kohteelle on kerrottu seuraavat tiedot, mikäli ne ova - Ennuste - Käyttösuunnitelman muutos -Painamalla hiirellä kohteen tai hankkeen nimen yhteydessä olevaa ikonia pääset hyppäämään kyseiselle hanke- tai kohdesivulle. Sivu aukeaa uuteen välilehteen. +Painamalla hiirellä kohteen tai hankkeen nimen yhteydessä olevaa ikonia pääset hyppäämään kyseiselle hanke- tai kohdesivulle. Sivu aukeaa uuteen välilehteen. -Kohderivit on järjestetty hankkeittain. Hankkeen nimi on korostettu vihreällä värillä. Hankkeen nimi on kirjattu vain ensimmäiselle kohteelle ja seuraavilla, samaan hankkeeseen kuuluvilla kohderiveillä hankkeen nimen tilalla on hakanen. +Kohderivit on järjestetty hankkeittain. Hankkeen nimi on korostettu vihreällä värillä. Hankkeen nimi on kirjattu vain ensimmäiselle kohteelle ja seuraavilla, samaan hankkeeseen kuuluvilla kohderiveillä hankkeen nimen tilalla on hakanen. -Taulukon solujen tietoja pystyy tiettyjä poikkeuksia lukuunottamatta muokkaamaan olettaen, että käyttäjällä on käyttöoikeudet kohteen hankkeeseen. Mikäli käyttäjällä ei ole oikeutta muokata hankkeen kohteita, on tekstin väri vaaleanharmaa. Käyttäjä voi muokata taulukossa näkyviä tietoja kaksoisklikkaamalla hiiren vasemmalla painikkeella haluttuun taulukon soluun, jolloin muokkausikkuna ponnahtaa esiin. Kaikki solut eivät kuitenkaan ole muokattavissa (toteuma, hankkeen nimi). +Taulukon solujen tietoja pystyy tiettyjä poikkeuksia lukuunottamatta muokkaamaan olettaen, että käyttäjällä on käyttöoikeudet kohteen hankkeeseen. Mikäli käyttäjällä ei ole oikeutta muokata hankkeen kohteita, on tekstin väri vaaleanharmaa. Käyttäjä voi muokata taulukossa näkyviä tietoja kaksoisklikkaamalla hiiren vasemmalla painikkeella haluttuun taulukon soluun, jolloin muokkausikkuna ponnahtaa esiin. Kaikki solut eivät kuitenkaan ole muokattavissa (toteuma, hankkeen nimi). ![Ponnahdusikkuna muokatessa](/images/muokkaus_ponnahdus.png) _Kuvassa käyttäjä on klikannut kohteen omaisuusluokkasolua, jolloin ponnahdusikkuna on auennut._ -Kun muokkaat mitä tahansa solua, ilmestyy taulukon vasempaan alakulmaan joukko painikkeita. `Tallenna` -painikkeesta tallennat kaikki muutokset, jotka olet tehnyt. Niitä voi siis olla kertynyt useampiakin. `Peru kaikki muutokset` -painike peruuttaa kaikki muutokset, joita olet tehnyt kyseisellä muokkauskerralla. Nuolipainike taaksepäin kumoaa edellisen muokkauksen. Nuolipainike eteenpäin vie muokkaushistoriassa yhden eteenpäin. Tallentaessasi sovellus ilmoittaa ponnahdusikkunalla tietojen tallentamisen tapahtuneen onnistuneesti. +Kun muokkaat mitä tahansa solua, ilmestyy taulukon vasempaan alakulmaan joukko painikkeita. `Tallenna` -painikkeesta tallennat kaikki muutokset, jotka olet tehnyt. Niitä voi siis olla kertynyt useampiakin. `Peru kaikki muutokset` -painike peruuttaa kaikki muutokset, joita olet tehnyt kyseisellä muokkauskerralla. Nuolipainike taaksepäin kumoaa edellisen muokkauksen. Nuolipainike eteenpäin vie muokkaushistoriassa yhden eteenpäin. Tallentaessasi sovellus ilmoittaa ponnahdusikkunalla tietojen tallentamisen tapahtuneen onnistuneesti. ![Tallennuspalkki](/images/tallenna_palkki.png) _Käyttäjän muokattua tietoja ilmestyy taulukon päälle sen vasempaan alareunaan kuvanmukainen palkki._ -Vierittämällä sivua alaspäin sivun yläosassa sijaitsevat hakusuodattimet (6) katoavat näkyvistä, jotta taulukko saisi enemmän tilaa käyttöönsä. Samalla taulukon vasempaan alakulmaan ilmestyy painike, jota painamalla käyttäjä pääsee takaisin ylös. Taulukon oikeassa alakulmassa käyttäjä voi vaihdella taulukon sivujen välillä, ja vaikuttaa siihen, kuinka monta riviä yhdellä sivulla näytetään. Oletus on tuhat riviä per sivu. +Vierittämällä sivua alaspäin sivun yläosassa sijaitsevat hakusuodattimet (6) katoavat näkyvistä, jotta taulukko saisi enemmän tilaa käyttöönsä. Samalla taulukon vasempaan alakulmaan ilmestyy painike, jota painamalla käyttäjä pääsee takaisin ylös. Taulukon oikeassa alakulmassa käyttäjä voi vaihdella taulukon sivujen välillä, ja vaikuttaa siihen, kuinka monta riviä yhdellä sivulla näytetään. Oletus on tuhat riviä per sivu. ![Palaa ylös painike](/images/palaa_ylos_painike.png) _Kuvassa näkyvä painike ilmestyy vasempaan alakulmaan, kun käyttäjä on vierittänyt taulukon sisältöä alaspäin. Painamalla siitä pääsee palaamaan takaisin ylös._ ## Vuosivalinta hakusuodattimet ja summarivi -Taulukko kohdistuu ensisijaisesti yhteen kalenterivuoteen. Sivun yläosassa on vuosivalinta, josta käsin käyttäjä pystyy valitsemaan häntä kiinnostavan vuoden (2). Vuosivalinta vaikuttaa siihen, miltä vuodelta kohderiveille katsotaan talousluvut, eli talousarvio, toteuma, ennuste ja käyttösuunnitelman muutos. Vuosivalinta vaikuttaa myös (talouden) summarivillä näytettäviin lukuihin. Valinta voi kohdistua kerrallaan vain yhteen vuoteen. -Huomioi, että kohde katsotaan mukaan aina, kun se _leikkaa_ valittua vuotta. Näin ollen kohde, jonka toteutusväli on 31.12.2023-31.12.2024, valikoituisi mukaan vuosivalinnan ollessa `2023`, ja sille näytettävät talousluvut johdettaisiin päivältä yksinään päivältä 31.12.2023. +Taulukko kohdistuu ensisijaisesti yhteen kalenterivuoteen. Sivun yläosassa on vuosivalinta, josta käsin käyttäjä pystyy valitsemaan häntä kiinnostavan vuoden (2). Vuosivalinta vaikuttaa siihen, miltä vuodelta kohderiveille katsotaan talousluvut, eli talousarvio, toteuma, ennuste ja käyttösuunnitelman muutos. Vuosivalinta vaikuttaa myös (talouden) summarivillä näytettäviin lukuihin. Valinta voi kohdistua kerrallaan vain yhteen vuoteen. + +Huomioi, että kohde katsotaan mukaan aina, kun se _leikkaa_ valittua vuotta. Näin ollen kohde, jonka toteutusväli on 31.12.2023-31.12.2024, valikoituisi mukaan vuosivalinnan ollessa `2023`, ja sille näytettävät talousluvut johdettaisiin päivältä yksinään päivältä 31.12.2023. + +Käyttäjä voi valita vuosivalitsimesta myös valinnan `koko elinkaari`, jolloin talousluvut johdetaan kohderiveille niiden koko elinkaaren ajalta. Tällöin talouslukuja ei pysty kuitenkaan muokkaamaan taulukossa. -Käyttäjä voi valita vuosivalitsimesta myös valinnan `koko elinkaari`, jolloin talousluvut johdetaan kohderiveille niiden koko elinkaaren ajalta. Tällöin talouslukuja ei pysty kuitenkaan muokkaamaan taulukossa. +Summarivi (7) sijaitsee näkymässä suoraan taulukon päällä. Siihen on laskettu taulukossa kyseisellä hetkellä ilmenevien kohteiden talousarvioiden, toteumien, ennusteiden ja käyttösuunnitelman muutosten summa. Summarivi juontaa lukunsa aina taulukossa sillä hetkellä näkyvistä riveistä, joten myös muut hakusuodattimet tulevat huomioiduksi sen luvuissa. -Summarivi (7) sijaitsee näkymässä suoraan taulukon päällä. Siihen on laskettu taulukossa kyseisellä hetkellä ilmenevien kohteiden talousarvioiden, toteumien, ennusteiden ja käyttösuunnitelman muutosten summa. Summarivi juontaa lukunsa aina taulukossa sillä hetkellä näkyvistä riveistä, joten myös muut hakusuodattimet tulevat huomioiduksi sen luvuissa. +Vuosivalinnan (2) lisäksi käyttäjän tarjolla on joukko muita hakusuodattimia (6), joilla vaikuttaa taulukkoon tulevien kohderivien joukkoon: -Vuosivalinnan (2) lisäksi käyttäjän tarjolla on joukko muita hakusuodattimia (6), joilla vaikuttaa taulukkoon tulevien kohderivien joukkoon: - Kohteen nimi - Hankkeen nimi - Kohteen laji (rakenuttaminen/suunnittelu) @@ -478,21 +531,24 @@ Vuosivalinnan (2) lisäksi käyttäjän tarjolla on joukko muita hakusuodattimia Taulukossa näkyvät kohderivit on mahdollista viedä Excel-taulukkoon valitsemalla painikkeen _lataa raportti_ (4). Tiedostoon viedään kohderivit, jotka sen hetkinen suodatus on palauttanut. -Hakusuodattimia voi käyttää yhdessä ja ne toimivat myös yhdessä vuosivalinnan kanssa. Jos käytössä on useampi hakusuodatin, on niiden välinen looginen operaattori `JA`. Näin ollen, jos käyttäjä on valinnut esimerkiksi vuodeksi `2024`, elinkaaritilaksi `aloittamatta` ja käyttötarkoitukseksi `ajoradat`, tulee taulukkoon ajoradat, jotka ovat aloittamatta ja joiden toteutus sijoittuu kokonaan tai osittain vuodelle 2024. +Hakusuodattimia voi käyttää yhdessä ja ne toimivat myös yhdessä vuosivalinnan kanssa. Jos käytössä on useampi hakusuodatin, on niiden välinen looginen operaattori `JA`. Näin ollen, jos käyttäjä on valinnut esimerkiksi vuodeksi `2024`, elinkaaritilaksi `aloittamatta` ja käyttötarkoitukseksi `ajoradat`, tulee taulukkoon ajoradat, jotka ovat aloittamatta ja joiden toteutus sijoittuu kokonaan tai osittain vuodelle 2024. -Käyttäjä voi myös valita vuosivalinnan vierestä painikkeen _Näytä vain omat kohteet_ (3). Tällöin taulukkoon tuodaan vain kohteet, joissa kirjautunut käyttäjä on merkitty johonkin rooliin kohteella (esim. rakennuttaja, valvoja. Ei kuitenkaan hankkeen omistaja). +Käyttäjä voi myös valita vuosivalinnan vierestä painikkeen _Näytä vain omat kohteet_ (3). Tällöin taulukkoon tuodaan vain kohteet, joissa kirjautunut käyttäjä on merkitty johonkin rooliin kohteella (esim. rakennuttaja, valvoja. Ei kuitenkaan hankkeen omistaja). ## Uuden kohteen lisääminen + Käyttäjä voi lisätä uuden kohteen myös investointiohjelmoinnista käsin (5). Valitsemalla oikeasta yläkulmasta `uusi kohde` painikkeen käyttäjä päätyy suoraan kohdesivulle, jossa hän samalla pääsee yksilöimään sen, mihin hankkeeseen kohde avataan. Hankevalinnan alasvetovalikko sisältää vain hankkeet, joissa käyttäjällä on muokkausoikeus. Mikäli käyttäjällä ei ole mihinkään hankkeeseen muokkausoikeutta, näkyy painike harmaana. Kohteen tallentamisen jälkeen käyttäjä palaa takaisin investointiohjelmointinäkymään. # SAP-raportit näkymä ## Yleistä SAP-raporteista -SAP-raportit sisältää kaksi taulukkoa, joista toinen tarjoaa tietoa ympäristökoodeista (ympäristöinvestoinnit) ja toinen puitesopimuksista. Näkymään voi navigoida valitsemalla sen päänavigointipalkista. Tiedot haetaan suoraan SAP:sta, mutta Hanna kyselee ne kaikki kerralla yöaikaan. Näin ollen tiedoissa on maksimissaan päivän viive. Kulunut aika edellisestä hausta on kerrottu sivun oikeassa ylälaidassa. Taulukkoon valikoituvia rivejä voi rajata valitsemalla yhdestä tai useammasta tarjolla olevasta hakusuodattimesta arvon. Jos arvoja useampi hakusuodatin on valittu, pitää rivin täyttää kaikki ehdot. Taulukossa näkyvät tiedot on mahdollista viedä excel-tiedostoon valitsemalla painike _lataa raportti_. -SAP:a mukaillen on menot esitetty positiivisina lukuina ja tulot negatiivisina. Toteuma on näiden summa. Taulukon yläpuolella on summarivi, johon johdetut luvut muodostuvat taulukossa näkyvistä riveistä. +SAP-raportit sisältää kaksi taulukkoa, joista toinen tarjoaa tietoa ympäristökoodeista (ympäristöinvestoinnit) ja toinen puitesopimuksista. Näkymään voi navigoida valitsemalla sen päänavigointipalkista. Tiedot haetaan suoraan SAP:sta, mutta Hanna kyselee ne kaikki kerralla yöaikaan. Näin ollen tiedoissa on maksimissaan päivän viive. Kulunut aika edellisestä hausta on kerrottu sivun oikeassa ylälaidassa. Taulukkoon valikoituvia rivejä voi rajata valitsemalla yhdestä tai useammasta tarjolla olevasta hakusuodattimesta arvon. Jos arvoja useampi hakusuodatin on valittu, pitää rivin täyttää kaikki ehdot. Taulukossa näkyvät tiedot on mahdollista viedä excel-tiedostoon valitsemalla painike _lataa raportti_. + +SAP:a mukaillen on menot esitetty positiivisina lukuina ja tulot negatiivisina. Toteuma on näiden summa. Taulukon yläpuolella on summarivi, johon johdetut luvut muodostuvat taulukossa näkyvistä riveistä. Muista, että SAP:sta haettavat tiedot on rajattu yrityksiin: + - 1110 (KAPA) - 1350 (KITIA) - 1540 (ELOSA) @@ -501,9 +557,11 @@ Muista, että SAP:sta haettavat tiedot on rajattu yrityksiin: _SAP-raporttien sivu näyttää tältä. Sivun löytää Hannan päänavigointipalkista. Sivulta käsin käyttäjä voi vaihtaa eri taulukkojen välillä, joita on tällä hetkellä kaksi: ympäristökoodit ja puitesopimukset._ ## Ympäristökoodit -Tässä taulukossa käyttäjä voi koostaa haluamansa tiedot kaupungin ympäristöinvestoinneista tarkentaen halutessaan yksittäiseen investointikohteeseen, toisin sanoen ympäristökoodiin (esim. ilmastonmuutoksen hillintä tai vesiensuojelu). Ota kuitenkin huomioon, että taulukkoon tulee luetuksi _kaikki_ SAP:n rakenneosat, eikä ennakoivaa rajoittamista vain ympäristökoodin yksilöiviin rakenneosiin ole tehty. Näin ollen käyttäjän on tärkeää huomioida mukaan ainakin ympäristökoodin valinta. Toisaalta on olemassa rakenneosia, joita epätarkoituksenmukaisesti puuttuu ympäristökoodi, ja siksi on hyvä pystyä hakemaan myös rakenneosia ilman koodia. + +Tässä taulukossa käyttäjä voi koostaa haluamansa tiedot kaupungin ympäristöinvestoinneista tarkentaen halutessaan yksittäiseen investointikohteeseen, toisin sanoen ympäristökoodiin (esim. ilmastonmuutoksen hillintä tai vesiensuojelu). Ota kuitenkin huomioon, että taulukkoon tulee luetuksi _kaikki_ SAP:n rakenneosat, eikä ennakoivaa rajoittamista vain ympäristökoodin yksilöiviin rakenneosiin ole tehty. Näin ollen käyttäjän on tärkeää huomioida mukaan ainakin ympäristökoodin valinta. Toisaalta on olemassa rakenneosia, joita epätarkoituksenmukaisesti puuttuu ympäristökoodi, ja siksi on hyvä pystyä hakemaan myös rakenneosia ilman koodia. Ympäristöinvestointien osalta taulukkoon listataan SAP:n rakenneosia, joille kerrotaan seuraavat tiedot: + - Projektin tunnus - Rakenneosan tunnus - Rakenneosan nimi @@ -515,18 +573,21 @@ Ympäristöinvestointien osalta taulukkoon listataan SAP:n rakenneosia, joille k - Toteuma Rakenneosien joukkoa voi suodattaa seuraavilla tiedoilla: + - Vapaahaku (projektin ja rakenneosan tunniste, rakenneosan nimi) - Ympäristökoodi (alasvetovalikko, jonka arvot johdettu datasta. Monivalinta) - Vuosi (alasvetovalikko, jonka arvot johdettu datasta. Monivalinta) -Jos käyttäjä ei valitse vuotta, muodostetaan talousluvut (menot, tulot, toteuma) koko rakenneosan elinkaarelta. Jos käyttäjä valitsee yhden vuoden, kohdistuvat luvut kyseiseen vuoteen. Jos käyttäjä valitsee useamman vuoden, ovat luvut kyseisten vuosien summa. +Jos käyttäjä ei valitse vuotta, muodostetaan talousluvut (menot, tulot, toteuma) koko rakenneosan elinkaarelta. Jos käyttäjä valitsee yhden vuoden, kohdistuvat luvut kyseiseen vuoteen. Jos käyttäjä valitsee useamman vuoden, ovat luvut kyseisten vuosien summa. -Rakenneosan tietoja on myös tarkennettu kumppaneilla. Jokaisen rivin voi avata hakasesta `kumppanit` -sarakkeessa, jossa on esitetty kumppanien lukumäärä. Avaamisen jälkeen talousluvut esitetään kumppaneittain. Kumppani luetaan tositteelta. Kaikki tositteet, joilta ei löydy kumppanitietoa, on summattu yhteen `Ei määriteltyä kumppanikoodia` -arvoon. +Rakenneosan tietoja on myös tarkennettu kumppaneilla. Jokaisen rivin voi avata hakasesta `kumppanit` -sarakkeessa, jossa on esitetty kumppanien lukumäärä. Avaamisen jälkeen talousluvut esitetään kumppaneittain. Kumppani luetaan tositteelta. Kaikki tositteet, joilta ei löydy kumppanitietoa, on summattu yhteen `Ei määriteltyä kumppanikoodia` -arvoon. -Käyttäjän viedessä tiedot Excel-tiedostoon, eritellään taulukossa olleet riville ensimmäiselle välilehdelle rakenneosittain ja toiselle välilehdelle kumppaneittain. +Käyttäjän viedessä tiedot Excel-tiedostoon, eritellään taulukossa olleet riville ensimmäiselle välilehdelle rakenneosittain ja toiselle välilehdelle kumppaneittain. ## Puitesopimukset -Puitesopimukset -välilehdellä käyttäjän on mahdollista tarkastella kaupungin eri puitesopimuksia ja niiden taloustoteumaa suhteessa sopimushintaan. Toisin kuin ympäristökoodien osalta, taulukossa on SAP:n verkkoja. Niistä kerrotaan seuraavat tiedot: + +Puitesopimukset -välilehdellä käyttäjän on mahdollista tarkastella kaupungin eri puitesopimuksia ja niiden taloustoteumaa suhteessa sopimushintaan. Toisin kuin ympäristökoodien osalta, taulukossa on SAP:n verkkoja. Niistä kerrotaan seuraavat tiedot: + - Projektin tunnus - Verkon tunnus - Verkon nimi @@ -541,24 +602,28 @@ Puitesopimukset -välilehdellä käyttäjän on mahdollista tarkastella kaupungi - Toteuma Verkkojen joukkoa voi suodattaa seuraavilla tiedoilla: + - Vapaahaku (projektin ja verkon tunniste, verkon nimi, projektin vastuuhenkilö) - Konsulttiyritys (alasvetovalikko, jonka arvot johdettu datasta; monivalinta.) - Puite- tai ostotilaus (alasvetovalikko, jonka arvot johdettu datasta; monivalinta) - Vuosi (alasvetovalikko, jonka arvot johdettu datasta; monivalinta) -Jos käyttäjä ei valitse vuotta, muodostetaan talousluvut (menot, tulot, toteuma) koko rakenneosan elinkaarelta. Jos käyttäjä valitsee yhden vuoden, kohdistuvat luvut kyseiseen vuoteen. Jos käyttäjä valitsee useamman vuoden, ovat luvut kyseisten vuosien summa. +Jos käyttäjä ei valitse vuotta, muodostetaan talousluvut (menot, tulot, toteuma) koko rakenneosan elinkaarelta. Jos käyttäjä valitsee yhden vuoden, kohdistuvat luvut kyseiseen vuoteen. Jos käyttäjä valitsee useamman vuoden, ovat luvut kyseisten vuosien summa. # Yrityksien ja heidän yhteyshenkilöiden hallinta + Hannan oikeasta ylälaidasta löytyvän Hallinta -näkymän kautta käyttäjät voivat luoda, muokata ja poistaa hankkeisiin liittyvien yrityksien ja heidän yhteyshenkilöiden tietoja. Yrityksille ja heidän yhteyshenkilöille on omat välilehtensä. ![hallinta_paneelin_sijainti](/images/hallintapaneelin_sijainti.png)
_Hallintapaneeliin pääsee käyttöliittymän sinisen yläpalkin oikeasta reunasta käsin._ Yrityksille voi antaa seuraavat tiedot: + - Nimi - Y-tunnus Yhteyshenkilölle voi antaa seuraavat tiedot: + - Nimi - Puhelin - Sähköposti @@ -569,4 +634,4 @@ Yllä olevan listan Yrityksen nimi -arvo valitaan alasvetovalikosta niistä yrit Yrityksen yhteyshenkilön voi toistaiseksi antaa vain investointihankkeella tehtävän urakoitsija -kenttään. ![yritysten_yhteyshenkilöt](/images/yritysten_yhteyshenkilot.png)
-_Yritysten yhteyshenkilöt hallintapaneelissa. Huomioi myös Yritykset välilehti, jolta käsin voi muokata yritystietoja._ \ No newline at end of file +_Yritysten yhteyshenkilöt hallintapaneelissa. Huomioi myös Yritykset välilehti, jolta käsin voi muokata yritystietoja._ diff --git a/frontend/src/views/Project/BudgetTable.tsx b/frontend/src/views/Project/BudgetTable.tsx index f73eafb4..20c9e13a 100644 --- a/frontend/src/views/Project/BudgetTable.tsx +++ b/frontend/src/views/Project/BudgetTable.tsx @@ -1,7 +1,5 @@ -import { Save, Undo } from '@mui/icons-material'; import { Box, - Button, CircularProgress, Skeleton, Table, @@ -13,7 +11,8 @@ import { Typography, css, } from '@mui/material'; -import { useEffect } from 'react'; +import { useSetAtom } from 'jotai'; +import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { HelpTooltip } from '@frontend/components/HelpTooltip'; @@ -21,6 +20,7 @@ import { FormField } from '@frontend/components/forms'; import { CurrencyInput, valueTextColor } from '@frontend/components/forms/CurrencyInput'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom } from '@frontend/stores/projectView'; import { YearBudget } from '@shared/schema/project'; import { YearlyActuals } from '@shared/schema/sapActuals'; @@ -80,7 +80,7 @@ const cellStyle = css` text-align: right; `; -export function BudgetTable(props: Props) { +export const BudgetTable = forwardRef(function BudgetTable(props: Props, ref) { const { years, budget, @@ -95,10 +95,22 @@ export function BudgetTable(props: Props) { ...props, }; + useImperativeHandle( + ref, + () => ({ + onSave: form.handleSubmit(onSubmit), + onCancel: form.reset, + }), + [], + ); + const tr = useTranslations(); const form = useForm({ mode: 'all', defaultValues: {} }); + const setDirtyViews = useSetAtom(dirtyViewsAtom); const watch = form.watch(); - useNavigationBlocker(form.formState.isDirty, 'budgetTable'); + useNavigationBlocker(form.formState.isDirty, 'budgetTable', () => { + setDirtyViews((prev) => ({ ...prev, finances: false })); + }); /** * Convert budget from object into a simple array for the form @@ -111,6 +123,15 @@ export function BudgetTable(props: Props) { form.reset(budgetToFormValues([...budget], years)); }, [budget, years]); + useEffect(() => { + setDirtyViews((prev) => { + return { + ...prev, + finances: form.formState.isDirty, + }; + }); + }, [form.formState.isDirty]); + async function onSubmit(data: BudgetFormValues) { await onSave(formValuesToBudget(data, years)); form.reset(); @@ -430,38 +451,8 @@ export function BudgetTable(props: Props) { - - - - ); -} +}); diff --git a/frontend/src/views/Project/DeleteProjectDialog.tsx b/frontend/src/views/Project/DeleteProjectDialog.tsx index b13d6204..6ab5fe67 100644 --- a/frontend/src/views/Project/DeleteProjectDialog.tsx +++ b/frontend/src/views/Project/DeleteProjectDialog.tsx @@ -1,3 +1,4 @@ +import { SerializedStyles } from '@emotion/react'; import { Delete } from '@mui/icons-material'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { useState } from 'react'; @@ -11,9 +12,10 @@ interface Props { projectId: string; message: string; disabled: boolean; + cssProp?: SerializedStyles; } -export function DeleteProjectDialog({ projectId, message, disabled }: Readonly) { +export function DeleteProjectDialog({ projectId, message, disabled, cssProp }: Readonly) { const navigate = useNavigate(); const notify = useNotifications(); const tr = useTranslations(); @@ -46,9 +48,14 @@ export function DeleteProjectDialog({ projectId, message, disabled }: Readonly

- )} - - -

- - { - addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); - }} - /> - {project.data && ( - - )} - - - handleFormCancel(formRef)} + renderHeaderContent={() => ( + - {tabs.length > 0 && ( - - {tabs.map((tab) => ( - - ))} - - )} + + {project.data ? ( + + ) : ( + + )} + + + )} + renderMainContent={(tabRefs) => ( +
+ + { + return getGeoJSONFeaturesString( + drawSource.getFeatures(), + mapProjection?.getCode() ?? mapOptions.projection.code, + ); + }} + onCancel={() => { + addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); + }} + /> + + + + {tabs.length > 0 && ( + + {tabs.map((tab) => ( + + ))} + + )} - {tabView === 'default' && ( - - {(!projectId || formsEditing) && ( - { - if (isChecked) { - setGeom(null); - } - setCoversMunicipality(isChecked); + {tabView === 'default' && ( + + {(!projectId || editing) && ( + setCoversMunicipality(isChecked)} + /> + )} + { + setFeatureSelector((prev) => ({ + features: deleteSelectedFeatures(drawSource, selectionSource), + pos: prev.pos, + })); + addFeaturesFromGeoJson(drawSource, project?.data?.geom ?? null); + }, }} + drawSource={drawSource} + fitExtent="geoJson" + vectorLayers={[ + ...(coversMunicipality ? [municipalityGeometryLayer] : []), + projectObjectsLayer, + ]} + projectObjects={ + projectObjects.data?.map((obj) => ({ + ...obj, + objectStage: obj.objectStage ?? '', + project: { + projectId: projectId, + projectName: project.data?.projectName ?? '', + projectType: 'investmentProject', + coversMunicipality: project.data?.coversMunicipality ?? false, + }, + })) ?? [] + } + interactiveLayers={['projectObjects']} /> - )} - { - if (!project.data || coversMunicipality !== project.data.coversMunicipality) { - setGeom(features); - } else { - geometryUpdate.mutate({ projectId, features }); - } - }, - }} - fitExtent="geoJson" - vectorLayers={[ - ...(coversMunicipality ? [municipalityGeometryLayer] : []), - projectObjectsLayer, - ]} - projectObjects={ - projectObjects.data?.map((obj) => ({ - ...obj, - objectStage: obj.objectStage ?? '', - project: { - projectId: projectId, - projectName: project.data?.projectName ?? '', - projectType: 'investmentProject', - coversMunicipality: project.data?.coversMunicipality ?? false, - }, - })) ?? [] - } - interactiveLayers={['projectObjects']} - /> - - )} + + )} - {tabView !== 'default' && ( - - {tabView === 'talous' && ( - - )} - {tabView === 'kohteet' && ( - - )} - {tabView === 'sidoshankkeet' && ( - - )} - {tabView === 'luvitus' && ( - - )} - - )} - -
- + {tabView !== 'default' && ( + + {tabView === 'talous' && ( + + )} + {tabView === 'kohteet' && ( + + )} + {tabView === 'sidoshankkeet' && ( + + )} + {tabView === 'luvitus' && ( + + )} + + )} +
+
+ )} + /> ); } diff --git a/frontend/src/views/Project/InvestmentProjectForm.tsx b/frontend/src/views/Project/InvestmentProjectForm.tsx index db2f4251..4e9770fd 100644 --- a/frontend/src/views/Project/InvestmentProjectForm.tsx +++ b/frontend/src/views/Project/InvestmentProjectForm.tsx @@ -1,11 +1,10 @@ import { css } from '@emotion/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AddCircle, Edit, HourglassFullTwoTone, Save, Undo } from '@mui/icons-material'; -import { Alert, Box, Button, TextField, Typography } from '@mui/material'; +import { TextField, Typography } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { useAtomValue } from 'jotai'; -import { useEffect, useMemo, useState } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { FormProvider, ResolverOptions, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; @@ -19,6 +18,7 @@ import { useNotifications } from '@frontend/services/notification'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom, projectEditingAtom } from '@frontend/stores/projectView'; import { getRequiredFields } from '@frontend/utils/form'; import { mergeErrors } from '@shared/formerror'; @@ -27,7 +27,7 @@ import { InvestmentProject, investmentProjectSchema, } from '@shared/schema/project/investment'; -import { hasWritePermission, isAdmin, ownsProject } from '@shared/schema/userPermissions'; +import { isAdmin, ownsProject } from '@shared/schema/userPermissions'; import { ProjectOwnerChangeDialog } from './ProjectOwnerChangeDialog'; @@ -38,25 +38,45 @@ const newProjectFormStyle = css` interface InvestmentProjectFormProps { project?: DbInvestmentProject | null; - geom: string | null; coversMunicipality: boolean; setCoversMunicipality: React.Dispatch>; - editing: boolean; - setEditing: React.Dispatch>; onCancel?: () => void; + getDrawGeometry: () => string; } -export function InvestmentProjectForm(props: InvestmentProjectFormProps) { - const { coversMunicipality, setCoversMunicipality, editing, setEditing } = props; +export const InvestmentProjectForm = forwardRef(function InvestmentProjectForm( + props: InvestmentProjectFormProps, + ref, +) { + const { coversMunicipality, setCoversMunicipality } = props; + + useImperativeHandle( + ref, + () => ({ + onSave: (geom?: string) => { + form.handleSubmit((data) => onSubmit(data, geom))(); + }, + onCancel: () => { + form.reset(); + externalForm.reset(); + setCoversMunicipality(form.getValues('coversMunicipality')); + }, + }), + [coversMunicipality], + ); + const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); const navigate = useNavigate(); const currentUser = useAtomValue(asyncUserAtom); + const setDirtyViews = useSetAtom(dirtyViewsAtom); const [ownerChangeDialogOpen, setOwnerChangeDialogOpen] = useState(false); const [keepOwnerRights, setKeepOwnerRights] = useState(false); const [displayInvalidSAPIdDialog, setDisplayInvalidSAPIdDialog] = useState(false); + const [editing, setEditing] = useAtom(projectEditingAtom); + const { user, sap } = trpc.useUtils(); const readonlyProps = useMemo(() => { @@ -133,6 +153,22 @@ export function InvestmentProjectForm(props: InvestmentProjectFormProps) { useNavigationBlocker(form.formState.isDirty, 'investmentForm'); const ownerWatch = form.watch('owner'); + useEffect(() => { + if (!props.project) { + setDirtyViews((prev) => ({ ...prev, form: form.formState.isValid })); + } else { + setDirtyViews((prev) => ({ + ...prev, + form: !submitDisabled(), + })); + } + }, [ + props.project, + form.formState.isValid, + form.formState.isDirty, + externalForm.formState.isDirty, + ]); + useEffect(() => { form.reset(props.project ?? formDefaultValues); }, [props.project]); @@ -154,7 +190,6 @@ export function InvestmentProjectForm(props: InvestmentProjectFormProps) { ], }); - setEditing(false); form.reset(data); externalForm.reset({ coversMunicipality: data.coversMunicipality }); } @@ -178,7 +213,7 @@ export function InvestmentProjectForm(props: InvestmentProjectFormProps) { } }, [form.formState.isSubmitSuccessful, form.reset, displayInvalidSAPIdDialog]); - const onSubmit = async (data: InvestmentProject | DbInvestmentProject) => { + const onSubmit = async (data: InvestmentProject | DbInvestmentProject, geom?: string) => { let validOrEmptySAPId; try { validOrEmptySAPId = data.sapProjectId @@ -197,7 +232,7 @@ export function InvestmentProjectForm(props: InvestmentProjectFormProps) { projectUpsert.mutate({ project: { ...data, - geom: props.geom, + geom: geom ?? null, coversMunicipality: coversMunicipality, }, keepOwnerRights, @@ -216,47 +251,8 @@ export function InvestmentProjectForm(props: InvestmentProjectFormProps) { {!props.project && ( {tr('newInvestmentProject.formTitle')} )} - {props.project && ( - - {tr('projectForm.formTitle')} - {!form.formState.isDirty && !editing ? ( - - ) : ( - - )} - - )} -
+ {props.project && {tr('projectForm.formTitle')}} + )} /> - - {!props.project && ( - <> - {(!props.geom || props.geom === '[]') && ( - - {tr('newProject.infoNoGeom')} - - )} - - - )} - - {props.project && editing && ( - - )} { form.reset(undefined, { keepValues: true }); setDisplayInvalidSAPIdDialog(false); + setEditing(true); }} /> ); -} +}); diff --git a/frontend/src/views/Project/ProjectFinances.tsx b/frontend/src/views/Project/ProjectFinances.tsx index 0d56ec92..45176170 100644 --- a/frontend/src/views/Project/ProjectFinances.tsx +++ b/frontend/src/views/Project/ProjectFinances.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { useEffect, useMemo } from 'react'; +import { forwardRef, useEffect, useMemo } from 'react'; import { trpc } from '@frontend/client'; import { useNotifications } from '@frontend/services/notification'; @@ -21,8 +21,9 @@ interface Props { onSave?: () => void; } -export function ProjectFinances(props: Props) { +export const ProjectFinances = forwardRef(function ProjectFinances(props: Props, ref) { const { project, editable = false } = props; + const budget = !project.data ? null : trpc.project.getBudget.useQuery({ projectId: project.data.projectId }); @@ -75,6 +76,7 @@ export function ProjectFinances(props: Props) { return !budget?.data || !project.data ? null : ( ); -} +}); diff --git a/frontend/src/views/Project/ProjectPermissions.tsx b/frontend/src/views/Project/ProjectPermissions.tsx index a8a6a526..fef238c4 100644 --- a/frontend/src/views/Project/ProjectPermissions.tsx +++ b/frontend/src/views/Project/ProjectPermissions.tsx @@ -1,9 +1,8 @@ import { css } from '@emotion/react'; -import { SaveSharp, SearchTwoTone, Undo } from '@mui/icons-material'; +import { SearchTwoTone } from '@mui/icons-material'; import { Alert, Box, - Button, Checkbox, CircularProgress, InputAdornment, @@ -15,22 +14,28 @@ import { TableRow, TextField, } from '@mui/material'; +import { useSetAtom } from 'jotai'; import diff from 'microdiff'; -import { useEffect, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { trpc } from '@frontend/client'; import { useNotifications } from '@frontend/services/notification'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom } from '@frontend/stores/projectView'; import { ProjectWritePermission } from '@shared/schema/project/base'; interface Props { projectId: string; + editing: boolean; ownerId?: string; } -export function ProjectPermissions({ projectId, ownerId }: Props) { +export const ProjectPermissions = forwardRef(function ProjectPermissions( + { projectId, editing, ownerId }: Props, + ref, +) { const notify = useNotifications(); const tr = useTranslations(); @@ -44,14 +49,39 @@ export function ProjectPermissions({ projectId, ownerId }: Props) { const permissionsUpdate = trpc.project.updatePermissions.useMutation(); const [searchterm, setSearchterm] = useState(''); const [localUserPermissions, setLocalUserPermissions] = useState([]); + const setDirtyViews = useSetAtom(dirtyViewsAtom); + const isDirty = !isLoading && !isError && diff(userPermissions, localUserPermissions).length !== 0; - useNavigationBlocker(isDirty, 'projectPermissions'); + useNavigationBlocker(isDirty, 'projectPermissions', () => + setDirtyViews((prev) => ({ ...prev, permissions: false })), + ); + + useImperativeHandle( + ref, + () => ({ + onSave: handleUpdatePermissions, + onCancel: handleCancelChanges, + }), + [userPermissions, localUserPermissions], + ); + + useEffect(() => { + if (!isLoading && !isError) + setDirtyViews((prev) => ({ + ...prev, + permissions: diff(userPermissions, localUserPermissions).length !== 0, + })); + }, [localUserPermissions]); useEffect(() => { if (userPermissions) setLocalUserPermissions([...userPermissions]); }, [userPermissions]); + function handleCancelChanges() { + setLocalUserPermissions([...(userPermissions ?? [])]); + } + function handleUpdatePermissions() { if (!userPermissions) return; const changedUserIds = diff(userPermissions, localUserPermissions).reduce((ids, diff) => { @@ -78,7 +108,7 @@ export function ProjectPermissions({ projectId, ownerId }: Props) { onSuccess: () => { notify({ severity: 'success', - title: tr('genericForm.notifySubmitSuccess'), + title: tr('projectPermissions.notifyPermissionsUpdated'), duration: 3000, }); refetch(); @@ -95,16 +125,14 @@ export function ProjectPermissions({ projectId, ownerId }: Props) { display: flex; background-color: white; z-index: 200; - @media screen and (max-width: 1150px) { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } justify-content: space-between; align-items: center; `} > setSearchterm(event.target.value)} /> - 1150px) { - margin-left: auto; - } - `} - > - - -
@@ -232,4 +217,4 @@ export function ProjectPermissions({ projectId, ownerId }: Props) {
); -} +}); diff --git a/frontend/src/views/Project/ProjectViewWrapper.tsx b/frontend/src/views/Project/ProjectViewWrapper.tsx new file mode 100644 index 00000000..4751682e --- /dev/null +++ b/frontend/src/views/Project/ProjectViewWrapper.tsx @@ -0,0 +1,314 @@ +import { Close, Create, Save, Undo } from '@mui/icons-material'; +import { Alert, Box, Button, css } from '@mui/material'; +import { useAtom, useAtomValue } from 'jotai'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router'; + +import { asyncUserAtom } from '@frontend/stores/auth'; +import { useTranslations } from '@frontend/stores/lang'; +import { ModifiableField, dirtyViewsAtom, projectEditingAtom } from '@frontend/stores/projectView'; +import { ProjectTypePath } from '@frontend/types'; + +import { + ProjectPermissionContext, + hasWritePermission, + ownsProject, +} from '@shared/schema/userPermissions'; + +import { DeleteProjectObjectDialog } from '../ProjectObject/DeleteProjectObjectDialog'; +import { SaveOptionsButton } from '../ProjectObject/SaveOptionsButton'; +import { DeleteProjectDialog } from './DeleteProjectDialog'; + +interface EditingFooterProps extends PropsWithChildren { + onSave: () => void; + onCancel: () => void; + saveAndReturn?: (navigateTo: string) => void; +} + +export function EditingFooter({ onSave, saveAndReturn, onCancel, children }: EditingFooterProps) { + const dirtyViews = useAtomValue(dirtyViewsAtom); + const location = useLocation(); + const navigateTo = new URLSearchParams(location.search).get('from'); + const isDirty = Object.values(dirtyViews).some((status) => Boolean(status)); + const tr = useTranslations(); + return ( + + {children} + + {navigateTo && saveAndReturn ? ( + saveAndReturn(navigateTo)}> + + + ) : ( + + )} + + ); +} + +interface RefContent { + form: { + onSave: (geom?: string) => void; + saveAndReturn?: (navigateTo: string, geom?: string) => void; + onCancel: () => void; + }; + map: { handleUndoDraw: () => void; handleSave: () => string }; + finances: { onSave: () => void; onCancel: () => void }; + permissions: { onSave: () => void; onCancel: () => void }; +} + +interface TabRefs { + form: React.RefObject; + map: React.RefObject; + finances: React.RefObject; + permissions: React.RefObject; +} + +interface Props { + renderHeaderContent?: () => JSX.Element; + renderMainContent?: (tabRefs: TabRefs) => JSX.Element; + handleFormCancel?: (formRef: TabRefs['form']) => void; + geom?: string | null; + permissionCtx: ProjectPermissionContext | null; + type?: 'project' | 'projectObject'; + projectType?: ProjectTypePath; +} + +export function ProjectViewWrapper({ type = 'project', ...props }: Props) { + const { projectId, projectObjectId } = useParams() as { + projectId: string; + projectObjectId?: string; + }; + + const isNewItem = + (type === 'project' && !projectId) || (type === 'projectObject' && !projectObjectId); + + const tr = useTranslations(); + const navigate = useNavigate(); + + const [editing, setEditing] = useAtom(projectEditingAtom); + const dirtyViews = useAtomValue(dirtyViewsAtom); + const user = useAtomValue(asyncUserAtom); + const footerVisible = editing || dirtyViews.finances; + + const tabRefs: TabRefs = { + form: useRef(null), + map: useRef(null), + finances: useRef(null), + permissions: useRef(null), + }; + + const viewSaveActions = { + form: () => { + if (!dirtyViews.map) { + tabRefs.form.current?.onSave(); + } + }, + map: () => { + const geom = tabRefs.map.current?.handleSave(); + tabRefs.form.current?.onSave(geom); + }, + finances: tabRefs.finances.current?.onSave, + permissions: tabRefs.permissions.current?.onSave, + }; + + function onSave() { + (Object.entries(dirtyViews) as [ModifiableField, boolean][]).forEach(([view, isDirty]) => { + if (isDirty) { + viewSaveActions[view]?.(); + } + }); + + setEditing(false); + } + + const viewCancelActions = { + form: () => props.handleFormCancel?.(tabRefs.form), + map: tabRefs.map.current?.handleUndoDraw, + finances: tabRefs.finances.current?.onCancel, + permissions: tabRefs.permissions.current?.onCancel, + }; + + function onCancel() { + (Object.entries(dirtyViews) as [ModifiableField, boolean][]).forEach(([view, isDirty]) => { + if (isDirty) { + viewCancelActions[view]?.(); + } + }); + if (isNewItem) { + navigate(-1); + } + setEditing(false); + } + + useEffect(() => { + setEditing(isNewItem); + return () => setEditing(false); + }, [projectId]); + + const isOwner = props.permissionCtx ? ownsProject(user, props.permissionCtx) : false; + const canWrite = props.permissionCtx ? hasWritePermission(user, props.permissionCtx) : false; + + return ( + + + {props.renderHeaderContent?.()} + {isNewItem ? ( + + ) : ( + + )} + + {props.renderMainContent?.(tabRefs)} + + {footerVisible && ( + { + let geom: string | undefined; + if (dirtyViews.map) { + geom = tabRefs.map.current?.handleSave(); + } + tabRefs.form.current?.saveAndReturn?.(navigateTo, geom); + }} + onCancel={onCancel} + > + {props.permissionCtx && + editing && + (type === 'project' ? ( + + ) : ( + props.projectType && + projectObjectId && ( + + ) + ))} + {isNewItem && (!props.geom || props.geom === '[]') && ( + + {type === 'project' + ? tr('newProject.infoNoGeom') + : tr('projectObjectForm.infoNoGeom')} + + )} + + )} + + + ); +} diff --git a/frontend/src/views/ProjectObject/DeleteProjectObjectDialog.tsx b/frontend/src/views/ProjectObject/DeleteProjectObjectDialog.tsx index f3eceefd..a754bdcb 100644 --- a/frontend/src/views/ProjectObject/DeleteProjectObjectDialog.tsx +++ b/frontend/src/views/ProjectObject/DeleteProjectObjectDialog.tsx @@ -1,3 +1,4 @@ +import { SerializedStyles } from '@emotion/react'; import { Delete } from '@mui/icons-material'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { useState } from 'react'; @@ -13,6 +14,7 @@ interface Props { projectType: ProjectTypePath; projectObjectId: string; userCanModify?: boolean; + cssProp?: SerializedStyles; } export function DeleteProjectObjectDialog({ @@ -20,6 +22,7 @@ export function DeleteProjectObjectDialog({ projectType, projectObjectId, userCanModify, + cssProp, }: Readonly) { const navigate = useNavigate(); const notify = useNotifications(); @@ -54,11 +57,11 @@ export function DeleteProjectObjectDialog({ return ( <> - - - - - ); -} - -export function InvestmentProjectObjectForm(props: Readonly) { const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [editing, setEditing] = useState(!props.projectObject); + const setDirtyViews = useSetAtom(dirtyViewsAtom); + const editing = useAtomValue(projectEditingAtom); + + useImperativeHandle( + ref, + () => ({ + onSave: (geom: string) => { + form.handleSubmit((data) => onSubmit(data, geom))(); + }, + saveAndReturn: (navigateTo: string, geom: string) => + form.handleSubmit((data) => saveAndReturn(data, navigateTo, geom))(), + onCancel: () => { + form.reset(); + }, + }), + [], + ); const projectUpsert = trpc.investmentProject.upsert.useMutation({ onSuccess: () => { @@ -244,6 +188,17 @@ export function InvestmentProjectObjectForm(props: Readonly) { } }, [props.projectObject]); + useEffect(() => { + if (!props.projectObject) { + setDirtyViews((prev) => ({ ...prev, form: form.formState.isValid })); + } else { + setDirtyViews((prev) => ({ + ...prev, + form: form.formState.isValid && form.formState.isDirty, + })); + } + }, [props.projectObject, form.formState.isValid, form.formState.isDirty]); + const formProjectId = form.watch('projectId'); const projectObjectUpsert = trpc.investmentProjectObject.upsert.useMutation({ @@ -261,7 +216,6 @@ export function InvestmentProjectObjectForm(props: Readonly) { ], }); - setEditing(false); form.reset((currentData) => currentData); } notify({ @@ -284,60 +238,27 @@ export function InvestmentProjectObjectForm(props: Readonly) { } }, [form.formState.isSubmitSuccessful, form.reset]); - const onSubmit = (data: UpsertInvestmentProjectObject) => { - projectObjectUpsert.mutate({ ...data, geom: props.geom }); + const onSubmit = (data: UpsertInvestmentProjectObject, geom?: string) => { + projectObjectUpsert.mutate({ ...data, geom: geom ?? null }); }; - const saveAndReturn = (data: UpsertInvestmentProjectObject) => { + function saveAndReturn(data: UpsertInvestmentProjectObject, navigateTo: string, geom?: string) { projectObjectUpsert.mutate( - { ...data, geom: props.geom }, + { ...data, geom: geom ?? null }, { onSuccess: (data) => { - navigate(`${props.navigateTo}?highlight=${data.projectObjectId}`); + navigate(`${navigateTo}?highlight=${data.projectObjectId}`); }, }, ); - }; + } return ( <> {!props.projectObject && } - {props.projectObject && ( - - - {!editing ? ( - - ) : ( - - )} - - )} -
+ {props.projectObject && } + ) { flex-direction: column; `} > - {!props.projectObject && (!props.geom || props.geom === '[]') && ( - - {tr('projectObjectForm.infoNoGeom')} - - )} - {(form.formState.errors?.endDate?.message === 'projectObject.error.projectNotIncluded' || form.formState.errors?.startDate?.message === @@ -570,51 +485,8 @@ export function InvestmentProjectObjectForm(props: Readonly) { )} - - {!props.projectObject && props.navigateTo && ( -
- - - -
- )} - - {!props.projectObject && !props.navigateTo && ( - - )} - - {props.projectObject && editing && ( - - )}
); -} +}); diff --git a/frontend/src/views/ProjectObject/MaintenanceProjectObjectForm.tsx b/frontend/src/views/ProjectObject/MaintenanceProjectObjectForm.tsx index d58c52bf..27206060 100644 --- a/frontend/src/views/ProjectObject/MaintenanceProjectObjectForm.tsx +++ b/frontend/src/views/ProjectObject/MaintenanceProjectObjectForm.tsx @@ -1,21 +1,12 @@ import { css } from '@emotion/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { AddCircle, ArrowDropDown, ArrowDropUp, Edit, Save, Undo } from '@mui/icons-material'; -import { - Alert, - Box, - Button, - ButtonGroup, - ButtonGroupTypeMap, - Popover, - TextField, -} from '@mui/material'; +import { Alert, Box, Button, TextField } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { forwardRef, useEffect, useImperativeHandle, useMemo } from 'react'; import { FormProvider, ResolverOptions, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; -import { Link } from 'react-router-dom'; import { trpc } from '@frontend/client'; import { FormDatePicker, FormField, getDateFieldErrorMessage } from '@frontend/components/forms'; @@ -25,6 +16,7 @@ import { SectionTitle } from '@frontend/components/forms/SectionTitle'; import { useNotifications } from '@frontend/services/notification'; import { useTranslations } from '@frontend/stores/lang'; import { useNavigationBlocker } from '@frontend/stores/navigationBlocker'; +import { dirtyViewsAtom, projectEditingAtom } from '@frontend/stores/projectView'; import { ProjectTypePath } from '@frontend/types'; import { getRequiredFields } from '@frontend/utils/form'; import { SapWBSSelect } from '@frontend/views/ProjectObject/SapWBSSelect'; @@ -47,82 +39,34 @@ interface Props { projectId?: string; projectType: ProjectTypePath; projectObject?: UpsertMaintenanceProjectObject | null; - geom?: string | null; setProjectId?: (projectId: string) => void; - navigateTo?: string | null; userIsOwner?: boolean; userCanWrite?: boolean; } -function SaveOptionsButton( - props: Readonly<{ - form: ReturnType>; - onSubmit: (data: UpsertMaintenanceProjectObject) => void; - }>, +export const MaintenanceProjectObjectForm = forwardRef(function MaintenanceProjectObjectForm( + props: Readonly, + ref, ) { - const tr = useTranslations(); - const { form, onSubmit } = props; - - const popperRef = useRef< - React.ElementRef & { - clientWidth: number; - } - >(null); - const [menuOpen, setMenuOpen] = useState(false); - - return ( - - - - - - - ); -} - -export function MaintenanceProjectObjectForm(props: Readonly) { const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [editing, setEditing] = useState(!props.projectObject); + const setDirtyViews = useSetAtom(dirtyViewsAtom); + const editing = useAtomValue(projectEditingAtom); + + useImperativeHandle( + ref, + () => ({ + onSave: (geom: string) => { + form.handleSubmit((data) => onSubmit(data, geom))(); + }, + onCancel: () => { + form.reset(); + }, + }), + [], + ); const projectUpsert = trpc.maintenanceProject.upsert.useMutation({ onSuccess: () => { @@ -262,7 +206,6 @@ export function MaintenanceProjectObjectForm(props: Readonly) { ], }); - setEditing(false); form.reset((currentData) => currentData); } notify({ @@ -285,60 +228,27 @@ export function MaintenanceProjectObjectForm(props: Readonly) { } }, [form.formState.isSubmitSuccessful, form.reset]); - const onSubmit = (data: UpsertMaintenanceProjectObject) => { - projectObjectUpsert.mutate({ ...data, geom: props.geom }); - }; + useEffect(() => { + if (!props.projectObject) { + setDirtyViews((prev) => ({ ...prev, form: form.formState.isValid })); + } else { + setDirtyViews((prev) => ({ + ...prev, + form: form.formState.isValid && form.formState.isDirty, + })); + } + }, [props.projectObject, form.formState.isValid, form.formState.isDirty]); - const saveAndReturn = (data: UpsertMaintenanceProjectObject) => { - projectObjectUpsert.mutate( - { ...data, geom: props.geom }, - { - onSuccess: (data) => { - navigate(`${props.navigateTo}?highlight=${data.projectObjectId}`); - }, - }, - ); + const onSubmit = (data: UpsertMaintenanceProjectObject, geom?: string) => { + projectObjectUpsert.mutate({ ...data, geom: geom ?? null }); }; return ( <> {!props.projectObject && } - {props.projectObject && ( - - - {!editing ? ( - - ) : ( - - )} - - )} -
+ {props.projectObject && } + ) { flex-direction: column; `} > - {!props.projectObject && (!props.geom || props.geom === '[]') && ( - - {tr('projectObjectForm.infoNoGeom')} - - )} - {(form.formState.errors?.endDate?.message === 'projectObject.error.projectNotIncluded' || form.formState.errors?.startDate?.message === @@ -560,51 +464,8 @@ export function MaintenanceProjectObjectForm(props: Readonly) { )} - - {!props.projectObject && props.navigateTo && ( -
- - - -
- )} - - {!props.projectObject && !props.navigateTo && ( - - )} - - {props.projectObject && editing && ( - - )}
); -} +}); diff --git a/frontend/src/views/ProjectObject/ProjectObject.tsx b/frontend/src/views/ProjectObject/ProjectObject.tsx index 2bfb2fcb..e6e440e0 100644 --- a/frontend/src/views/ProjectObject/ProjectObject.tsx +++ b/frontend/src/views/ProjectObject/ProjectObject.tsx @@ -1,10 +1,10 @@ import { css } from '@emotion/react'; -import { Assignment, Euro, Map, Undo } from '@mui/icons-material'; -import { Box, Breadcrumbs, Button, Chip, Paper, Tab, Tabs, Typography } from '@mui/material'; +import { Assignment, Euro, Map } from '@mui/icons-material'; +import { Box, Breadcrumbs, Chip, Paper, Tab, Tabs, Typography } from '@mui/material'; import { useAtomValue } from 'jotai'; import VectorSource from 'ol/source/Vector'; import { ReactElement, useMemo, useState } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router'; +import { useParams } from 'react-router'; import { Link, useSearchParams } from 'react-router-dom'; import { trpc } from '@frontend/client'; @@ -13,10 +13,10 @@ import { MapWrapper } from '@frontend/components/Map/MapWrapper'; import { getProjectObjectGeoJSON } from '@frontend/components/Map/mapFunctions'; import { featuresFromGeoJSON } from '@frontend/components/Map/mapInteractions'; import { PROJ_OBJ_DRAW_STYLE } from '@frontend/components/Map/styles'; -import { useNotifications } from '@frontend/services/notification'; import { asyncUserAtom } from '@frontend/stores/auth'; import { useTranslations } from '@frontend/stores/lang'; import { getProjectObjectsLayer, getProjectsLayer } from '@frontend/stores/map'; +import { projectEditingAtom } from '@frontend/stores/projectView'; import { ProjectTypePath } from '@frontend/types'; import Tasks from '@frontend/views/Task/Tasks'; @@ -28,7 +28,7 @@ import { ownsProject, } from '@shared/schema/userPermissions'; -import { DeleteProjectObjectDialog } from './DeleteProjectObjectDialog'; +import { ProjectViewWrapper } from '../Project/ProjectViewWrapper'; import { InvestmentProjectObjectForm } from './InvestmentProjectObjectForm'; import { MaintenanceProjectObjectForm } from './MaintenanceProjectObjectForm'; import { ProjectObjectFinances } from './ProjectObjectFinances'; @@ -94,12 +94,10 @@ export function ProjectObject(props: Props) { projectObjectId: string; tabView?: TabView; }; - const location = useLocation(); - const navigateTo = new URLSearchParams(location.search).get('from'); const projectObjectId = routeParams?.projectObjectId; const [searchParams] = useSearchParams(); - const navigate = useNavigate(); + const tabView = searchParams.get('tab') || 'default'; const tabs = projectObjectTabs(routeParams.projectId, props.projectType, projectObjectId); const tabIndex = tabs.findIndex((tab) => tab.tabView === tabView); @@ -125,29 +123,11 @@ export function ProjectObject(props: Props) { }); const user = useAtomValue(asyncUserAtom); + const editing = useAtomValue(projectEditingAtom); - const [geom, setGeom] = useState(null); const [projectId, setProjectId] = useState(routeParams.projectId); const tr = useTranslations(); - const notify = useNotifications(); - const geometryUpdate = trpc.projectObject.updateGeometry.useMutation({ - onSuccess: () => { - projectObject.refetch(); - projectObjectGeometries.refetch(); - notify({ - severity: 'success', - title: tr('projectObject.notifyGeometryUpdateTitle'), - duration: 5000, - }); - }, - onError: () => { - notify({ - severity: 'error', - title: tr('projectObject.notifyGeometryUpdateFailedTitle'), - }); - }, - }); const project = trpc.project.get.useQuery({ projectId }, { enabled: Boolean(projectId) }); const projectObjectGeometries = trpc.projectObject.getGeometriesByProjectId.useQuery( @@ -193,6 +173,12 @@ export function ProjectObject(props: Props) { return getProjectsLayer(projectSource); }, [project.data]); + function handleFormCancel(formRef: React.RefObject<{ onCancel: () => void }>) { + if (formRef.current?.onCancel) { + formRef.current?.onCancel(); + } + } + if (projectObjectId && projectObject?.isLoading) { return {tr('loading')}; } @@ -216,196 +202,167 @@ export function ProjectObject(props: Props) { const isOwner = ownsProject(user, projectObject.data?.acl); const canWrite = hasWritePermission(user, projectObject.data?.acl); return ( - - - - {routeParams.projectId && ( - {project.data?.projectName}} - /> - )} - {projectObject.data ? ( - - ) : ( - - )} - - {!projectObject.data && ( - - )} - - -
- - {props.projectType === 'investointihanke' && ( - - )} - {props.projectType === 'kunnossapitohanke' && ( - - )} - {projectObject.data && ( - - )} - - - handleFormCancel(formRef)} + renderHeaderContent={() => ( + - - {tabs.map((tab) => ( - + {routeParams.projectId && ( + {project.data?.projectName}} /> - ))} - - - {!searchParams.get('tab') && ( - - { - if (!projectObject.data) { - setGeom(features); - projectObjectGeometries.refetch(); - } else { - geometryUpdate.mutate({ projectObjectId, features }); - } - }, - }} - vectorLayers={[projectLayer, projectObjectLayer]} - fitExtent="all" - projectObjects={ - projectObjects.data - ?.filter((obj) => obj.projectObjectId !== projectObjectId) - .map((obj) => ({ - ...obj, - project: { - projectId: projectId, - projectName: project.data?.projectName ?? '', - projectType: project.data?.projectType, - coversMunicipality: project.data?.coversMunicipality ?? false, - }, - })) ?? [] - } - interactiveLayers={['projectObjects', 'projects']} - projects={project.data ? [project.data] : []} + )} + {projectObject.data ? ( + + ) : ( + + )} + + + )} + renderMainContent={(tabRefs) => ( +
+ + {props.projectType === 'investointihanke' && ( + - - )} - - {searchParams.get('tab') && ( - - {searchParams.get('tab') === 'talous' && projectObject.data && ( - { - document.dispatchEvent(new Event('budgetUpdated')); - }, - })} - userIsAdmin={isAdmin(user.role)} - userIsEditor={isOwner || canWrite} - userIsFinanceEditor={hasPermission( - user, - props.projectType === 'investointihanke' - ? 'investmentFinancials.write' - : 'maintenanceFinancials.write', - )} - projectObject={{ - data: projectObject.data, - projectType: - props.projectType === 'investointihanke' - ? 'investmentProject' - : 'maintenaceProject', - }} + )} + {props.projectType === 'kunnossapitohanke' && ( + + )} + + + + + {tabs.map((tab) => ( + - )} - {searchParams.get('tab') === 'vaiheet' && ( - + + {!searchParams.get('tab') && ( + + obj.projectObjectId !== projectObjectId) + .map((obj) => ({ + ...obj, + project: { + projectId: projectId, + projectName: project.data?.projectName ?? '', + projectType: project.data?.projectType, + coversMunicipality: project.data?.coversMunicipality ?? false, + }, + })) ?? [] + } + interactiveLayers={['projectObjects', 'projects']} + projects={project.data ? [project.data] : []} /> - )} - - )} - -
-
+ + )} + + {searchParams.get('tab') && ( + + {searchParams.get('tab') === 'talous' && projectObject.data && ( + { + document.dispatchEvent(new Event('budgetUpdated')); + }, + })} + userIsAdmin={isAdmin(user.role)} + userIsEditor={isOwner || canWrite} + userIsFinanceEditor={hasPermission( + user, + props.projectType === 'investointihanke' + ? 'investmentFinancials.write' + : 'maintenanceFinancials.write', + )} + projectObject={{ + data: projectObject.data, + projectType: + props.projectType === 'investointihanke' + ? 'investmentProject' + : 'maintenaceProject', + }} + /> + )} + {searchParams.get('tab') === 'vaiheet' && ( + + )} + + )} +
+
+ )} + /> ); } diff --git a/frontend/src/views/ProjectObject/ProjectObjectFinances.tsx b/frontend/src/views/ProjectObject/ProjectObjectFinances.tsx index 6341e361..2590ecd8 100644 --- a/frontend/src/views/ProjectObject/ProjectObjectFinances.tsx +++ b/frontend/src/views/ProjectObject/ProjectObjectFinances.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { useEffect, useMemo } from 'react'; +import { forwardRef, useEffect, useMemo } from 'react'; import { trpc } from '@frontend/client'; import { useNotifications } from '@frontend/services/notification'; @@ -20,9 +20,9 @@ interface Props { onSave?: () => void; } -export function ProjectObjectFinances( - props: Props, -) { +export const ProjectObjectFinances = forwardRef(function ProjectObjectFinances< + TProjectObject extends CommonDbProjectObject, +>(props: Props, ref: React.ForwardedRef) { const { projectObject } = props; const budget = !projectObject.data.projectObjectId ? null @@ -68,12 +68,13 @@ export function ProjectObjectFinances ); -} +}); diff --git a/frontend/src/views/ProjectObject/SaveOptionsButton.tsx b/frontend/src/views/ProjectObject/SaveOptionsButton.tsx new file mode 100644 index 00000000..0317d3c9 --- /dev/null +++ b/frontend/src/views/ProjectObject/SaveOptionsButton.tsx @@ -0,0 +1,52 @@ +import { ArrowDropDown, ArrowDropUp } from '@mui/icons-material'; +import { Button, ButtonGroup, ButtonGroupTypeMap, Popover } from '@mui/material'; +import { PropsWithChildren, useRef, useState } from 'react'; + +import { useTranslations } from '@frontend/stores/lang'; + +export function SaveOptionsButton( + props: { + saveAndReturn: () => void; + disabled: boolean; + } & PropsWithChildren, +) { + const tr = useTranslations(); + const { saveAndReturn } = props; + + const popperRef = useRef< + React.ElementRef & { + clientWidth: number; + } + >(null); + const [menuOpen, setMenuOpen] = useState(false); + return ( + + + + + ); +} diff --git a/shared/src/language/fi.ts b/shared/src/language/fi.ts index 8361e401..c2384087 100644 --- a/shared/src/language/fi.ts +++ b/shared/src/language/fi.ts @@ -39,12 +39,14 @@ export const fi = { 'itemSearch.calendarQuickSelection.thisYear': 'Kuluva vuosi', 'itemSearch.calendarQuickSelection.nextYear': 'Seuraava vuosi', 'itemSearch.calendarQuickSelection.year': 'Vuosi {year}', + 'projectView.modify': 'Muokkaa hanketta', 'projectSearch.projectType': 'Hanketyyppi', 'projectSearch.geometry': 'Alue', 'projectSearch.showOnlyItemsWithGeom': 'Vain sijaintitiedon sisältävät hankkeet', 'projectSearch.showOnlyItemsThatCoverMunicipality': 'Vain koko kunnan aluetta koskevat hankkeet', 'projectSearch.generateReport': 'Lataa raportti', 'projectSearch.reportFailed': 'Raportin luonti epäonnistui.', + 'projectObjectView.modify': 'Muokkaa kohdetta', 'projectObjectSearch.projectObjectSearchLabel': 'Kohteiden rajaus', 'projectObjectSearch.rakennuttajaUser': 'Rakennuttaja', 'projectObjectSearch.suunnitteluttajaUser': 'Suunnitteluttaja', @@ -141,6 +143,7 @@ export const fi = { 'externalProjectForm.confirmDialog.content': 'Hankealueen muuttaminen koko kuntaa koskevaksi poistaa olemassa olevat piirretyt alueet lomakkeen tallennuksen yhteydessä. Haluatko varmasti jatkaa?', 'projectPermissions.editPermission': 'Muokkausoikeus', + 'projectPermissions.notifyPermissionsUpdated': 'Muokkausoikeudet päivitetty', 'projectRelations.parentRelations': 'Ylähankkeet', 'projectRelations.childRelations': 'Alahankkeet', 'projectRelations.relatedRelations': 'Rinnakkaishankkeet', @@ -227,6 +230,8 @@ export const fi = { 'search.searchTerm': 'Hakusana (vähintään {0} merkkiä)', add: 'Lisää', delete: 'Poista', + reject: 'Hylkää', + save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje', continue: 'Jatka',