diff --git a/config-demo/modules.json b/config-demo/modules.json
index 2ac1b89f..52f56c24 100644
--- a/config-demo/modules.json
+++ b/config-demo/modules.json
@@ -80,6 +80,10 @@
"id": "news",
"enabled": true
},
+ {
+ "id": "hub-map",
+ "enabled": true
+ },
{
"id": "profile-questions",
"enabled": true,
diff --git a/package.json b/package.json
index 36d4608f..9a0384ec 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"pino-pretty": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-easy-panzoom": "^0.4.4",
"react-query": "^3.39.1",
"react-table": "^7.8.0",
"sequelize": "^6.29.0",
diff --git a/src/client/components/Home.tsx b/src/client/components/Home.tsx
index bd2a1b5f..d2c99ac7 100644
--- a/src/client/components/Home.tsx
+++ b/src/client/components/Home.tsx
@@ -98,7 +98,7 @@ const Tabs: React.FC<{
[]
)
return (
-
+
{TABS.map((tab) => {
const IconComponent = Icons[tab.icon as Icon]
return (
diff --git a/src/client/components/OfficeFloorMap.tsx b/src/client/components/OfficeFloorMap.tsx
index e78addfd..2a49368f 100644
--- a/src/client/components/OfficeFloorMap.tsx
+++ b/src/client/components/OfficeFloorMap.tsx
@@ -1,73 +1,208 @@
-import React from 'react'
-import { Button } from '#client/components/ui'
-import { OfficeArea } from '#shared/types'
+import React, { MouseEventHandler } from 'react'
+import { Avatar, Button, P } from '#client/components/ui'
+import {
+ ScheduledItemType,
+ OfficeArea,
+ OfficeAreaDesk,
+ OfficeRoom,
+ VisitType,
+} from '#shared/types'
import { cn } from '#client/utils'
+import { useStore } from '@nanostores/react'
+import * as stores from '#client/stores'
+import { ImageWithPanZoom } from './ui/ImageWithPanZoom'
+
+type PointComponentFunctionProps = (
+ item: OfficeAreaDesk | OfficeRoom,
+ isSelected: boolean,
+ isAvailable: boolean,
+ onClick: (id: string, kind: string) => MouseEventHandler
+) => Element | JSX.Element
+
+const pointCommonStyle =
+ 'rounded-sm border-2 -translate-y-1/2 -translate-x-1/2 hover:scale-105 transition-all delay-100 '
+
+const PointComponent: Record<
+ VisitType.Visit | VisitType.RoomReservation,
+ PointComponentFunctionProps
+> = {
+ [VisitType.Visit]: (item, isSelected, isAvailable, onClick) => (
+
+ ),
+ [VisitType.RoomReservation]: (item, isSelected, isAvailable, onClick) => (
+
+ ),
+}
type OfficeFloorMapProps = {
area: OfficeArea
- availableDeskIds: string[]
- selectedDeskId: string | null
- onToggleDesk: (deskId: string) => void
+ mappablePoints?: Array
+ panZoom?: boolean
+ officeVisits?: Record>
+ showUsers?: boolean
+ selectedPointId: string | null
+ clickablePoints?: string[]
+ onToggle: (id: string, kind: string) => void
}
+
export const OfficeFloorMap: React.FC = ({
area,
- availableDeskIds,
- selectedDeskId,
- onToggleDesk,
+ mappablePoints,
+ panZoom = false,
+ officeVisits,
+ showUsers = false,
+ selectedPointId,
+ clickablePoints,
+ onToggle,
}) => {
+ const me = useStore(stores.me)
+ const initialStartPosition = selectedPointId
+ ? mappablePoints?.find(
+ (point: ScheduledItemType) => point.id === selectedPointId
+ )
+ : null
+
const onClick = React.useCallback(
- (deskId: string) => (ev: React.MouseEvent) => {
+ (id: string, kind: string) => (ev: React.MouseEvent) => {
ev.preventDefault()
- onToggleDesk(deskId)
+ onToggle(id, kind)
},
- [onToggleDesk]
+ [onToggle]
)
+
+ // @todo fix types here
+ const mapObjects = (scale: number) =>
+ !mappablePoints
+ ? []
+ : mappablePoints
+ .filter((x) => x.position)
+ .map((x) => {
+ let isSelected = selectedPointId === x.id
+ let isAvailable = false
+ let user = null
+
+ if (!!officeVisits && me && showUsers) {
+ const bookedVisit: ScheduledItemType | undefined =
+ officeVisits.visit?.find(
+ (v) => v.areaId === area.id && v.objectId === x.id
+ )
+ if (!!bookedVisit) {
+ if (bookedVisit?.user?.id === me?.id) {
+ user = me
+ } else {
+ user = bookedVisit.user
+ }
+ }
+ }
+ isSelected = selectedPointId === x.id
+ isAvailable = !!clickablePoints?.includes(x.id)
+
+ const style = {
+ left: `${x.position?.x}%`,
+ top: `${x.position?.y}%`,
+ transform: `scale(${1 / scale})`,
+ transformOrigin: 'top left',
+ }
+
+ if (!!user && !!me) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ {/* @ts-ignore */}
+ {PointComponent[x.kind](x, isSelected, isAvailable, onClick)}
+
+ )
+ })
+
return (
-
- {area.desks
- .filter((x) => x.position)
- .map((x) => {
- const isSelected = selectedDeskId === x.id
- const isAvailable = availableDeskIds.includes(x.id)
- return (
-
-
-
-
- )
- })}
+
+
+ {mapObjects(1)}
+
+
+ mapObjects(scale)}
+ initialStartPosition={
+ initialStartPosition ? initialStartPosition.position : undefined
+ }
+ />
+
)
}
diff --git a/src/client/components/ui/Avatar.tsx b/src/client/components/ui/Avatar.tsx
index 35f53271..e28deed8 100644
--- a/src/client/components/ui/Avatar.tsx
+++ b/src/client/components/ui/Avatar.tsx
@@ -57,7 +57,7 @@ export const Avatar: React.FC = ({
const [hasError, setHasError] = React.useState(false)
const setError = () => setHasError(true)
const resultClassName = cn(
- 'rounded-full bg-gray-200',
+ 'block rounded-full bg-gray-200',
SIZE_CLASSNAME[size],
className
)
diff --git a/src/client/components/ui/Button.tsx b/src/client/components/ui/Button.tsx
index 837917a4..5ebdb737 100644
--- a/src/client/components/ui/Button.tsx
+++ b/src/client/components/ui/Button.tsx
@@ -153,11 +153,13 @@ export const RoundButton = ({
icon,
className,
disabled = false,
+ size = 'small',
}: {
onClick: (ev: React.MouseEvent) => void
icon: Icon
className?: string
disabled?: boolean
+ size?: ButtonSize
}) => {
let IconComponent = Icons[icon]
if (!IconComponent) {
@@ -166,11 +168,13 @@ export const RoundButton = ({
}
return (
)
-}
\ No newline at end of file
+}
diff --git a/src/modules/events/client/components/EventPage.tsx b/src/modules/events/client/components/EventPage.tsx
index c4dde7ee..a0b334b6 100644
--- a/src/modules/events/client/components/EventPage.tsx
+++ b/src/modules/events/client/components/EventPage.tsx
@@ -185,7 +185,7 @@ export const EventPage = () => {
>
) : null}
@@ -386,7 +386,7 @@ export const EventPage = () => {
size="small"
label="Sign in to apply for the event"
className="w-full h-full"
- callbackPath={`/event/${eventId}`}
+ callbackPath={`/events/${eventId}`}
/>
)}
{providers.includes('polkadot') && (
@@ -398,7 +398,7 @@ export const EventPage = () => {
className="bg-black hover:opacity-80 hover:bg-black w-full"
provider="polkadot"
currentState={'Login'}
- callbackPath={`/event/${eventId}`}
+ callbackPath={`/events/${eventId}`}
/>
)}
diff --git a/src/modules/events/client/components/EventsPage.tsx b/src/modules/events/client/components/EventsPage.tsx
new file mode 100644
index 00000000..8edef3e5
--- /dev/null
+++ b/src/modules/events/client/components/EventsPage.tsx
@@ -0,0 +1,127 @@
+import { useStore } from '@nanostores/react'
+import * as React from 'react'
+import { BackButton, ComponentWrapper, H1, H2 } from '#client/components/ui'
+import * as stores from '#client/stores'
+import { useMyEventsView, useEventsView } from '../queries'
+import { EventApplicationStatus } from '#shared/types'
+import { EventBadge } from './EventBadge'
+import { cn } from '#client/utils'
+
+const Sections = {
+ waiting: 'waiting for approval',
+ confirmed: 'confirmed',
+ past: 'past',
+ rejected: 'rejected',
+ optedOut: 'opted out',
+ upcoming: 'upcoming',
+}
+
+import { EventTimeCategory, Event } from '#shared/types'
+
+const TitleStatus = {
+ [Sections.waiting]: EventApplicationStatus.Pending,
+ [Sections.confirmed]: EventApplicationStatus.Confirmed,
+ [Sections.rejected]: EventApplicationStatus.CancelledAdmin,
+ [Sections.optedOut]: EventApplicationStatus.CancelledUser,
+}
+const EventsBg = {
+ [Sections.waiting]: 'bg-yellow-50 border-yellow-100',
+ [Sections.confirmed]: 'bg-green-50 border-green-100',
+ [Sections.past]: 'bg-gray-100',
+}
+
+const EventsList = ({ events, title }: { title: string; events: Event[] }) => (
+
+
{title}
+
+ {events?.map((x: any, i) => {
+ if (title === Sections.upcoming && !!x.applications?.length) {
+ return
+ }
+ return (
+
+
+
+ )
+ })}
+
+
+)
+
+export const EventsPage = () => {
+ const officeId = useStore(stores.officeId)
+ const { data: events, isFetched } = useEventsView(officeId, 'time')
+ const { data: myEvents, isFetched: isMineFetched } = useMyEventsView(
+ officeId,
+ 'status'
+ )
+ const [uniqueUpcoming, setUniqueUpcoming] = React.useState([])
+
+ React.useEffect(() => {
+ if (!!events && !!myEvents && !myEvents.pending.length) {
+ return
+ } else if (!!events && !!myEvents) {
+ const upcomingEventIds = new Set(myEvents.pending.map((e) => e.id))
+ const uniqueEvents = events[EventTimeCategory.upcoming].filter(
+ (e) => !upcomingEventIds.has(e.id)
+ )
+ if (!!uniqueEvents.length) {
+ setUniqueUpcoming(uniqueEvents)
+ }
+ }
+ }, [myEvents, events])
+ if (!isFetched && !isMineFetched) {
+ return null
+ }
+
+ if (!events || !Object.keys(events).length) {
+ return (
+
+ Events
+ No events to show yet
+
+ )
+ }
+
+ return (
+
+
+ Events
+ {!!myEvents && (
+
+ {Object.values(Sections).map((title) => {
+ // @ts-ignore
+ const evs = myEvents[TitleStatus[title] as EventApplicationStatus]
+ if (!!evs?.length) {
+ return
+ }
+ })}
+
+ )}
+ {!!uniqueUpcoming?.length && (
+
+ )}
+ {!!events?.past && (
+
+ )}
+
+ )
+}
diff --git a/src/modules/events/client/components/GlobalEvents.tsx b/src/modules/events/client/components/GlobalEvents.tsx
index 704dcbbd..6ea95422 100644
--- a/src/modules/events/client/components/GlobalEvents.tsx
+++ b/src/modules/events/client/components/GlobalEvents.tsx
@@ -80,7 +80,7 @@ const _GlobalEvents: React.FC = () => {
{event.title}{' '}
diff --git a/src/modules/events/client/components/UpcomingEvents.tsx b/src/modules/events/client/components/UpcomingEvents.tsx
index 0f23afac..2e00abe6 100644
--- a/src/modules/events/client/components/UpcomingEvents.tsx
+++ b/src/modules/events/client/components/UpcomingEvents.tsx
@@ -12,16 +12,14 @@ const pageSize = 3
export const UpcomingEvents: React.FC = () => {
const officeId = useStore(stores.officeId)
const { data: events, isFetched } = useUpcomingEvents(officeId)
- const [noMoreData, setNoMoreData] = useState(false)
const [page, setPage] = useState(1)
const [eventData, setEventData] = useState>([])
useEffect(() => {
- if (events?.length && isFetched) {
+ if (!!events?.length && isFetched) {
let limit = page === 1 ? pageSize : page * pageSize
const result = paginateArray(events, 1, limit)
setEventData(result)
- setNoMoreData(events.length <= limit)
}
}, [events, officeId, page])
@@ -46,15 +44,13 @@ export const UpcomingEvents: React.FC = () => {
))}
)}
- {!noMoreData && (
- setPage(page + 1)}
- >
- Show more
-
- )}
+ stores.goTo('events')}
+ >
+ Show more
+
) : null
}
diff --git a/src/modules/events/client/components/index.ts b/src/modules/events/client/components/index.ts
index e80dd878..3a3f4a7a 100644
--- a/src/modules/events/client/components/index.ts
+++ b/src/modules/events/client/components/index.ts
@@ -11,3 +11,4 @@ export { GlobalEvents } from './GlobalEvents'
export { MyEvents } from './MyEvents'
export { UncompletedActions } from './UncompletedActions'
export { UpcomingEvents } from './UpcomingEvents'
+export { EventsPage } from './EventsPage'
diff --git a/src/modules/events/client/queries.ts b/src/modules/events/client/queries.ts
index 57074351..3f39292d 100644
--- a/src/modules/events/client/queries.ts
+++ b/src/modules/events/client/queries.ts
@@ -13,6 +13,8 @@ import {
EventPreview,
EventPublicResponse,
EventToogleCheckboxRequest,
+ EventsByStatusCategory,
+ EventsByTimeCategory,
FormSubmissionRequest,
GlobalEvent,
PublicForm,
@@ -50,16 +52,37 @@ export const useUpcomingEvents = (officeId: string | null) => {
)
}
-export const useMyEvents = (officeId: string | null) => {
+export const useEventsView = (officeId: string | null, sortBy?: string) => {
+ const path = '/user-api/events/event/view'
+ return useQuery(
+ [path, { office: officeId, sortBy }],
+ async ({ queryKey }) =>
+ (await api.get(path, { params: queryKey[1] })).data,
+ { enabled: !!officeId }
+ )
+}
+
+export const useMyEvents = (officeId: string | null, sortBy?: string) => {
const path = '/user-api/events/event/me'
return useQuery(
- [path, { office: officeId }],
+ [path, { office: officeId, sortBy }],
async ({ queryKey }) =>
(await api.get(path, { params: queryKey[1] })).data,
{ enabled: !!officeId }
)
}
+export const useMyEventsView = (officeId: string | null, sortBy?: string) => {
+ const path = '/user-api/events/event/me/view'
+ return useQuery(
+ [path, { office: officeId, sortBy }],
+ async ({ queryKey }) =>
+ (await api.get(path, { params: queryKey[1] }))
+ .data,
+ { enabled: !!officeId }
+ )
+}
+
export const useUncompletedActions = (officeId: string | null) => {
const path = '/user-api/events/event/uncompleted'
return useQuery(
diff --git a/src/modules/events/manifest.json b/src/modules/events/manifest.json
index fb15573d..559b3884 100644
--- a/src/modules/events/manifest.json
+++ b/src/modules/events/manifest.json
@@ -28,7 +28,13 @@
"fullScreen": true
}
},
- "user": {},
+ "user": {
+ "events": {
+ "path": "/events",
+ "componentId": "EventsPage",
+ "fullScreen": false
+ }
+ },
"admin": {
"adminEvents": {
"path": "/admin/events",
diff --git a/src/modules/events/server/helpers/index.ts b/src/modules/events/server/helpers/index.ts
index 2d8fdd8e..e2ce7d2c 100644
--- a/src/modules/events/server/helpers/index.ts
+++ b/src/modules/events/server/helpers/index.ts
@@ -3,7 +3,9 @@ import { User } from '#modules/users/server/models'
import config from '#server/config'
import { appConfig } from '#server/app-config'
import * as fp from '#shared/utils/fp'
-import { Event } from '#shared/types'
+import { EntityVisibility, Event, EventApplicationStatus } from '#shared/types'
+import { Op } from 'sequelize'
+import { FastifyInstance } from 'fastify'
export * as checklists from './checklists'
export * as globalEventTemplates from './global-event-templates'
@@ -44,3 +46,69 @@ export const getApplicationUpdateMessage = (
user: user.usePublicProfileView(),
status,
})
+
+export const getUpcomingEventsForUser = async (
+ fastify: FastifyInstance,
+ user: User,
+ officeId: string
+) =>
+ fastify.db.Event.findAll({
+ include: {
+ model: fastify.db.EventApplication,
+ as: 'applications',
+ where: {
+ userId: user.id,
+ },
+ required: false,
+ },
+ where: {
+ endDate: {
+ [Op.gte]: new Date(),
+ },
+ visibility: EntityVisibility.Visible,
+ allowedRoles: { [Op.overlap]: user.roles },
+ offices: { [Op.contains]: [officeId] },
+ },
+ order: ['startDate'],
+ })
+
+export const getUpcomingEventApplicationsForUser = (
+ fastify: FastifyInstance,
+ userId: string
+) =>
+ fastify.db.EventApplication.findAll({
+ include: {
+ model: fastify.db.Event,
+ as: 'event',
+ where: {
+ endDate: {
+ [Op.gte]: new Date(),
+ },
+ visibility: {
+ [Op.in]: [EntityVisibility.Visible, EntityVisibility.Url],
+ },
+ },
+ attributes: [
+ 'id',
+ 'title',
+ 'startDate',
+ 'endDate',
+ 'coverImageUrl',
+ 'metadata',
+ 'checklist',
+ ],
+ required: true,
+ order: [['startDate', 'ASC']],
+ },
+ where: {
+ status: {
+ [Op.in]: [
+ EventApplicationStatus.Opened,
+ EventApplicationStatus.Pending,
+ EventApplicationStatus.Confirmed,
+ ],
+ },
+ userId: userId,
+ },
+ attributes: ['eventId', 'status', 'id'],
+ })
diff --git a/src/modules/events/server/models/event-application.ts b/src/modules/events/server/models/event-application.ts
index fbd8994b..814bfc9f 100644
--- a/src/modules/events/server/models/event-application.ts
+++ b/src/modules/events/server/models/event-application.ts
@@ -32,6 +32,7 @@ export class EventApplication
declare creatorUserId: string
declare formId: string | null
declare formSubmissionId: string | null
+ declare event?: Event
static async countByEventId(): Promise> {
const entries = (await this.findAll({
diff --git a/src/modules/events/server/models/event.ts b/src/modules/events/server/models/event.ts
index 584ec79f..0b59d31a 100644
--- a/src/modules/events/server/models/event.ts
+++ b/src/modules/events/server/models/event.ts
@@ -63,6 +63,7 @@ export class Event
declare notificationRule: EventModel['notificationRule']
declare metadata: EventModel['metadata']
declare responsibleUserIds: EventModel['responsibleUserIds']
+ declare applications?: EventModel['applications']
usePublicView(
application: EventApplication | null,
diff --git a/src/modules/events/server/models/index.ts b/src/modules/events/server/models/index.ts
index fe2d8e96..913db8ed 100644
--- a/src/modules/events/server/models/index.ts
+++ b/src/modules/events/server/models/index.ts
@@ -1,4 +1,17 @@
+import { Event } from './event'
+import { EventApplication } from './event-application'
+
export { Event } from './event'
export { EventApplication } from './event-application'
export { EventCheckmark } from './event-checkmark'
export { EventChecklistReminderJob } from './event-checklist-reminder-job'
+
+Event.hasMany(EventApplication, {
+ foreignKey: 'eventId',
+ as: 'applications',
+})
+
+EventApplication.belongsTo(Event, {
+ foreignKey: 'eventId',
+ as: 'event',
+})
diff --git a/src/modules/events/server/router.ts b/src/modules/events/server/router.ts
index 450fe872..8cd0c159 100644
--- a/src/modules/events/server/router.ts
+++ b/src/modules/events/server/router.ts
@@ -11,7 +11,7 @@ import {
FormSubmissionRequest,
PublicForm,
} from '#modules/forms/types'
-import { EntityVisibility } from '#shared/types'
+import { EntityVisibility, EventStatusCategory } from '#shared/types'
import * as fp from '#shared/utils/fp'
import { Permissions } from '../permissions'
import {
@@ -23,8 +23,14 @@ import {
EventParticipant,
EventPublicResponse,
EventToogleCheckboxRequest,
+ EventWithApplications,
} from '../types'
-import { getApplicationMessage, getApplicationUpdateMessage } from './helpers'
+import {
+ getApplicationMessage,
+ getApplicationUpdateMessage,
+ getUpcomingEventApplicationsForUser,
+ getUpcomingEventsForUser,
+} from './helpers'
import { isEventApplicationUncompleted } from './helpers/checklists'
import { Metadata } from '../metadata-schema'
@@ -437,70 +443,159 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
}
)
- fastify.get('/event', async (req, reply) => {
- if (!req.office) {
- return reply.throw.badParams('Invalid office ID')
+ fastify.get(
+ '/event',
+ async (
+ req: FastifyRequest<{
+ Querystring: { sortBy?: string }
+ }>,
+ reply
+ ) => {
+ if (!req.office) {
+ return reply.throw.badParams('Invalid office ID')
+ }
+ const events = await getUpcomingEventsForUser(
+ fastify,
+ req.user,
+ req.office.id
+ )
+
+ return events.map((e) => {
+ const application = !!e?.applications?.length ? e.applications[0] : null
+ return e.usePublicView(application, [], null)
+ })
}
- const events = await fastify.db.Event.findAll({
- where: {
- endDate: {
- [Op.gte]: new Date(),
- },
- visibility: EntityVisibility.Visible,
- allowedRoles: { [Op.overlap]: req.user.roles },
- offices: { [Op.contains]: [req.office.id] },
- },
- order: ['startDate'],
- })
+ )
- const applications = await fastify.db.EventApplication.findAll({
- where: {
- userId: req.user.id,
- eventId: {
- [Op.in]: events.map((x: Event) => x.id),
- },
- },
- })
+ fastify.get(
+ '/event/view',
+ async (
+ req: FastifyRequest<{
+ Querystring: { sortBy?: string }
+ }>,
+ reply
+ ) => {
+ if (!req.office) {
+ return reply.throw.badParams('Invalid office ID')
+ }
+ const sort = ['time']
+ if (!req.query.sortBy || !sort.includes(req.query.sortBy)) {
+ return reply.throw.badParams('Invalid sortBy parameter')
+ }
- return events.map((e) => {
- const application = applications.find((a) => a.eventId === e.id)
- return e.usePublicView(application || null, [], null)
- })
- })
+ if (req.query.sortBy === 'time') {
+ const upcomingEvents = await getUpcomingEventsForUser(
+ fastify,
+ req.user,
+ req.office.id
+ )
+ const pastEvents = await fastify.db.Event.findAll({
+ where: {
+ endDate: {
+ [Op.lt]: new Date(),
+ },
+ visibility: EntityVisibility.Visible,
+ allowedRoles: { [Op.overlap]: req.user.roles },
+ offices: { [Op.contains]: [req.office.id] },
+ },
+ order: ['startDate'],
+ })
+ return {
+ upcoming: upcomingEvents,
+ past: pastEvents,
+ }
+ }
- fastify.get('/event/me', async (req, reply) => {
- if (!req.office) {
- return reply.throw.badParams('Invalid office ID')
+ return []
}
+ )
- const applicationEventIds = await fastify.db.EventApplication.findAll({
- where: {
- status: {
- [Op.in]: [
- EventApplicationStatus.Opened,
- EventApplicationStatus.Pending,
- EventApplicationStatus.Confirmed,
- ],
- },
- userId: req.user.id,
- },
- attributes: ['eventId'],
- }).then((xs) => xs.map((x) => x.eventId))
+ fastify.get(
+ '/event/me/view',
+ async (
+ req: FastifyRequest<{
+ Querystring: { sortBy?: string }
+ }>,
+ reply
+ ) => {
+ if (!req.office) {
+ return reply.throw.badParams('Invalid office ID')
+ }
- const events = await fastify.db.Event.findAll({
- where: {
- id: { [Op.in]: applicationEventIds },
- endDate: {
- [Op.gte]: new Date(),
- },
- visibility: {
- [Op.in]: [EntityVisibility.Visible, EntityVisibility.Url],
- },
- },
- order: [['startDate', 'ASC']],
- })
- return events
- })
+ const sort = ['status']
+ if (!req.query.sortBy || !sort.includes(req.query.sortBy)) {
+ return reply.throw.badParams('Invalid sortBy parameter')
+ }
+ const eventApplications = await getUpcomingEventApplicationsForUser(
+ fastify,
+ req.user.id
+ )
+
+ if (req.query.sortBy === 'status') {
+ const result: Record<
+ EventStatusCategory,
+ Array
+ > = {
+ [EventApplicationStatus.Confirmed]: [],
+ [EventApplicationStatus.Pending]: [],
+ [EventApplicationStatus.Opened]: [],
+ }
+
+ for (const app of eventApplications) {
+ const event = app.event?.get({ plain: true })
+ const checklistLength = app?.event?.checklist?.length
+ let checkmarks = []
+
+ if (checklistLength) {
+ checkmarks = await fastify.db.EventCheckmark.findAll({
+ where: {
+ userId: req.user.id,
+ eventId: app.eventId,
+ },
+ })
+ }
+
+ const objectToAdd: EventWithApplications & {
+ applicationId: string
+ applicationComplete: boolean
+ } = {
+ ...event,
+ applicationId: app.id,
+ applicationComplete: checkmarks?.length == checklistLength,
+ }
+ if (app.status == EventApplicationStatus.Opened) {
+ result[EventApplicationStatus.Pending].push(objectToAdd)
+ } else {
+ result[app.status as EventStatusCategory].push(objectToAdd)
+ }
+ }
+ return result
+ }
+
+ return eventApplications.map((e) => e.event)
+ }
+ )
+
+ fastify.get(
+ '/event/me',
+ async (
+ req: FastifyRequest<{
+ Querystring: { sortBy?: string }
+ }>,
+ reply
+ ) => {
+ if (!req.office) {
+ return reply.throw.badParams('Invalid office ID')
+ }
+
+ const eventApplications = await getUpcomingEventApplicationsForUser(
+ fastify,
+ req.user.id
+ )
+
+ return eventApplications.map((e) => e.event)
+ }
+ )
fastify.get('/event/uncompleted', async (req, reply) => {
if (!req.office) {
diff --git a/src/modules/events/types.ts b/src/modules/events/types.ts
index 3546045e..b2ef1bb8 100644
--- a/src/modules/events/types.ts
+++ b/src/modules/events/types.ts
@@ -29,6 +29,7 @@ export interface Event {
notificationRule: EventNotificationRule
metadata: Record
responsibleUserIds: string[]
+ applications?: EventApplication[]
}
export type EventMetadata = {
@@ -73,6 +74,7 @@ export interface EventApplication {
creatorUserId: string
formId: string | null
formSubmissionId: string | null
+ event?: Event
}
export enum EventApplicationStatus {
@@ -127,3 +129,20 @@ export interface GlobalEvent {
export type EventParticipant = Pick
export type EventPreview = Pick
+
+export enum EventTimeCategory {
+ past = 'past',
+ upcoming = 'upcoming',
+}
+export type EventsByTimeCategory = Record
+
+export type EventStatusCategory =
+ | EventApplicationStatus.Opened
+ | EventApplicationStatus.Pending
+ | EventApplicationStatus.Confirmed
+
+export type EventsByStatusCategory = Record
+
+export type EventWithApplications = Event & { applications: EventApplication[] }
+
+export type EventApplicationWithEvent = EventApplication & { event: Event }
diff --git a/src/modules/guest-invites/client/components/AdminGuestInvite.tsx b/src/modules/guest-invites/client/components/AdminGuestInvite.tsx
index 2b4329c3..9d08fe8c 100644
--- a/src/modules/guest-invites/client/components/AdminGuestInvite.tsx
+++ b/src/modules/guest-invites/client/components/AdminGuestInvite.tsx
@@ -24,6 +24,7 @@ import { OfficeFloorMap } from '#client/components/OfficeFloorMap'
import { propEq } from '#shared/utils/fp'
import { DATE_FORMAT } from '#client/constants'
import { useGuestInviteAdmin, useUpdateGuestInvite } from '../queries'
+import { VisitType } from '#shared/types'
export const AdminGuestInvite: React.FC = () => {
const page = useStore(stores.router)
@@ -170,9 +171,16 @@ export const AdminGuestInvite: React.FC = () => {
{!!area && (
({
+ ...desk,
+ areaId: area.id,
+ kind: VisitType.Visit,
+ })),
+ ]}
+ clickablePoints={availableAreaDeskIds}
+ selectedPointId={selectedDeskId}
+ onToggle={onToggleDesk}
/>
)}
diff --git a/src/modules/guest-invites/client/components/GuestInviteDetail.tsx b/src/modules/guest-invites/client/components/GuestInviteDetail.tsx
index c75c4b51..04f88913 100644
--- a/src/modules/guest-invites/client/components/GuestInviteDetail.tsx
+++ b/src/modules/guest-invites/client/components/GuestInviteDetail.tsx
@@ -17,11 +17,13 @@ import { propEq } from '#shared/utils'
import React from 'react'
import dayjs from 'dayjs'
import { GuestInviteStatusTag } from './GuestInviteStatusTag'
-import { GuestInviteStatus } from '#shared/types'
+import { GuestInviteStatus, VisitType } from '#shared/types'
import { DATE_FORMAT_DAY_NAME_FULL } from '#client/constants'
import { useVisitsAreas } from '#modules/visits/client/queries'
import { OfficeFloorMap } from '#client/components/OfficeFloorMap'
import { useUserCompact } from '#modules/users/client/queries'
+//@todo better place for this function
+import { addParams } from '#modules/hub-map/client/helpers'
export const GuestInviteDetail = () => (
{
null}
+ mappablePoints={[
+ ...area?.desks.map((desk) => ({
+ ...desk,
+ areaId: area.id,
+ kind: VisitType.Visit,
+ })),
+ ]}
+ clickablePoints={[]}
+ selectedPointId={guestInivite.deskId}
+ onToggle={() => null}
/>
)}
diff --git a/src/modules/hub-map/client/components/HubMap.tsx b/src/modules/hub-map/client/components/HubMap.tsx
new file mode 100644
index 00000000..20cfc42e
--- /dev/null
+++ b/src/modules/hub-map/client/components/HubMap.tsx
@@ -0,0 +1,256 @@
+import * as React from 'react'
+import {
+ Avatar,
+ Input,
+ Select,
+ StealthMode,
+ WidgetWrapper,
+} from '#client/components/ui'
+import { useStore } from '@nanostores/react'
+import * as stores from '#client/stores'
+import { useOffice } from '#client/utils/hooks'
+import dayjs from 'dayjs'
+import { DaySlider } from '#client/components/ui/DaySlider'
+import { DATE_FORMAT } from '#client/constants'
+import { OfficeFloorMap } from '#client/components/OfficeFloorMap'
+import { ScheduledItemsList } from './ScheduledItemsList'
+import {
+ useAvailableDesks,
+ useOfficeVisitors,
+ useToggleStealthMode,
+ useVisitsAreas,
+} from '#modules/visits/client/queries'
+import { propEq } from '#shared/utils'
+import { getPoints, goToMeetings, goToVisits } from '../helpers'
+import { VisitType } from '#shared/types'
+import { useUpcoming } from '../queries'
+import { PermissionsValidator } from '#client/components/PermissionsValidator'
+import Permissions from '#shared/permissions'
+
+export const HubMap = () => (
+
+ <_HubMap />
+
+)
+
+export const _HubMap = () => {
+ const officeId = useStore(stores.officeId)
+ const office = useOffice(officeId)
+ const me = useStore(stores.me)
+
+ const { data: areas = [] } = useVisitsAreas(office?.id || '')
+ const [areaId, setAreaId] = React.useState(null)
+ const area = React.useMemo(() => areas.find((x) => areaId === x.id), [areaId])
+ const [mappablePoints, setMappablePoints] = React.useState([])
+
+ const [date, setDate] = React.useState(dayjs())
+ const [selectedDailyEvent, setSelectedDailyEvent] = React.useState<
+ string | null
+ >(null)
+
+ const { data: upcomingVisitsAll, refetch: refetchVisits } = useUpcoming(
+ officeId,
+ dayjs().toString()
+ )
+
+ const { mutate: toggleStealthMode } = useToggleStealthMode(() => {
+ refetchVisits()
+ refetchVisitors()
+ })
+ const onToggleStealthMode = React.useCallback((value: boolean) => {
+ toggleStealthMode({ stealthMode: value })
+ }, [])
+
+ React.useEffect(() => {
+ setOfficeVisits(upcomingVisitsAll?.byDate[date.format(DATE_FORMAT)])
+ }, [upcomingVisitsAll?.byDate, date])
+
+ const [officeVisits, setOfficeVisits] = React.useState([])
+ React.useEffect(() => {
+ if (!!areas.length) {
+ setAreaId(areas[0].id)
+ setMappablePoints(getPoints(areas[0]))
+ }
+ }, [areas])
+
+ React.useEffect(() => {
+ if (!!area) {
+ setMappablePoints(getPoints(area))
+ }
+ }, [area])
+
+ const onAreaChange = React.useCallback(
+ (areaId: string) => setAreaId(areaId),
+ []
+ )
+
+ const { data: visitors, refetch: refetchVisitors } = useOfficeVisitors(
+ officeId,
+ dayjs(date).format(DATE_FORMAT)
+ )
+
+ const userIsInOffce = React.useMemo(
+ () => me && visitors?.some(propEq('userId', me.id)),
+ [visitors, me]
+ )
+ const visitorsNumber = React.useMemo(() => visitors?.length || 0, [visitors])
+ const [width, setWidth] = React.useState(window.innerWidth)
+
+ function handleWindowSizeChange() {
+ setWidth(window.innerWidth)
+ }
+ React.useEffect(() => {
+ window.addEventListener('resize', handleWindowSizeChange)
+ return () => {
+ window.removeEventListener('resize', handleWindowSizeChange)
+ }
+ }, [])
+
+ const { data: availableDesks = [] } = useAvailableDesks(
+ office?.id || '',
+ [date.format(DATE_FORMAT)] || []
+ )
+
+ const resetOfficeVisits = React.useCallback(() => {
+ setOfficeVisits(upcomingVisitsAll.byDate[dayjs().format(DATE_FORMAT)] ?? [])
+ }, [upcomingVisitsAll?.byDate])
+
+ const availableAreaDeskIds = React.useMemo(() => {
+ let available = []
+ const desks = availableDesks
+ .filter((x) => x.areaId === area?.id)
+ .map((x) => x.deskId)
+
+ available = [...desks]
+ if (!!area?.meetingRooms) {
+ available = [...available, ...area?.meetingRooms.map((x) => x.id)]
+ }
+ return available
+ }, [availableDesks, area])
+
+ const isMobile = width <= 480
+
+ if (!office?.allowDeskReservation) {
+ return <>>
+ }
+ return (
+
+
+
{
+ setSelectedDailyEvent(id)
+ setAreaId(areaId)
+ setDate(chosenDate)
+ resetOfficeVisits()
+ }}
+ setDate={setDate}
+ date={date}
+ className={'mb-6'}
+ />
+
+ {area && (
+
+
+
+
+ {
+ setDate(d)
+ }}
+ reverse={true}
+ slideDate={date.format(DATE_FORMAT)}
+ className="mx-auto sm:mx-0"
+ />
+
+
+
+
+
+ {!!visitors?.length &&
+ visitors.map((v) => {
+ return (
+
+ )
+ })}
+
+
+
+ {userIsInOffce ? `You and ${visitorsNumber - 1}` : visitorsNumber}{' '}
+ people in the {office?.name} hub
+
+
+ )}
+
+
+ )
+}
diff --git a/src/modules/hub-map/client/components/ScheduledItem.tsx b/src/modules/hub-map/client/components/ScheduledItem.tsx
new file mode 100644
index 00000000..94e671ed
--- /dev/null
+++ b/src/modules/hub-map/client/components/ScheduledItem.tsx
@@ -0,0 +1,144 @@
+import React from 'react'
+import { cn } from '#client/utils'
+import { ScheduledItemType, VisitType } from '#shared/types'
+import dayjs from 'dayjs'
+import { FButton, P } from '#client/components/ui'
+
+export const PageUrls: Record = {
+ event: '/events',
+}
+
+const ColorsBg: Record = {
+ [VisitType.RoomReservation]: 'bg-cta-hover-jade',
+ [VisitType.Visit]: 'bg-cta-hover-purple',
+ [VisitType.Guest]: 'bg-cta-hover-cerullean',
+ event: 'bg-gray-50',
+}
+
+const ColorsBorder: Record = {
+ [VisitType.RoomReservation]: 'border-cta-jade',
+ [VisitType.Visit]: 'border-cta-purple',
+ [VisitType.Guest]: 'border-cta-hover-cerullean',
+ event: 'border-gray-300',
+}
+
+const ColorsHover: Record = {
+ [VisitType.RoomReservation]: `hover:border-cta-jade`,
+ [VisitType.Visit]: `hover:border-cta-purple`,
+ [VisitType.Guest]: `hover:border-cta-hover-cerullean'`,
+ event: 'hover:border-gray-300',
+}
+
+const StatusColor: Record = {
+ confirmed: 'bg-green-500',
+ pending: 'bg-yellow-500',
+ opened: 'bg-yellow-500',
+ cancelled: 'bg-red-500',
+}
+
+const DateHeader = ({ dateValue }: { dateValue: string | Date }) => {
+ const date = dayjs(dateValue).isToday()
+ ? `Today`
+ : dayjs(dateValue).format('dddd')
+ return (
+
+
+ {date}
+ {' ยท '}
+
+
+ {dayjs(dateValue).format('D MMMM')}
+
+
+ )
+}
+
+const Status = ({ status }: { status: string }) => (
+
+)
+
+export const ScheduledItem = ({
+ sheduledItem,
+ selected,
+ onClick,
+ onEntityCancel,
+}: {
+ sheduledItem: ScheduledItemType
+ selected: string | null
+ onClick: (item: ScheduledItemType) => void
+ onEntityCancel: (
+ id: string,
+ type: string,
+ value: string,
+ date: string
+ ) => void
+}) => {
+ const iAmSelected = selected == sheduledItem.id
+ return (
+
+
{
+ if (!!PageUrls[sheduledItem.type]) {
+ window.location.href = PageUrls[sheduledItem.type]
+ } else {
+ onClick(sheduledItem)
+ }
+ }}
+ className={cn(
+ 'transition-all',
+ ' w-full sm:w-[224px] sm:h-[192px] flex flex-col justify-between rounded-sm px-4 py-4 sm:px-6 cursor-pointer',
+ ColorsBg[sheduledItem.type],
+ 'border border-transparent',
+ iAmSelected && ColorsBorder[sheduledItem.type],
+ ColorsHover[sheduledItem.type] && `${ColorsHover[sheduledItem.type]}`
+ )}
+ >
+
+
+
+
+ {sheduledItem.status && }
+
+
+
+ {sheduledItem.value.slice(0, 16)}
+ {sheduledItem.value.length > 16 && '...'}
+
+ {' '}
+ {sheduledItem.description}
+
+
+
+
+ {sheduledItem.dateTime ? sheduledItem.dateTime : ''}
+
+
+ {sheduledItem.description}
+
+
+
+ {!!selected && (
+
+ onEntityCancel(
+ sheduledItem.id,
+ sheduledItem.type,
+ sheduledItem.value,
+ sheduledItem.date
+ )
+ }
+ >
+ Cancel
+
+ )}
+
+
+ )
+}
diff --git a/src/modules/hub-map/client/components/ScheduledItemsList.tsx b/src/modules/hub-map/client/components/ScheduledItemsList.tsx
new file mode 100644
index 00000000..3b38464e
--- /dev/null
+++ b/src/modules/hub-map/client/components/ScheduledItemsList.tsx
@@ -0,0 +1,151 @@
+import { BackButton, Icons, showNotification } from '#client/components/ui'
+import { useOffice } from '#client/utils/hooks'
+import { useUpdateVisit } from '#modules/visits/client/queries'
+import { useStore } from '@nanostores/react'
+import dayjs, { Dayjs } from 'dayjs'
+import React from 'react'
+import * as stores from '#client/stores'
+import { useUpdateRoomReservationByUser } from '#modules/room-reservation/client/queries'
+import { useUpdateGuestInviteByUser } from '#modules/guest-invites/client/queries'
+import {
+ ScheduledItemType,
+ GuestInviteStatus,
+ RoomReservationStatus,
+ VisitStatus,
+ VisitType,
+} from '#shared/types'
+import { FRIENDLY_DATE_FORMAT } from '#client/constants'
+import { ScheduledItem } from './ScheduledItem'
+import { useUpcoming } from '../queries'
+
+export const ScheduledItemsList: React.FC<{
+ onChooseCard: (id: string | null, areaId: string | null, date: Dayjs) => void
+ setDate: (d: Dayjs) => void
+ date: Dayjs
+ className?: string
+}> = ({ onChooseCard, setDate, date, className }) => {
+ const officeId = useStore(stores.officeId)
+ const office = useOffice(officeId)
+ const [scheduledItems, setScheduledItems] = React.useState([])
+ const [selected, setSelected] = React.useState(null)
+
+ const cancellationCallback = () => {
+ showNotification(`Successfully cancelled.`, 'success')
+ refetchUpcoming()
+ }
+
+ const me = useStore(stores.me)
+ const { mutate: updateVisit } = useUpdateVisit(cancellationCallback)
+ const { mutate: updateRoomReservation } =
+ useUpdateRoomReservationByUser(cancellationCallback)
+ const { mutate: updateGuestInvite } =
+ useUpdateGuestInviteByUser(cancellationCallback)
+
+ type updateData = {
+ id: string
+ status: VisitStatus | RoomReservationStatus | GuestInviteStatus
+ }
+
+ const { data: myUpcomingScheduledItems, refetch: refetchUpcoming } =
+ useUpcoming(officeId, dayjs().toString(), me?.id)
+
+ React.useEffect(() => {
+ if (!!myUpcomingScheduledItems?.upcoming) {
+ setScheduledItems(myUpcomingScheduledItems.upcoming)
+ }
+ }, [myUpcomingScheduledItems])
+
+ React.useEffect(() => {
+ if (selected) {
+ // if you removed the last item of this type
+ if (myUpcomingScheduledItems?.byType[selected?.type].length === 0) {
+ resetView()
+ } else {
+ setScheduledItems(myUpcomingScheduledItems?.byType[selected?.type])
+ }
+ }
+ }, [myUpcomingScheduledItems?.byType, date])
+
+ const resetView = () => {
+ setSelected(null)
+ setScheduledItems(myUpcomingScheduledItems.upcoming)
+ onChooseCard(null, selected?.areaId ?? '', dayjs())
+ }
+
+ const processOnClick = (scheduledItem: ScheduledItemType) => {
+ if (!scheduledItem) {
+ return
+ }
+ setScheduledItems(myUpcomingScheduledItems.byType[scheduledItem.type])
+ setSelected(scheduledItem)
+ setDate(dayjs(scheduledItem.date))
+ onChooseCard(
+ scheduledItem.objectId ?? '',
+ scheduledItem.areaId ?? '',
+ dayjs(scheduledItem.date)
+ )
+ }
+
+ const updateFns: Record void> = {
+ [VisitType.Visit]: (data: updateData & { status: VisitStatus }) =>
+ updateVisit(data),
+ [VisitType.Guest]: (data: updateData & { status: GuestInviteStatus }) =>
+ updateGuestInvite(data),
+ [VisitType.RoomReservation]: (
+ data: updateData & { status: RoomReservationStatus }
+ ) => updateRoomReservation(data),
+ }
+
+ const onEntityCancel = (
+ id: string,
+ type: string,
+ value: string,
+ date: string
+ ) => {
+ const confirmMessage = `Are you sure you want to cancel this ${type}: ${value} on ${dayjs(
+ date
+ ).format(FRIENDLY_DATE_FORMAT)}?`
+ if (window.confirm(confirmMessage)) {
+ const data: updateData = {
+ id,
+ status: 'cancelled',
+ }
+ updateFns[type](data)
+ setSelected(null)
+ refetchUpcoming()
+ }
+ }
+
+ return (
+
+ {!!selected && (
+
+ resetView()}
+ />
+
+ )}
+
+ {!!selected && (
+
+ )}
+ {!!scheduledItems?.length &&
+ scheduledItems.map((item: ScheduledItemType, index) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/modules/hub-map/client/components/index.ts b/src/modules/hub-map/client/components/index.ts
new file mode 100644
index 00000000..d3bd6869
--- /dev/null
+++ b/src/modules/hub-map/client/components/index.ts
@@ -0,0 +1 @@
+export { HubMap } from './HubMap'
diff --git a/src/modules/hub-map/client/helpers/index.ts b/src/modules/hub-map/client/helpers/index.ts
new file mode 100644
index 00000000..1c3d2404
--- /dev/null
+++ b/src/modules/hub-map/client/helpers/index.ts
@@ -0,0 +1,49 @@
+import config from '#client/config'
+import { DATE_FORMAT } from '#client/constants'
+import {
+ OfficeArea,
+ OfficeAreaDesk,
+ OfficeRoom,
+ VisitType,
+} from '#shared/types'
+import { Dayjs } from 'dayjs'
+
+export const getPoints = (area: OfficeArea) => {
+ const points: Array = [
+ ...area?.desks.map((desk) => ({
+ ...desk,
+ areaId: area.id,
+ kind: VisitType.Visit,
+ })),
+ ]
+ if (!!area?.meetingRooms?.length) {
+ points.push(
+ ...area?.meetingRooms.map((room) => ({
+ ...room,
+ areaId: area.id,
+ kind: VisitType.RoomReservation,
+ }))
+ )
+ }
+ return points
+}
+
+export const goToVisits = (
+ selectedDesk: string,
+ areaId: string,
+ date: Dayjs
+) => {
+ const url = new URL(config.appHost + '/visits/request')
+ url.searchParams.set('deskId', String(selectedDesk))
+ url.searchParams.set('areaId', String(areaId))
+ url.searchParams.set('date', date.format(DATE_FORMAT))
+ window.location.href = url.toString()
+}
+
+export const goToMeetings = (roomId: string, date: Dayjs) => {
+ const url = new URL(config.appHost + '/room-reservation/request')
+ url.searchParams.set('roomId', String(roomId))
+ url.searchParams.set('date', date.format(DATE_FORMAT))
+ url.hash = String(roomId)
+ window.location.href = url.toString()
+}
diff --git a/src/modules/hub-map/client/queries.ts b/src/modules/hub-map/client/queries.ts
new file mode 100644
index 00000000..2c1241a4
--- /dev/null
+++ b/src/modules/hub-map/client/queries.ts
@@ -0,0 +1,19 @@
+import { useQuery } from 'react-query'
+import { AxiosError } from 'axios'
+import { api } from '#client/utils/api'
+import dayjs from 'dayjs'
+import { DATE_FORMAT } from '#server/constants'
+
+export const useUpcoming = (
+ officeId: string,
+ date: string,
+ userId?: string
+) => {
+ const path = '/user-api/hub-map/upcoming'
+ return useQuery(
+ [path, { officeId, date: dayjs(date).format(DATE_FORMAT), userId }],
+ async ({ queryKey }) =>
+ (await api.get(path, { params: queryKey[1] })).data,
+ { enabled: !!officeId }
+ )
+}
diff --git a/src/modules/hub-map/manifest.json b/src/modules/hub-map/manifest.json
new file mode 100644
index 00000000..909f6c48
--- /dev/null
+++ b/src/modules/hub-map/manifest.json
@@ -0,0 +1,19 @@
+{
+ "id": "hub-map",
+ "name": "HubMap",
+ "dependencies": [
+ "visits",
+ "guest-invites",
+ "room-reservation",
+ "users",
+ "events"
+ ],
+ "requiredIntegrations": [],
+ "recommendedIntegrations": [],
+ "models": [],
+ "clientRouter": {
+ "public": {},
+ "user": {},
+ "admin": {}
+ }
+}
diff --git a/src/modules/hub-map/metadata-schema.ts b/src/modules/hub-map/metadata-schema.ts
new file mode 100644
index 00000000..897c0b0c
--- /dev/null
+++ b/src/modules/hub-map/metadata-schema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const schema = z.object({}).strict().optional()
+
+export type Metadata = z.infer
diff --git a/src/modules/hub-map/permissions.ts b/src/modules/hub-map/permissions.ts
new file mode 100644
index 00000000..c09358aa
--- /dev/null
+++ b/src/modules/hub-map/permissions.ts
@@ -0,0 +1,3 @@
+export const Permissions = {
+ // __Admin: '{MODULE_ID}.__admin',
+}
diff --git a/src/modules/hub-map/server/helpers/index.ts b/src/modules/hub-map/server/helpers/index.ts
new file mode 100644
index 00000000..14775deb
--- /dev/null
+++ b/src/modules/hub-map/server/helpers/index.ts
@@ -0,0 +1,145 @@
+import { appConfig } from '#server/app-config'
+import { DATE_FORMAT, FRIENDLY_DATE_FORMAT_SHORT } from '#server/constants'
+import {
+ Event,
+ EventApplicationStatus,
+ RoomReservation,
+ ScheduledItemType,
+ User,
+ Visit,
+ VisitType,
+} from '#shared/types'
+import dayjs from 'dayjs'
+import { FastifyInstance } from 'fastify'
+import { Op } from 'sequelize'
+import { Filterable } from 'sequelize'
+
+export const getTime = (date: string | Date) => dayjs(date).format('LT')
+
+export const getDate = (d: string) => dayjs(d).format(DATE_FORMAT)
+
+export const formatRoomReservationsResult = (
+ reservation: RoomReservation,
+ officeId: string,
+ areaId: string | undefined
+): any => {
+ // @todo put this somewhere central
+ const office = appConfig.offices.find((o) => o.id === officeId)
+ const area = office?.areas?.find((a) => a.id === areaId)
+ const officeRoom = area?.meetingRooms?.find(
+ (m) => m.id === reservation.roomId
+ )
+ return {
+ id: reservation.id,
+ dateTime: `${getTime(reservation.startDate)} - ${getTime(
+ reservation.endDate
+ )}`,
+ objectId: reservation.roomId,
+ areaId,
+ date: dayjs(reservation.startDate).format('YYYY-MM-DD'),
+ value: 'Room ' + officeRoom?.name ?? '',
+ description: officeRoom?.description ?? '',
+ type: VisitType.RoomReservation,
+ status: reservation.status,
+ }
+}
+
+export const formatVisit = (
+ v: Visit,
+ user?: User | null
+): ScheduledItemType & (User | { id: string; avatar: string | null }) => {
+ return {
+ id: v.id,
+ value: `Desk ${v.deskName}`,
+ type: VisitType.Visit,
+ deskId: v.deskId,
+ objectId: v.deskId,
+ description: v.areaName,
+ areaId: v.areaId,
+ date: v.date,
+ status: v.status,
+ userId: v.userId,
+ user: user
+ ? {
+ id: user?.id,
+ avatar: user?.avatar,
+ }
+ : null,
+ }
+}
+
+export const formatEvent = (
+ event: Event,
+ applicationStatus: EventApplicationStatus,
+ complete: boolean
+) => {
+ const url = `/events/${event.id}`
+ const now = dayjs()
+ const start = dayjs(event.startDate)
+ const end = dayjs(event.endDate)
+ const isToday = now >= start && now <= end
+ const isSingleDay = start.isSame(end, 'day')
+
+ return {
+ id: event.id,
+ value: event.title,
+ url: url,
+ // @todo add different types
+ type: 'event',
+ status: applicationStatus,
+ date: start.format(DATE_FORMAT),
+ complete: complete,
+ description: isToday
+ ? 'Today'
+ : isSingleDay
+ ? event.startDate
+ : `${start.format(FRIENDLY_DATE_FORMAT_SHORT)} - ${end.format(
+ FRIENDLY_DATE_FORMAT_SHORT
+ )}`,
+ }
+}
+
+export const getVisits = async (
+ fastify: FastifyInstance,
+ officeId: string,
+ date: string,
+ userId: string
+) => {
+ const where: Filterable['where'] = {
+ officeId,
+ status: {
+ [Op.in]: ['confirmed', 'pending'],
+ },
+ date: {
+ [Op.gte]: dayjs(date).toDate(),
+ },
+ }
+ if (userId) {
+ where['userId'] = userId
+ }
+ return fastify.db.Visit.findAll({
+ where,
+ order: ['date'],
+ })
+}
+
+export const getRoomReservations = async (
+ fastify: FastifyInstance,
+ officeId: string,
+ creatorUserId: string,
+ date: string
+) => {
+ return fastify.db.RoomReservation.findAll({
+ where: {
+ office: officeId,
+ creatorUserId,
+ status: {
+ [Op.in]: ['confirmed', 'pending'],
+ },
+ startDate: {
+ [Op.gte]: dayjs(date).toDate(),
+ },
+ },
+ order: ['startDate'],
+ })
+}
diff --git a/src/modules/hub-map/server/models/index.ts b/src/modules/hub-map/server/models/index.ts
new file mode 100644
index 00000000..336ce12b
--- /dev/null
+++ b/src/modules/hub-map/server/models/index.ts
@@ -0,0 +1 @@
+export {}
diff --git a/src/modules/hub-map/server/router.ts b/src/modules/hub-map/server/router.ts
new file mode 100644
index 00000000..3349fad7
--- /dev/null
+++ b/src/modules/hub-map/server/router.ts
@@ -0,0 +1,199 @@
+import { User } from '#modules/users/server/models'
+import { appConfig } from '#server/app-config'
+import {
+ ScheduledItemType,
+ EntityVisibility,
+ EventApplicationStatus,
+ GenericVisit,
+ VisitType,
+} from '#shared/types'
+import dayjs from 'dayjs'
+import { FastifyPluginCallback, FastifyRequest } from 'fastify'
+import {
+ formatEvent,
+ formatRoomReservationsResult,
+ formatVisit,
+ getDate,
+ getRoomReservations,
+ getVisits,
+} from './helpers'
+import { Op } from 'sequelize'
+import { Event } from '#modules/events/server/models'
+import * as fp from '#shared/utils/fp'
+
+const publicRouter: FastifyPluginCallback = async function (fastify, opts) {}
+
+const addToUpcomingByDate = (
+ upcomingByDate: Record,
+ value: GenericVisit,
+ date: string,
+ type: string
+) => {
+ const dateKey = getDate(date)
+ upcomingByDate[dateKey] = upcomingByDate[dateKey] || {}
+ upcomingByDate[dateKey][type] = upcomingByDate[dateKey][type] || []
+ upcomingByDate[dateKey][type].push(value)
+}
+
+const userRouter: FastifyPluginCallback = async function (fastify, opts) {
+ fastify.get(
+ '/upcoming',
+ async (
+ req: FastifyRequest<{
+ Querystring: {
+ date: string
+ limit: number
+ officeId: string
+ userId: string
+ }
+ }>,
+ reply
+ ) => {
+ const { date, officeId } = req.query
+ if (!officeId) {
+ return reply.throw.badParams('Missing office ID')
+ }
+ const office = appConfig.getOfficeById(officeId)
+
+ let visits = await getVisits(fastify, officeId, date, req.query.userId)
+ let roomReservations = await getRoomReservations(
+ fastify,
+ officeId,
+ req.user.id,
+ date
+ )
+
+ const upcomingItems: Array = []
+ const upcomingByDate: Record = {}
+
+ const dailyEventsReservations = roomReservations.map(
+ (reservation, idx) => {
+ const area = office?.areas?.find((area) =>
+ area.meetingRooms?.find((room) => room.id === reservation.roomId)
+ )
+ const item = formatRoomReservationsResult(
+ reservation,
+ officeId,
+ area?.id
+ )
+ // add the first item in array to show at the top of the map in a list
+ if (!idx) {
+ upcomingItems.push(item)
+ }
+ addToUpcomingByDate(
+ upcomingByDate,
+ item,
+ dayjs(reservation.startDate).toString(),
+ VisitType.RoomReservation
+ )
+ return item
+ }
+ )
+
+ let dailyEventsVisits = []
+ const userIds = Array.from(new Set(visits.map(fp.prop('userId'))))
+ const users = await fastify.db.User.findAll({
+ where: { id: { [Op.in]: userIds }, stealthMode: false },
+ raw: true,
+ })
+ const usersById = users.reduce(fp.by('id'), {})
+
+ for (const [idx, v] of visits.entries()) {
+ const item = formatVisit(v)
+ if (!idx) {
+ upcomingItems.push(item)
+ }
+ addToUpcomingByDate(
+ upcomingByDate,
+ formatVisit(v, usersById[v.userId]),
+ v.date,
+ VisitType.Visit
+ )
+ dailyEventsVisits.push(item)
+ }
+
+ const eventApplications = await fastify.db.EventApplication.findAll({
+ include: {
+ model: Event,
+ as: 'event',
+ where: {
+ startDate: {
+ [Op.between]: [
+ dayjs().startOf('day').toDate(),
+ dayjs().startOf('day').add(14, 'days').toDate(),
+ ],
+ },
+
+ visibility: {
+ [Op.in]: [EntityVisibility.Visible, EntityVisibility.Url],
+ },
+ },
+ attributes: ['id', 'title', 'startDate', 'endDate', 'checklist'],
+ required: true,
+ order: [['startDate', 'ASC']],
+ },
+ where: {
+ status: {
+ [Op.in]: [
+ EventApplicationStatus.Opened,
+ EventApplicationStatus.Pending,
+ EventApplicationStatus.Confirmed,
+ ],
+ },
+ userId: req.user.id,
+ },
+ attributes: ['eventId', 'status'],
+ })
+
+ let myEvents = []
+ if (!!eventApplications.length) {
+ for (const application of eventApplications) {
+ const checklistLength = application?.event?.checklist.length
+ let checkmarks = []
+
+ if (checklistLength) {
+ checkmarks = await fastify.db.EventCheckmark.findAll({
+ where: {
+ userId: req.user.id,
+ eventId: application.eventId,
+ },
+ })
+ }
+
+ const eventFormatted = formatEvent(
+ application?.event,
+ application.status,
+ checkmarks.length === checklistLength
+ )
+
+ myEvents.push(eventFormatted)
+ }
+
+ if (!!myEvents.length) {
+ upcomingItems.push(myEvents[0])
+ }
+ }
+
+ return {
+ upcoming: upcomingItems.sort(
+ (a: ScheduledItemType, b: ScheduledItemType) =>
+ dayjs(a.date).isAfter(dayjs(b.date)) ? 1 : -1
+ ),
+ byType: {
+ [VisitType.Visit]: dailyEventsVisits,
+ [VisitType.RoomReservation]: dailyEventsReservations,
+ event: myEvents,
+ },
+ byDate: upcomingByDate,
+ }
+ }
+ )
+}
+
+const adminRouter: FastifyPluginCallback = async function (fastify, opts) {}
+
+module.exports = {
+ publicRouter,
+ userRouter,
+ adminRouter,
+}
diff --git a/src/modules/hub-map/types.ts b/src/modules/hub-map/types.ts
new file mode 100644
index 00000000..9184d282
--- /dev/null
+++ b/src/modules/hub-map/types.ts
@@ -0,0 +1,14 @@
+import { User } from '#shared/types'
+
+export type ScheduledItemType = {
+ id: string
+ value: string
+ type: string
+ date: string
+ dateTime: string
+ description: string
+ areaId?: string
+ objectId?: string
+ user?: User
+ status: string
+}
diff --git a/src/modules/news/client/components/LatestNews.tsx b/src/modules/news/client/components/LatestNews.tsx
index 23a20112..3cbd1cdb 100644
--- a/src/modules/news/client/components/LatestNews.tsx
+++ b/src/modules/news/client/components/LatestNews.tsx
@@ -45,38 +45,40 @@ export const LatestNews = () => {
) : (
-
- {filteredNews.map((x, i) => (
-
stores.goTo('newsPage', { newsId: x.id })}
- className={
- 'flex flex-col gap-4 pl-4 pr-4 hover:bg-applied-hover hover:rounded-tiny cursor-pointer'
- }
- >
-
-
- {dayjs(x.publishedAt).format('D MMM')}
-
-
{x.title}
+
+
+ {filteredNews.map((x, i) => (
+
stores.goTo('newsPage', { newsId: x.id })}
+ className={
+ 'flex flex-col gap-4 pl-4 pr-4 hover:bg-applied-hover hover:rounded-tiny cursor-pointer'
+ }
+ >
+
+
+ {dayjs(x.publishedAt).format('D MMM')}
+
+
{x.title}
+
+ {i + 1 === filteredNews.length &&
+ filteredNews.length <= MAX_NEWS_TO_SHOW ? (
+ ''
+ ) : (
+
+ )}
- {i + 1 === filteredNews.length &&
- filteredNews.length <= MAX_NEWS_TO_SHOW ? (
- ''
- ) : (
-
- )}
-
- ))}
- {!showAll && news.length > MAX_NEWS_TO_SHOW ? (
+ ))}
+
+ {filteredNews.length >= 3 && (
setShowAll(true)}
+ className="w-auto self-start"
+ onClick={() => stores.goTo('newsList')}
>
Show more
- ) : null}
+ )}
)}
diff --git a/src/modules/news/client/components/NewsListPage.tsx b/src/modules/news/client/components/NewsListPage.tsx
new file mode 100644
index 00000000..c8ba4fb5
--- /dev/null
+++ b/src/modules/news/client/components/NewsListPage.tsx
@@ -0,0 +1,73 @@
+import { useStore } from '@nanostores/react'
+import * as React from 'react'
+import { Header } from '#client/components/Header'
+import {
+ BackButton,
+ Background,
+ ComponentWrapper,
+ FButton,
+ H1,
+ P,
+} from '#client/components/ui'
+import * as stores from '#client/stores'
+import { useNews } from '../queries'
+import { NewsItem } from '#shared/types'
+import dayjs from 'dayjs'
+import { paginateArray } from '#modules/events/client/helpers'
+
+const pageSize = 10
+
+export const NewsListPage = () => {
+ const officeId = useStore(stores.officeId)
+ const { data: news, isFetched } = useNews(officeId)
+ const [newsData, setNewsData] = React.useState
>([])
+ const [page, setPage] = React.useState(1)
+
+ React.useEffect(() => {
+ if (news?.length && isFetched) {
+ let limit = page === 1 ? pageSize : page * pageSize
+ const result = paginateArray(news, 1, limit)
+ setNewsData(result)
+ }
+ }, [news, officeId, page])
+
+ return (
+
+
+ News
+
+ {!!newsData &&
+ newsData.map((x, i) => (
+
stores.goTo('newsPage', { newsId: x.id })}
+ className={
+ 'flex flex-col gap-4 pl-4 pr-4 hover:bg-applied-hover hover:rounded-tiny cursor-pointer'
+ }
+ >
+
+
+ {dayjs(x.publishedAt).format('D MMM')}
+
+
{x.title}
+
+ {i + 1 === news?.length && news?.length <= pageSize ? (
+ ''
+ ) : (
+
+ )}
+
+ ))}
+ {!!news && news.length !== newsData.length ? (
+
setPage((p) => p + 1)}
+ >
+ Show more
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/modules/news/client/components/index.ts b/src/modules/news/client/components/index.ts
index 78601202..abcfe576 100644
--- a/src/modules/news/client/components/index.ts
+++ b/src/modules/news/client/components/index.ts
@@ -2,3 +2,4 @@ export { AdminNews } from './AdminNews'
export { AdminNewsEditor } from './AdminNewsEditor'
export { LatestNews } from './LatestNews'
export { NewsPage } from './NewsPage'
+export { NewsListPage } from './NewsListPage'
diff --git a/src/modules/news/manifest.json b/src/modules/news/manifest.json
index 0030c0f5..bc052349 100644
--- a/src/modules/news/manifest.json
+++ b/src/modules/news/manifest.json
@@ -13,6 +13,13 @@
"fullScreen": true
}
},
+ "user": {
+ "newsList": {
+ "path": "/news",
+ "componentId": "NewsListPage",
+ "fullScreen": false
+ }
+ },
"admin": {
"adminNews": {
"path": "/admin/news",
diff --git a/src/modules/office-visits/server/helpers/index.ts b/src/modules/office-visits/server/helpers/index.ts
index bd6c3929..9be5ae9d 100644
--- a/src/modules/office-visits/server/helpers/index.ts
+++ b/src/modules/office-visits/server/helpers/index.ts
@@ -4,7 +4,7 @@ import { DATE_FORMAT } from '#server/constants'
export const BUSINESS_DAYS_LIMIT: number = 40
// FIXME: temporary fix
-export const getDate = (d: string, timezone: string) =>
+export const getDate = (d: string, timezone?: string) =>
dayjs(d).format(DATE_FORMAT)
// export const getDate = (d: string, timezone: string) =>
diff --git a/src/modules/office-visits/server/router.ts b/src/modules/office-visits/server/router.ts
index cc3ac4f0..125c5c75 100644
--- a/src/modules/office-visits/server/router.ts
+++ b/src/modules/office-visits/server/router.ts
@@ -3,21 +3,21 @@ import dayjs, { Dayjs } from 'dayjs'
import localizedFormat from 'dayjs/plugin/localizedFormat'
import { FastifyPluginCallback, FastifyRequest } from 'fastify'
import { Op } from 'sequelize'
-import { appConfig } from '#server/app-config'
import { DATE_FORMAT } from '#server/constants'
import {
BUSINESS_DAYS_LIMIT,
getBusinessDaysFromDate,
getDate,
} from './helpers'
-import { Visit, GenericVisit, VisitType, VisitsDailyStats } from '#shared/types'
-import * as fp from '#shared/utils'
import { Permissions } from '../permissions'
import { Metadata } from '../metadata-schema'
+import { Visit, GenericVisit, VisitType, VisitsDailyStats } from '#shared/types'
+import * as fp from '#shared/utils'
+import { appConfig } from '#server/app-config'
+import { getRoom } from '#modules/room-reservation/shared-helpers'
dayjs.extend(localizedFormat)
-// @todo fix types
const publicRouter: FastifyPluginCallback = async function (fastify, opts) {}
const userRouter: FastifyPluginCallback = async function (fastify, opts) {
@@ -34,13 +34,12 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
return reply.throw.badParams('Missing office ID')
}
const office = appConfig.getOfficeById(officeId)
-
const nextBusinessDays = getBusinessDaysFromDate(
date,
BUSINESS_DAYS_LIMIT
)
// @todo REwrite this using native SQL query using JOIN on dates
- // or rewrite by mergin all the tables into one
+ // or rewrite by merging all the tables into one
let result: Record = {}
const addToResult = (value: GenericVisit, date: string, type: string) => {
@@ -135,10 +134,8 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
)
})
roomReservations.forEach((reservation) => {
- const office = appConfig.offices.find((o) => o.id === officeId)
- const officeRoom = (office?.rooms || []).find(
- (r) => r.id === reservation.roomId
- )
+ let officeRoom = getRoom(office, reservation.roomId)
+
return addToResult(
{
id: reservation.id,
diff --git a/src/modules/room-reservation/client/components/RoomListing.tsx b/src/modules/room-reservation/client/components/RoomListing.tsx
index 905535c7..02e8906f 100644
--- a/src/modules/room-reservation/client/components/RoomListing.tsx
+++ b/src/modules/room-reservation/client/components/RoomListing.tsx
@@ -39,7 +39,7 @@ export const RoomListing: React.FC<{
rooms.map((room: OfficeRoom) => {
const iAmChosen = chosenRoom === room.id
return (
-
+
{
const _RoomReservationRequest: React.FC = () => {
const officeId = useStore(stores.officeId)
const [showModal, setShowModal] = useState(false)
+ const roomRefs = React.useRef
>({})
const [timeDuration, setTimeDuration] = useState(
dayjs.duration(30, 'minutes')
)
@@ -62,6 +63,38 @@ const _RoomReservationRequest: React.FC = () => {
timeSlot: '',
})
+ React.useEffect(() => {
+ const url = new URL(document.location.href)
+ const room = url.searchParams.get('roomId')
+ const date = url.searchParams.get('date')
+ if (!!room && !!date) {
+ setMode(RoomBookingModes.SpecificRoom)
+ setRequest({
+ ...request,
+ roomId: room,
+ date: date,
+ })
+ if (room) {
+ history.pushState(
+ '',
+ document.title,
+ window.location.pathname + window.location.search
+ )
+ setTimeout(scrollToRoom, 500, room)
+ }
+ }
+ }, [])
+
+ const scrollToRoom = React.useCallback((roomId: string) => {
+ const selected = roomRefs.current[roomId]
+ if (selected) {
+ window.scrollTo({
+ top: selected.offsetTop - 200,
+ behavior: 'smooth',
+ })
+ }
+ }, [])
+
const [mode, setMode] = useState(RoomBookingModes.AnyRoom)
const [timeSlots, setTimeSlots] = useState>([])
const { data: placeholderMessage } = usePlaceholderMessage(officeId)
@@ -154,6 +187,7 @@ const _RoomReservationRequest: React.FC = () => {
>
),
+
[RoomBookingModes.SpecificRoom]: (
{
buttonTitle="Select room"
onRoomSelect={(roomId: string) => updateRequest('roomId', roomId)}
>
-
+
{
+ if (el && roomRefs?.current) {
+ roomRefs.current[request.roomId] = el
+ }
+ }}
+ >
x.id === device?.roomId)
+ const room = getRoom(office, device?.roomId)
if (!room) {
reply.clearCookie(RoomDisplayDeviceCookie)
return emptyResponse
@@ -198,7 +198,7 @@ const publicRouter: FastifyPluginCallback = async function (fastify, opts) {
const data = req.body
const officeId = req.query?.office
const office = appConfig.getOfficeById(officeId)
- const room = (office.rooms || []).find((x) => x.id === data.roomId)
+ const room = getRoom(office, data.roomId)
if (!room) {
return reply.throw.badParams('Invalid room ID')
}
@@ -317,7 +317,10 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
})
const reservedRooms = reservations.map((room) => room.roomId)
- return (req.office?.rooms || []).filter((room) => {
+ return getRooms(req.office).filter((room) => {
+ if (!room) {
+ return false
+ }
return (
room.available &&
!reservedRooms.includes(room.id) &&
@@ -352,7 +355,9 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
}
const requestedDuration = req.query.duration ?? 30
const office = req.office
- const room = office.rooms!.find((room) => room.id === req.params.roomId)
+ const room = getRooms(office).find(
+ (room) => room?.id === req.params.roomId
+ )
if (!room || !room.available) {
return reply.throw.badParams('Invalid room ID')
}
@@ -460,7 +465,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
const office = req.office
let earliestStart: Array = []
let latestEnd: Array = []
- const rooms = (office.rooms || []).filter((room) => room.available)
+ const rooms = getRooms(office).filter((room) => room?.available)
rooms.forEach((room) => {
const [workingHoursStart, workingHoursEnd] = room.workingHours.map(
(time) => time.split(':')
@@ -571,11 +576,11 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
) => {
req.check(Permissions.Create)
if (req.office) {
- const rooms = req.office.rooms || []
+ const rooms = getRooms(req.office)
return req.query.allRooms ? rooms : rooms.filter((x) => x.available)
}
const rooms = appConfig.offices
- .map((x) => x.rooms)
+ .map((x) => getRooms(x))
.flat()
.filter(Boolean)
return req.query.allRooms ? rooms : rooms.filter((x) => x?.available)
@@ -596,7 +601,9 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
return reply.throw.badParams('Invalid office ID')
}
req.check(Permissions.Create, req.office.id)
- const room = req.office.rooms!.find((x) => x.id === req.params.roomId)
+ const room = getRooms(req.office)!.find(
+ (x) => x?.id === req.params.roomId
+ )
if (!room || !room.available) {
return reply.throw.badParams('Invalid room ID')
}
@@ -632,7 +639,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
}
req.check(Permissions.Create, req.office.id)
const data = req.body
- const room = (req.office.rooms || []).find((x) => x.id === data.roomId)
+ const room = getRooms(req.office).find((x) => x?.id === data.roomId)
if (!room || !room.available) {
return reply.throw.badParams('Invalid room ID')
}
@@ -767,7 +774,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
// Send user notification to the user via Matrix
if (fastify.integrations.Matrix) {
const office = appConfig.getOfficeById(reservation.office)
- const room = office.rooms!.find((x) => x.id === reservation.roomId)
+ const room = getRoom(office, reservation.roomId)
const data = {
status,
user: req.user.usePublicProfileView(),
@@ -830,9 +837,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) {
}
const office = appConfig.getOfficeById(reservation.office) || {}
- const roomDetail = office?.rooms!.find(
- (room) => room.id == reservation?.roomId
- )
+ const roomDetail = getRoom(office, reservation.roomId)
return {
id: reservation.id,
status: reservation.status,
@@ -870,7 +875,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) {
}
const roomId = req.body.roomId
const office = appConfig.offices.find((o) =>
- (o.rooms || []).some((r) => r.id === roomId)
+ (getRooms(o) || []).some((r) => r.id === roomId)
)
if (!office) {
return reply.throw.rejected("Can't resolve a submitted room ID")
@@ -928,7 +933,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) {
process.nextTick(async () => {
try {
const office = appConfig.getOfficeById(reservation.office)
- const room = office.rooms!.find((x) => x.id === reservation.roomId)
+ const room = getRoom(office, reservation.roomId)
const data = {
room: room ? room.name : reservation.roomId,
date: getDateTimeString(reservation, office.timezone),
@@ -979,7 +984,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) {
return appConfig.offices
.filter((x) => (officeIds.length ? officeIds.includes(x.id) : true))
.reduce((acc, x) => {
- const rooms = (x.rooms || []).map((r) => ({
+ const rooms = getRooms(x).map((r) => ({
id: r.id,
name: r.name,
officeId: x.id,
diff --git a/src/modules/room-reservation/shared-helpers/index.ts b/src/modules/room-reservation/shared-helpers/index.ts
index f8595653..7076828e 100644
--- a/src/modules/room-reservation/shared-helpers/index.ts
+++ b/src/modules/room-reservation/shared-helpers/index.ts
@@ -1,4 +1,6 @@
+import { Office, OfficeArea, OfficeRoom } from '#shared/types'
import dayjs from 'dayjs'
+import { boolean } from 'zod'
export function isWithinWorkingHours(
timeSlot: string,
@@ -9,3 +11,12 @@ export function isWithinWorkingHours(
const checkTime = dayjs(timeSlot, 'HH:mm')
return checkTime.isSameOrAfter(startTime) && checkTime.isBefore(endTime)
}
+
+export const getRooms = (office: Office) =>
+ office?.areas?.flatMap((area) => area.meetingRooms).filter((a) => !!a) || []
+
+export const getRoom = (
+ office: Office,
+ roomId: string
+): OfficeRoom | undefined =>
+ getRooms(office).find((room) => room?.id === roomId)
diff --git a/src/modules/visits/client/components/DateDeskPicker.tsx b/src/modules/visits/client/components/DateDeskPicker.tsx
index 3c7fc498..b782740a 100644
--- a/src/modules/visits/client/components/DateDeskPicker.tsx
+++ b/src/modules/visits/client/components/DateDeskPicker.tsx
@@ -41,10 +41,19 @@ type Result = {
type Props = {
officeId: string
+ deskId?: string
+ areaIdProp?: string
onSubmit: (request: VisitRequest[]) => void
+ date: string
}
-export const DateDeskPicker: React.FC = ({ officeId, onSubmit }) => {
+export const DateDeskPicker: React.FC = ({
+ officeId,
+ onSubmit,
+ deskId,
+ areaIdProp,
+ date,
+}) => {
const permissions = useStore(stores.permissions)
const [selectedDates, setSelectedDates] = React.useState([])
const onToggleDate = React.useCallback(
@@ -66,15 +75,20 @@ export const DateDeskPicker: React.FC = ({ officeId, onSubmit }) => {
}, [upcomingVisits, permissions])
const [selectedDeskId, setSelectedDeskId] = React.useState(
- null
+ deskId ?? null
)
const onToggleDesk = React.useCallback((desk: string) => {
setSelectedDeskId((value) => (value === desk ? null : desk))
}, [])
- const [areaId, setAreaId] = React.useState(null)
+ const [areaId, setAreaId] = React.useState(areaIdProp ?? null)
const [pendingResult, setPendingResult] = React.useState(null)
+ React.useEffect(() => {
+ if (!!date) {
+ selectedDates.push(date)
+ }
+ }, [])
React.useEffect(() => {
if (areaId && selectedDates.length && selectedDeskId) {
setPendingResult({
diff --git a/src/modules/visits/client/components/DeskPicker.tsx b/src/modules/visits/client/components/DeskPicker.tsx
index e2bd0990..7d21e6d9 100644
--- a/src/modules/visits/client/components/DeskPicker.tsx
+++ b/src/modules/visits/client/components/DeskPicker.tsx
@@ -3,6 +3,7 @@ import React from 'react'
import { useAvailableDesks, useVisitsAreas } from '../queries'
import { OfficeFloorMap } from '#client/components/OfficeFloorMap'
import { prop, propNotIn } from '#shared/utils/fp'
+import { VisitType } from '#shared/types'
type Props = {
officeId: string
@@ -81,7 +82,7 @@ export const DeskPicker: React.FC = ({
)
return (
-
+
{unavailableDeskNames.length ? (
<>
@@ -144,12 +145,19 @@ export const DeskPicker: React.FC = ({
({
+ ...desk,
+ areaId: area.id,
+ kind: VisitType.Visit,
+ })),
+ ]}
+ clickablePoints={availableAreaDeskIds}
+ selectedPointId={selectedDeskId}
+ onToggle={onToggleDesk}
/>
)}
-
+
)
}
diff --git a/src/modules/visits/client/components/VisitDetail.tsx b/src/modules/visits/client/components/VisitDetail.tsx
index ab18f58c..128f7d2d 100644
--- a/src/modules/visits/client/components/VisitDetail.tsx
+++ b/src/modules/visits/client/components/VisitDetail.tsx
@@ -11,7 +11,7 @@ import { showNotification } from '#client/components/ui/Notifications'
import { PermissionsValidator } from '#client/components/PermissionsValidator'
import config from '#client/config'
import * as stores from '#client/stores'
-import { VisitStatus } from '#shared/types'
+import { VisitStatus, VisitType } from '#shared/types'
import { propEq } from '#shared/utils/fp'
import { useStore } from '@nanostores/react'
import dayjs from 'dayjs'
@@ -124,9 +124,16 @@ export const _VisitDetail = () => {
null}
+ mappablePoints={[
+ ...area?.desks.map((desk) => ({
+ ...desk,
+ areaId: area.id,
+ kind: VisitType.Visit,
+ })),
+ ]}
+ clickablePoints={[]}
+ selectedPointId={visit.deskId}
+ onToggle={() => null}
/>
)}
diff --git a/src/modules/visits/client/components/VisitRequestForm.tsx b/src/modules/visits/client/components/VisitRequestForm.tsx
index 605162d5..baeb4e54 100644
--- a/src/modules/visits/client/components/VisitRequestForm.tsx
+++ b/src/modules/visits/client/components/VisitRequestForm.tsx
@@ -49,6 +49,11 @@ export const _VisitRequestForm: React.FC = () => {
const { data: visitNotice } = useVisitNotice(officeId)
const [step, setStep] = React.useState()
+ const [preselected, setPreselected] = React.useState({
+ deskId: '',
+ areaId: '',
+ date: '',
+ })
const { mutate: createVisit, error } = useCreateVisit(() => {
setStep(
@@ -76,6 +81,15 @@ export const _VisitRequestForm: React.FC = () => {
createVisit(request.current)
}, [])
+ React.useEffect(() => {
+ const url = new URL(document.location.href)
+ setPreselected({
+ deskId: url.searchParams.get('deskId') || '',
+ areaId: url.searchParams.get('areaId') || '',
+ date: url.searchParams.get('date') || '',
+ })
+ }, [])
+
React.useEffect(() => {
if (!!visitNotice) {
setStep('health_check')
@@ -120,6 +134,9 @@ export const _VisitRequestForm: React.FC = () => {
)
}
diff --git a/src/modules/visits/server/router.ts b/src/modules/visits/server/router.ts
index 32fd4c9f..b13d7c09 100644
--- a/src/modules/visits/server/router.ts
+++ b/src/modules/visits/server/router.ts
@@ -596,6 +596,8 @@ const userRouter: FastifyPluginCallback = async (fastify, opts) => {
avatar: !isRobot ? u.avatar || '' : '',
fullName: visit?.metadata.guestFullName || u?.fullName,
areaName: visit?.areaName || 'UNKNOWN',
+ deskId: visit?.deskId,
+ areaId: visit?.areaId,
}
return result
})
diff --git a/src/modules/visits/types.ts b/src/modules/visits/types.ts
index 621b3c40..0e187089 100644
--- a/src/modules/visits/types.ts
+++ b/src/modules/visits/types.ts
@@ -51,6 +51,8 @@ export type OfficeVisitor = {
fullName: string
avatar: string
areaName: string
+ areaId: string
+ deskId: string
}
export type VisitReminderJob = {
diff --git a/src/server/app-config/schemas.ts b/src/server/app-config/schemas.ts
index 83484871..42bd3d30 100644
--- a/src/server/app-config/schemas.ts
+++ b/src/server/app-config/schemas.ts
@@ -1,4 +1,4 @@
-import { any, z } from 'zod'
+import { z } from 'zod'
const componentRef: z.ZodTypeAny = z.lazy(() =>
z.union([
@@ -8,7 +8,6 @@ const componentRef: z.ZodTypeAny = z.lazy(() =>
z.string(),
z.object({ offices: z.array(z.string()).optional() }),
]),
- z.array(componentRef),
])
)
@@ -21,7 +20,12 @@ export const layout = z.object({
}),
desktop: z.object({
sidebar: z.array(componentRef),
- main: z.array(z.array(componentRef)),
+ main: z.array(
+ z.union([
+ z.array(componentRef).length(1),
+ z.array(componentRef).length(2),
+ ])
+ ),
}),
})
@@ -70,17 +74,6 @@ export const officeAreaDesk = z
])
)
-export const officeArea = z.object({
- id: z.string(),
- available: z.boolean().default(true),
- name: z.string(),
- capacity: z.number().min(1),
- map: z.string(),
- // @todo remove type: "desks"
- bookable: z.boolean().default(false),
- desks: z.array(officeAreaDesk).min(1),
-})
-
export const officeRoom = z.object({
id: z.string(),
name: z.string(),
@@ -89,6 +82,10 @@ export const officeRoom = z.object({
photo: z.string(),
equipment: z.string(),
capacity: z.number().min(1),
+ position: z.object({
+ x: z.number().min(0).max(100),
+ y: z.number().min(0).max(100),
+ }),
workingHours: z.tuple([
z.string().regex(/^([01][0-9]|2[0-4]):[0-5][0-9]$/),
z.string().regex(/^([01][0-9]|2[0-4]):[0-5][0-9]$/),
@@ -96,6 +93,16 @@ export const officeRoom = z.object({
autoConfirm: z.boolean(),
})
+export const officeArea = z.object({
+ id: z.string(),
+ available: z.boolean().default(true),
+ name: z.string(),
+ capacity: z.number().min(1),
+ map: z.string(),
+ bookable: z.boolean().default(false),
+ desks: z.array(officeAreaDesk).min(1),
+ meetingRooms: z.array(officeRoom).min(1).optional(),
+})
export const office = z
.object({
id: z.string(),
@@ -111,7 +118,6 @@ export const office = z
address: z.string().optional(),
visitsConfig: officeVisitsConfig.optional(),
areas: z.array(officeArea).min(1).optional(),
- rooms: z.array(officeRoom).min(1).optional(),
})
.superRefine((office, ctx) => {
if (office.allowDeskReservation) {
@@ -133,15 +139,23 @@ export const office = z
}
}
if (office.allowRoomReservation) {
- const roomsParsed = z.array(officeRoom).min(1).safeParse(office.rooms)
+ let hasMeetingRooms = false
+ if (office.areas) {
+ for (const area of office.areas) {
+ if (area.meetingRooms && area.meetingRooms.length > 0) {
+ hasMeetingRooms = true
+ break
+ }
+ }
+ }
const roomsPlaceholderMessageParsed = z
.string()
.nonempty()
.safeParse(office.roomsPlaceholderMessage)
- if (!roomsParsed.success && !roomsPlaceholderMessageParsed.success) {
+ if (!hasMeetingRooms && !roomsPlaceholderMessageParsed.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: `Property 'allowRoomReservation' is set but 'rooms' or 'roomsPlaceholderMessageParsed' is missing`,
+ message: `Property 'allowRoomReservation' is set but 'meetingRooms' or 'roomsPlaceholderMessageParsed' is missing in your office areas configuration`,
})
}
}
diff --git a/src/server/constants.ts b/src/server/constants.ts
index d0c80593..9c797e9d 100644
--- a/src/server/constants.ts
+++ b/src/server/constants.ts
@@ -7,6 +7,8 @@ export const DATE_FORMAT = 'YYYY-MM-DD'
export const FRIENDLY_DATE_FORMAT = 'MMMM D YYYY'
+export const FRIENDLY_DATE_FORMAT_SHORT = 'MMM D'
+
export const DATE_FORMAT_DAY_NAME = 'ddd, MMMM D'
// TODO: implement shared constants and move it there
diff --git a/yarn.lock b/yarn.lock
index c76373f7..7f5c0228 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2177,7 +2177,7 @@ lodash@^4.17.21:
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
-loose-envify@^1.1.0:
+loose-envify@^1.0.0, loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -2865,6 +2865,13 @@ react-dom@^18.2.0:
loose-envify "^1.1.0"
scheduler "^0.23.0"
+react-easy-panzoom@^0.4.4:
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/react-easy-panzoom/-/react-easy-panzoom-0.4.4.tgz#b153951e270f579449af837aac5d215244b21e4a"
+ integrity sha512-1zgT6boDVPcrR3Egcz8KEVpM3fs50o22iIWPRlAqvev0/4nw5RnUNFsvmOJ/b5M2nd8MDGknLmyfBdhjoLB6+g==
+ dependencies:
+ warning "4.0.3"
+
react-query@^3.39.1:
version "3.39.3"
resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.3.tgz"
@@ -3533,6 +3540,13 @@ validator@^13.9.0:
resolved "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz"
integrity sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==
+warning@4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
+ integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
+ dependencies:
+ loose-envify "^1.0.0"
+
web-streams-polyfill@^3.0.3:
version "3.2.1"
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz"