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..e60241b 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"; @@ -58,132 +60,123 @@ export class ScheduleModel { } 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 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") + 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(); - 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[] = []; + if (seriesList.length > 0) { + const seriesIds = seriesList.map((series) => series.id); + const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd); - 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 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 defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); - const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); + 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 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; + 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 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 || ""; + const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); + const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); - 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 doctor = exception?.override_doctor ?? series.doctor; + const room = exception?.override_room ?? series.room; + const eventStart = exception?.override_start_at ? exception.override_start_at : defaultStart; + const eventEnd = exception?.override_end_at ? 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}`, + series_id: series.id, + title: eventTitle, + room_id: roomId, + room_name: roomName, + doctor_id: doctorId, + doctor_name: doctorName, + start_at: eventStart, + end_at: eventEnd, + quota: series.quota ?? null, + 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, + is_recurring: !!series.recurrence_rule, + is_exception: !!exception, + status: "confirmed", + }); + } } } - const allResourcesMap = new Map(); - for (const event of allEvents) { - allResourcesMap.set(event._resource.id, event._resource); + for (const room of rooms) { + const roomEvents = allEvents.filter((event) => event.room_id === room.id); + room.events = roomEvents; } - 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, }; } @@ -469,8 +462,8 @@ export class ScheduleModel { }; } - private static combineDateAndTime(ymd: string, hhmmss: string): Date { - return new Date(`${ymd}T${hhmmss}`); + private static combineDateAndTime(ymd: string, hhmmss: string) { + return `${ymd} ${hhmmss}`; } private static startOfDay(date: Date): Date {