From 6d30e60dacb689de1affd8e432ee2904f4a69861 Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Fri, 3 May 2024 11:46:59 +0300 Subject: [PATCH 1/5] move: application page out of components --- apps/admin-ui/src/App.tsx | 2 +- .../src/component/applications/util.ts | 56 ------------------- apps/admin-ui/src/helpers/index.ts | 54 ++++++++++++++++++ .../applications/TimeSelector.tsx | 0 .../applications/ValueBox.tsx | 0 .../applications/[id]/index.tsx} | 8 +-- .../applications/queries.tsx | 0 .../[id]/allocation/AllocationCard.tsx | 2 +- .../[id]/allocation/ApplicationEventCard.tsx | 2 +- .../modules/applicationRoundAllocation.ts | 10 ---- .../[id]/review/AllocatedEventsTable.tsx | 3 +- .../[id]/review/ApplicationEventsTable.tsx | 3 +- .../[id]/review/ApplicationsTable.tsx | 3 +- .../[id]/review/StatusCell.tsx | 2 +- 14 files changed, 65 insertions(+), 80 deletions(-) delete mode 100644 apps/admin-ui/src/component/applications/util.ts rename apps/admin-ui/src/{component => spa}/applications/TimeSelector.tsx (100%) rename apps/admin-ui/src/{component => spa}/applications/ValueBox.tsx (100%) rename apps/admin-ui/src/{component/applications/ApplicationDetails.tsx => spa/applications/[id]/index.tsx} (98%) rename apps/admin-ui/src/{component => spa}/applications/queries.tsx (100%) diff --git a/apps/admin-ui/src/App.tsx b/apps/admin-ui/src/App.tsx index d18d868ab..4cc716ef3 100644 --- a/apps/admin-ui/src/App.tsx +++ b/apps/admin-ui/src/App.tsx @@ -34,7 +34,7 @@ const ResourceEditorView = dynamic( ); const ApplicationDetails = dynamic( - () => import("./component/applications/ApplicationDetails") + () => import("./spa/applications/[id]/index") ); const ReservationUnits = dynamic( diff --git a/apps/admin-ui/src/component/applications/util.ts b/apps/admin-ui/src/component/applications/util.ts deleted file mode 100644 index 9cf9d4e33..000000000 --- a/apps/admin-ui/src/component/applications/util.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - type ApplicationNode, - ApplicationSectionStatusChoice, - ApplicationStatusChoice, - ApplicantTypeChoice, -} from "common/types/gql-types"; - -export function getApplicantName(app: ApplicationNode): string { - if (app.applicantType === ApplicantTypeChoice.Individual) { - const { firstName, lastName } = app.contactPerson || {}; - return `${firstName || "-"} ${lastName || "-"}`; - } - return app.organisation?.name || "-"; -} - -export function getApplicationStatusColor( - status: ApplicationStatusChoice, - size: "s" | "l" -): string { - switch (status) { - case ApplicationStatusChoice.Handled: - return "var(--color-info)"; - case ApplicationStatusChoice.InAllocation: - return "var(--color-alert-dark)"; - case ApplicationStatusChoice.Received: - case ApplicationStatusChoice.Draft: - case ApplicationStatusChoice.ResultsSent: - return "var(--color-white)"; - case ApplicationStatusChoice.Expired: - case ApplicationStatusChoice.Cancelled: - default: - switch (size) { - case "s": - return "var(--color-error)"; - case "l": - default: - return "var(--color-error-dark)"; - } - } -} - -export function getApplicationSectiontatusColor( - status: ApplicationSectionStatusChoice -): string { - switch (status) { - case ApplicationSectionStatusChoice.Reserved: - case ApplicationSectionStatusChoice.Unallocated: - case ApplicationSectionStatusChoice.InAllocation: - return "var(--color-alert-dark)"; - case ApplicationSectionStatusChoice.Handled: - return "var(--color-success)"; - case ApplicationSectionStatusChoice.Failed: - default: - return "var(--color-error)"; - } -} diff --git a/apps/admin-ui/src/helpers/index.ts b/apps/admin-ui/src/helpers/index.ts index 6f166a12b..9205b365a 100644 --- a/apps/admin-ui/src/helpers/index.ts +++ b/apps/admin-ui/src/helpers/index.ts @@ -2,6 +2,10 @@ import { type ReservationNode, ReservationTypeChoice, + ApplicantTypeChoice, + type ApplicationNode, + ApplicationSectionStatusChoice, + ApplicationStatusChoice, } from "common/types/gql-types"; import { addSeconds } from "date-fns"; @@ -61,3 +65,53 @@ export const reservationToInterval = ( type: x.type ?? undefined, }; }; + +export function getApplicantName(app: ApplicationNode): string { + if (app.applicantType === ApplicantTypeChoice.Individual) { + const { firstName, lastName } = app.contactPerson || {}; + return `${firstName || "-"} ${lastName || "-"}`; + } + return app.organisation?.name || "-"; +} + +export function getApplicationStatusColor( + status: ApplicationStatusChoice, + size: "s" | "l" +): string { + switch (status) { + case ApplicationStatusChoice.Handled: + return "var(--color-info)"; + case ApplicationStatusChoice.InAllocation: + return "var(--color-alert-dark)"; + case ApplicationStatusChoice.Received: + case ApplicationStatusChoice.Draft: + case ApplicationStatusChoice.ResultsSent: + return "var(--color-white)"; + case ApplicationStatusChoice.Expired: + case ApplicationStatusChoice.Cancelled: + default: + switch (size) { + case "s": + return "var(--color-error)"; + case "l": + default: + return "var(--color-error-dark)"; + } + } +} + +export function getApplicationSectiontatusColor( + status: ApplicationSectionStatusChoice +): string { + switch (status) { + case ApplicationSectionStatusChoice.Reserved: + case ApplicationSectionStatusChoice.Unallocated: + case ApplicationSectionStatusChoice.InAllocation: + return "var(--color-alert-dark)"; + case ApplicationSectionStatusChoice.Handled: + return "var(--color-success)"; + case ApplicationSectionStatusChoice.Failed: + default: + return "var(--color-error)"; + } +} diff --git a/apps/admin-ui/src/component/applications/TimeSelector.tsx b/apps/admin-ui/src/spa/applications/TimeSelector.tsx similarity index 100% rename from apps/admin-ui/src/component/applications/TimeSelector.tsx rename to apps/admin-ui/src/spa/applications/TimeSelector.tsx diff --git a/apps/admin-ui/src/component/applications/ValueBox.tsx b/apps/admin-ui/src/spa/applications/ValueBox.tsx similarity index 100% rename from apps/admin-ui/src/component/applications/ValueBox.tsx rename to apps/admin-ui/src/spa/applications/ValueBox.tsx diff --git a/apps/admin-ui/src/component/applications/ApplicationDetails.tsx b/apps/admin-ui/src/spa/applications/[id]/index.tsx similarity index 98% rename from apps/admin-ui/src/component/applications/ApplicationDetails.tsx rename to apps/admin-ui/src/spa/applications/[id]/index.tsx index f37d7aff1..72f3e94da 100644 --- a/apps/admin-ui/src/component/applications/ApplicationDetails.tsx +++ b/apps/admin-ui/src/spa/applications/[id]/index.tsx @@ -35,10 +35,10 @@ import StickyHeader from "@/component/StickyHeader"; import StatusBlock from "@/component/StatusBlock"; import { BirthDate } from "@/component/BirthDate"; import { Container } from "@/styles/layout"; -import { ValueBox } from "./ValueBox"; -import { getApplicantName, getApplicationStatusColor } from "./util"; -import { TimeSelector } from "./TimeSelector"; -import { APPLICATION_ADMIN_QUERY } from "./queries"; +import { ValueBox } from "../ValueBox"; +import { TimeSelector } from "../TimeSelector"; +import { APPLICATION_ADMIN_QUERY } from "../queries"; +import { getApplicantName, getApplicationStatusColor } from "@/helpers"; function printSuitableTimes( timeRanges: SuitableTimeRangeNode[], diff --git a/apps/admin-ui/src/component/applications/queries.tsx b/apps/admin-ui/src/spa/applications/queries.tsx similarity index 100% rename from apps/admin-ui/src/component/applications/queries.tsx rename to apps/admin-ui/src/spa/applications/queries.tsx diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/AllocationCard.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/AllocationCard.tsx index 1b40aaa8c..39f581054 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/AllocationCard.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/AllocationCard.tsx @@ -15,7 +15,6 @@ import { filterNonNullable } from "common/src/helpers"; import { NotificationInline } from "common/src/components/NotificationInline"; import { convertWeekday } from "common/src/conversion"; import { SemiBold } from "common"; -import { getApplicantName } from "@/component/applications/util"; import { formatDuration } from "@/common/util"; import { Accordion } from "@/component/Accordion"; import { @@ -30,6 +29,7 @@ import { useRefreshApplications, useRemoveAllocation, } from "./hooks"; +import { getApplicantName } from "@/helpers"; type Props = { applicationSection: ApplicationSectionNode; diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/ApplicationEventCard.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/ApplicationEventCard.tsx index c9e75329c..781057987 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/ApplicationEventCard.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/ApplicationEventCard.tsx @@ -11,7 +11,6 @@ import type { ReservationUnitNode, } from "common/types/gql-types"; import { SemiBold, fontMedium } from "common"; -import { getApplicantName } from "@/component/applications/util"; import { ageGroup } from "@/component/reservations/requested/util"; import { filterNonNullable } from "common/src/helpers"; import { convertWeekday } from "common/src/conversion"; @@ -25,6 +24,7 @@ import { type ApolloQueryResult, useMutation } from "@apollo/client"; import { getApplicationSectionUrl } from "@/common/urls"; import { useNotification } from "@/context/NotificationContext"; import { UPDATE_RESERVATION_UNIT_OPTION } from "./queries"; +import { getApplicantName } from "@/helpers"; export type AllocationApplicationSectionCardType = | "unallocated" diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/modules/applicationRoundAllocation.ts b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/modules/applicationRoundAllocation.ts index 089739ac9..fe8214f84 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/modules/applicationRoundAllocation.ts +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/allocation/modules/applicationRoundAllocation.ts @@ -2,8 +2,6 @@ import { padStart, sortBy } from "lodash"; import { type SuitableTimeRangeNode, type ApplicationSectionNode, - ApplicantTypeChoice, - type ApplicationNode, Priority, type AllocatedTimeSlotNode, Weekday, @@ -154,14 +152,6 @@ export const getTimeSeries = ( return timeSlots; }; -export function getApplicantName( - application: ApplicationNode | undefined -): string { - return application?.applicantType === ApplicantTypeChoice.Individual - ? `${application?.contactPerson?.firstName} ${application?.contactPerson?.lastName}`.trim() - : application?.user?.name || ""; -} - // TODO is this parse? or format? it looks like a format function formatTimeRange(range: SuitableTimeRangeNode): string { // TODO convert the day of the week diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/AllocatedEventsTable.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/AllocatedEventsTable.tsx index 0037b013d..8d2092641 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/AllocatedEventsTable.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/AllocatedEventsTable.tsx @@ -6,10 +6,9 @@ import { IconLinkExternal } from "hds-react"; import type { AllocatedTimeSlotNode } from "common/types/gql-types"; import { convertWeekday } from "common/src/conversion"; import { PUBLIC_URL } from "@/common/const"; -import { truncate } from "@/helpers"; +import { getApplicantName, truncate } from "@/helpers"; import { applicationDetailsUrl } from "@/common/urls"; import { CustomTable, ExternalTableLink } from "@/component/Table"; -import { getApplicantName } from "@/component/applications/util"; import { TimeSlotStatusCell } from "./StatusCell"; const unitsTruncateLen = 23; diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationEventsTable.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationEventsTable.tsx index 54063afc4..e01f40da4 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationEventsTable.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationEventsTable.tsx @@ -5,9 +5,8 @@ import { memoize, orderBy, uniqBy } from "lodash"; import { IconLinkExternal } from "hds-react"; import type { ApplicationSectionNode } from "common/types/gql-types"; import { MAX_APPLICATION_ROUND_NAME_LENGTH, PUBLIC_URL } from "@/common/const"; -import { truncate } from "@/helpers"; +import { getApplicantName, truncate } from "@/helpers"; import { applicationDetailsUrl } from "@/common/urls"; -import { getApplicantName } from "@/component/applications/util"; import { CustomTable, ExternalTableLink } from "@/component/Table"; import { ApplicationSectionStatusCell } from "./StatusCell"; import { diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationsTable.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationsTable.tsx index 2762d4625..6e852befa 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationsTable.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/ApplicationsTable.tsx @@ -10,8 +10,7 @@ import type { import { filterNonNullable } from "common/src/helpers"; import { PUBLIC_URL } from "@/common/const"; import { applicationDetailsUrl } from "@/common/urls"; -import { truncate } from "@/helpers"; -import { getApplicantName } from "@/component/applications/util"; +import { getApplicantName, truncate } from "@/helpers"; import { CustomTable, ExternalTableLink } from "@/component/Table"; import { ApplicationStatusCell } from "./StatusCell"; import { diff --git a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/StatusCell.tsx b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/StatusCell.tsx index fc8aaa785..5e9a654be 100644 --- a/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/StatusCell.tsx +++ b/apps/admin-ui/src/spa/recurring-reservations/application-rounds/[id]/review/StatusCell.tsx @@ -9,7 +9,7 @@ import { import { getApplicationSectiontatusColor, getApplicationStatusColor, -} from "@/component//applications/util"; +} from "@/helpers"; const dotCss = css` display: inline-block; From 2937cbdac07055e70730fe7a72bf0f105cdd71ae Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Fri, 3 May 2024 15:20:08 +0300 Subject: [PATCH 2/5] refactor: type table columns function --- .../src/component/Unit/UnitsTable.tsx | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/apps/admin-ui/src/component/Unit/UnitsTable.tsx b/apps/admin-ui/src/component/Unit/UnitsTable.tsx index 0c0eb5af0..3fd7fc5f4 100644 --- a/apps/admin-ui/src/component/Unit/UnitsTable.tsx +++ b/apps/admin-ui/src/component/Unit/UnitsTable.tsx @@ -16,38 +16,48 @@ type Props = { isLoading?: boolean; }; -const getColConfig = (t: TFunction, isMyUnits?: boolean) => [ - { - headerName: t("Units.headings.name"), - key: "nameFi", - transform: ({ nameFi, pk }: UnitNode) => ( - - {truncate(nameFi ?? "-", MAX_UNIT_NAME_LENGTH)} - - ), - width: "50%", - isSortable: true, - }, - { - headerName: t("Units.headings.serviceSector"), - key: "serviceSector", - isSortable: false, - transform: (unit: UnitNode) => - (unit?.serviceSectors || []) - .map((serviceSector) => serviceSector?.nameFi) - .join(","), - width: "25%", - }, - { - headerName: t("Units.headings.reservationUnitCount"), - key: "typeFi", - isSortable: false, - transform: (unit: UnitNode) => ( - <> {unit?.reservationunitSet?.length ?? 0} - ), - width: "25%", - }, -]; +type ColumnType = { + headerName: string; + key: string; + transform?: (unit: UnitNode) => JSX.Element | string; + width: string; + isSortable: boolean; +}; + +function getColConfig(t: TFunction, isMyUnits?: boolean): ColumnType[] { + return [ + { + headerName: t("Units.headings.name"), + key: "nameFi", + transform: ({ nameFi, pk }: UnitNode) => ( + + {truncate(nameFi ?? "-", MAX_UNIT_NAME_LENGTH)} + + ), + width: "50%", + isSortable: true, + }, + { + headerName: t("Units.headings.serviceSector"), + key: "serviceSector", + isSortable: false, + transform: (unit: UnitNode) => + (unit?.serviceSectors || []) + .map((serviceSector) => serviceSector?.nameFi) + .join(","), + width: "25%", + }, + { + headerName: t("Units.headings.reservationUnitCount"), + key: "typeFi", + isSortable: false, + transform: (unit: UnitNode) => ( + <> {unit?.reservationunitSet?.length ?? 0} + ), + width: "25%", + }, + ]; +} export function UnitsTable({ sort, From 0a821ae619be0d46ee7199716d4d2d37fbe2f7ea Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Fri, 3 May 2024 14:13:48 +0300 Subject: [PATCH 3/5] add: rejecting reservation unit options --- apps/admin-ui/src/i18n/messages.ts | 2 + .../src/spa/applications/[id]/index.tsx | 187 +++++++++++++++--- .../admin-ui/src/spa/applications/queries.tsx | 4 +- apps/ui/pages/application/[...params].tsx | 27 +-- packages/common/src/apolloUtils.ts | 56 ++++++ packages/common/src/queries/application.tsx | 137 ++++++++----- 6 files changed, 316 insertions(+), 97 deletions(-) create mode 100644 packages/common/src/apolloUtils.ts diff --git a/apps/admin-ui/src/i18n/messages.ts b/apps/admin-ui/src/i18n/messages.ts index e91a55d54..213cf9626 100644 --- a/apps/admin-ui/src/i18n/messages.ts +++ b/apps/admin-ui/src/i18n/messages.ts @@ -200,6 +200,8 @@ const translations: ITranslations = { mutationFailed: ["Muutos epäonnistui"], noPermission: ["Sinulla ei ole käyttöoikeutta."], mutationNoDataReturned: ["Odottamaton vastaus."], + cantRejectAlreadyAllocated: ["Jo jaettua vuoroa ei voi hylätä."], + formValidationError: ["Lomakkeessa on virheitä. {{ message }}"], descriptive: { "Reservation overlaps with reservation before due to buffer time.": [ "Varaus menee päällekkäin edellisen varauksen kanssa tauon takia.", diff --git a/apps/admin-ui/src/spa/applications/[id]/index.tsx b/apps/admin-ui/src/spa/applications/[id]/index.tsx index 72f3e94da..0b9b866d7 100644 --- a/apps/admin-ui/src/spa/applications/[id]/index.tsx +++ b/apps/admin-ui/src/spa/applications/[id]/index.tsx @@ -2,13 +2,14 @@ import React, { useRef, type ReactNode } from "react"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { Card, Table, IconCheck, IconEnvelope } from "hds-react"; +import { Card, Table, IconCheck, IconEnvelope, Button } from "hds-react"; import { isEqual, trim } from "lodash"; -import { useQuery } from "@apollo/client"; -import { TFunction } from "i18next"; +import { type ApolloQueryResult, useMutation, useQuery } from "@apollo/client"; +import { type TFunction } from "i18next"; import { H2, H4, H5, Strong } from "common/src/common/typography"; import { breakpoints } from "common/src/common/style"; import { base64encode, filterNonNullable } from "common/src/helpers"; +import { getValidationErrors } from "common/src/apolloUtils"; import { type Query, ApplicationStatusChoice, @@ -18,6 +19,9 @@ import { type SuitableTimeRangeNode, Priority, type QueryApplicationArgs, + type MutationUpdateReservationUnitOptionArgs, + type ReservationUnitOptionNode, + type Maybe, } from "common/types/gql-types"; import { formatDuration } from "common/src/common/util"; import { convertWeekday, type Day } from "common/src/conversion"; @@ -39,6 +43,9 @@ import { ValueBox } from "../ValueBox"; import { TimeSelector } from "../TimeSelector"; import { APPLICATION_ADMIN_QUERY } from "../queries"; import { getApplicantName, getApplicationStatusColor } from "@/helpers"; +// TODO move +import { UPDATE_RESERVATION_UNIT_OPTION } from "@/spa/recurring-reservations/application-rounds/[id]/allocation/queries"; +import Error404 from "@/common/Error404"; function printSuitableTimes( timeRanges: SuitableTimeRangeNode[], @@ -226,24 +233,24 @@ const KV = ({ ); -const formatApplicationDuration = ( +function formatApplicationDuration( durationSeconds: number | undefined, t: TFunction, type?: "min" | "max" -): string => { +): string { if (!durationSeconds) { return ""; } const durMinutes = durationSeconds / 60; const translationKey = `common.${type}Amount`; return `${type ? t(translationKey) : ""} ${formatDuration(durMinutes, t)}`; -}; +} -const appEventDuration = ( +function appEventDuration( min: number | undefined, max: number | undefined, t: TFunction -): string => { +): string { let duration = ""; if (isEqual(min, max)) { duration += formatApplicationDuration(min, t); @@ -252,7 +259,7 @@ const appEventDuration = ( duration += `, ${formatApplicationDuration(max, t, "max")}`; } return trim(duration, ", "); -}; +} function SchedulesContent({ as, @@ -285,12 +292,110 @@ function SchedulesContent({ ); } +function RejectOptionButton({ + option, + refetch, +}: { + option: ReservationUnitOptionNode; + refetch: () => Promise>; +}) { + const [mutation, { loading }] = useMutation< + Query, + MutationUpdateReservationUnitOptionArgs + >(UPDATE_RESERVATION_UNIT_OPTION); + + const { notifyError } = useNotification(); + const { t } = useTranslation(); + + const updateOption = async ( + pk: Maybe | undefined, + rejected: boolean + ) => { + if (pk == null) { + return; + } + try { + await mutation({ + variables: { + input: { + pk, + rejected, + }, + }, + }); + refetch(); + } catch (err) { + const mutationErrors = getValidationErrors(err); + if (mutationErrors.length > 0) { + // TODO handle other codes also + const isInvalidState = mutationErrors.find( + (e) => e.code === "invalid" && e.field === "rejected" + ); + if (isInvalidState) { + notifyError(t("errors.cantRejectAlreadyAllocated")); + } else { + // TODO this should show them with cleaner formatting (multiple errors) + // TODO these should be translated + const message = mutationErrors.map((e) => e.message).join(", "); + notifyError(t("errors.formValidationError", { message })); + } + } else { + notifyError(t("errors.errorRejectingOption")); + } + } + }; + + const handleReject = async () => { + updateOption(option.pk, true); + }; + + const handleRevert = async () => { + updateOption(option.pk, false); + }; + + const isRejected = option.rejected; + + // codegen types are allow nulls so have to do this for debugging + if (option.allocatedTimeSlots == null) { + // eslint-disable-next-line no-console + console.warn("no allocatedTimeSlots", option); + } + + const isDisabled = option.allocatedTimeSlots?.length > 0; + return ( + + ); +} + +interface DataType extends ReservationUnitOptionNode { + index: number; +} +type ColumnType = { + headerName: string; + key: string; + transform: (data: DataType) => JSX.Element | string; +}; + function ApplicationSectionDetails({ section, application, + refetch, }: { section: ApplicationSectionNode; application: ApplicationNode; + refetch: () => Promise>; }): JSX.Element { const { t } = useTranslation(); @@ -308,14 +413,48 @@ function ApplicationSectionDetails({ )}` : "No dates"; - const rows = filterNonNullable(section?.reservationUnitOptions).map( - (ru, index) => ({ - index: index + 1, - pk: ru?.pk, - unit: ru?.reservationUnit?.unit?.nameFi, - name: ru?.reservationUnit?.nameFi, - }) - ); + const cols: Array = [ + { + headerName: "a", + key: "index", + transform: (d: DataType) => { + return d.index.toString(); + }, + }, + { + headerName: "b", + key: "unit", + transform: (reservationUnitOption: ReservationUnitOptionNode) => { + return reservationUnitOption?.reservationUnit?.unit?.nameFi ?? "-"; + }, + }, + { + headerName: "c", + key: "name", + transform: (reservationUnitOption: ReservationUnitOptionNode) => { + return reservationUnitOption?.reservationUnit?.nameFi ?? "-"; + }, + }, + { + headerName: "d", + key: "reject", + transform: (reservationUnitOption: ReservationUnitOptionNode) => { + return ( + + ); + }, + }, + ]; + + const rows: DataType[] = filterNonNullable( + section?.reservationUnitOptions + ).map((ru, index) => ({ + ...ru, + index: index + 1, + })); return ( @@ -352,15 +491,7 @@ function ApplicationSectionDetails({

{t("ApplicationEvent.requestedReservationUnits")}

- +

{t("ApplicationEvent.requestedTimes")}

@@ -433,7 +564,7 @@ function ApplicationDetails({ ); if (application == null || applicationRound == null) { - return null; + return ; } const route = [ @@ -446,6 +577,7 @@ function ApplicationDetails({ alias: t("breadcrumb.application-rounds"), }, { + // TODO url builder slug: `/recurring-reservations/application-rounds/${applicationRound.pk}`, alias: applicationRound.nameFi ?? "-", }, @@ -520,6 +652,7 @@ function ApplicationDetails({ section={section} application={application} key={section.pk} + refetch={refetch} /> ))}

{t("Application.customerBasicInfo")}

diff --git a/apps/admin-ui/src/spa/applications/queries.tsx b/apps/admin-ui/src/spa/applications/queries.tsx index 8f5265cb4..9697002f1 100644 --- a/apps/admin-ui/src/spa/applications/queries.tsx +++ b/apps/admin-ui/src/spa/applications/queries.tsx @@ -1,9 +1,9 @@ import { gql } from "@apollo/client"; -import { APPLICATION_FRAGMENT } from "common/src/queries/application"; +import { APPLICATION_ADMIN_FRAGMENT } from "common/src/queries/application"; /// NOTE Requires higher backend optimizer complexity limit (21 works) export const APPLICATION_ADMIN_QUERY = gql` - ${APPLICATION_FRAGMENT} + ${APPLICATION_ADMIN_FRAGMENT} query ApplicationAdmin($id: ID!) { application(id: $id) { ...ApplicationCommon diff --git a/apps/ui/pages/application/[...params].tsx b/apps/ui/pages/application/[...params].tsx index 10dc7164b..bdb8af82e 100644 --- a/apps/ui/pages/application/[...params].tsx +++ b/apps/ui/pages/application/[...params].tsx @@ -22,6 +22,7 @@ import { convertApplication, ApplicationFormSchemaRefined, } from "@/components/application/Form"; +import { getValidationErrors } from "common/src/apolloUtils"; import useReservationUnitsList from "@/hooks/useReservationUnitList"; import { useApplicationUpdate } from "@/hooks/useApplicationUpdate"; import { ErrorToast } from "@/components/common/ErrorToast"; @@ -65,24 +66,16 @@ function getErrorMessages(error: unknown): string { } return networkError.message; } + // Possible mutations errors (there are others too) + // 1. message: "Voi hakea vain 1-7 varausta viikossa." + // - code: "invalid" + // 2. message: "Reservations begin date cannot be before the application round's reservation period begin date." + // - code: "" + const mutationErrors = getValidationErrors(error); + if (mutationErrors.length > 0) { + return "Form validation error"; + } if (graphQLErrors.length > 0) { - // TODO separate validation errors: this is invalid form values (user error) - const MUTATION_ERROR_CODE = "MUTATION_VALIDATION_ERROR"; - const isMutationError = - graphQLErrors.find((e) => { - if (e.extensions == null) { - return false; - } - return e.extensions.code === MUTATION_ERROR_CODE; - }) != null; - // Possible mutations errors (there are others too) - // 1. message: "Voi hakea vain 1-7 varausta viikossa." - // - code: "invalid" - // 2. message: "Reservations begin date cannot be before the application round's reservation period begin date." - // - code: "" - if (isMutationError) { - return "Form validation error"; - } return "Unknown GQL error"; } } diff --git a/packages/common/src/apolloUtils.ts b/packages/common/src/apolloUtils.ts new file mode 100644 index 000000000..7b60240f0 --- /dev/null +++ b/packages/common/src/apolloUtils.ts @@ -0,0 +1,56 @@ +import { ApolloError } from "@apollo/client"; +import { type GraphQLErrors } from "@apollo/client/errors"; + +type ValidationError = { + message: string; + code: string; + field: string; +}; + +function mapGQLErrors(gqlError: GraphQLErrors[0]) { + const { extensions } = gqlError; + if ("errors" in extensions && Array.isArray(extensions.errors)) { + const { errors } = extensions; + const errs = errors.map((err: unknown) => { + if (typeof err !== "object" || err == null) { + return null; + } + if ("message" in err && "code" in err && "field" in err) { + const { message, code, field } = err ?? {}; + if ( + typeof message !== "string" || + typeof code !== "string" || + typeof field !== "string" + ) { + return null; + } + return { message, code, field }; + } + return null; + }); + return errs.filter((e): e is ValidationError => e != null); + } + return []; +} + +export function getValidationErrors(error: unknown): ValidationError[] { + if (error == null) { + return []; + } + + if (error instanceof ApolloError) { + const { graphQLErrors } = error; + if (graphQLErrors.length > 0) { + // TODO separate validation errors: this is invalid form values (user error) + const MUTATION_ERROR_CODE = "MUTATION_VALIDATION_ERROR"; + const isMutationError = (e: (typeof graphQLErrors)[0]) => { + if (e.extensions == null) { + return false; + } + return e.extensions.code === MUTATION_ERROR_CODE; + }; + return graphQLErrors.filter(isMutationError).flatMap(mapGQLErrors); + } + } + return []; +} diff --git a/packages/common/src/queries/application.tsx b/packages/common/src/queries/application.tsx index d1e736c0c..9c34a590e 100644 --- a/packages/common/src/queries/application.tsx +++ b/packages/common/src/queries/application.tsx @@ -114,58 +114,9 @@ const APPLICATION_SECTION_UI_FRAGMENT = gql` } `; -// TODO fragment this futher -// ex. admin side doesn't need nameEn / nameSv -// a lot of the deep hierarchy is only needed in the client side -// other uncommon fields ? -export const APPLICATION_FRAGMENT = gql` - ${APPLICATION_SECTION_UI_FRAGMENT} - ${IMAGE_FRAGMENT} - fragment ApplicationCommon on ApplicationNode { - pk - status +const APPLICANT_FRAGMENT = gql` + fragment ApplicantFragment on ApplicationNode { applicantType - lastModifiedDate - user { - name - email - pk - } - applicationRound { - pk - nameFi - nameSv - nameEn - serviceSector { - pk - nameFi - } - reservationUnits { - pk - nameFi - nameSv - nameEn - minPersons - maxPersons - images { - ...ImageFragment - } - unit { - pk - nameFi - nameSv - nameEn - } - } - applicationPeriodBegin - applicationPeriodEnd - reservationPeriodBegin - reservationPeriodEnd - status - applicationsCount - reservationUnitCount - statusTimestamp - } contactPerson { pk firstName @@ -198,6 +149,90 @@ export const APPLICATION_FRAGMENT = gql` streetAddress city } + user { + name + email + pk + } + } +`; + +const APPLICATION_ROUND_FRAGMENT = gql` + ${IMAGE_FRAGMENT} + fragment ApplicationRoundFragment on ApplicationRoundNode { + pk + nameFi + nameSv + nameEn + serviceSector { + pk + nameFi + } + reservationUnits { + pk + nameFi + nameSv + nameEn + minPersons + maxPersons + images { + ...ImageFragment + } + unit { + pk + nameFi + nameSv + nameEn + } + } + applicationPeriodBegin + applicationPeriodEnd + reservationPeriodBegin + reservationPeriodEnd + status + applicationsCount + reservationUnitCount + statusTimestamp + } +`; + +// TODO what does admin side require from UIFragment? +export const APPLICATION_ADMIN_FRAGMENT = gql` + ${APPLICANT_FRAGMENT} + ${APPLICATION_SECTION_UI_FRAGMENT} + fragment ApplicationCommon on ApplicationNode { + pk + status + lastModifiedDate + ...ApplicantFragment + applicationRound { + pk + nameFi + } + applicationSections { + ...ApplicationSectionUIFragment + reservationUnitOptions { + rejected + allocatedTimeSlots { + pk + } + } + } + } +`; + +export const APPLICATION_FRAGMENT = gql` + ${APPLICATION_SECTION_UI_FRAGMENT} + ${APPLICANT_FRAGMENT} + ${APPLICATION_ROUND_FRAGMENT} + fragment ApplicationCommon on ApplicationNode { + pk + status + lastModifiedDate + ...ApplicantFragment + applicationRound { + ...ApplicationRoundFragment + } applicationSections { ...ApplicationSectionUIFragment } From 8f5c5d37d43f04c82e4e33e541cd9d09d173d692 Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Mon, 6 May 2024 10:12:20 +0300 Subject: [PATCH 4/5] refactor: replace Table with Grid --- apps/admin-ui/src/i18n/messages.ts | 3 + .../src/spa/applications/[id]/index.tsx | 127 +++++++++++++----- 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/apps/admin-ui/src/i18n/messages.ts b/apps/admin-ui/src/i18n/messages.ts index 213cf9626..1f2eb3434 100644 --- a/apps/admin-ui/src/i18n/messages.ts +++ b/apps/admin-ui/src/i18n/messages.ts @@ -415,6 +415,9 @@ const translations: ITranslations = { numTurns: ["Vuorojen määrä"], authenticatedUser: ["Tunnistautunut käyttäjä"], emptyFilterPageName: ["hakemusta"], + rejected: ["Hylätty"], + btnReject: ["Hylkää"], + btnRevert: ["Palauta"], headings: { id: ["id"], customer: ["Hakija"], diff --git a/apps/admin-ui/src/spa/applications/[id]/index.tsx b/apps/admin-ui/src/spa/applications/[id]/index.tsx index 0b9b866d7..405a87246 100644 --- a/apps/admin-ui/src/spa/applications/[id]/index.tsx +++ b/apps/admin-ui/src/spa/applications/[id]/index.tsx @@ -2,7 +2,15 @@ import React, { useRef, type ReactNode } from "react"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; -import { Card, Table, IconCheck, IconEnvelope, Button } from "hds-react"; +import { + Card, + IconCheck, + IconEnvelope, + Button, + IconCross, + IconArrowRedo, + Tag, +} from "hds-react"; import { isEqual, trim } from "lodash"; import { type ApolloQueryResult, useMutation, useQuery } from "@apollo/client"; import { type TFunction } from "i18next"; @@ -158,17 +166,6 @@ const PreCard = styled.div` margin-bottom: var(--spacing-m); `; -const StyledTable = styled(Table)` - width: 100%; - border-spacing: 0; - thead { - display: none; - } - td:nth-child(1) { - padding-left: var(--spacing-xs); - } -`; - const EventSchedules = styled.div` gap: var(--spacing-l); display: flex; @@ -216,6 +213,57 @@ const HeadingContainer = styled.div` margin-top: var(--spacing-s); `; +const ApplicationSectionsContainer = styled.div` + display: grid; + + /* responsive shinanigans the tag takes too much space, so we only use 4 columns on mobile */ + grid-template-columns: 1rem repeat(2, auto) 8rem; + align-items: stretch; + justify-content: stretch; + gap: 0; + + border-collapse: collapse; + > div > div { + border: 1px solid var(--color-black-20); + border-left: none; + border-right: none; + display: flex; + align-items: center; + padding-left: 1rem; + + /* responsive shinanigans the tag takes too much space */ + :nth-child(4) { + display: none; + } + } + + > div:nth-child(2n) { + > div { + border-top: none; + } + } + + @media (min-width: ${breakpoints.m}) { + grid-template-columns: 3rem repeat(2, auto) repeat(2, 8rem); + + /* undo responsive shinanigans, and align the HDS tag */ + > div > div:nth-child(4) { + display: flex; + align-items: center; + } + } +`; + +// the default HDS tag css can't align icons properly so we have to do this +// TODO reusable Tags that allow setting both the background and optional Icon +const DeclinedTag = styled(Tag)` + background-color: var(--color-metro-medium-light); + > span > span { + display: flex; + align-items: center; + } +`; + const KV = ({ k, v, @@ -364,17 +412,16 @@ function RejectOptionButton({ const isDisabled = option.allocatedTimeSlots?.length > 0; return ( ); } @@ -383,9 +430,8 @@ interface DataType extends ReservationUnitOptionNode { index: number; } type ColumnType = { - headerName: string; key: string; - transform: (data: DataType) => JSX.Element | string; + transform: (data: DataType) => ReactNode; }; function ApplicationSectionDetails({ @@ -415,30 +461,39 @@ function ApplicationSectionDetails({ const cols: Array = [ { - headerName: "a", key: "index", - transform: (d: DataType) => { - return d.index.toString(); - }, + transform: (d: DataType) => d.index.toString(), }, { - headerName: "b", key: "unit", - transform: (reservationUnitOption: ReservationUnitOptionNode) => { - return reservationUnitOption?.reservationUnit?.unit?.nameFi ?? "-"; - }, + transform: (reservationUnitOption: ReservationUnitOptionNode) => + reservationUnitOption?.reservationUnit?.unit?.nameFi ?? "-", }, { - headerName: "c", key: "name", + transform: (reservationUnitOption: ReservationUnitOptionNode) => + reservationUnitOption?.reservationUnit?.nameFi ?? "-", + }, + { + key: "status", transform: (reservationUnitOption: ReservationUnitOptionNode) => { - return reservationUnitOption?.reservationUnit?.nameFi ?? "-"; + if (reservationUnitOption.rejected) { + return ( + + + {t("Application.rejected")} + + ); + } }, }, { - headerName: "d", key: "reject", transform: (reservationUnitOption: ReservationUnitOptionNode) => { + // TODO button should only be visible if user has "can_handle_applications" permission + // the application is visible to the user if they have "can_view_application" permission + // but they aren't allowed to reject it + // requires mergin a PR with changes to application permission checks return (

{t("ApplicationEvent.requestedReservationUnits")}

- + + {rows.map((row) => ( +
+ {cols.map((col) => ( +
{col.transform(row)}
+ ))} +
+ ))} +

{t("ApplicationEvent.requestedTimes")}

From 3e5470ba05feed0f052b8c4ffe5d60840f0d9eee Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Mon, 6 May 2024 12:58:06 +0300 Subject: [PATCH 5/5] fix: dont allow reject if not in allocation state --- apps/admin-ui/src/spa/applications/[id]/index.tsx | 8 +++++++- apps/admin-ui/src/spa/applications/queries.tsx | 2 +- packages/common/src/queries/application.tsx | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/admin-ui/src/spa/applications/[id]/index.tsx b/apps/admin-ui/src/spa/applications/[id]/index.tsx index 405a87246..681a9c82c 100644 --- a/apps/admin-ui/src/spa/applications/[id]/index.tsx +++ b/apps/admin-ui/src/spa/applications/[id]/index.tsx @@ -342,9 +342,11 @@ function SchedulesContent({ function RejectOptionButton({ option, + applicationStatus, refetch, }: { option: ReservationUnitOptionNode; + applicationStatus: ApplicationStatusChoice; refetch: () => Promise>; }) { const [mutation, { loading }] = useMutation< @@ -409,7 +411,8 @@ function RejectOptionButton({ console.warn("no allocatedTimeSlots", option); } - const isDisabled = option.allocatedTimeSlots?.length > 0; + const canReject = applicationStatus === ApplicationStatusChoice.InAllocation; + const isDisabled = !canReject || option.allocatedTimeSlots?.length > 0; return (