From e4331fb31fec7aa9ef465357592c4c4414600ee4 Mon Sep 17 00:00:00 2001 From: inmake Date: Wed, 5 Jun 2024 19:14:56 +0500 Subject: [PATCH] upd --- client/.env | 4 +- client/package.json | 4 + client/src/components/Button.tsx | 2 +- client/src/components/ChoiceChips.tsx | 51 ++++ client/src/components/DatePicker.tsx | 148 +++++++++++ client/src/components/EmptyCard.tsx | 13 +- client/src/components/Select.tsx | 4 +- client/src/components/icons/CalendarIcon.tsx | 35 +++ .../{ChevronDown.tsx => ChevronDownIcon.tsx} | 10 +- .../src/components/icons/ChevronLeftIcon.tsx | 39 ++- client/src/components/icons/PlusIcon.tsx | 21 ++ ...teSchedule.tsx => CreateScheduleModal.tsx} | 232 ++++++++---------- client/src/index.css | 14 -- client/src/pages/DashboardPage.tsx | 29 ++- client/src/types/ISchedule.ts | 14 ++ client/src/types/IScheduledSession.ts | 12 + client/yarn.lock | 69 +++++- server/ecosystem.config.js | 2 +- server/src/index.ts | 8 +- server/src/models/Schedule.ts | 9 +- server/src/routes/companies.ts | 178 ++++++++------ server/src/routes/scheduledSessions.ts | 194 +++++++++++++-- 22 files changed, 799 insertions(+), 293 deletions(-) create mode 100644 client/src/components/ChoiceChips.tsx create mode 100644 client/src/components/DatePicker.tsx create mode 100644 client/src/components/icons/CalendarIcon.tsx rename client/src/components/icons/{ChevronDown.tsx => ChevronDownIcon.tsx} (69%) create mode 100644 client/src/components/icons/PlusIcon.tsx rename client/src/components/modals/{CreateSchedule.tsx => CreateScheduleModal.tsx} (62%) create mode 100644 client/src/types/ISchedule.ts create mode 100644 client/src/types/IScheduledSession.ts diff --git a/client/.env b/client/.env index 0038af3..30293ab 100644 --- a/client/.env +++ b/client/.env @@ -1,4 +1,4 @@ # VITE_API_URL=http://localhost:3001 -# VITE_API_URL=http://192.168.1.171:3001 -VITE_API_URL=https://crm.stream.graff.tech/api +VITE_API_URL=http://192.168.1.171:3001 +# VITE_API_URL=https://crm.stream.graff.tech/api VITE_STREAM_URL=https://stream.graff.tech diff --git a/client/package.json b/client/package.json index fdcb3f5..5510550 100644 --- a/client/package.json +++ b/client/package.json @@ -10,9 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@uidotdev/usehooks": "^2.4.1", "date-fns": "^2.30.0", "ky": "^1.0.1", + "lodash": "^4.17.21", "react": "^18.2.0", + "react-calendar": "^5.0.0", "react-datepicker": "^4.20.0", "react-dom": "^18.2.0", "react-google-recaptcha": "^3.1.0", @@ -21,6 +24,7 @@ "zustand": "^4.4.1" }, "devDependencies": { + "@types/lodash": "^4.17.4", "@types/node": "^20.8.7", "@types/react": "^18.2.15", "@types/react-datepicker": "^4.19.0", diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx index 5329fa8..50d17d3 100644 --- a/client/src/components/Button.tsx +++ b/client/src/components/Button.tsx @@ -30,7 +30,7 @@ function Button({ disabled={disabled || loading} type={type} onClick={handleClick} - className={`relative outline-none rounded-lg transition-colors font-semibold flex justify-center items-center gap-1 active:bg-[#49A1F5] disabled:bg-[#F2F2F2] disabled:text-[#CCCCCC] ${ + className={`relative outline-none rounded-lg transition-all font-semibold flex justify-center items-center gap-1 active:bg-[#49A1F5] disabled:bg-[#F2F2F2] disabled:text-[#CCCCCC] ${ (color === "primary" && "bg-[#49A1F5] text-white hover:bg-[#4190DB]") || (color === "secondary" && "bg-[#F0F1F2] text-[#77828C] hover:bg-[#E6ECF2] active:text-white") || diff --git a/client/src/components/ChoiceChips.tsx b/client/src/components/ChoiceChips.tsx new file mode 100644 index 0000000..10591de --- /dev/null +++ b/client/src/components/ChoiceChips.tsx @@ -0,0 +1,51 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useState } from "react"; + +interface Props { + options: string[]; + selected?: string[]; + onChange: (options: string[]) => void; +} + +function ChoiceChips({ options, selected, onChange }: Props) { + const [selectedOptions, setSelectedOptions] = useState( + selected && selected.length > 0 + ? options.filter((option) => selected.includes(option)) + : [] + ); + + function handleClick(option: string) { + let newSelectedOptions: string[]; + + if (!selectedOptions.includes(option)) { + newSelectedOptions = [...selectedOptions, option]; + } else { + newSelectedOptions = selectedOptions.filter( + (selectedOption) => selectedOption !== option + ); + } + + setSelectedOptions(newSelectedOptions); + onChange(newSelectedOptions); + } + + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +} + +export default ChoiceChips; diff --git a/client/src/components/DatePicker.tsx b/client/src/components/DatePicker.tsx new file mode 100644 index 0000000..8853dc1 --- /dev/null +++ b/client/src/components/DatePicker.tsx @@ -0,0 +1,148 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + addMonths, + format, + getDaysInMonth, + getISODay, + isAfter, + isEqual, + isToday, + setDate, + startOfDay, + startOfMonth, + subMonths, +} from "date-fns"; +import { useState } from "react"; +import ChevronLeftIcon from "./icons/ChevronLeftIcon"; +import ChevronRightIcon from "./icons/ChevronRightIcon"; +import _ from "lodash"; +import { ru } from "date-fns/locale"; +import { useClickAway } from "@uidotdev/usehooks"; +import CalendarIcon from "./icons/CalendarIcon"; +import ChevronDownIcon from "./icons/ChevronDownIcon"; + +interface Props { + defaultValue?: Date; + startDate?: Date; + onChange?: (date: Date) => void; +} + +function DatePicker({ defaultValue, startDate, onChange }: Props) { + const [value, setValue] = useState( + startDate && isAfter(startOfDay(startDate), new Date()) + ? startOfDay(startDate) + : defaultValue || startOfDay(new Date()) + ); + const [selectedMonth, setSelectedMonth] = useState(startOfMonth(value)); + const [isShowCalendar, setIsShowCalendar] = useState(false); + + const ref = useClickAway(() => { + setIsShowCalendar(false); + setSelectedMonth(startOfMonth(value)); + }); + + function selectPrevMonth() { + setSelectedMonth(subMonths(selectedMonth, 1)); + } + + function selectNextMonth() { + setSelectedMonth(addMonths(selectedMonth, 1)); + } + + function handleClick(date: Date) { + setValue(date); + onChange && onChange(value); + } + + return ( +
+
+ setIsShowCalendar((prev) => { + setSelectedMonth(startOfMonth(value)); + return !prev; + }) + } + > +
+
+ + {format(value, "dd.MM.yyyy")} +
+
+ +
+
+
+ {isShowCalendar && ( +
+
+ +

+ {_.capitalize( + format(selectedMonth, "LLLL, yyyy", { locale: ru }) + )} +

+ +
+
+ {["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"].map((value, index) => ( +
+ {value} +
+ ))} +
+
+ {Array.from({ length: getISODay(selectedMonth) - 1 }).map( + (_, index) => ( +
+ ) + )} + {Array.from({ length: getDaysInMonth(selectedMonth) }).map( + (_, index) => ( + + ) + )} +
+
+ )} +
+ ); +} + +export default DatePicker; diff --git a/client/src/components/EmptyCard.tsx b/client/src/components/EmptyCard.tsx index 3ecc374..861d65f 100644 --- a/client/src/components/EmptyCard.tsx +++ b/client/src/components/EmptyCard.tsx @@ -1,6 +1,17 @@ +import Button from "./Button"; +import PlusIcon from "./icons/PlusIcon"; + function EmptyCard() { return ( -
+
+ +
); } diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx index bef933b..039b757 100644 --- a/client/src/components/Select.tsx +++ b/client/src/components/Select.tsx @@ -4,7 +4,7 @@ import Input from "./Input"; import CheckIcon from "./icons/CheckIcon"; import { Transition } from "react-transition-group"; import useOutsideClick from "../hooks/useOutsideClick"; -import ChevronDown from "./icons/ChevronDown"; +import ChevronDownIcon from "./icons/ChevronDownIcon"; /* eslint-disable @typescript-eslint/no-explicit-any */ interface SelectProps { @@ -40,7 +40,7 @@ function Select({ defaultValue, options, handleChange }: SelectProps) { onClick={() => setIsShow(true)} className="absolute right-0 top-0 px-3 py-2 text-[#77828C]" > - + {(state) => ( diff --git a/client/src/components/icons/CalendarIcon.tsx b/client/src/components/icons/CalendarIcon.tsx new file mode 100644 index 0000000..638a7d5 --- /dev/null +++ b/client/src/components/icons/CalendarIcon.tsx @@ -0,0 +1,35 @@ +const SVGComponent = () => ( + + + + + + + + + + +); +export default SVGComponent; diff --git a/client/src/components/icons/ChevronDown.tsx b/client/src/components/icons/ChevronDownIcon.tsx similarity index 69% rename from client/src/components/icons/ChevronDown.tsx rename to client/src/components/icons/ChevronDownIcon.tsx index c975d27..ceafcf8 100644 --- a/client/src/components/icons/ChevronDown.tsx +++ b/client/src/components/icons/ChevronDownIcon.tsx @@ -1,8 +1,8 @@ -function ChevronDown() { +function ChevronDownIcon() { return ( @@ -18,4 +18,4 @@ function ChevronDown() { ); } -export default ChevronDown; +export default ChevronDownIcon; diff --git a/client/src/components/icons/ChevronLeftIcon.tsx b/client/src/components/icons/ChevronLeftIcon.tsx index fbbfc7b..8ac1e1b 100644 --- a/client/src/components/icons/ChevronLeftIcon.tsx +++ b/client/src/components/icons/ChevronLeftIcon.tsx @@ -1,21 +1,18 @@ -function ChevronLeftIcon() { - return ( - - - - ); -} - -export default ChevronLeftIcon; +const SVGComponent = () => ( + + + +); +export default SVGComponent; diff --git a/client/src/components/icons/PlusIcon.tsx b/client/src/components/icons/PlusIcon.tsx new file mode 100644 index 0000000..8babe5a --- /dev/null +++ b/client/src/components/icons/PlusIcon.tsx @@ -0,0 +1,21 @@ +function PlusIcon() { + return ( + + + + ); +} + +export default PlusIcon; diff --git a/client/src/components/modals/CreateSchedule.tsx b/client/src/components/modals/CreateScheduleModal.tsx similarity index 62% rename from client/src/components/modals/CreateSchedule.tsx rename to client/src/components/modals/CreateScheduleModal.tsx index 6fc76c5..a1b647a 100644 --- a/client/src/components/modals/CreateSchedule.tsx +++ b/client/src/components/modals/CreateScheduleModal.tsx @@ -1,73 +1,72 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable no-irregular-whitespace */ -import { ru } from "date-fns/locale"; import Button from "../Button"; import CloseIcon from "../icons/CloseIcon"; -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; import Label from "../Label"; import { useEffect, useState } from "react"; import Select from "../Select"; import useModalStore from "../../stores/useModalStore"; import Input from "../Input"; import { - addMonths, - addWeeks, - areIntervalsOverlapping, - differenceInDays, eachMinuteOfInterval, - endOfDay, - format, + isAfter, + isBefore, parse, parseISO, startOfDay, - subDays, } from "date-fns"; import api from "../../utils/api"; +import ChoiceChips from "../ChoiceChips"; +import ISchedule from "../../types/ISchedule"; +import DatePicker from "../DatePicker"; +import IScheduledSession from "../../types/IScheduledSession"; -interface CreateScheduleProps { +interface Props { companyId: string; buildId: string; - schedules?: any[]; + schedules: ISchedule[]; handleCreate: () => void; } -function CreateSchedule({ +function CreateScheduleModal({ companyId, buildId, schedules, handleCreate, -}: CreateScheduleProps) { +}: Props) { const setModal = useModalStore((state) => state.setModal); - const [date, setDate] = useState(new Date()); - const [scheduleDuration, setScheduleDuration] = useState(3); + const [date, setDate] = useState(new Date()); const [startDate, setStartDate] = useState(); - const [endDate, setEndDate] = useState(); const [sessionDuration, setSessionDuration] = useState(30); const [sessionBreak, setSessionBreak] = useState(5); const [startTime, setStartTime] = useState("10:00"); const [endTime, setEndTime] = useState("20:00"); - const [sessionCount, setSessionCount] = useState(); + const [weekends, setWeekends] = useState(["Сб", "Вс"]); + const [sessionsPerDay, setSessionsPerDay] = useState(); + + async function getLastScheduledSessionDate() { + try { + const { startAt }: IScheduledSession = await api + .get(`companies/${companyId}/builds/${buildId}/last_scheduled_session`) + .json(); + + if (!startAt) return; + + setStartDate(startOfDay(parseISO(startAt))); + } catch (error) { + if (error instanceof Error) { + alert(error.message); + } + } + } useEffect(() => { - if (!date || !scheduleDuration) return; + getLastScheduledSessionDate(); + }, []); - setStartDate(startOfDay(date)); - setEndDate( - endOfDay( - subDays( - scheduleDuration !== 3 - ? addWeeks(date, scheduleDuration) - : addMonths(date, 1), - 1 - ) - ) - ); - }, [date, scheduleDuration]); - - function calculateSessionCount() { - if (!startDate || !endDate) return; + function calculateSessionsPerDay() { + if (!startDate) return; const sessionsPerDay = eachMinuteOfInterval( { @@ -77,20 +76,10 @@ function CreateSchedule({ { step: sessionDuration + sessionBreak } ).length; - const days = differenceInDays(endDate, startDate); - - setSessionCount(days * sessionsPerDay); + setSessionsPerDay(sessionsPerDay); } - function handleChangedate(value: Date) { - if (differenceInDays(value, new Date()) >= 0) { - setDate(value); - } else { - setDate(new Date()); - } - } - - function changeendTime(value: string) { + function changeEndTime(value: string) { if (value.split(":")[0] > startTime.split(":")[0]) { setEndTime(value); } @@ -100,35 +89,18 @@ function CreateSchedule({ await api.post(`companies/${companyId}/builds/${buildId}/schedules`, { json: { startDate, - endDate, startTime, endTime, + weekends, sessionDuration, sessionBreak, - sessionCount, + sessionsPerDay, }, }); } async function handleClickCreateSchedule() { - if (!startDate || !endDate) return; - - if ( - schedules?.some((schedule) => - areIntervalsOverlapping( - { start: startDate, end: endDate }, - { - start: parseISO(schedule.startDate), - end: parseISO(schedule.endDate), - } - ) - ) - ) { - alert( - "Данные даты пересекаются с другим расписанием! Выберите другие даты." - ); - return; - } + if (!startDate) return; await createSchedule(); handleCreate(); @@ -136,8 +108,8 @@ function CreateSchedule({ } useEffect(() => { - calculateSessionCount(); - }, [startDate, endDate, endTime, sessionDuration, sessionBreak]); + calculateSessionsPerDay(); + }, [startDate, endTime, sessionDuration, sessionBreak, weekends]); return (
@@ -155,68 +127,73 @@ function CreateSchedule({
-
-

Срок действия расписания

-
+
+

Начало действия расписания

+
+

+ Максимальный доступный клиенту промежуток для записи — две + календарные недели +

+
- -
-
+

-

+
+
+
+
- -
-
-
-
-

-

-
-
@@ -290,19 +267,10 @@ function CreateSchedule({

Предварительный просмотр

- {startDate && endDate && ( -

- {format(startDate, "dd.MM.yyyy")} - - - {format(endDate, "dd.MM.yyyy")} -

- )}
-

- Общее кол-во сеансов -

-

{sessionCount}

+

Cеансов в день

+

{sessionsPerDay}

@@ -320,6 +288,14 @@ function CreateSchedule({ {startTime} - {endTime}

+ {/*
+

Выходные дни

+

+ {weekends.map((value) => ( + {value} + ))} +

+
*/}
@@ -336,4 +312,4 @@ function CreateSchedule({ ); } -export default CreateSchedule; +export default CreateScheduleModal; diff --git a/client/src/index.css b/client/src/index.css index 9fa9c09..b930442 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -42,17 +42,3 @@ body { .exited { opacity: 0; } - -/* DatePicker */ - -.react-datepicker { - font-family: inherit !important; -} - -.react-datepicker__day--selected { - background-color: #49a1f5 !important; -} - -.react-datepicker__day--selected:hover { - background-color: #4190db !important; -} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index c8520e6..8a28381 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -15,7 +15,6 @@ import { parse, addDays, subDays, - parseISO, isWithinInterval, } from "date-fns"; import Button from "../components/Button"; @@ -24,8 +23,9 @@ import { Transition } from "react-transition-group"; import SpinnerIcon from "../components/icons/SpinnerIcon"; import useModalStore from "../stores/useModalStore"; import ModalContainer from "../components/ModalContainer"; -import CreateSchedule from "../components/modals/CreateSchedule"; +import CreateScheduleModal from "../components/modals/CreateScheduleModal"; import MoreIcon from "../components/icons/MoreIcon"; +import ISchedule from "../types/ISchedule"; function DashboardPage() { const [user, setAccessToken] = useAuthStore((state) => [ @@ -36,7 +36,7 @@ function DashboardPage() { const [selectedBuildManagers, setSelectedBuildManagers] = useState(); const [builds, setBuilds] = useState(); const [selectedBuild, setSelectedBuild] = useState<{ [key: string]: any }>(); - const [schedules, setSchedules] = useState(); + const [schedules, setSchedules] = useState(); const [scheduledSessions, setScheduledSessions] = useState(); const [generatedScheduledSessions, setGeneratedScheduledSessions] = useState(); @@ -67,16 +67,20 @@ function DashboardPage() { } useEffect(() => { + console.log("schedules", schedules); + if (!selectedDate || !selectedBuild || !schedules?.length) return; const foundSchedule = schedules.find( (schedule) => isWithinInterval(selectedDate, { - start: parseISO(schedule.startDate), - end: parseISO(schedule.endDate), + start: new Date(schedule.startDate), + end: addDays(new Date(schedule.startDate), 14), }) && selectedBuild.id === schedule.buildId ); + console.log("foundSchedule", foundSchedule); + if (foundSchedule) { const startDateTime = parse( foundSchedule.startTime, @@ -477,20 +481,19 @@ function DashboardPage() {

- {format(parseISO(schedule.startDate), "dd.MM.yyyy")} + Действует с{" "} + {format(new Date(schedule.startDate), "dd.MM.yyyy")} - - + {/* - */} - {format(parseISO(schedule.endDate), "dd.MM.yyyy")} + {/* {format(parseISO(schedule.endDate), "dd.MM.yyyy")} */}

-

- Общее кол-во сеансов -

-

{schedule.sessionCount}

+

Сеансов в день

+

{schedule.sessionsPerDay}

@@ -517,7 +520,7 @@ function DashboardPage() { className="w-full" handleClick={() => setModal( - { console.log(`Server listening on port ${port}`); diff --git a/server/src/models/Schedule.ts b/server/src/models/Schedule.ts index d2f4a82..413c5ca 100644 --- a/server/src/models/Schedule.ts +++ b/server/src/models/Schedule.ts @@ -16,10 +16,6 @@ const scheduleSchema = new Schema( type: Date, required: true, }, - endDate: { - type: Date, - required: true, - }, startTime: { type: String, required: true, @@ -28,6 +24,9 @@ const scheduleSchema = new Schema( type: String, required: true, }, + weekends: { + type: [String], + }, sessionDuration: { type: Number, required: true, @@ -36,7 +35,7 @@ const scheduleSchema = new Schema( type: Number, required: true, }, - sessionCount: { + sessionsPerDay: { type: Number, required: true, }, diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 0612ef0..267b046 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -6,15 +6,15 @@ import Schedule from "../models/Schedule"; import BuildUser from "../models/BuildUser"; import User from "../models/User"; -const companiesRouter = Router(); +const router = Router(); -// companiesRouter.get("/", async (_req, res) => { +// router.get("/", async (_req, res) => { // const companies = await Company.find(); // res.json(companies); // }); -companiesRouter.get("/:id", async (req, res) => { +router.get("/:id", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; @@ -25,7 +25,7 @@ companiesRouter.get("/:id", async (req, res) => { res.json(company); }); -companiesRouter.get("/:id/builds", async (req, res) => { +router.get("/:id/builds", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; @@ -37,7 +37,7 @@ companiesRouter.get("/:id/builds", async (req, res) => { res.json(builds); }); -companiesRouter.get("/:id/users", async (req, res) => { +router.get("/:id/users", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; @@ -49,7 +49,7 @@ companiesRouter.get("/:id/users", async (req, res) => { res.json(users); }); -companiesRouter.get("/:id/builds/:buildId/users", async (req, res) => { +router.get("/:id/builds/:buildId/users", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; @@ -69,44 +69,41 @@ companiesRouter.get("/:id/builds/:buildId/users", async (req, res) => { res.json(users); }); -companiesRouter.get( - "/:id/builds/:buildId/scheduled_sessions", - async (req, res) => { - if (req.params.id != res.locals.user.companyId) { - res.json({ error: "Access denied" }); - return; - } +router.get("/:id/builds/:buildId/scheduled_sessions", async (req, res) => { + if (req.params.id != res.locals.user.companyId) { + res.json({ error: "Access denied" }); + return; + } - if (!req.query.date) { - res.json({ error: "Query parameter `date` is required" }); - return; - } + if (!req.query.date) { + res.json({ error: "Query parameter `date` is required" }); + return; + } - const date = parseISO(req.query.date as string); + const date = parseISO(req.query.date as string); - const company: any = await Company.findById(req.params.id).populate({ - path: "builds", + const company: any = await Company.findById(req.params.id).populate({ + path: "builds", + match: { + _id: req.params.buildId, + }, + populate: { + path: "scheduledSessions", match: { - _id: req.params.buildId, - }, - populate: { - path: "scheduledSessions", - match: { - startAt: { - $gte: startOfDay(date), - $lte: endOfDay(date), - }, + startAt: { + $gte: startOfDay(date), + $lte: endOfDay(date), }, }, - }); + }, + }); - const { scheduledSessions } = company.builds[0]; + const { scheduledSessions } = company.builds[0]; - res.json(scheduledSessions); - } -); + res.json(scheduledSessions); +}); -// companiesRouter.post( +// router.post( // "/:id/builds/:buildId/scheduled_sessions", // async (req, res) => { // if (req.params.id != res.locals.user.companyId) { @@ -129,48 +126,45 @@ companiesRouter.get( // } // ); -companiesRouter.put( - "/:id/scheduled_sessions/:scheduledSessionId", - async (req, res) => { - if (req.params.id != res.locals.user.companyId) { - res.json({ error: "Access denied" }); +router.put("/:id/scheduled_sessions/:scheduledSessionId", async (req, res) => { + if (req.params.id != res.locals.user.companyId) { + res.json({ error: "Access denied" }); + return; + } + + try { + const scheduledSession = await ScheduledSession.findById( + req.params.scheduledSessionId + ); + + const scheduledSessionAtSameTime = await ScheduledSession.findOne({ + startAt: scheduledSession?.startAt, + userId: req.body.userId, + }); + + if (scheduledSessionAtSameTime) { + res.json({ error: "Scheduled session at same time" }); return; } - try { - const scheduledSession = await ScheduledSession.findById( - req.params.scheduledSessionId - ); - - const scheduledSessionAtSameTime = await ScheduledSession.findOne({ - startAt: scheduledSession?.startAt, - userId: req.body.userId, - }); - - if (scheduledSessionAtSameTime) { - res.json({ error: "Scheduled session at same time" }); - return; + const updatedScheduledSession = await ScheduledSession.findByIdAndUpdate( + req.params.scheduledSessionId, + req.body, + { + new: true, + upsert: true, } + ); - const updatedScheduledSession = await ScheduledSession.findByIdAndUpdate( - req.params.scheduledSessionId, - req.body, - { - new: true, - upsert: true, - } - ); - - res.json(updatedScheduledSession); - } catch (error) { - if (error instanceof Error) { - res.json({ error }); - } + res.json(updatedScheduledSession); + } catch (error) { + if (error instanceof Error) { + res.json({ error }); } } -); +}); -companiesRouter.get( +router.get( "/:id/builds/:buildId/scheduled_sessions/:scheduledSessionId/availableManagers", async (req, res) => { if (!req.query.startAt) { @@ -225,7 +219,7 @@ companiesRouter.get( } ); -companiesRouter.get("/:id/builds/:buildId/schedules", async (req, res) => { +router.get("/:id/builds/:buildId/schedules", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; @@ -239,19 +233,47 @@ companiesRouter.get("/:id/builds/:buildId/schedules", async (req, res) => { res.json(schedules); }); -companiesRouter.post("/:id/builds/:buildId/schedules", async (req, res) => { +router.post("/:id/builds/:buildId/schedules", async (req, res) => { if (req.params.id != res.locals.user.companyId) { res.json({ error: "Access denied" }); return; } - const schedule = await Schedule.create({ - companyId: req.params.id, - buildId: req.params.buildId, - ...req.body, - }); - - res.json(schedule); + try { + const schedule = await Schedule.create({ + companyId: req.params.id, + buildId: req.params.buildId, + ...req.body, + }); + res.json(schedule); + } catch (error) { + if (error instanceof Error) { + res.json({ error: error.message }); + } + } }); +router.get( + "/:companyId/builds/:buildId/last_scheduled_session", + async (req, res) => { + const { companyId, buildId } = req.params; + + console.log("companyId", companyId); + console.log("buildId", buildId); + + try { + const lastScheduledSession = await ScheduledSession.findOne({ + companyId, + buildId, + }).sort({ startAt: -1 }); + + res.json(lastScheduledSession); + } catch (error) { + res.json({ error: (error as Error).message }); + } + } +); + +const companiesRouter = router; + export default companiesRouter; diff --git a/server/src/routes/scheduledSessions.ts b/server/src/routes/scheduledSessions.ts index 795523b..2429860 100644 --- a/server/src/routes/scheduledSessions.ts +++ b/server/src/routes/scheduledSessions.ts @@ -5,11 +5,13 @@ import Schedule from "../models/Schedule"; import { addMinutes, areIntervalsOverlapping, + differenceInMinutes, endOfDay, isValid, parseISO, startOfDay, } from "date-fns"; +import { isValidObjectId } from "mongoose"; const scheduledSessionsRouter = Router(); @@ -23,6 +25,15 @@ scheduledSessionsRouter.get("/", async (_req, res) => { }); scheduledSessionsRouter.get("/:id", async (req, res) => { + const scheduledSessionId = req.params.id; + + if (!isValidObjectId(scheduledSessionId)) { + return res.json({ + status: "error", + message: "Invalid session ID value", + }); + } + const scheduledSession = await ScheduledSession.findById(req.params.id); res.json(scheduledSession); @@ -41,6 +52,14 @@ scheduledSessionsRouter.get("/builds/:buildId", async (req, res) => { const buildId = req.params.buildId; const date = req.query.date as string; + + if (!isValidObjectId(buildId)) { + return res.json({ + status: "error", + message: "Invalid build ID value", + }); + } + const scheduledSessions = await ScheduledSession.find({ buildId, startAt: { @@ -77,13 +96,19 @@ scheduledSessionsRouter.get("/:buildId", async (req, res) => { }); scheduledSessionsRouter.post("/", async (req, res) => { - const { buildId, startAt, client } = req.body; + const { buildId, startAt, client, duration } = req.body; + + if (!isValidObjectId(buildId)) { + return res.json({ + status: "error", + message: "Invalid build ID value", + }); + } if (!buildId || !startAt) { return res.json({ status: "error", - message: - "Parameters `buildId`, `startAt` are required!", // Параметры `compamyId`, `buildId`, `startAt`, `client` обязательны! + message: "Parameters `buildId`, `startAt` are required!", // Параметры `compamyId`, `buildId`, `startAt`, `client` обязательны! }); } @@ -96,6 +121,65 @@ scheduledSessionsRouter.post("/", async (req, res) => { }); } + const build = await Build.findById(buildId); + + if (!build) { + return res.json({ + status: "error", + message: "An assembly with such a `buildId` was not found", // Сборка с таким `buildId` не найдена + }); + } + + if (duration) { + const scheduledSessions = await ScheduledSession.find({ + buildId, + startAt: { + $gte: startOfDay(startAtISO), + $lte: endOfDay(startAtISO), + }, + }); + + const endAtISO = addMinutes(startAtISO, duration); + + if (scheduledSessions.length) { + const overlappingSessions = []; + + for (const session of scheduledSessions) { + if ( + areIntervalsOverlapping( + { + start: session.startAt, + end: addMinutes(session.endAt, duration), + }, + { start: startAtISO, end: endAtISO } + ) + ) { + overlappingSessions.push(session); + } + } + + if (overlappingSessions.length >= build.sessionLimit) { + return res.json({ + status: "error", + message: + "It is not possible to create a session because it overlaps with the time of another session", // Невозможно создать сеанс, поскольку он перекрывается со временем другого сеанса. + }); + } + } + + const scheduledSession = await ScheduledSession.create({ + buildId, + startAt: startAtISO, + endAt: endAtISO, + }); + + return res.json({ + status: "success", + scheduledSessionId: scheduledSession.id, + url: `https://stream.graff.tech/scheduled/${scheduledSession.id}`, + }); + } + const schedule = await Schedule.findOne({ buildId, startDate: { $lte: startAtISO }, @@ -109,15 +193,6 @@ scheduledSessionsRouter.post("/", async (req, res) => { }); } - const build = await Build.findById(buildId); - - if (!build) { - return res.json({ - status: "error", - message: "An assembly with such a `buildId` was not found", // Сборка с таким `buildId` не найдена - }); - } - const scheduledSessions = await ScheduledSession.find({ buildId, startAt: { @@ -163,18 +238,103 @@ scheduledSessionsRouter.post("/", async (req, res) => { res.json({ status: "success", + scheduledSessionId: scheduledSession.id, url: `https://stream.graff.tech/scheduled/${scheduledSession.id}`, }); }); scheduledSessionsRouter.put("/:id", async (req, res) => { - const scheduledSession = await ScheduledSession.findByIdAndUpdate( - req.params.id, - req.body, - { new: true, upsert: true } + const scheduledSessionId = req.params.id; + + if (!isValidObjectId(scheduledSessionId)) { + return res.json({ + status: "error", + message: "Invalid session ID value", + }); + } + + let { startAt, duration }: { startAt: string; duration: number } = req.body; + + if (!startAt && !duration) { + return res.json({ + status: "error", + message: "Parameter `startAt` or `duration` is required, or both", + }); + } + + if (startAt && !isValid(parseISO(startAt))) { + return res.json({ + status: "error", + message: "Invalid value of the `startAt` parameter", + }); + } + + function isInteger(num: number) { + return (num ^ 0) === num; + } + + if (duration && !isInteger(duration)) { + return res.json({ + status: "error", + message: "Parameter `duration` is not an integer", + }); + } + + const scheduledSession = await ScheduledSession.findById(scheduledSessionId); + + if (!scheduledSession) { + return res.json({ + status: "error", + message: "Session with this ID not found", + }); + } + + if (!duration) { + duration = differenceInMinutes( + scheduledSession.endAt, + scheduledSession.startAt + ); + } + + const endAt = addMinutes( + (startAt && parseISO(startAt.toString())) || scheduledSession.startAt, + duration ); - res.json(scheduledSession); + try { + const scheduledSession = await ScheduledSession.findByIdAndUpdate( + scheduledSessionId, + { startAt, endAt }, + { new: true } + ); + + res.json({ status: "success", scheduledSession }); + } catch (error) { + if (error instanceof Error) { + res.json({ status: "error", message: error.message }); + } + } +}); + +scheduledSessionsRouter.delete("/:id", async (req, res) => { + const scheduledSessionId = req.params.id; + + if (!isValidObjectId(scheduledSessionId)) { + return res.json({ + status: "error", + message: "Invalid session ID value", + }); + } + + try { + await ScheduledSession.findByIdAndDelete(scheduledSessionId); + + res.json({ status: "success" }); + } catch (error) { + if (error instanceof Error) { + res.json({ status: "error", message: error.message }); + } + } }); export default scheduledSessionsRouter;