diff --git a/scripts/src/generateSwaggerSchema.ts b/scripts/src/generateSwaggerSchema.ts index dba8979..c02b549 100644 --- a/scripts/src/generateSwaggerSchema.ts +++ b/scripts/src/generateSwaggerSchema.ts @@ -1,6 +1,7 @@ import { exec } from "child_process" -const url = "https://api.zwd.woon-o.azure.amsterdam.nl/api/schema/?format=json" +// const url = "https://api.zwd.woon-o.azure.amsterdam.nl/api/schema/?format=json" +const url = "http://localhost:8081/api/schema/?format=json" exec(`dtsgen -o ./src/__generated__/apiSchema.d.ts --url ${ url }`, (error, stdout, stderr) => { diff --git a/src/__generated__/apiSchema.d.ts b/src/__generated__/apiSchema.d.ts index 8e05677..95ba040 100644 --- a/src/__generated__/apiSchema.d.ts +++ b/src/__generated__/apiSchema.d.ts @@ -63,6 +63,13 @@ declare namespace Components { description?: string; date_added: string; // date-time } + export interface HomeownerAssociation { + id: number; + name: string; + build_year: number; + number_of_appartments: number; + message?: string | null; + } /** * * `CASE` - CASE * * `CASE_CLOSE` - CASE_CLOSE @@ -72,6 +79,17 @@ declare namespace Components { } } declare namespace Paths { + namespace AddressHomeownerAssociationRetrieve { + namespace Parameters { + export type Id = string; + } + export interface PathParameters { + id: Parameters.Id; + } + namespace Responses { + export type $200 = Components.Schemas.HomeownerAssociation; + } + } namespace ApiSchemaRetrieve { namespace Parameters { export type Format = "json" | "yaml"; diff --git a/src/app/components/Descriptions/Descriptions.tsx b/src/app/components/Descriptions/Descriptions.tsx new file mode 100644 index 0000000..3f6a880 --- /dev/null +++ b/src/app/components/Descriptions/Descriptions.tsx @@ -0,0 +1,28 @@ +import { DescriptionList } from "@amsterdam/design-system-react" +import { Fragment } from "react" + +type DescriptionItem = { + label: string; + children: React.ReactNode +} + +type DescriptionsProps = { + items: DescriptionItem[] +} + +export const Descriptions: React.FC = ({ items }) => ( + + {items.map((item, index) => ( + + + {item.label} + + + {item.children} + + + ))} + +) + +export default Descriptions diff --git a/src/app/components/Panorama/PanoramaPreview.tsx b/src/app/components/Panorama/PanoramaPreview.tsx new file mode 100644 index 0000000..42cecd4 --- /dev/null +++ b/src/app/components/Panorama/PanoramaPreview.tsx @@ -0,0 +1,56 @@ +import { useRef } from "react" +import styled from "styled-components" +import { usePanoramaByBagId } from "app/state/rest/custom/usePanoramaByBagId" +import useRect from "./hooks/useRect" + +type Props = { + bagId: string + width?: number + aspect?: number + radius?: number + fov?: number +} + +const Div = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + .skeleton { + width: 100%; + height: 100%; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + } + @keyframes loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +` +const Img = styled.img` + width: 100%; +` + +export const PanoramaPreview: React.FC = ({ bagId, width: w, aspect = 1.5, radius = 180, fov = 80 }) => { + const ref = useRef(null) + const rect = useRect(ref, 100) + const width = w ?? rect.width + const height = width !== undefined ? width / aspect : undefined + const [data] = usePanoramaByBagId(bagId, width, aspect, radius, fov) + + return ( +
+ { data ? ( + { + ) :
+ } +
+ ) +} + +export default PanoramaPreview diff --git a/src/app/components/Panorama/hooks/useRect.ts b/src/app/components/Panorama/hooks/useRect.ts new file mode 100644 index 0000000..d12c900 --- /dev/null +++ b/src/app/components/Panorama/hooks/useRect.ts @@ -0,0 +1,60 @@ +import { useLayoutEffect, useCallback, useState } from "react" +import debounce from "lodash.debounce" + +type RectResult = { + bottom: number + height: number + left: number + right: number + top: number + width: number +}; + +const getRect = (element?: T): RectResult => { + let rect: RectResult = { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0 + } + if (element) rect = element.getBoundingClientRect() + return rect +} + +export default ( + ref: React.RefObject, + delay = 0 +): RectResult => { + const [rect, setRect] = useState( + ref?.current ? getRect(ref.current) : getRect() + ) + + const handleResize = useCallback(() => { + if (ref.current == null) return + setRect(getRect(ref.current)) // Update client rect + }, [ref]) + + useLayoutEffect(() => { + const element = ref.current + if (element == null) return + + handleResize() + + const debounced = debounce(handleResize, delay) + + if (typeof ResizeObserver === "function") { + const resizeObserver = new ResizeObserver(debounced) + resizeObserver.observe(element) + return () => { + resizeObserver.disconnect() + } + } else { + window.addEventListener("resize", debounced) // Browser support, remove freely + return () => window.removeEventListener("resize", debounced) + } + }, [ref, delay, handleResize]) + + return rect +} diff --git a/src/app/components/index.ts b/src/app/components/index.ts index 943131b..426730e 100644 --- a/src/app/components/index.ts +++ b/src/app/components/index.ts @@ -1,14 +1,16 @@ +export * from "./Descriptions/Descriptions" export * from "./DefaultLayout/DefaultLayout" export * from "./DetailsList/DetailsList" -export * from "./TimelineEvents/TimelineEvents" export * from "./icons/icons" -export * from "./Modal/Modal" +export * from "./LinkButton/LinkButton" export * from "./Modal/hooks/useModal" +export * from "./Modal/Modal" export * from "./NavMenu/NavMenu" export * from "./PageHeading/PageHeading" -export * from "./LinkButton/LinkButton" +export * from "./Panorama/PanoramaPreview" export * from "./SmallSkeleton/SmallSkeleton" export * from "./Spinner/Spinner" export * from "./Table/Table" export * from "./Table/types" +export * from "./TimelineEvents/TimelineEvents" export * from "./User/User" diff --git a/src/app/pages/AddressPage/AddressPage.tsx b/src/app/pages/AddressPage/AddressPage.tsx new file mode 100644 index 0000000..648ae3c --- /dev/null +++ b/src/app/pages/AddressPage/AddressPage.tsx @@ -0,0 +1,20 @@ +import { HousingIcon } from "@amsterdam/design-system-react-icons" +import { useParams } from "react-router-dom" +import { PanoramaPreview, PageHeading } from "app/components" +import HoaDescription from "./HoaDescription" + + +export const AddressPage: React.FC = () => { + const { bagId } = useParams<{ bagId: string }>() + + return ( + <> + + { bagId && } + { bagId && } + + ) +} + +export default AddressPage + \ No newline at end of file diff --git a/src/app/pages/AddressPage/HoaDescription.tsx b/src/app/pages/AddressPage/HoaDescription.tsx new file mode 100644 index 0000000..3c5c3d2 --- /dev/null +++ b/src/app/pages/AddressPage/HoaDescription.tsx @@ -0,0 +1,47 @@ +import { Button } from "@amsterdam/design-system-react" +import { styled } from "styled-components" +import { useNavigate } from "react-router-dom" +import { PageSpinner, Descriptions } from "app/components" +import { useHomeownerAssociation } from "app/state/rest" + + +type Props = { + bagId: string +} + +const Wrapper = styled.div` + margin: 24px 0; +` + +export const HoaDescription: React.FC = ({ bagId }) => { + const [data, { isBusy }] = useHomeownerAssociation(bagId) + const navigate = useNavigate() + + if (isBusy) { + return + } + if (data?.message) { + return

Er zijn geen VVE-gegevens gevonden voor dit adres.

+ } + if (data?.id) { + const items = [ + { label: "VVE statutaire naam", children: data?.name }, + { label: "Bouwjaar", children: data?.build_year }, + { label: "Aantal appartementen", children: data?.number_of_appartments } + ] + return ( + <> + + + + + + ) + } + return null +} + +export default HoaDescription + \ No newline at end of file diff --git a/src/app/pages/SearchPage/SearchResults/SearchResults.tsx b/src/app/pages/SearchPage/SearchResults/SearchResults.tsx index 346989e..f49ed5a 100644 --- a/src/app/pages/SearchPage/SearchResults/SearchResults.tsx +++ b/src/app/pages/SearchPage/SearchResults/SearchResults.tsx @@ -21,17 +21,15 @@ const SearchResults: React.FC = ({ searchString }) => { const dataSource = bagData?.response?.docs?.filter((obj) => obj.adresseerbaarobject_id) || [] return ( - isValid ? ( - navigate(`vve/${ adresseerbaarobject_id }/zaken/nieuw`)} - emptyPlaceholder="Er zijn geen adressen gevonden" - pagination={ false } - /> - ) : null +
navigate(`adres/${ adresseerbaarobject_id }`)} + emptyPlaceholder={ isValid ? "Geen resultaten gevonden" : "Voer minimaal 3 karakters in" } + pagination={ false } + /> ) } diff --git a/src/app/pages/SearchVvePage/SearchResultsTable.tsx b/src/app/pages/SearchVvePage/SearchResultsTable.tsx index 13a4c3b..34a30ad 100644 --- a/src/app/pages/SearchVvePage/SearchResultsTable.tsx +++ b/src/app/pages/SearchVvePage/SearchResultsTable.tsx @@ -33,7 +33,7 @@ export const SearchResultsTable: React.FC = () => { columns={ columns } data={ vveList } loading={ false } - onClickRow={(_, id) => navigate(`vve/${ id }/zaken/nieuw`)} + onClickRow={(_, id) => navigate(`/vve/${ id }/zaken/nieuw`)} /> ) diff --git a/src/app/pages/index.ts b/src/app/pages/index.ts index 45f32ea..66e237a 100644 --- a/src/app/pages/index.ts +++ b/src/app/pages/index.ts @@ -1,4 +1,5 @@ export * from "./AuthPage/AuthPage" +export * from "./AddressPage/AddressPage" export * from "./CaseCreatePage/CaseCreatePage" export * from "./CaseDetailsPage/CaseDetailsPage" export * from "./CasesPage/CasesPage" diff --git a/src/app/routing/router.tsx b/src/app/routing/router.tsx index ea95e78..b8ac849 100644 --- a/src/app/routing/router.tsx +++ b/src/app/routing/router.tsx @@ -1,7 +1,7 @@ import { DefaultLayout } from "app/components" import { - AuthPage, CaseCreatePage, CaseDetailsPage, CasesPage, - NotFoundPage, SearchPage, TasksPage, BpmnPage, SearchVvePage + AuthPage, AddressPage, CaseCreatePage, CaseDetailsPage, CasesPage, + NotFoundPage, SearchPage, TasksPage, BpmnPage, SearchVvePage } from "app/pages" import { createBrowserRouter } from "react-router-dom" @@ -15,6 +15,10 @@ const router = createBrowserRouter([ path: "/", element: }, + { + path: "adres/:bagId", + element: + }, { path: "bpmn", element: diff --git a/src/app/state/rest/address.ts b/src/app/state/rest/address.ts new file mode 100644 index 0000000..2332ca8 --- /dev/null +++ b/src/app/state/rest/address.ts @@ -0,0 +1,16 @@ +import type { Options } from "." +import { makeApiUrl, useErrorHandler } from "./hooks/utils" +import useApiRequest from "./hooks/useApiRequest" + + +export const useHomeownerAssociation = (bagId?: string ,options?: Options) => { + const handleError = useErrorHandler() + return useApiRequest({ + ...options, + url: `${ makeApiUrl("address", bagId, "homeowner-association") }`, + lazy: bagId === undefined, + groupName: "address", + handleError, + isProtected: true + }) +} diff --git a/src/app/state/rest/bagPdok.ts b/src/app/state/rest/bagPdok.ts index c23d055..ef0ed81 100644 --- a/src/app/state/rest/bagPdok.ts +++ b/src/app/state/rest/bagPdok.ts @@ -4,7 +4,7 @@ import useApiRequest from "./hooks/useApiRequest" import qs from "qs" // Constants -const PDOK_URL = "https://api.pdok.nl/bzk/locatieserver/search/v3_1/suggest" +const PDOK_URL = "https://api.pdok.nl/bzk/locatieserver/search/v3_1" const MUNICIPALITY_FILTER = "gemeentenaam:(amsterdam)" const ADDRESS_FILTER = "AND (type:adres) AND (adrestype: hoofdadres)" const DEFAULT_SORT = "score desc, weergavenaam asc" @@ -29,7 +29,20 @@ export const useBagPdok = (searchString?: string, options?: Options) => { const query = constructQuery(searchString) return useApiRequest({ - url: `${ PDOK_URL }${ query }`, + url: `${ PDOK_URL }/suggest${ query }`, + lazy: searchString === undefined, + ...options, + groupName: "bagPdok", + handleError + }) +} + +export const useBagPdokByBagId = (searchString?: string, options?: Options) => { + const handleError = useErrorHandler() + const query = constructQuery(searchString) + + return useApiRequest({ + url: `${ PDOK_URL }/free${ query }`, lazy: searchString === undefined, ...options, groupName: "bagPdok", diff --git a/src/app/state/rest/custom/usePanoramaByBagId.ts b/src/app/state/rest/custom/usePanoramaByBagId.ts new file mode 100644 index 0000000..90cc5b8 --- /dev/null +++ b/src/app/state/rest/custom/usePanoramaByBagId.ts @@ -0,0 +1,36 @@ +import { useBagPdokByBagId, usePanorama } from "app/state/rest" + +const extractLatLng = (point?: BAGPdokAddress["centroide_ll"]) => { + // Ensure the string starts with "POINT(" and ends with ")" + if (point && point.startsWith("POINT(") && point.endsWith(")")) { + // Remove "POINT(" from the start and ")" from the end + const coordinates = point.slice(6, -1) + // Split the coordinates by space + const [lng, lat] = coordinates.split(" ") + // Parse the coordinates to floats + return { + lat: parseFloat(lat), + lng: parseFloat(lng) + } + } + return null +} + +export const usePanoramaByBagId = (bagId: string, width: number | undefined, aspect: number | undefined, radius: number, fov: number | undefined) => { + const [data] = useBagPdokByBagId(bagId) + const docs = data?.response?.docs + const foundAddress = docs && docs[0] ? docs[0] : undefined + const latLng = extractLatLng(foundAddress?.centroide_ll) + + return usePanorama( + latLng?.lat, + latLng?.lng, + width, + aspect, + radius, + fov, + { lazy: foundAddress === undefined || width === undefined } + ) +} + +export default usePanoramaByBagId diff --git a/src/app/state/rest/dataPunt.ts b/src/app/state/rest/dataPunt.ts new file mode 100644 index 0000000..e7401d7 --- /dev/null +++ b/src/app/state/rest/dataPunt.ts @@ -0,0 +1,15 @@ +import qs from "qs" +import type { Options } from "./" +import { useSuppressErrorHandler } from "./hooks/utils" +import useApiRequest from "./hooks/useApiRequest" + +export const usePanorama = (lat?: number, lon?: number, width?: number, aspect?: number, radius?: number, fov?: number, options?: Options) => { + const handleError = useSuppressErrorHandler() + const queryString = qs.stringify({ lat, lon, width, fov, aspect, radius }, { addQueryPrefix: true }) + return useApiRequest<{ url: string }>({ + ...options, + url: `https://api.data.amsterdam.nl/panorama/thumbnail/${ queryString }`, + groupName: "dataPunt", + handleError + }) +} diff --git a/src/app/state/rest/index.ts b/src/app/state/rest/index.ts index 14f928d..26831ff 100644 --- a/src/app/state/rest/index.ts +++ b/src/app/state/rest/index.ts @@ -1,8 +1,10 @@ export type ApiGroup = + | "address" + | "bagPdok" + | "bpmn" | "cases" + | "dataPunt" | "tasks" - | "bpmn" - | "bagPdok" export type Options = { keepUsingInvalidCache?: boolean @@ -10,7 +12,9 @@ export type Options = { isMockExtended?: boolean } +export * from "./address" +export * from "./bagPdok" +export * from "./bpmn" export * from "./cases" +export * from "./dataPunt" export * from "./tasks" -export * from "./bpmn" -export * from "./bagPdok" diff --git a/src/app/state/rest/provider/ApiProvider.tsx b/src/app/state/rest/provider/ApiProvider.tsx index 6e91632..b50c49b 100644 --- a/src/app/state/rest/provider/ApiProvider.tsx +++ b/src/app/state/rest/provider/ApiProvider.tsx @@ -9,10 +9,12 @@ import { ApiGroup } from "../index" type GroupedContext = Record export const ApiContext = createContext({ + address: noopContext, + bagPdok: noopContext, bpmn: noopContext, cases: noopContext, - tasks: noopContext, - bagPdok: noopContext + dataPunt: noopContext, + tasks: noopContext }) type Props = { @@ -21,6 +23,10 @@ type Props = { const ApiProvider: React.FC = ({ children }) => { const value: GroupedContext = { + address: { + ...useApiCache(), + ...useRequestQueue() + }, bagPdok: { ...useApiCache(), ...useRequestQueue() @@ -33,6 +39,10 @@ const ApiProvider: React.FC = ({ children }) => { ...useApiCache(), ...useRequestQueue() }, + dataPunt: { + ...useApiCache(), + ...useRequestQueue() + }, tasks: { ...useApiCache(), ...useRequestQueue()