) {
const navigate = useNavigate();
const notify = useNotifications();
const tr = useTranslations();
@@ -46,9 +48,14 @@ export function DeleteProjectDialog({ projectId, message, disabled }: Readonly
}
onClick={() => setIsDialogOpen(true)}
diff --git a/frontend/src/views/Project/InvestmentProject.tsx b/frontend/src/views/Project/InvestmentProject.tsx
index 2514b88c..8c08d14e 100644
--- a/frontend/src/views/Project/InvestmentProject.tsx
+++ b/frontend/src/views/Project/InvestmentProject.tsx
@@ -1,10 +1,10 @@
import { css } from '@emotion/react';
-import { AccountTree, Euro, KeyTwoTone, ListAlt, Map, Undo } from '@mui/icons-material';
-import { Box, Breadcrumbs, Button, Chip, Paper, Tab, Tabs, Typography } from '@mui/material';
-import { useAtomValue } from 'jotai';
+import { AccountTree, Euro, KeyTwoTone, ListAlt, Map } from '@mui/icons-material';
+import { Box, Breadcrumbs, Chip, Paper, Tab, Tabs, Typography } from '@mui/material';
+import { useAtomValue, useSetAtom } 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';
@@ -14,14 +14,22 @@ import { MapWrapper } from '@frontend/components/Map/MapWrapper';
import {
DRAW_LAYER_Z_INDEX,
addFeaturesFromGeoJson,
+ deleteSelectedFeatures,
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 {
+ featureSelectorAtom,
+ getProjectMunicipalityLayer,
+ getProjectObjectsLayer,
+ mapProjectionAtom,
+ selectionSourceAtom,
+} from '@frontend/stores/map';
+import { projectEditingAtom } from '@frontend/stores/projectView';
import { ProjectRelations } from '@frontend/views/Project/ProjectRelations';
import { ProjectObjectList } from '@frontend/views/ProjectObject/ProjectObjectList';
@@ -32,19 +40,19 @@ import {
ownsProject,
} from '@shared/schema/userPermissions';
-import { DeleteProjectDialog } from './DeleteProjectDialog';
import { InvestmentProjectForm } from './InvestmentProjectForm';
import { ProjectAreaSelectorForm } from './ProjectAreaSelectorForm';
import { ProjectFinances } from './ProjectFinances';
import { ProjectPermissions } from './ProjectPermissions';
+import { ProjectViewWrapper } from './ProjectViewWrapper';
const pageContentStyle = css`
display: grid;
grid-template-columns: minmax(384px, 1fr) minmax(512px, 2fr);
gap: 16px;
- height: 100%;
flex: 1;
overflow: hidden;
+ padding: 0 16px;
`;
const mapContainerStyle = css`
@@ -99,21 +107,23 @@ function getTabs(projectId: string) {
export function InvestmentProject() {
const routeParams = useParams() as { projectId: string };
const [searchParams] = useSearchParams();
- const navigate = useNavigate();
const tabView = searchParams.get('tab') || 'default';
const user = useAtomValue(asyncUserAtom);
+ const setFeatureSelector = useSetAtom(featureSelectorAtom);
+ const selectionSource = useAtomValue(selectionSourceAtom);
+ const mapProjection = useAtomValue(mapProjectionAtom);
+ const editing = useAtomValue(projectEditingAtom);
const projectId = routeParams?.projectId;
const project = trpc.investmentProject.get.useQuery(
{ projectId },
{ enabled: Boolean(projectId), queryKey: ['investmentProject.get', { projectId }] },
);
+
const [coversMunicipality, setCoversMunicipality] = useState(
project.data?.coversMunicipality ?? false,
);
- const [formsEditing, setFormsEditing] = useState(false);
-
const userCanModify = Boolean(
project.data &&
user &&
@@ -125,26 +135,7 @@ export function InvestmentProject() {
);
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 },
@@ -195,15 +186,18 @@ export function InvestmentProject() {
}
}, [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')};
}
@@ -220,177 +214,161 @@ export function InvestmentProject() {
}
return (
-
-
-
- {project.data ? (
-
- ) : (
-
- )}
-
- {!projectId && (
- }
- variant="contained"
- sx={{ mt: 2 }}
- onClick={() => navigate(-1)}
- >
- {tr('cancel')}
-
- )}
-
-
-
-
- {
- 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 ? (
-
- ) : (
-
- )}
-
- )}
-
{
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;
- }
- `}
- >
- }
- disabled={
- isLoading ||
- isError ||
- localUserPermissions.length === 0 ||
- diff(userPermissions, localUserPermissions).length === 0
- }
- onClick={() => setLocalUserPermissions([...(userPermissions ?? [])])}
- >
- {tr('genericForm.cancelAll')}
-
- }
- disabled={
- isLoading ||
- isError ||
- localUserPermissions.length === 0 ||
- diff(userPermissions, localUserPermissions).length === 0
- }
- onClick={() => handleUpdatePermissions()}
- >
- {tr('genericForm.saveChanges')}
-
-
@@ -232,4 +217,4 @@ export function ProjectPermissions({ projectId, ownerId }: Props) {