diff --git a/apps/ui/components/application/ApplicantInfoPreview.tsx b/apps/ui/components/application/ApplicantInfoPreview.tsx index 3ea58e1db..e7d6aa11a 100644 --- a/apps/ui/components/application/ApplicantInfoPreview.tsx +++ b/apps/ui/components/application/ApplicantInfoPreview.tsx @@ -1,11 +1,28 @@ import React from "react"; import { useTranslation } from "next-i18next"; import { ApplicantTypeChoice, type ApplicationQuery } from "@gql/gql-types"; -import { getTranslation } from "common/src/common/util"; -import { SpanTwoColumns, TwoColumnContainer } from "../common/common"; -import Address from "./AddressPreview"; -import { StyledLabelValue } from "./styled"; +import { + ApplicationInfoContainer, + InfoItemContainer, + InfoItem, +} from "./styled"; +const LabelValue = ({ + label, + value, +}: { + label: string; + value?: string | number | null; +}) => { + return ( + + +

{label}

+

{value}

+
+
+ ); +}; type Node = NonNullable; export function ApplicantInfoPreview({ application, @@ -13,74 +30,48 @@ export function ApplicantInfoPreview({ application: Node; }): JSX.Element { const { t } = useTranslation(); - + const applicant = { + firstName: application.contactPerson?.firstName, + lastName: application.contactPerson?.lastName, + email: application.contactPerson?.email, + phoneNumber: application.contactPerson?.phoneNumber, + }; + const fullName = `${applicant.firstName} ${applicant.lastName}`; + const contactsString = `${applicant.phoneNumber} / ${applicant.email}`; + const addressString = `${application.billingAddress?.streetAddressFi}, ${application.billingAddress?.postCode} ${application.billingAddress?.cityFi}`; return ( - + {application.applicantType == null ? ( // TODO translate (though this is more a system error than a user error)
ERROR: applicantType is null
) : application.applicantType !== ApplicantTypeChoice.Individual ? ( <> - - - - - - - - -
-
) : null} - - - - - {application.applicantType === ApplicantTypeChoice.Individual ? ( - <> -
- - - ) : null} - + ); } diff --git a/apps/ui/components/application/ApplicationEventList.tsx b/apps/ui/components/application/ApplicationEventList.tsx index ff6ddb510..bc272278d 100644 --- a/apps/ui/components/application/ApplicationEventList.tsx +++ b/apps/ui/components/application/ApplicationEventList.tsx @@ -7,15 +7,30 @@ import { type Maybe, type SuitableTimeRangeNode, Priority, + ApplicationSectionStatusChoice, } from "@gql/gql-types"; import { getTranslation } from "common/src/common/util"; import { convertWeekday } from "common/src/conversion"; -import { TimePreview } from "./TimePreview"; -import { StyledLabelValue, TimePreviewContainer } from "./styled"; -import { TwoColumnContainer, FormSubHeading } from "../common/common"; -import { AccordionWithState as Accordion } from "../common/Accordion"; -import { UnitList } from "./UnitList"; -import { filterNonNullable } from "common/src/helpers"; +import { + ApplicationInfoContainer, + ApplicationSection, + ApplicationSectionHeader, + InfoItemContainer, + InfoItem, + ScheduleDay, +} from "./styled"; +import { ApplicationEventScheduleFormType } from "@/components/application/Form"; +import { WEEKDAYS } from "common/src/const"; +import { filterNonNullable, fromMondayFirstUnsafe } from "common/src/helpers"; +import StatusLabel from "common/src/components/StatusLabel"; +import type { StatusLabelType } from "common/src/tags"; +import { + IconCheck, + IconCross, + IconQuestionCircleFill, + Tooltip, +} from "hds-react"; +import { apiDateToUIDate } from "@/modules/util"; const filterPrimary = (n: { priority: Priority }) => n.priority === Priority.Primary; @@ -51,6 +66,12 @@ const formatDurationSeconds = (seconds: number, t: TFunction): string => { )}`; }; +const getDuration = (begin: number, end: number, t: TFunction): string => { + const beginHours = formatDurationSeconds(begin, t); + const endHours = formatDurationSeconds(end, t); + return `${beginHours} - ${endHours}`; +}; + const ageGroupToString = (ag: Maybe | undefined): string => { if (!ag) { return ""; @@ -58,105 +79,191 @@ const ageGroupToString = (ag: Maybe | undefined): string => { return `${ag.minimum} - ${ag.maximum}`; }; +const getLabelProps = ( + status: ApplicationSectionStatusChoice | undefined | null +): { type: StatusLabelType; icon: JSX.Element } => { + switch (status) { + case ApplicationSectionStatusChoice.Handled: + return { type: "success", icon: }; + case ApplicationSectionStatusChoice.Rejected: + return { type: "error", icon: }; + default: + return { type: "info", icon: }; + } +}; + // NOTE: used by Preview and View // No form context unlike the edit pages, use application query result type Node = NonNullable; -export function ApplicationEventList({ application }: { application: Node }) { +export function ApplicationEventList({ + application, +}: { + application: Node; +}): JSX.Element { const { t } = useTranslation(); const aes = filterNonNullable(application.applicationSections); - const reservationUnits = aes.map((evt) => - evt?.reservationUnitOptions?.map((eru, index) => ({ - pk: eru.reservationUnit?.pk ?? 0, - priority: index, - nameFi: eru.reservationUnit?.nameFi ?? undefined, - nameSv: eru.reservationUnit?.nameSv ?? undefined, - nameEn: eru.reservationUnit?.nameEn ?? undefined, - })) - ); + const sections = aes.map((applicationEvent, i) => { + const primaryTimes = + applicationEvent.suitableTimeRanges + ?.filter(filterPrimary) + .map(convertApplicationSchedule) ?? []; + const secondaryTimes = + applicationEvent.suitableTimeRanges + ?.filter(filterSecondary) + .map(convertApplicationSchedule) ?? []; + const reservationUnits = + applicationEvent.reservationUnitOptions?.map((eru, index) => ({ + pk: eru.reservationUnit?.pk ?? 0, + priority: index, + nameFi: eru.reservationUnit?.nameFi ?? undefined, + nameSv: eru.reservationUnit?.nameSv ?? undefined, + nameEn: eru.reservationUnit?.nameEn ?? undefined, + })) ?? []; + const { type, icon } = getLabelProps(applicationEvent.status); + return ( + + + {applicationEvent.name} + {/* Show status label only for rejected and handled applicationEvents */} + {applicationEvent.status === + ApplicationSectionStatusChoice.Rejected || + (applicationEvent.status === + ApplicationSectionStatusChoice.Handled && ( + + {t( + `application:preview.applicationEvent.status.${applicationEvent.status}` + )} + + ))} + + + + +

+ {t("application:preview.applicationEvent.applicationInfo")} +

+
    + + + + + + +
+
+
+ + +

+ {t("application:preview.applicationEvent.appliedSpaces")} +

+
    + {reservationUnits?.map((ru) => ( +
  1. + {getTranslation(ru ?? {}, "name").trim()} +
  2. + ))} +
+
+
+ + +

+ + {t("application:preview.applicationEvent.schedules")} + + + {t("application:preview.applicationEvent.scheduleTooltip")} + +

+
+ +
+
+
+
+
+ ); + }); + + // we need to wrap the return value in a fragment to avoid a React warning, since we are essentially returning an array of elements + return <>{sections}; +} + +const InfoListItem = ({ label, value }: { label: string; value: string }) => ( +
  • + {`${label}: `} + {value} +
  • +); + +function Weekdays({ + primary, + secondary, +}: { + primary: ApplicationEventScheduleFormType[]; + secondary: ApplicationEventScheduleFormType[]; +}) { + const { t } = useTranslation(); + function getDayTimes( + schedule: ApplicationEventScheduleFormType[], + day: number + ) { + return schedule + .filter((s) => s.day === day) + .map( + (cur) => + `${Number(cur.begin.substring(0, 2))}-${Number( + cur.end.startsWith("00") ? 24 : cur.end.substring(0, 2) + )}` + ) + .join(", "); + } return ( <> - {aes.map((applicationEvent, i) => ( - - - - {t("application:preview.subHeading.applicationInfo")} - - - - - - - - - - -
    - - - {t("application:Page1.spacesSubHeading")} - - {/* TODO why is this taking from array? */} - - - {t("application:preview.applicationEventSchedules")} - - - - - - ))} + {WEEKDAYS.map((day) => { + return ( + + {t(`common:weekDay.${fromMondayFirstUnsafe(day)}`)} + {getDayTimes(primary, day) || "-"} + + {getDayTimes(secondary, day) + ? `(${getDayTimes(secondary, day)})` + : "-"} + + + ); + })} ); } diff --git a/apps/ui/components/application/ViewApplication.tsx b/apps/ui/components/application/ViewApplication.tsx new file mode 100644 index 000000000..098116e48 --- /dev/null +++ b/apps/ui/components/application/ViewApplication.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { Checkbox } from "hds-react"; +import { useTranslation } from "next-i18next"; +import type { + ApplicationQuery, + Maybe, + TermsOfUseTextFieldsFragment, +} from "@gql/gql-types"; +import { getTranslation } from "@/modules/util"; +import { ApplicantInfoPreview } from "./ApplicantInfoPreview"; +import { + ApplicationSection, + ApplicationSectionHeader, + CheckboxContainer, + CompactTermsBox, + StyledNotification, + TermsAccordion as Accordion, +} from "./styled"; +import { ApplicationEventList } from "./ApplicationEventList"; +import Sanitize from "../common/Sanitize"; + +type Node = NonNullable; +export function ViewApplication({ + application, + tos, + acceptTermsOfUse, + setAcceptTermsOfUse, +}: { + application: Node; + tos: Maybe; + acceptTermsOfUse?: boolean; + setAcceptTermsOfUse?: (value: boolean) => void; +}): JSX.Element { + const { t } = useTranslation(); + + const tos2 = application.applicationRound?.termsOfUse; + + return ( + <> + + + {t("application:preview.basicInfoSubHeading")} + + + + + {tos && ( + + } + /> + + )} + {tos2 && ( + + } + /* TODO TermsBox has accepted and checkbox we could use but for now leaving the single + * page specfici checkbox to accept all terms */ + /> + + )} + {acceptTermsOfUse != null && setAcceptTermsOfUse != null && ( + + + setAcceptTermsOfUse(e.target.checked)} + label={t("application:preview.userAcceptsTerms")} + // NOTE I'm assuming we can just hide the whole checkbox in View + /> + + + )} +
    + {/* Wrap the notification in a div, since HDS-notification has
    as the root element and we need section:last-of-type to hit the last ApplicationSection */} + + {t("application:preview.notification.body")} + +
    + + ); +} diff --git a/apps/ui/components/application/styled.tsx b/apps/ui/components/application/styled.tsx index e5e3d8f78..b0aa98922 100644 --- a/apps/ui/components/application/styled.tsx +++ b/apps/ui/components/application/styled.tsx @@ -1,14 +1,9 @@ import styled from "styled-components"; import { Notification } from "hds-react"; import LabelValue from "../common/LabelValue"; - -export const TimePreviewContainer = styled.div` - margin: var(--spacing-xl) 0; - - svg { - margin-top: 2px; - } -`; +import TermsBox from "common/src/termsbox/TermsBox"; +import { breakpoints, fontMedium } from "common"; +import { AccordionWithState } from "@/components/common/Accordion"; export const CheckboxContainer = styled.div` margin-top: var(--spacing-m); @@ -27,3 +22,146 @@ export const StyledNotification = styled(Notification)` `; export const StyledLabelValue = styled(LabelValue).attrs({ theme: "thin" })``; + +export const ApplicationSection = styled.section` + display: flex; + flex-flow: column; + padding: 0; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--color-black-90); + /* --application-content-border is the dashed border used between items within an ApplicationSection */ + --application-content-border: 1px dashed var(--color-black-30); + border-bottom: 0; + + &:last-of-type { + border-bottom: 1px solid var(--color-black-90); + margin-bottom: var(--spacing-layout-s); + } +`; + +export const ApplicationSectionHeader = styled.h2` + display: flex; + justify-content: space-between; + width: calc(100% - 2 * var(--spacing-m)); + height: 26px; + margin: 0 var(--spacing-m); + padding: var(--spacing-m) 0; + border-bottom: var(--application-content-border); + font-size: var(--fontsize-heading-s); + [class*="statusLabel__"] { + position: relative; + transform: translateY(-4px); + } +`; + +export const ApplicationInfoContainer = styled.div` + margin: 0 var(--spacing-m); + display: flex; + flex-flow: column; + @media (min-width: ${breakpoints.m}) { + flex-flow: row; + } +`; + +export const InfoItemContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + align-content: flex-end; + position: relative; + background: var(--color-white); + width: 100%; + font-size: var(--fontsize-body-m); + border-top: var(--application-content-border); + + > [class^="styled__InfoItem"] { + box-sizing: border-box; + width: 100%; + display: flex; + gap: var(--spacing-m); + flex-direction: column; + align-items: stretch; + padding: 0; + } + + ol { + padding-inline: 40px; + } + + li span { + white-space: nowrap; + ${fontMedium}; + } + + .info-label { + display: flex; + gap: var(--spacing-m); + margin: 0; + line-height: 1; + ${fontMedium}; + font-size: inherit; + [class*="Tooltip-module_root"] { + /* Position the tooltip button and prevent it from stretching the info-label heading */ + margin-top: -4px; + margin-bottom: -8px; + } + } + + p { + font-size: var(--fontsize-body-m); + margin: 0; + } + + /* Make InfoItemContainers rows of three columns, with application-content-border between the cols */ + @media (min-width: ${breakpoints.m}) { + width: calc(100% / 3); + > [class^="styled__InfoItem"] { + padding-inline: var(--spacing-m); + } + + /* first child in the row */ + &:nth-child(3n-2) > [class^="styled__InfoItem"] { + padding-left: 0; + } + + /* every child except the last one in each row */ + &:not(:nth-child(3n)) > [class^="styled__InfoItem"] { + border-right: var(--application-content-border); + } + + /* last child in each row */ + &:nth-child(-n + 3) { + border-top: 0; + } + } +`; + +export const InfoItem = styled.div` + margin: var(--spacing-m) 0; + height: 100%; + display: flex; + ul, + ol { + margin: 0; + padding-inline: var(--spacing-m); + } +`; + +export const ScheduleDay = styled.div` + display: grid; + grid-template-columns: 3rem 1fr 1fr; + span:last-child { + padding-left: var(--spacing-xs); + } +`; + +export const TermsAccordion = styled(AccordionWithState)` + --accordion-border-color: var(--color-black-90); + [class^="Button-module_label"] div { + font-size: var(--fontsize-heading-s); + } +`; + +export const CompactTermsBox = styled(TermsBox)` + margin-bottom: 0; +`; diff --git a/apps/ui/components/common/Accordion.tsx b/apps/ui/components/common/Accordion.tsx index ced0107d3..1ffefd2df 100644 --- a/apps/ui/components/common/Accordion.tsx +++ b/apps/ui/components/common/Accordion.tsx @@ -19,6 +19,7 @@ const AccordionElement = styled.div` margin-bottom: var(--spacing-layout-xs); margin-left: 0; padding-left: 0; + --accordion-border-color: var(--color-black-50); `; const HeadingButton = styled(Button).attrs({ @@ -32,7 +33,7 @@ const HeadingButton = styled(Button).attrs({ width: 100%; padding-left: 0; border-left: 0; - border-bottom: 1px solid var(--color-black-50) !important; + border-bottom: 1px solid var(--accordion-border-color) !important; span { ${({ theme }) => { diff --git a/apps/ui/pages/application/[id]/view.tsx b/apps/ui/pages/application/[id]/view.tsx new file mode 100644 index 000000000..24639064f --- /dev/null +++ b/apps/ui/pages/application/[id]/view.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import Error from "next/error"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; +import { getTranslation } from "common/src/common/util"; +import type { GetServerSidePropsContext } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { BlackButton } from "@/styles/util"; +import { ApplicationPageWrapper } from "@/components/application/ApplicationPage"; +import { createApolloClient } from "@/modules/apolloClient"; +import { ViewApplication } from "@/components/application/ViewApplication"; +import { ButtonContainer, CenterSpinner } from "@/components/common/common"; +import { + getCommonServerSideProps, + getGenericTerms, +} from "@/modules/serverUtils"; +import { base64encode } from "common/src/helpers"; +import { useApplicationQuery } from "@gql/gql-types"; + +const View = ({ id: pk, tos }: Props): JSX.Element => { + const { t } = useTranslation(); + + const router = useRouter(); + + const id = base64encode(`ApplicationNode:${pk}`); + const { + data, + error, + loading: isLoading, + } = useApplicationQuery({ + variables: { id }, + skip: !pk, + }); + const { application } = data ?? {}; + + if (id == null) { + return ; + } + if (error) { + // eslint-disable-next-line no-console -- TODO use logger (sentry) + console.error("application query error: ", error); + return ; + } + if (isLoading) { + return ; + } + if (!application) { + return ; + } + + const round = application.applicationRound; + const applicationRoundName = + round != null ? getTranslation(round, "name") : "-"; + + return ( + + + + router.back()}> + {t("common:prev")} + + + + ); +}; + +type Props = Awaited>["props"]; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const { locale } = ctx; + const commonProps = getCommonServerSideProps(); + const apolloClient = createApolloClient(commonProps.apiBaseUrl, ctx); + + const tos = await getGenericTerms(apolloClient); + + // TODO should fetch on SSR but we need authentication for it + const { query } = ctx; + const { id } = query; + const pkstring = Array.isArray(id) ? id[0] : id; + const pk = Number.isNaN(Number(pkstring)) ? undefined : Number(pkstring); + + return { + props: { + ...commonProps, + key: locale ?? "fi", + id: pk ?? null, + tos, + ...(await serverSideTranslations(locale ?? "fi")), + }, + }; +}; + +export default View; diff --git a/apps/ui/pages/applications/[id]/preview.tsx b/apps/ui/pages/applications/[id]/preview.tsx index 4ece9c4bd..055e7123b 100644 --- a/apps/ui/pages/applications/[id]/preview.tsx +++ b/apps/ui/pages/applications/[id]/preview.tsx @@ -10,7 +10,7 @@ import { serverSideTranslations } from "next-i18next/serverSideTranslations"; import { default as ErrorComponent } from "next/error"; import { MediumButton } from "@/styles/util"; import { ButtonContainer, CenterSpinner } from "@/components/common/common"; -import { ViewInner } from "@/components/application/ViewInner"; +import { ViewApplication } from "@/components/application/ViewApplication"; import { createApolloClient } from "@/modules/apolloClient"; import { ApplicationPageWrapper } from "@/components/application/ApplicationPage"; import { @@ -93,7 +93,7 @@ function Preview(props: PropsNarrowed): JSX.Element { application={application} >
    - - + ) : ( - + )} ); diff --git a/apps/ui/public/locales/fi/application.json b/apps/ui/public/locales/fi/application.json index 32c1ef7e6..c5afad6ee 100644 --- a/apps/ui/public/locales/fi/application.json +++ b/apps/ui/public/locales/fi/application.json @@ -165,31 +165,37 @@ "homeCity": "Kotipaikka", "heading": "4. Tarkista ja lähetä hakemus", "text": "Yhteenveto hakemuksestasi", - "basicInfoSubHeading": "Varaajan tiedot", - "firstName": "Yhteyshenkilön etunimi", - "lastName": "Yhteyshenkilön sukunimi", - "applicantTypeLabel": "Hakijan tyyppi", - "email": "Sähköpostiosoite", - "phoneNumber": "Puhelinnumero", + "basicInfoSubHeading": "Hakijan tiedot", + "contactPerson": "Yhteyshenkilö", + "contactInfo": "Yhteystiedot", + "address": "Postiosoite", "additionalInformation": "Lisätietoja varauksen käyttötarkoituksesta", "organisation": { - "name": "Hakijan nimi", - "coreBusiness": "Ydintoiminta" + "name": "Yhdistyksen tai yhteisön virallinen nimi", + "coreBusiness": "Ydintoiminta", + "registrationNumber": "Rekisterinumero" }, "applicationEvent": { - "name": "Kausivarauksen nimi", + "applicationInfo": "Hakemuksen tiedot", "numPersons": "Ryhmän koko", "ageGroup": "Ikäryhmä", "abilityGroup": "Tasoryhmä", "purpose": "Varauksen käyttötarkoitus", "additionalInfo": "Lisätietoja varaukseen liittyen", - "begin": "Kauden aloituspäivä", - "end": "Kauden päätöspäivä", - "minDuration": "Varauksen vähimmäiskesto", - "maxDuration": "Varauksen enimmäiskesto", - "eventsPerWeek": "Varausten määrä viikossa" + "period": "Kausi", + "duration": "Varauksen kesto", + "eventsPerWeek": "Varausten määrä viikossa", + "appliedSpaces": "Tilatoiveet", + "schedules": "Aikatoiveet", + "scheduleTooltip": "Toissijaiset aikatoiveet merkitty sulkumerkeillä ( )", + "status": { + "UNALLOCATED": "Vastaanotettu", + "IN_ALLOCATION": "Käsittelyssä", + "ALLOCATED": "Hyväksytty", + "REJECTED": "Hylätty" + } }, - "applicationEventSchedules": "Toivotut ajat", + "applicationEventSchedules": "Aikatoiveet", "noData": { "heading": "Lomakkeen tietoja ei ole vielä olemassa", "text": "Täytä ensin lomake" @@ -200,10 +206,7 @@ }, "reservationUnitTerms": "Täydentävät varausehdot", "termsOfService": "Helsingin kaupungin tilojen ja laitteiden varausehdot", - "userAcceptsTerms": "Olen lukenut ja hyväksyn sopimusehdot ja täydentävät varausehdot.", - "subHeading": { - "applicationInfo": "Varauksen tiedot" - } + "userAcceptsTerms": "Olen lukenut ja hyväksyn sopimusehdot ja täydentävät varausehdot." }, "view": { "heading": "Kausivaraushakemus", diff --git a/apps/ui/public/locales/fi/common.json b/apps/ui/public/locales/fi/common.json index 10ebfe899..c32279f81 100644 --- a/apps/ui/public/locales/fi/common.json +++ b/apps/ui/public/locales/fi/common.json @@ -107,5 +107,8 @@ "add": "Lisää", "subtract": "Vähennä", "inclTax": "sis. alv {{taxPercentage}}%", - "headAlt": "Tilan ikoni" + "headAlt": "Tilan ikoni", + "peopleSuffixShort": "hlö", + "yearSuffixShort": "v", + "amountSuffixShort": "kpl" } diff --git a/packages/common/src/termsbox/TermsBox.tsx b/packages/common/src/termsbox/TermsBox.tsx index e1bfa41c8..a18c2771a 100644 --- a/packages/common/src/termsbox/TermsBox.tsx +++ b/packages/common/src/termsbox/TermsBox.tsx @@ -12,7 +12,7 @@ type LinkT = { export type Props = { id?: string; - heading: string; + heading?: string; body?: string | JSX.Element; links?: LinkT[]; acceptLabel?: string; @@ -105,7 +105,7 @@ const TermsBox = ({ return ( - {heading} + {heading && {heading}}

    {body}

    {links && links?.length > 0 && (