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.name} - {area.desks - .filter((x) => x.position) - .map((x) => { - const isSelected = selectedDeskId === x.id - const isAvailable = availableDeskIds.includes(x.id) - return ( -
- - -
- ) - })} +
+ {`${area.name} + {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" + /> +
+ ({ + label: x.name, + value: x.id, + }))} + value={area?.id} + onChange={onAreaChange} + placeholder={'Select area'} + containerClassName="w-full sm:w-auto mb-2 block sm:hidden" + /> +
+ { + switch (kind) { + case VisitType.Visit: + return goToVisits(id, String(areaId), date) + case VisitType.RoomReservation: + return goToMeetings(id, date) + default: + return + } + }} + /> +
+
+ +
+
+ )} +
+ + ) +} 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 && ( +
resetView()}> +
+ +
+
+ )} + {!!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"