From 5bae6abe963ccbbfcd1be2224d9bc7ccb48ad997 Mon Sep 17 00:00:00 2001 From: Joona Laamanen Date: Thu, 16 Mar 2023 16:04:03 +0200 Subject: [PATCH 1/5] WIP --- .sqlfluff | 1 + README.md | 41 +++++- .../db_migrations/0039_project-investment.sql | 39 ++++++ backend/src/components/project/base.ts | 5 + backend/src/components/project/detailplan.ts | 7 +- backend/src/components/project/index.ts | 29 +---- .../project/{common.ts => investment.ts} | 31 ++--- .../src/components/report/projectReport.ts | 14 +- backend/src/components/taskQueue/index.ts | 2 +- backend/src/router/index.ts | 6 +- .../project/{common.ts => investment.ts} | 8 +- e2e/test/api/project.test.ts | 3 +- frontend/src/App.tsx | 17 ++- frontend/src/components/Fieldset.tsx | 25 ++++ .../components/forms/ProjectTypeSelect.tsx | 26 ++++ frontend/src/stores/search/project.ts | 3 +- frontend/src/types.d.ts | 2 +- .../DetailplanProject/DetailplanProject.tsx | 4 +- .../DetailplanProjectForm.tsx | 54 +++++++- .../DetailplanProjectNotification.tsx | 8 +- .../views/Project/DetailplanProjectSearch.tsx | 13 ++ .../{Project.tsx => InvestmentProject.tsx} | 14 +- ...jectForm.tsx => InvestmentProjectForm.tsx} | 49 +++---- .../views/Project/InvestmentProjectSearch.tsx | 13 ++ .../src/views/Project/ProjectFinances.tsx | 4 +- frontend/src/views/Project/Projects.tsx | 8 +- .../src/views/Project/RelationsContainer.tsx | 10 +- frontend/src/views/Project/SearchControls.tsx | 121 +++++++++--------- .../DeleteProjectObjectDialog.tsx | 4 +- .../src/views/ProjectObject/ProjectObject.tsx | 6 +- .../views/ProjectObject/ProjectObjectForm.tsx | 4 +- .../views/ProjectObject/ProjectObjectList.tsx | 4 +- shared/src/language/fi.json | 9 +- shared/src/schema/project/base.ts | 5 +- shared/src/schema/project/common.ts | 27 ---- shared/src/schema/project/index.ts | 2 - shared/src/schema/project/investment.ts | 23 ++++ shared/src/schema/project/type.ts | 3 + 38 files changed, 408 insertions(+), 236 deletions(-) create mode 100644 backend/db_migrations/0039_project-investment.sql rename backend/src/components/project/{common.ts => investment.ts} (79%) rename backend/src/router/project/{common.ts => investment.ts} (65%) create mode 100644 frontend/src/components/Fieldset.tsx create mode 100644 frontend/src/components/forms/ProjectTypeSelect.tsx create mode 100644 frontend/src/views/Project/DetailplanProjectSearch.tsx rename frontend/src/views/Project/{Project.tsx => InvestmentProject.tsx} (95%) rename frontend/src/views/Project/{ProjectForm.tsx => InvestmentProjectForm.tsx} (86%) create mode 100644 frontend/src/views/Project/InvestmentProjectSearch.tsx delete mode 100644 shared/src/schema/project/common.ts create mode 100644 shared/src/schema/project/investment.ts create mode 100644 shared/src/schema/project/type.ts diff --git a/.sqlfluff b/.sqlfluff index a5af8bd6..31231ac5 100644 --- a/.sqlfluff +++ b/.sqlfluff @@ -1,5 +1,6 @@ [sqlfluff] exclude_rules = L031 +dialect = postgres [sqlfluff:rules] tab_space_size = 2 diff --git a/README.md b/README.md index 3ebf7a39..11b7104e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Hanna + Hanna is a project management application. It is designed and built initially for the needs of KAPA and KITIA, but with capabilities of expanding to other departments' needs in the future. Minimum implementation is planned to be finished by 2023 and the software with full functionality and integration list by July 2023. @@ -6,8 +7,8 @@ Minimum implementation is planned to be finished by 2023 and the software with f ## Development - Steps required when running for the first time - * `cp backend/.template.env backend/.env` - * See the `.template.env` file to check if you need to replace or fill some initial values + - `cp backend/.template.env backend/.env` + - See the `.template.env` file to check if you need to replace or fill some initial values When `backend/.env` is properly set, start the development by running: @@ -42,6 +43,38 @@ Web application is served at: https://localhost NOTE! Due to using https on development at localhost, Caddy Root Certificate need to be configured and trusted (only once) - After starting the Caddy proxy (via docker compose) for the first time, root certificate can be found from `PROJECT_ROOT/docker/proxy/data/caddy/pki/authorities/local/root.crt` -- MacOS users: ```open ./docker/proxy/data/caddy/pki/authorities/local/root.crt``` +- MacOS users: `open ./docker/proxy/data/caddy/pki/authorities/local/root.crt` - Open the root file in Keychain Access - - Find the Caddy Root CA using the search and right-clicking it, then select *Get Info* and expand the *Trust* section. Finally set SSL setting to *Always Trust* + - Find the Caddy Root CA using the search and right-clicking it, then select _Get Info_ and expand the _Trust_ section. Finally set SSL setting to _Always Trust_ + +## Hotfix releases + +### Releasing fixes existing on `main` + +1. Create a new branch off the latest release tag + > hotfix/_\_ +2. Cherry pick the hotfix commits onto the new branch + + ```sh + # Single commit + $ git cherry-pick + + # Merge commit + $ git cherry-pick -m 1 + ``` + +3. Create a release manually + - Target: the hotfix branch + - Tag: new patch version number (create on publish) + - Description: write manually or copy from the current release draft + +### Committing and deploying new fixes + +1. Create a new branch off the latest release tag + > hotfix/_\_ +2. Commit new fixes onto the branch +3. Create a release manually + - Target: the hotfix branch + - Tag: new patch version number (create on publish) + - Description: write manually or copy from the current release draft +4. Merge the hotfix branch back to `main` diff --git a/backend/db_migrations/0039_project-investment.sql b/backend/db_migrations/0039_project-investment.sql new file mode 100644 index 00000000..92e53dbd --- /dev/null +++ b/backend/db_migrations/0039_project-investment.sql @@ -0,0 +1,39 @@ +ALTER TABLE project_common RENAME TO project_investment; +ALTER TABLE project_investment DROP COLUMN project_type; + +-- Add new columns as optional +ALTER TABLE project +ADD COLUMN start_date date, +ADD COLUMN end_date date, +ADD COLUMN lifecycle_state code_id; + +-- Set default values +UPDATE project +SET start_date = current_date, + end_date = current_date, + lifecycle_state = ('HankkeenElinkaarentila', '01')::code_id; + +-- Override with existing values if found +UPDATE project p +SET start_date = pi.start_date, + end_date = pi.end_date, + lifecycle_state = pi.lifecycle_state +FROM project_investment AS pi +WHERE p.id = pi.id; + +-- Set the columns required +ALTER TABLE project +ALTER COLUMN start_date +SET NOT NULL, +ALTER COLUMN end_date +SET NOT NULL, +ALTER COLUMN lifecycle_state +SET NOT NULL; + +-- Remove the old columns +ALTER TABLE project_investment DROP COLUMN start_date, +DROP COLUMN end_date, +DROP COLUMN lifecycle_state; + +-- Remove old project type code list +DELETE FROM code WHERE (id) .code_list_id = 'HankeTyyppi'; diff --git a/backend/src/components/project/base.ts b/backend/src/components/project/base.ts index d589b6b4..4ac87aba 100644 --- a/backend/src/components/project/base.ts +++ b/backend/src/components/project/base.ts @@ -10,6 +10,8 @@ import { FormErrors, fieldError, hasErrors } from '@shared/formerror'; import { UpsertProject, projectIdSchema } from '@shared/schema/project/base'; import { User } from '@shared/schema/user'; +import { codeIdFragment } from '../code'; + async function upsertBaseProject( tx: DatabaseTransactionConnection, project: UpsertProject, @@ -18,6 +20,9 @@ async function upsertBaseProject( const data = { project_name: project.projectName, description: project.description, + start_date: project.startDate, + end_date: project.endDate, + lifecycle_state: codeIdFragment('HankkeenElinkaarentila', project.lifecycleState), owner: project.owner, sap_project_id: project.sapProjectId, updated_by: userId, diff --git a/backend/src/components/project/detailplan.ts b/backend/src/components/project/detailplan.ts index 9a6e23dc..40ee2eda 100644 --- a/backend/src/components/project/detailplan.ts +++ b/backend/src/components/project/detailplan.ts @@ -16,9 +16,12 @@ const selectProjectFragment = sql.fragment` project.id, project_name AS "projectName", description, + project.start_date AS "startDate", + project.end_date AS "endDate", owner, created_at AS "createdAt", ST_AsGeoJSON(ST_CollectionExtract(geom)) AS geom, + (project.lifecycle_state).id AS "lifecycleState", sap_project_id AS "sapProjectId", diary_id AS "diaryId", diary_date AS "diaryDate", @@ -64,7 +67,7 @@ export async function projectUpsert(project: DetailplanProject, user: User) { return await getPool().transaction(async (tx) => { const id = await baseProjectUpsert(tx, project, user); await addAuditEvent(tx, { - eventType: 'projectDetailplan.upsertProject', + eventType: 'detailplanProject.upsertProject', eventData: project, eventUser: user.id, }); @@ -108,6 +111,6 @@ export async function projectUpsert(project: DetailplanProject, user: User) { } export async function validateUpsertProject(input: DetailplanProject) { - // !FIXME: implement, first validate base project, then common project + // !FIXME: implement, first validate base project, then detailplan project return { errors: {} }; } diff --git a/backend/src/components/project/index.ts b/backend/src/components/project/index.ts index 5c20eb1d..cb1a1935 100644 --- a/backend/src/components/project/index.ts +++ b/backend/src/components/project/index.ts @@ -36,7 +36,7 @@ function timePeriodFragment(input: ProjectSearch) { const endDate = input.dateRange?.endDate; if (startDate && endDate) { return sql.fragment` - daterange(project_common.start_date, project_common.end_date, '[]') && daterange(${startDate}, ${endDate}, '[]') + daterange(project.start_date, project.end_date, '[]') && daterange(${startDate}, ${endDate}, '[]') `; } return sql.fragment`true`; @@ -65,7 +65,7 @@ function orderByFragment(input: ProjectSearch) { if (input?.text && input.text.trim().length > 0) { return sql.fragment`ORDER BY ts_rank(tsv, to_tsquery('simple', ${input.text})) DESC`; } - return sql.fragment`ORDER BY project_common.start_date DESC`; + return sql.fragment`ORDER BY project.start_date DESC`; } export function getFilterFragment(input: ProjectSearch) { @@ -75,27 +75,13 @@ export function getFilterFragment(input: ProjectSearch) { AND ${timePeriodFragment(input)} AND ${ input.lifecycleStates && input.lifecycleStates?.length > 0 - ? sql.fragment`(project_common.lifecycle_state).id = ANY(${sql.array( + ? sql.fragment`(project.lifecycle_state).id = ANY(${sql.array( input.lifecycleStates, 'text' )}) ` : sql.fragment`true` } - AND ${ - input.projectTypes && input.projectTypes?.length > 0 - ? sql.fragment`(project_type).id = ANY(${sql.array(input.projectTypes, 'text')}) - ` - : sql.fragment`true` - } - AND ${ - input.committees && input.committees.length > 0 - ? sql.fragment`project.id IN ( - SELECT project_id FROM app.project_committee - WHERE (committee_type).id = ANY(${sql.array(input.committees, 'text')}) - )` - : sql.fragment`true` - } ${orderByFragment(input)} `; } @@ -251,23 +237,18 @@ function costEstimateWhereFragment(costEstimateInput: CostEstimatesInput) { `; } -// !FIXME: This should not depend on the project common schema const searchProjectFragment = sql.fragment` SELECT project.id, project_name AS "projectName", description, owner, - person_in_charge AS "personInCharge", start_date AS "startDate", end_date AS "endDate", geohash, ST_AsGeoJSON(ST_CollectionExtract(geom)) AS geom, - (lifecycle_state).id AS "lifecycleState", - (project_type).id AS "projectType", - sap_project_id AS "sapProjectId" - FROM app.project_common - INNER JOIN app.project ON project.id = project_common.id + (lifecycle_state).id AS "lifecycleState" + FROM app.project WHERE deleted = false `; diff --git a/backend/src/components/project/common.ts b/backend/src/components/project/investment.ts similarity index 79% rename from backend/src/components/project/common.ts rename to backend/src/components/project/investment.ts index 91f0d4ae..b9d442c1 100644 --- a/backend/src/components/project/common.ts +++ b/backend/src/components/project/investment.ts @@ -8,35 +8,34 @@ import { baseProjectUpsert } from '@backend/components/project/base'; import { getPool, sql } from '@backend/db'; import { projectIdSchema } from '@shared/schema/project/base'; -import { CommonProject, dbCommonProjectSchema } from '@shared/schema/project/common'; +import { InvestmentProject, dbInvestmentProjectSchema } from '@shared/schema/project/investment'; import { User } from '@shared/schema/user'; const selectProjectFragment = sql.fragment` SELECT - project_common.id, + project_investment.id, project.id AS "parentId", project_name AS "projectName", description, owner, person_in_charge AS "personInCharge", - start_date AS "startDate", - end_date AS "endDate", + project.start_date AS "startDate", + project.end_date AS "endDate", geohash, ST_AsGeoJSON(ST_CollectionExtract(geom)) AS geom, - (lifecycle_state).id AS "lifecycleState", - (project_type).id AS "projectType", + (project.lifecycle_state).id AS "lifecycleState", sap_project_id AS "sapProjectId" FROM app.project - LEFT JOIN app.project_common ON project_common.id = project.id + LEFT JOIN app.project_investment ON project_investment.id = project.id WHERE deleted = false `; export async function getProject(id: string, tx?: DatabaseTransactionConnection) { const conn = tx ?? getPool(); - const project = await conn.maybeOne(sql.type(dbCommonProjectSchema)` + const project = await conn.maybeOne(sql.type(dbInvestmentProjectSchema)` ${selectProjectFragment} - AND project_common.id = ${id} + AND project_investment.id = ${id} `); if (!project) throw new TRPCError({ code: 'NOT_FOUND' }); @@ -50,21 +49,17 @@ export async function getProject(id: string, tx?: DatabaseTransactionConnection) return { ...project, committees: committees.map(({ id }) => id) }; } -export async function projectUpsert(project: CommonProject, user: User) { +export async function projectUpsert(project: InvestmentProject, user: User) { return getPool().transaction(async (tx) => { const id = await baseProjectUpsert(tx, project, user); await addAuditEvent(tx, { - eventType: 'projectCommon.upsertProject', + eventType: 'investmentProject.upsertProject', eventData: project, eventUser: user.id, }); const data = { id, - start_date: project.startDate, - end_date: project.endDate, - lifecycle_state: codeIdFragment('HankkeenElinkaarentila', project.lifecycleState), - project_type: codeIdFragment('HankeTyyppi', project.projectType), person_in_charge: project.personInCharge, }; @@ -73,13 +68,13 @@ export async function projectUpsert(project: CommonProject, user: User) { const upsertResult = project.id ? await tx.one(sql.type(projectIdSchema)` - UPDATE app.project_common + UPDATE app.project_investment SET (${sql.join(identifiers, sql.fragment`,`)}) = (${sql.join(values, sql.fragment`,`)}) WHERE id = ${project.id} RETURNING id `) : await tx.one(sql.type(projectIdSchema)` - INSERT INTO app.project_common (${sql.join(identifiers, sql.fragment`,`)}) + INSERT INTO app.project_investment (${sql.join(identifiers, sql.fragment`,`)}) VALUES (${sql.join(values, sql.fragment`,`)}) RETURNING id `); @@ -105,7 +100,7 @@ export async function projectUpsert(project: CommonProject, user: User) { }); } -export async function validateUpsertProject(input: CommonProject) { +export async function validateUpsertProject(input: InvestmentProject) { // !FIXME: implement, first validate base project, then common project /* if (values?.id) { diff --git a/backend/src/components/report/projectReport.ts b/backend/src/components/report/projectReport.ts index c97f465f..baa990d2 100644 --- a/backend/src/components/report/projectReport.ts +++ b/backend/src/components/report/projectReport.ts @@ -17,13 +17,12 @@ const projectReportFragment = sql.fragment` project.id AS "projectId", project.description AS "projectDescription", project.created_at AS "projectCreatedAt", - project_common.start_date AS "projectStartDate", - project_common.end_date AS "projectEndDate", - (SELECT text_fi FROM app.code WHERE id = project_common.lifecycle_state) AS "projectLifecycleState", - (SELECT text_fi FROM app.code WHERE id = project_common.project_type) AS "projectType", + project.start_date AS "projectStartDate", + project.end_date AS "projectEndDate", + (SELECT text_fi FROM app.code WHERE id = project.lifecycle_state) AS "projectLifecycleState", project.sap_project_id AS "projectSAPProjectId", (SELECT email FROM app.user WHERE id = project.owner) AS "projectOwnerEmail", - (SELECT email FROM app.user WHERE id = project_common.person_in_charge) AS "projectPersonInChargeEmail", + (SELECT email FROM app.user WHERE id = project_investment.person_in_charge) AS "projectPersonInChargeEmail", project_object.id AS "projectObjectId", project_object.object_name AS "projectObjectName", project_object.description AS "projectObjectDescription", @@ -38,8 +37,8 @@ const projectReportFragment = sql.fragment` (SELECT text_fi FROM app.code WHERE id = project_object.landownership) AS "projectObjectLandownership", (SELECT text_fi FROM app.code WHERE id = project_object.location_on_property) AS "projectObjectLocationOnProperty", project_object.sap_wbs_id AS "projectObjectSAPWBSId" - FROM app.project_common - INNER JOIN app.project ON (project_common.id = project.id AND project.deleted IS FALSE) + FROM app.project_investment + INNER JOIN app.project ON (project_investment.id = project.id AND project.deleted IS FALSE) LEFT JOIN app.project_object ON (project.id = project_object.project_id AND project_object.deleted IS FALSE) `; @@ -51,7 +50,6 @@ const reportRowSchema = z.object({ projectStartDate: dateStringSchema, projectEndDate: dateStringSchema, projectLifecycleState: z.string(), - projectType: z.string(), projectSAPProjectId: z.string().nullish(), projectOwnerEmail: z.string(), projectPersonInChargeEmail: z.string(), diff --git a/backend/src/components/taskQueue/index.ts b/backend/src/components/taskQueue/index.ts index ab62f8b4..c8d40175 100644 --- a/backend/src/components/taskQueue/index.ts +++ b/backend/src/components/taskQueue/index.ts @@ -15,7 +15,7 @@ function assertTaskQueueInitialized(boss: PgBoss | null): asserts boss is PgBoss export async function initializeTaskQueue() { boss = new PgBoss(connectionDsn); - boss.on('error', (error) => logger.error(`Error in task queue: ${error}`)); + boss.on('error', (error) => logger.error(`Error in task queue: ${JSON.stringify(error)}`)); await boss.start(); } diff --git a/backend/src/router/index.ts b/backend/src/router/index.ts index 76b5280f..12cae5ef 100644 --- a/backend/src/router/index.ts +++ b/backend/src/router/index.ts @@ -6,8 +6,8 @@ import { logger } from '@backend/logging'; import { createCodeRouter } from '@backend/router/code'; import { createContractorRouter } from '@backend/router/contractor'; import { createProjectRouter } from '@backend/router/project/base'; -import { createCommonProjectRouter } from '@backend/router/project/common'; import { createDetailplanProjectRouter } from '@backend/router/project/detailplan'; +import { createInvestmentProjectRouter } from '@backend/router/project/investment'; import { createProjectObjectRouter } from '@backend/router/projectObject'; import { createSapRouter } from '@backend/router/sap'; import { createTaskRouter } from '@backend/router/task'; @@ -38,8 +38,8 @@ const t = initTRPC.context().create({ export const appRouter = t.router({ project: createProjectRouter(t), - projectCommon: createCommonProjectRouter(t), - projectDetailplan: createDetailplanProjectRouter(t), + investmentProject: createInvestmentProjectRouter(t), + detailplanProject: createDetailplanProjectRouter(t), projectObject: createProjectObjectRouter(t), code: createCodeRouter(t), sap: createSapRouter(t), diff --git a/backend/src/router/project/common.ts b/backend/src/router/project/investment.ts similarity index 65% rename from backend/src/router/project/common.ts rename to backend/src/router/project/investment.ts index 71aa48d6..fece0e00 100644 --- a/backend/src/router/project/common.ts +++ b/backend/src/router/project/investment.ts @@ -4,19 +4,19 @@ import { getProject, projectUpsert, validateUpsertProject, -} from '@backend/components/project/common'; +} from '@backend/components/project/investment'; import { TRPC } from '@backend/router'; import { projectIdSchema } from '@shared/schema/project/base'; -import { commonProjectSchema } from '@shared/schema/project/common'; +import { investmentProjectSchema } from '@shared/schema/project/investment'; -export const createCommonProjectRouter = (t: TRPC) => +export const createInvestmentProjectRouter = (t: TRPC) => t.router({ upsertValidate: t.procedure.input(z.any()).query(async ({ input }) => { return validateUpsertProject(input); }), - upsert: t.procedure.input(commonProjectSchema).mutation(async ({ input, ctx }) => { + upsert: t.procedure.input(investmentProjectSchema).mutation(async ({ input, ctx }) => { return projectUpsert(input, ctx.user); }), diff --git a/e2e/test/api/project.test.ts b/e2e/test/api/project.test.ts index b099067c..4c967967 100644 --- a/e2e/test/api/project.test.ts +++ b/e2e/test/api/project.test.ts @@ -25,7 +25,7 @@ test.describe('Project endpoints', () => { // There should be at least one user because this is executed after login const [user] = await client.user.getAll.query(); - const project = await client.projectCommon.upsert.mutate({ + const project = await client.investmentProject.upsert.mutate({ projectName: 'Test project', description: 'Test description', owner: user.id, @@ -33,7 +33,6 @@ test.describe('Project endpoints', () => { startDate: '2021-01-01', endDate: '2022-01-01', lifecycleState: '01', - projectType: '01', committees: ['01'], sapProjectId: null, }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e7871e8..aedcea0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { Layout } from '@frontend/Layout'; import { DetailplanProject } from '@frontend/views/DetailplanProject/DetailplanProject'; import { Management } from '@frontend/views/Management'; import { NotFound } from '@frontend/views/NotFound'; -import { Project } from '@frontend/views/Project/Project'; +import { InvestmentProject } from '@frontend/views/Project/InvestmentProject'; import { ProjectsPage } from '@frontend/views/Project/Projects'; import { ProjectObject } from '@frontend/views/ProjectObject/ProjectObject'; import { SapDebugView } from '@frontend/views/SapDebug'; @@ -39,17 +39,20 @@ const router = createBrowserRouter( }> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } + /> + } /> } + element={} /> } + element={} /> } /> } /> diff --git a/frontend/src/components/Fieldset.tsx b/frontend/src/components/Fieldset.tsx new file mode 100644 index 00000000..f26852b5 --- /dev/null +++ b/frontend/src/components/Fieldset.tsx @@ -0,0 +1,25 @@ +import { Typography, css } from '@mui/material'; +import { ReactNode } from 'react'; + +interface Props { + legend?: string; + children?: ReactNode; +} + +export function Fieldset({ legend, children }: Props) { + return ( +
+ {legend && ( + + {legend} + + )} + {children} +
+ ); +} diff --git a/frontend/src/components/forms/ProjectTypeSelect.tsx b/frontend/src/components/forms/ProjectTypeSelect.tsx new file mode 100644 index 00000000..61f3f797 --- /dev/null +++ b/frontend/src/components/forms/ProjectTypeSelect.tsx @@ -0,0 +1,26 @@ +import { useTranslations } from '@frontend/stores/lang'; + +import { ProjectType, projectTypes } from '@shared/schema/project/type'; + +import { MultiSelect } from './MultiSelect'; + +interface Props { + id?: string; + value: ProjectType[]; + onChange: (value: ProjectType[]) => void; +} + +export function ProjectTypeSelect({ id, value, onChange }: Props) { + const tr = useTranslations(); + return ( + tr(`projectType.${value}`)} + getOptionId={(code) => String(code)} + value={value} + onChange={onChange} + multiple + /> + ); +} diff --git a/frontend/src/stores/search/project.ts b/frontend/src/stores/search/project.ts index 98605c98..821886b1 100644 --- a/frontend/src/stores/search/project.ts +++ b/frontend/src/stores/search/project.ts @@ -5,6 +5,7 @@ import { mapOptions } from '@frontend/components/Map/mapOptions'; import { unwrapAtomSetters, unwrapAtomValues } from '@frontend/utils/atom'; import { MapSearch, Period } from '@shared/schema/project'; +import { ProjectType } from '@shared/schema/project/type'; export const projectSearchParamAtoms = { text: atom(''), @@ -13,7 +14,7 @@ export const projectSearchParamAtoms = { endDate: dayjs().endOf('year').format('YYYY-MM-DD'), }), lifecycleStates: atom([]), - projectTypes: atom([]), + projectTypes: atom([]), committees: atom([]), map: atom({ zoom: mapOptions.tre.defaultZoom, diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 80624dda..3c5266e7 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -1 +1 @@ -export type ProjectType = 'hanke' | 'asemakaavahanke'; +export type ProjectTypePath = 'investointihanke' | 'asemakaavahanke'; diff --git a/frontend/src/views/DetailplanProject/DetailplanProject.tsx b/frontend/src/views/DetailplanProject/DetailplanProject.tsx index 3a2c2d70..59d80a1e 100644 --- a/frontend/src/views/DetailplanProject/DetailplanProject.tsx +++ b/frontend/src/views/DetailplanProject/DetailplanProject.tsx @@ -77,9 +77,9 @@ export function DetailplanProject() { const projectId = routeParams?.projectId; const tabIndex = tabs.findIndex((tab) => tab.tabView === tabView); - const project = trpc.projectDetailplan.get.useQuery( + const project = trpc.detailplanProject.get.useQuery( { id: projectId }, - { enabled: Boolean(projectId), queryKey: ['projectDetailplan.get', { id: projectId }] } + { enabled: Boolean(projectId), queryKey: ['detailplanProject.get', { id: projectId }] } ); const tr = useTranslations(); const notify = useNotifications(); diff --git a/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx b/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx index 8fab916a..6e9d85e5 100644 --- a/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx +++ b/frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx @@ -3,6 +3,7 @@ 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 { useQueryClient } from '@tanstack/react-query'; +import dayjs from 'dayjs'; import { useAtomValue } from 'jotai'; import { useEffect, useMemo, useState } from 'react'; import { FormProvider, ResolverOptions, useForm } from 'react-hook-form'; @@ -59,6 +60,9 @@ export function DetailplanProjectForm(props: Props) { owner: currentUser?.id, projectName: '', description: '', + startDate: '', + endDate: '', + lifecycleState: '01', sapProjectId: null, diaryId: '', diaryDate: null, @@ -77,7 +81,7 @@ export function DetailplanProjectForm(props: Props) { [currentUser] ); - const { projectDetailplan } = trpc.useContext(); + const { detailplanProject } = trpc.useContext(); const formValidator = useMemo(() => { const schemaValidation = zodResolver(detailplanProjectSchema); @@ -88,7 +92,7 @@ export function DetailplanProjectForm(props: Props) { ) { const fields = options.names ?? []; const isFormValidation = fields && fields.length > 1; - const serverErrors = isFormValidation ? projectDetailplan.upsertValidate.fetch(values) : null; + const serverErrors = isFormValidation ? detailplanProject.upsertValidate.fetch(values) : null; const shapeErrors = schemaValidation(values, context, options); const errors = await Promise.all([serverErrors, shapeErrors]); return { @@ -111,17 +115,17 @@ export function DetailplanProjectForm(props: Props) { form.reset(props.project ?? formDefaultValues); }, [props.project]); - const projectUpsert = trpc.projectDetailplan.upsert.useMutation({ + const projectUpsert = trpc.detailplanProject.upsert.useMutation({ onSuccess: (data) => { // Navigate to new url if we are creating a new project if (!props.project && data.id) { navigate(`/asemakaavahanke/${data.id}`); } else { queryClient.invalidateQueries({ - queryKey: [['projectDetailplan', 'get'], { input: { id: data.id } }], + queryKey: [['detailplanProject', 'get'], { input: { id: data.id } }], }); queryClient.invalidateQueries({ - queryKey: [['projectDetailplan', 'previewNotificationMail'], { input: { id: data.id } }], + queryKey: [['detailplanProject', 'previewNotificationMail'], { input: { id: data.id } }], }); setEditing(false); form.reset(data); @@ -192,6 +196,31 @@ export function DetailplanProjectForm(props: Props) { component={(field) => } /> + ( + + )} + /> + ( + + )} + /> + formField="owner" label={tr('project.ownerLabel')} @@ -210,6 +239,21 @@ export function DetailplanProjectForm(props: Props) { )} /> + ( + + )} + /> + formField="district" label={tr('detailplanProject.districtLabel')} diff --git a/frontend/src/views/DetailplanProject/DetailplanProjectNotification.tsx b/frontend/src/views/DetailplanProject/DetailplanProjectNotification.tsx index df6a5bb8..68b51237 100644 --- a/frontend/src/views/DetailplanProject/DetailplanProjectNotification.tsx +++ b/frontend/src/views/DetailplanProject/DetailplanProjectNotification.tsx @@ -16,19 +16,19 @@ export function DetailplanProjectNotification({ projectId }: Props) { const tr = useTranslations(); const notify = useNotifications(); - const { data: preview, isLoading } = trpc.projectDetailplan.previewNotificationMail.useQuery( + const { data: preview, isLoading } = trpc.detailplanProject.previewNotificationMail.useQuery( { id: projectId, }, { - queryKey: ['projectDetailplan.previewNotificationMail', { id: projectId }], + queryKey: ['detailplanProject.previewNotificationMail', { id: projectId }], } ); const { data: defaultRecipients, isLoading: defaultRecipientsLoading } = - trpc.projectDetailplan.getNotificationRecipients.useQuery(); + trpc.detailplanProject.getNotificationRecipients.useQuery(); const [recipients, setRecipients] = useState([]); const [recipientInputValue, setRecipientInputValue] = useState(''); - const sendNotificationMail = trpc.projectDetailplan.sendNotificationMail.useMutation(); + const sendNotificationMail = trpc.detailplanProject.sendNotificationMail.useMutation(); // Set default recipients once after they're loaded useEffect(() => { diff --git a/frontend/src/views/Project/DetailplanProjectSearch.tsx b/frontend/src/views/Project/DetailplanProjectSearch.tsx new file mode 100644 index 00000000..b01caf9c --- /dev/null +++ b/frontend/src/views/Project/DetailplanProjectSearch.tsx @@ -0,0 +1,13 @@ +import { TextField } from '@mui/material'; + +import { Fieldset } from '@frontend/components/Fieldset'; +import { useTranslations } from '@frontend/stores/lang'; + +export function DetailplanProjectSearch() { + const tr = useTranslations(); + return ( +
+ +
+ ); +} diff --git a/frontend/src/views/Project/Project.tsx b/frontend/src/views/Project/InvestmentProject.tsx similarity index 95% rename from frontend/src/views/Project/Project.tsx rename to frontend/src/views/Project/InvestmentProject.tsx index 04901bde..e6078bd0 100644 --- a/frontend/src/views/Project/Project.tsx +++ b/frontend/src/views/Project/InvestmentProject.tsx @@ -17,11 +17,9 @@ import { useTranslations } from '@frontend/stores/lang'; import { ProjectRelations } from '@frontend/views/Project/ProjectRelations'; import { ProjectObjectList } from '@frontend/views/ProjectObject/ProjectObjectList'; -import { DbCommonProject } from '@shared/schema/project/common'; - import { DeleteProjectDialog } from './DeleteProjectDialog'; +import { InvestmentProjectForm } from './InvestmentProjectForm'; import { ProjectFinances } from './ProjectFinances'; -import { ProjectForm } from './ProjectForm'; const pageContentStyle = css` display: grid; @@ -66,15 +64,15 @@ function projectTabs(projectId: string) { ] as const; } -export function Project() { +export function InvestmentProject() { const routeParams = useParams() as { projectId: string; tabView?: TabView }; const tabView = routeParams.tabView || 'default'; const tabs = projectTabs(routeParams.projectId); const tabIndex = tabs.findIndex((tab) => tab.tabView === tabView); const projectId = routeParams?.projectId; - const project = trpc.projectCommon.get.useQuery( + const project = trpc.investmentProject.get.useQuery( { id: projectId }, - { enabled: Boolean(projectId), queryKey: ['projectCommon.get', { id: projectId }] } + { enabled: Boolean(projectId), queryKey: ['investmentProject.get', { id: projectId }] } ); const tr = useTranslations(); @@ -156,7 +154,7 @@ export function Project() {
- + {project.data && } @@ -206,7 +204,7 @@ export function Project() { {routeParams.tabView === 'talous' && } {routeParams.tabView === 'kohteet' && ( - + )} {routeParams.tabView === 'sidoshankkeet' && ( diff --git a/frontend/src/views/Project/ProjectForm.tsx b/frontend/src/views/Project/InvestmentProjectForm.tsx similarity index 86% rename from frontend/src/views/Project/ProjectForm.tsx rename to frontend/src/views/Project/InvestmentProjectForm.tsx index 09f2a1a2..af07909e 100644 --- a/frontend/src/views/Project/ProjectForm.tsx +++ b/frontend/src/views/Project/InvestmentProjectForm.tsx @@ -19,18 +19,22 @@ import { useTranslations } from '@frontend/stores/lang'; import { getRequiredFields } from '@frontend/utils/form'; import { mergeErrors } from '@shared/formerror'; -import { CommonProject, DbCommonProject, commonProjectSchema } from '@shared/schema/project/common'; +import { + DbInvestmentProject, + InvestmentProject, + investmentProjectSchema, +} from '@shared/schema/project/investment'; const newProjectFormStyle = css` display: grid; margin-top: 16px; `; -interface ProjectFormProps { - project?: DbCommonProject | null; +interface InvestmentProjectFormProps { + project?: DbInvestmentProject | null; } -export function ProjectForm(props: ProjectFormProps) { +export function InvestmentProjectForm(props: InvestmentProjectFormProps) { const tr = useTranslations(); const notify = useNotifications(); const queryClient = useQueryClient(); @@ -49,7 +53,7 @@ export function ProjectForm(props: ProjectFormProps) { } as const; }, [editing]); - const formDefaultValues = useMemo>( + const formDefaultValues = useMemo>( () => ({ owner: currentUser?.id, personInCharge: currentUser?.id, @@ -63,32 +67,32 @@ export function ProjectForm(props: ProjectFormProps) { [currentUser] ); - const { projectCommon } = trpc.useContext(); + const { investmentProject } = trpc.useContext(); const formValidator = useMemo(() => { - const schemaValidation = zodResolver(commonProjectSchema); + const schemaValidation = zodResolver(investmentProjectSchema); return async function formValidation( - values: CommonProject, + values: InvestmentProject, context: any, - options: ResolverOptions + options: ResolverOptions ) { const fields = options.names ?? []; const isFormValidation = fields && fields.length > 1; - const serverErrors = isFormValidation ? projectCommon.upsertValidate.fetch(values) : null; + const serverErrors = isFormValidation ? investmentProject.upsertValidate.fetch(values) : null; const shapeErrors = schemaValidation(values, context, options); const errors = await Promise.all([serverErrors, shapeErrors]); return { values, - errors: mergeErrors(errors).errors, + errors: mergeErrors(errors).errors, }; }; }, []); - const form = useForm({ + const form = useForm({ mode: 'all', resolver: formValidator, context: { - requiredFields: getRequiredFields(commonProjectSchema), + requiredFields: getRequiredFields(investmentProjectSchema), }, defaultValues: props.project ?? formDefaultValues, }); @@ -97,7 +101,7 @@ export function ProjectForm(props: ProjectFormProps) { form.reset(props.project ?? formDefaultValues); }, [props.project]); - const projectUpsert = trpc.projectCommon.upsert.useMutation({ + const projectUpsert = trpc.investmentProject.upsert.useMutation({ onSuccess: (data) => { // Navigate to new url if we are creating a new project if (!props.project && data.id) { @@ -123,7 +127,7 @@ export function ProjectForm(props: ProjectFormProps) { }, }); - const onSubmit = (data: CommonProject | DbCommonProject) => projectUpsert.mutate(data); + const onSubmit = (data: InvestmentProject | DbInvestmentProject) => projectUpsert.mutate(data); return ( @@ -231,21 +235,6 @@ export function ProjectForm(props: ProjectFormProps) { )} /> - ( - - )} - /> - + + + ); +} diff --git a/frontend/src/views/Project/ProjectFinances.tsx b/frontend/src/views/Project/ProjectFinances.tsx index 49474581..6870a1bc 100644 --- a/frontend/src/views/Project/ProjectFinances.tsx +++ b/frontend/src/views/Project/ProjectFinances.tsx @@ -6,12 +6,12 @@ import { useNotifications } from '@frontend/services/notification'; import { useTranslations } from '@frontend/stores/lang'; import { getRange } from '@frontend/utils/array'; -import { DbCommonProject } from '@shared/schema/project/common'; +import { DbInvestmentProject } from '@shared/schema/project/investment'; import { CostEstimatesTable } from './CostEstimatesTable'; interface Props { - project?: DbCommonProject | null; + project?: DbInvestmentProject | null; } export function ProjectFinances(props: Props) { diff --git a/frontend/src/views/Project/Projects.tsx b/frontend/src/views/Project/Projects.tsx index bf1340be..b5d26128 100644 --- a/frontend/src/views/Project/Projects.tsx +++ b/frontend/src/views/Project/Projects.tsx @@ -24,7 +24,7 @@ import { getProjectSearchParams } from '@frontend/stores/search/project'; import { useDebounce } from '@frontend/utils/useDebounce'; import { ResultsMap } from '@frontend/views/Project/ResultsMap'; -import { DbCommonProject } from '@shared/schema/project/common'; +import { DbInvestmentProject } from '@shared/schema/project/investment'; import { SearchControls } from './SearchControls'; @@ -70,7 +70,7 @@ function Toolbar() { - {tr('newProject.newProject')} + {tr('newProject.newInvestmentProject')} @@ -95,7 +95,7 @@ const projectCardStyle = css` transition: background 0.5s; `; -function ProjectCard({ result }: { result: DbCommonProject }) { +function ProjectCard({ result }: { result: DbInvestmentProject }) { const tr = useTranslations(); return ( @@ -116,7 +116,7 @@ function ProjectCard({ result }: { result: DbCommonProject }) { } interface SearchResultsProps { - results: readonly DbCommonProject[]; + results: readonly DbInvestmentProject[]; loading?: boolean; } diff --git a/frontend/src/views/Project/RelationsContainer.tsx b/frontend/src/views/Project/RelationsContainer.tsx index 8ea655ed..c82d9766 100644 --- a/frontend/src/views/Project/RelationsContainer.tsx +++ b/frontend/src/views/Project/RelationsContainer.tsx @@ -10,7 +10,7 @@ import { useTranslations } from '@frontend/stores/lang'; import { useDebounce } from '@frontend/utils/useDebounce'; import { ProjectRelation, Relation } from '@shared/schema/project'; -import { DbCommonProject } from '@shared/schema/project/common'; +import { DbInvestmentProject } from '@shared/schema/project/investment'; const rowStyle = css` display: flex; @@ -96,7 +96,7 @@ export function RelationsContainer({ id="project-relation-search" options={ projects?.data?.projects.filter( - (project: DbCommonProject) => !unrelatableProjectIds.includes(project.id) + (project: DbInvestmentProject) => !unrelatableProjectIds.includes(project.id) ) ?? [] } noOptionsText={tr('projectRelations.noFoundProjects')} @@ -105,13 +105,13 @@ export function RelationsContainer({ )} size="small" - getOptionLabel={(option: DbCommonProject) => option.projectName} + getOptionLabel={(option: DbInvestmentProject) => option.projectName} loading={projects.isLoading} - onChange={(event: React.SyntheticEvent, newValue: DbCommonProject | null) => { + onChange={(event: React.SyntheticEvent, newValue: DbInvestmentProject | null) => { setSelectedObjectProjectId(newValue?.id ?? null); }} value={projects?.data?.projects?.find( - (project: DbCommonProject) => project.id === selectedObjectProjectId + (project: DbInvestmentProject) => project.id === selectedObjectProjectId )} /> diff --git a/frontend/src/views/Project/SearchControls.tsx b/frontend/src/views/Project/SearchControls.tsx index 07896b6e..5dfd32cb 100644 --- a/frontend/src/views/Project/SearchControls.tsx +++ b/frontend/src/views/Project/SearchControls.tsx @@ -17,14 +17,17 @@ import { useState } from 'react'; import { CodeSelect } from '@frontend/components/forms/CodeSelect'; import { DateRange } from '@frontend/components/forms/DateRange'; +import { ProjectTypeSelect } from '@frontend/components/forms/ProjectTypeSelect'; import { useTranslations } from '@frontend/stores/lang'; import { getProjectSearchParamSetters, getProjectSearchParams, } from '@frontend/stores/search/project'; +import { DetailplanProjectSearch } from './DetailplanProjectSearch'; +import { InvestmentProjectSearch } from './InvestmentProjectSearch'; + const searchControlContainerStyle = css` - padding: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; @@ -74,68 +77,66 @@ export function SearchControls() { const setSearchParams = getProjectSearchParamSetters(); return ( - - - {tr('projectSearch.textSearchLabel')} - { - setSearchParams.text(event.currentTarget.value); - }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - + +
- {tr('projectSearch.dateRange')} - setSearchParams.dateRange(period)} - quickSelections={makeCalendarQuickSelections(tr)} + {tr('projectSearch.textSearchLabel')} + { + setSearchParams.text(event.currentTarget.value); + }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - - - {tr('project.lifecycleStateLabel')} - - - - {tr('project.projectTypeLabel')} - - - {expanded && ( - <> + - {tr('project.committeeLabel')} - {tr('projectSearch.dateRange')} + setSearchParams.dateRange(period)} + quickSelections={makeCalendarQuickSelections(tr)} /> + + + {tr('project.lifecycleStateLabel')} + + + + {tr('projectSearch.projectType')} + + +
+ {expanded && ( + <> {tr('projectSearch.geometry')} + {searchParams.projectTypes.includes('investmentProject') && } + {searchParams.projectTypes.includes('detailplanProject') && } )}
{expanded && ( @@ -142,17 +144,17 @@ export function SearchControls() { { - setSearchParams.includeWithoutGeom(checked); + setIncludeWithoutGeom(checked); }} /> } label={tr('projectSearch.showWithoutGeom')} /> - {searchParams.projectTypes.includes('investmentProject') && } - {searchParams.projectTypes.includes('detailplanProject') && } + {projectTypes.includes('investmentProject') && } + {projectTypes.includes('detailplanProject') && } )}