From f055d612d3d7855353a00f79a83fbb06eb7e64dc Mon Sep 17 00:00:00 2001 From: avicenan Date: Sun, 19 Apr 2026 19:32:33 +0700 Subject: [PATCH] fix(schedule): calendar --- src/controllers/schedule.ts | 54 ++++-- src/model/schedule.ts | 377 ++++++++++++++++++++++++++++++------ 2 files changed, 348 insertions(+), 83 deletions(-) diff --git a/src/controllers/schedule.ts b/src/controllers/schedule.ts index 02676e4..eb1a2a4 100644 --- a/src/controllers/schedule.ts +++ b/src/controllers/schedule.ts @@ -137,31 +137,45 @@ export class ScheduleController { #swagger.parameters['limit'] = { in: 'query', required: false, type: 'number' } #swagger.parameters['page'] = { in: 'query', required: false, type: 'number' } #swagger.responses[200] = { - description: 'Schedule calendar resources and events', + description: 'Schedule calendar rooms and events', schema: { status: true, code: 200, message: 'success', data: { - resources: [{ - id: 'room-1:doctor-1', - roomId: 'room-1', - roomName: 'Room 1', - doctorId: 'doctor-1', - doctorName: 'Dr A' - }], - events: [{ - id: 'occ:series-101:2026-03-20', - seriesId: 'series-101', - resourceId: 'room-1:doctor-1', - title: 'Practice', - startAt: '2026-03-20T09:00:00+07:00', - endAt: '2026-03-20T12:00:00+07:00', - isRecurring: true, - isException: false, - status: 'confirmed' - }], - meta: { total_count: 24, page: 1, limit: 20 } + count: 1, + page: 1, + total_count: 1, + list: { + rooms: [{ + id: 'uuid-string', + name: 'string', + code: 'string', + picture: 'string', + location: 'string', + status: 'string', + department: { + name: 'string', + }, + }], + events: [{ + id: 'occ:series-101:2026-03-20', + series_id: 'series-101', + room_id: 'room-1', + room_name: 'Room 1', + doctor_id: 'doctor-1', + doctor_name: 'Doctor 1', + title: 'Practice', + start_at: '2026-03-20T09:00:00+07:00', + end_at: '2026-03-20T12:00:00+07:00', + quota: 10, + used_quota: 5, + left_quota: 5, + is_recurring: true, + is_exception: false, + status: 'confirmed' + }], + } } } } diff --git a/src/model/schedule.ts b/src/model/schedule.ts index b74a61f..28660fd 100644 --- a/src/model/schedule.ts +++ b/src/model/schedule.ts @@ -1,7 +1,9 @@ import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity"; -import { SelectQueryBuilder } from "typeorm"; +import { IsNull, SelectQueryBuilder } from "typeorm"; import CommonHelper from "../helpers/common"; import { OrmHelper } from "../helpers/orm"; +import dayjs from "dayjs"; +import { RoomModel } from "./room"; type RecurrenceInput = { type: "daily" | "weekly" | "monthly"; @@ -57,32 +59,309 @@ export class ScheduleModel { return query; } + // static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise { + // const startDate = new Date(start); + // const endDate = new Date(end); + // if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + // throw new Error("Invalid start or end date"); + // } + + // const rangeStart = ScheduleModel.startOfDay(startDate); + // const rangeEnd = ScheduleModel.endOfDay(endDate); + // if (rangeStart > rangeEnd) { + // throw new Error("Start date must be before end date"); + // } + + // const seriesQuery = await ScheduleModel.listSeries(filterObj); + // seriesQuery + // .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) }) + // .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { + // rangeStartDate: ScheduleModel.formatYmd(rangeStart), + // }) + // .andWhere("ScheduleSeries.deleted_at IS NULL"); + + // const seriesList: ScheduleSeries[] = await seriesQuery.getMany(); + // if (seriesList.length === 0) return { resources: [], events: [] }; + + // const seriesIds = seriesList.map((series) => series.id); + // const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd); + // const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException); + // const exceptions = await exceptionRepo + // .createQueryBuilder("ScheduleException") + // .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") + // .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") + // .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds }) + // .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", { + // rangeStart: ScheduleModel.formatYmd(rangeStart), + // rangeEnd: ScheduleModel.formatYmd(rangeEnd), + // }) + // .andWhere("ScheduleException.deleted_at IS NULL") + // .getMany(); + + // const exceptionMap = new Map(); + // for (const ex of exceptions) { + // const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`; + // exceptionMap.set(key, ex); + // } + + // const allEvents: any[] = []; + + // for (const series of seriesList) { + // const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); + // for (const occurrenceDate of occurrenceDates) { + // const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); + // const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); + // if (exception && exception.is_cancelled) continue; + + // const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); + // const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); + + // // const doctor = exception?.override_doctor ?? series.doctor; + // const room = exception?.override_room ?? series.room; + // const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart; + // const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd; + // const eventTitle = exception?.override_title || series.title; + + // const roomId = room?.id || null; + // // const doctorId = doctor?.id || null; + // const resourceId = `${roomId || "unknown-room"}`; + // // const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`; + // const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || ""; + // // const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || ""; + + // allEvents.push({ + // id: `occ:${series.id}:${occurrenceYmd}`, + // seriesId: series.id, + // resourceId, + // title: eventTitle, + // startAt: eventStart.toISOString(), + // endAt: eventEnd.toISOString(), + // quota: series.quota ?? null, + // usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, + // leftQuota: + // typeof series.quota === "number" + // ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0)) + // : null, + // isRecurring: !!series.recurrence_rule, + // isException: !!exception, + // status: "confirmed", + // _resource: { + // id: resourceId, + // roomId, + // roomName, + // // doctorId, + // // doctorName, + // }, + // }); + // } + // } + + // const allResourcesMap = new Map(); + // for (const event of allEvents) { + // allResourcesMap.set(event._resource.id, event._resource); + // } + // const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id)); + // const totalCount = allResources.length; + // const page = options.page ?? 1; + // const limit = options.limit ?? totalCount; + // const offset = (page - 1) * limit; + // const pagedResources = allResources.slice(offset, offset + limit); + // const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id)); + + // const pagedEvents = allEvents + // .filter((event) => allowedResourceIds.has(event.resourceId)) + // .sort((a, b) => { + // if (a.startAt === b.startAt) return a.id.localeCompare(b.id); + // return a.startAt.localeCompare(b.startAt); + // }) + // .map((event) => { + // const { _resource, ...cleanEvent } = event; + // return cleanEvent; + // }); + + // return { + // resources: pagedResources, + // events: pagedEvents, + // meta: { + // total_count: totalCount, + // page, + // limit, + // }, + // }; + // } + + // return resource as rooms and all the events with relation of room and doctor + // static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise { + // const startDate = dayjs(start); + // const endDate = dayjs(end); + // if (!startDate.isValid() || !endDate.isValid()) { + // throw new Error("Invalid start or end date"); + // } + + // const rangeStart = startDate.startOf("day").toDate(); + // const rangeEnd = endDate.endOf("day").toDate(); + // if (rangeStart > rangeEnd) { + // throw new Error("Start date must be before end date"); + // } + + // const seriesQuery = await ScheduleModel.listSeries(filterObj); + // seriesQuery + // .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") }) + // .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { + // rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD"), + // }) + // .andWhere("ScheduleSeries.deleted_at IS NULL"); + + // const seriesList: ScheduleSeries[] = await seriesQuery.getMany(); + // if (seriesList.length === 0) return { rooms: [], events: [] }; + + // const seriesIds = seriesList.map((series) => series.id); + // const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd); + // const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException); + // const exceptions = await exceptionRepo + // .createQueryBuilder("ScheduleException") + // .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom") + // .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor") + // .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds }) + // .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", { + // rangeStart: ScheduleModel.formatYmd(rangeStart), + // rangeEnd: ScheduleModel.formatYmd(rangeEnd), + // }) + // .andWhere("ScheduleException.deleted_at IS NULL") + // .getMany(); + + // const exceptionMap = new Map(); + // for (const ex of exceptions) { + // const key = `${ex.series_id}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`; + // exceptionMap.set(key, ex); + // } + + // const allEvents: any[] = []; + + // for (const series of seriesList) { + // const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); + // for (const occurrenceDate of occurrenceDates) { + // const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD"); + // const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); + // if (exception && exception.is_cancelled) continue; + + // const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); + // const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); + + // const doctor = exception?.override_doctor ?? series.doctor; + // const room = exception?.override_room ?? series.room; + // const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart; + // const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd; + // const eventTitle = exception?.override_title || series.title; + + // const roomId = room?.id || null; + // const doctorId = doctor?.id || null; + // const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || ""; + // const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || ""; + + // allEvents.push({ + // id: `occ:${series.id}:${occurrenceYmd}`, + // seriesId: series.id, + // roomId, + // doctorId, + // doctorName, + // title: eventTitle, + // startAt: eventStart.toISOString(), + // endAt: eventEnd.toISOString(), + // quota: series.quota ?? null, + // usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, + // leftQuota: + // typeof series.quota === "number" + // ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0)) + // : null, + // isRecurring: !!series.recurrence_rule, + // isException: !!exception, + // status: "confirmed", + // _room: { + // id: roomId, + // name: roomName, + // }, + // }); + // } + // } + + // const allRoomsMap = new Map(); + // for (const event of allEvents) { + // allRoomsMap.set(event._room.id, event._room); + // } + // const allRooms = [...allRoomsMap.values()].sort((a, b) => a.id.localeCompare(b.id)); + // const totalCount = allRooms.length; + // const page = options.page ?? 1; + // const limit = options.limit ?? totalCount; + // const offset = (page - 1) * limit; + // const pagedRooms = allRooms.slice(offset, offset + limit); + // const allowedRoomIds = new Set(pagedRooms.map((room) => room.id)); + + // const pagedEvents = allEvents + // .filter((event) => allowedRoomIds.has(event.roomId)) + // .sort((a, b) => { + // if (a.startAt === b.startAt) return a.id.localeCompare(b.id); + // return a.startAt.localeCompare(b.startAt); + // }) + // .map((event) => { + // const { _room, ...cleanEvent } = event; + // return cleanEvent; + // }); + + // return { + // rooms: pagedRooms, + // events: pagedEvents, + // meta: { + // total_count: totalCount, + // page, + // limit, + // }, + // }; + // } + static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise { - const startDate = new Date(start); - const endDate = new Date(end); - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + const startDate = dayjs(start); + const endDate = dayjs(end); + if (!startDate.isValid() || !endDate.isValid()) { throw new Error("Invalid start or end date"); } - const rangeStart = ScheduleModel.startOfDay(startDate); - const rangeEnd = ScheduleModel.endOfDay(endDate); + const rangeStart = startDate.startOf("day").toDate(); + const rangeEnd = endDate.endOf("day").toDate(); if (rangeStart > rangeEnd) { throw new Error("Start date must be before end date"); } + const page = options.page ?? 1; + const limit = options.limit ?? 10; + const offset = (page - 1) * limit; + + const query = await RoomModel.list({ ...filterObj, with_deleted: false }); + const roomQuery = query + .select(["Room.id", "Room.room", "Room.code", "Room.picture", "Room.location", "Room.status", "Department.name"]) + .leftJoin("Room.department", "Department") + .where("Department.name = :departmentName", { departmentName: "Outpatient" }); + const roomTotalCount = await roomQuery + .getCount(); + const rooms = await roomQuery + .orderBy("Room.room", "ASC") + .offset(offset) + .limit(limit) + .getMany(); + const roomIds = rooms.map((room) => room.id); + const seriesQuery = await ScheduleModel.listSeries(filterObj); - seriesQuery - .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) }) - .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { - rangeStartDate: ScheduleModel.formatYmd(rangeStart), - }) - .andWhere("ScheduleSeries.deleted_at IS NULL"); - - const seriesList: ScheduleSeries[] = await seriesQuery.getMany(); - if (seriesList.length === 0) return { resources: [], events: [] }; + const seriesList = await seriesQuery + .where("ScheduleSeries.room_id IN (:...roomIds)", { roomIds }) + .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") }) + .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD") }) + .andWhere("ScheduleSeries.deleted_at IS NULL") + .getMany(); + if (seriesList.length === 0) return { rooms: rooms, events: [], meta: { total_count: rooms.length, page, limit } }; const seriesIds = seriesList.map((series) => series.id); const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd); + const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException); const exceptions = await exceptionRepo .createQueryBuilder("ScheduleException") @@ -98,7 +377,7 @@ export class ScheduleModel { const exceptionMap = new Map(); for (const ex of exceptions) { - const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`; + const key = `${ex.series_id}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`; exceptionMap.set(key, ex); } @@ -107,7 +386,7 @@ export class ScheduleModel { for (const series of seriesList) { const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); for (const occurrenceDate of occurrenceDates) { - const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); + const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD"); const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); if (exception && exception.is_cancelled) continue; @@ -122,68 +401,40 @@ export class ScheduleModel { const roomId = room?.id || null; const doctorId = doctor?.id || null; - const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`; const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || ""; const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || ""; allEvents.push({ id: `occ:${series.id}:${occurrenceYmd}`, - seriesId: series.id, - resourceId, + series_id: series.id, title: eventTitle, - startAt: eventStart.toISOString(), - endAt: eventEnd.toISOString(), + room_id: roomId, + room_name: roomName, + doctor_id: doctorId, + doctor_name: doctorName, + start_at: eventStart.toISOString(), + end_at: eventEnd.toISOString(), quota: series.quota ?? null, - usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, - leftQuota: + used_quota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, + left_quota: typeof series.quota === "number" ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0)) : null, - isRecurring: !!series.recurrence_rule, - isException: !!exception, + is_recurring: !!series.recurrence_rule, + is_exception: !!exception, status: "confirmed", - _resource: { - id: resourceId, - roomId, - roomName, - doctorId, - doctorName, - }, }); } } - const allResourcesMap = new Map(); - for (const event of allEvents) { - allResourcesMap.set(event._resource.id, event._resource); - } - const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id)); - const totalCount = allResources.length; - const page = options.page ?? 1; - const limit = options.limit ?? totalCount; - const offset = (page - 1) * limit; - const pagedResources = allResources.slice(offset, offset + limit); - const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id)); - - const pagedEvents = allEvents - .filter((event) => allowedResourceIds.has(event.resourceId)) - .sort((a, b) => { - if (a.startAt === b.startAt) return a.id.localeCompare(b.id); - return a.startAt.localeCompare(b.startAt); - }) - .map((event) => { - const { _resource, ...cleanEvent } = event; - return cleanEvent; - }); - return { - resources: pagedResources, - events: pagedEvents, - meta: { - total_count: totalCount, - page, - limit, - }, + count: rooms.length, + page: page, + total_count: roomTotalCount, + list: { + rooms: rooms, + events: allEvents, + } }; }