Refactor Card, Managers, Schedule, and SelectUser components to enhance functionality and UI; integrate manager selection logic, improve session handling, and add loading states for better user experience.

This commit is contained in:
2026-03-16 16:53:46 +05:00
parent b1557453be
commit 1e22853146
16 changed files with 810 additions and 290 deletions
+160 -127
View File
@@ -1,184 +1,217 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useState } from "react";
import SelectUser from "./SelectUser";
import MoreIcon from "./icons/MoreIcon";
import { Link } from "react-router-dom";
import { isAfter, isBefore, subMinutes } from "date-fns";
import api from "../utils/api";
import Button from "./Button";
import { Link } from "react-router-dom";
import IUser from "../types/IUser";
import useAuthStore from "../stores/useAuthStore";
import SelectUser from "./SelectUser";
import EntryIcon from "./icons/EntryIcon";
import { isAfter, subMinutes } from "date-fns";
import MoreIcon from "./icons/MoreIcon";
import ShareIcon from "./icons/ShareIcon";
import ShareModal from "./modals/ShareModal";
import useAuthStore from "../stores/useAuthStore";
import useModalStore from "../stores/useModalStore";
import IUser from "../types/IUser";
interface CardProps {
companyId: string;
buildId: string;
buildName?: string;
scheduledSessionId: string;
scheduleSessionStartAt: string;
client?: {
name: string;
phone: string;
email: string;
};
scheduleSessionEndAt?: string;
client?: { name: string; phone: string; email: string };
manager?: IUser;
managers: IUser[];
handleSelect: (scheduledSessionId: string, managerId: string | null) => void;
fitContainer?: boolean;
}
const STREAM_URL = import.meta.env.VITE_STREAM_URL as string;
const STATUS = {
noManager: { bg: "bg-[#F2DADA]", dot: "bg-[#EB5757]", text: "text-[#EB5757]", label: "Нет менеджера" },
ready: { bg: "bg-[#E6F2FE]", dot: "bg-[#49A1F5]", text: "text-[#49A1F5]", label: "Готов" },
done: { bg: "bg-[#F0F1F2]", dot: "bg-[#77828C]", text: "text-[#77828C]", label: "Проведена" },
scheduled: { bg: "bg-[#F0F1F2]", dot: "bg-[#77828C]", text: "text-[#77828C]", label: "Запланировано" },
} as const;
function getStatus(
manager: IUser | undefined,
canStart: boolean,
isCompleted: boolean
) {
if (!manager) return STATUS.noManager;
if (canStart) return STATUS.ready;
if (isCompleted) return STATUS.done;
return STATUS.scheduled;
}
function Card({
companyId,
buildId,
buildName,
scheduledSessionId,
scheduleSessionStartAt,
scheduleSessionEndAt,
client,
manager,
managers,
handleSelect,
fitContainer = false,
}: CardProps) {
const { user } = useAuthStore();
const [isShow, setIsShow] = useState<boolean>(false);
const setModal = useModalStore((state) => state.setModal);
const [isShowManagerSelect, setIsShowManagerSelect] = useState(false);
const [availableManagers, setAvailableManagers] = useState<IUser[]>();
async function getAvailableManagers() {
const result: any[] = await api
useEffect(() => {
if (!isShowManagerSelect) return;
api
.get(
`companies/${companyId}/builds/${buildId}/scheduledSessions/${scheduledSessionId}/availableManagers?startAt=${scheduleSessionStartAt}`
)
.json();
.json<string[]>()
.then((ids) => setAvailableManagers(managers.filter((m) => ids.includes(m.id))));
}, [isShowManagerSelect]);
const filteredManagers = managers.filter((manager) =>
result.includes(manager.id)
);
const now = new Date();
const canStart = !!manager && isAfter(now, subMinutes(new Date(scheduleSessionStartAt), 10));
const isCompleted = !!scheduleSessionEndAt && isBefore(new Date(scheduleSessionEndAt), now);
const status = getStatus(manager, canStart, isCompleted);
setAvailableManagers(filteredManagers);
}
useEffect(() => {
if (!isShow) return;
getAvailableManagers();
}, [isShow]);
const sessionUrl = `${STREAM_URL}/scheduled/${scheduledSessionId}?admin=true`;
return (
<div className="relative flex flex-col gap-2">
<div className="w-[264px] h-[164px] px-3 py-2 bg-white border-r border-b border-[#DAE0E5] flex flex-col justify-between gap-2">
<div className="space-y-2">
<div className="flex justify-between">
<div className="">
<p className="text-[10px] font-semibold text-[#77828C]">Клиент</p>
<p className="text-sm">{client?.name || "Имя не указано"}</p>
</div>
{manager ? (
<div className="bg-[#E6F2FE] rounded-full px-2 h-[20px] flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-[#49A1F5]"></div>
<p className="text-[10px] font-semibold text-[#49A1F5]">
Готов
</p>
</div>
) : (
<div className="bg-[#F2DADA] rounded-full px-2 h-[20px] flex items-center gap-1">
<div className="w-1 h-1 rounded-full bg-[#EB5757]"></div>
<p className="text-[10px] font-semibold text-[#EB5757] pt-0.5">
Нет менеджера
</p>
</div>
)}
<div className="relative">
<div
className={`w-[264px] bg-white flex flex-col gap-2 px-3 pt-3 pb-2 border-r border-b border-[#DAE0E5] ${
fitContainer ? "h-full min-h-[128px] overflow-hidden" : ""
}`}
>
{/* SessionInfo */}
<div className="relative flex flex-col gap-2 pb-2 border-b border-[#DAE0E5]">
<p className="text-[12px] font-semibold leading-[1.3] text-[#77828C]">
{buildName ? `ЖК «${buildName}»` : "ЖК"}
</p>
<div className="flex flex-col">
<span className="text-[10px] font-semibold leading-[1.2] text-[#77828C]">
Клиент
</span>
<span className="text-[14px] leading-[1.4] text-[#111C26]">
{client?.name || "Имя не указано"}
</span>
</div>
<div>
<p className="text-xs text-[#77828C] leading-tight">
<div className="flex flex-col">
<span className="text-[12px] leading-[1.3] text-[#77828C]">
{client?.phone || "Телефон не указан"}
</p>
<p className="text-xs text-[#77828C] leading-tight">
</span>
<span className="text-[12px] leading-[1.3] text-[#77828C]">
{client?.email || "Email не указан"}
</p>
</span>
</div>
{/* Status indicator */}
<div
className={`absolute top-0 right-0 flex items-center gap-1 rounded-full px-2 py-1 ${status.bg}`}
>
<div className={`w-1 h-1 rounded-full shrink-0 ${status.dot}`} />
<span className={`text-[10px] font-semibold leading-[1.2] ${status.text}`}>
{status.label}
</span>
</div>
{/* Share button */}
<div className="absolute left-[208px] top-16">
<Button
variant="tertiary"
icon={<ShareIcon />}
onlyIcon
onClick={() => setModal(<ShareModal scheduledSessionId={scheduledSessionId} />)}
/>
</div>
</div>
<div className="flex justify-between items-center pt-2 py-1 border-t border-[#DAE0E5] h-[45px]">
<div className="flex items-center gap-2">
{manager ? (
<img
src={manager.avatar || "/images/no-avatar.png"}
alt=""
className="w-6 h-6 rounded-full"
/>
) : (
<div className="w-6 h-6 bg-[#E6ECF2] rounded-full"></div>
)}
<p className="text-xs font-semibold">
{/* Manager row */}
<div className="flex gap-2 justify-between items-center min-h-8">
<div className="flex gap-2 items-center min-w-0">
<div className="relative w-6 h-6 shrink-0">
{manager ? (
<>
<img
src={manager.avatar || "/images/no-avatar.png"}
alt=""
className="w-6 h-6 rounded-full object-cover bg-[#E6ECF2]"
/>
{/* Online dot */}
<div
className={`absolute left-4 top-4 w-2 h-2 rounded-full border border-white ${
canStart ? "bg-[#49A1F5]" : "bg-[#77828C]"
}`}
/>
</>
) : (
<div className="w-6 h-6 rounded-full bg-[#E6ECF2]" />
)}
</div>
<span className="text-[12px] leading-[1.3] text-[#111C26] truncate">
{manager ? manager.name : "Не назначен"}
</p>
</span>
</div>
<div className="flex gap-2">
{user?.role === "manager" &&
(manager ? (
isAfter(
new Date(),
subMinutes(new Date(scheduleSessionStartAt), 10)
) ? (
<Link
to={`${
import.meta.env.VITE_STREAM_URL
}/scheduled/${scheduledSessionId}?admin=true`}
target="_blank"
>
<div className="flex gap-1 items-center shrink-0">
{user?.role === "manager" && (
<>
{!manager && (
<Button variant="secondary" onClick={() => handleSelect(scheduledSessionId, user.id)}>
Выбрать
</Button>
)}
{manager && canStart && (
<Link to={sessionUrl} target="_blank">
<Button>Начать</Button>
</Link>
) : (
manager.id === user.id && (
<Button
variant="secondary"
onClick={() => handleSelect(scheduledSessionId, null)}
>
Отменить
</Button>
)
)
) : (
<Button
variant="secondary"
onClick={() => handleSelect(scheduledSessionId, user.id)}
>
Выбрать
</Button>
))}
)}
{manager && !canStart && manager.id === user.id && (
<Button variant="secondary" onClick={() => handleSelect(scheduledSessionId, null)}>
Отменить
</Button>
)}
</>
)}
{user?.role === "admin" && (
<div className="flex gap-1">
{manager && (
<Link
to={`${
import.meta.env.VITE_STREAM_URL
}/scheduled/${scheduledSessionId}?admin=true`}
target="_blank"
>
<Button variant="secondary" icon={<EntryIcon />} onlyIcon />
</Link>
)}
<button
onClick={() => setIsShow(!isShow)}
className="p-1 text-[#77828C] hover:bg-neutral-100 rounded-lg"
>
<MoreIcon />
</button>
</div>
<>
<Link to={sessionUrl} target="_blank">
<Button variant="tertiary" icon={<EntryIcon />} onlyIcon />
</Link>
<Button
variant="tertiary"
icon={<MoreIcon />}
onlyIcon
onClick={() => setIsShowManagerSelect((v) => !v)}
/>
</>
)}
</div>
</div>
</div>
<div className="absolute z-10 pl-[calc(264px+8px)]">
{availableManagers && availableManagers.length > 0 && (
<SelectUser
shown={isShow}
selectedManagerId={manager?.id}
managers={availableManagers}
handleClick={(managerId) => (
handleSelect(scheduledSessionId, managerId), setIsShow(false)
)}
handleShown={() => setIsShow((prev) => !prev)}
/>
)}
<SelectUser
shown={isShowManagerSelect}
selectedManagerId={manager?.id}
managers={availableManagers ?? []}
loading={isShowManagerSelect && !availableManagers}
handleClick={(managerId) => {
handleSelect(scheduledSessionId, managerId);
setIsShowManagerSelect(false);
}}
handleShown={() => setIsShowManagerSelect(false)}
/>
</div>
</div>
);
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Transition } from "react-transition-group";
import { HTTPError } from "ky";
import toast from "react-hot-toast";
import api from "../utils/api";
import IUser from "../types/IUser";
interface ManagerMoreMenuProps {
shown: boolean;
manager: IUser;
companyId: string;
anchorEl: HTMLElement | null;
onClose: () => void;
onManagerDeleted: () => void;
}
function ManagerMoreMenu({
shown,
manager,
companyId,
anchorEl,
onClose,
onManagerDeleted,
}: ManagerMoreMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (shown && anchorEl) {
const rect = anchorEl.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left,
});
}
}, [shown, anchorEl]);
useEffect(() => {
if (!shown) return;
const handleMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleMouseDown);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [shown, onClose]);
async function handleDelete() {
if (isDeleting) return;
setIsDeleting(true);
try {
const result = await api
.delete(`companies/${companyId}/users/${manager.id}`)
.json<{ success?: boolean; error?: string }>();
if ("error" in result) {
toast.error(result.error ?? "Ошибка");
return;
}
onClose();
onManagerDeleted();
toast.success("Менеджер удалён");
} catch (error) {
if (error instanceof HTTPError) {
const body = (await error.response.json()) as { error?: string };
toast.error(body?.error ?? (error as Error).message ?? "Ошибка");
} else {
toast.error((error as Error).message ?? "Ошибка");
}
} finally {
setIsDeleting(false);
}
}
const menuContent = (
<Transition in={shown} timeout={150} mountOnEnter unmountOnExit>
{(state) => (
<div
ref={ref}
className={`fixed z-50 bg-white w-[280px] rounded-lg flex flex-col shadow-[0px_1px_4px_0px_rgba(0,0,0,0.16)] transition-opacity py-2 ${state}`}
style={{ top: position.top, left: position.left }}
>
<button
disabled
className="px-4 flex items-center gap-2 w-full h-8 transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed text-[#77828C]"
>
<span className="text-[12px] leading-[1.3]">Профиль</span>
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 flex items-center gap-2 w-full h-8 transition-colors hover:bg-[#F2DADA] text-left text-[#EB5757] disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="text-[12px] leading-[1.3]">
{isDeleting ? "Удаление…" : "Удалить"}
</span>
</button>
</div>
)}
</Transition>
);
return createPortal(menuContent, document.body);
}
export default ManagerMoreMenu;
+69 -19
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import useAuthStore from "../stores/useAuthStore";
import useModalStore from "../stores/useModalStore";
import useSettingsStore from "../stores/useSettingsStore";
@@ -5,18 +6,21 @@ import useStore from "../stores/useStore";
import Button from "./Button";
import ChevronDownIcon from "./icons/ChevronDownIcon";
import ChevronUpIcon from "./icons/ChevronUpIcon";
// import MoreIcon from "./icons/MoreIcon";
import MoreIcon from "./icons/MoreIcon";
import ManagerMoreMenu from "./ManagerMoreMenu";
import AddManagerModal from "./modals/AddManagerModal";
function Managers() {
const { user } = useAuthStore();
const { managers } = useStore();
const { company, managers, setManagers } = useStore();
const [openMenuManagerId, setOpenMenuManagerId] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { isShowManagers, setIsShowManagers } = useSettingsStore();
const { setModal } = useModalStore();
return (
<div className="p-4 flex flex-col gap-4 border-b border-[#DAE0E5]">
<div className="flex items-center justify-between">
<div className="flex justify-between items-center">
<p className="text-sm font-semibold">Менеджеры</p>
<Button
variant="tertiary"
@@ -29,25 +33,71 @@ function Managers() {
{isShowManagers && (
<div className="space-y-4">
<div className="space-y-2">
{managers.map((manager) => (
<div key={manager.id} className="flex justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full text-[10px] font-semibold flex items-center justify-center bg-[#E6ECF2]">
{manager?.name.split(" ")[0][0]}
{managers.filter((m) => m.role === "manager").length === 0 ? (
<p className="text-sm text-[#6B7280]">Менеджеры не добавлены</p>
) : (
managers
.filter((m) => m.role === "manager")
.map((manager) => (
<div
key={manager.id}
data-manager-row
className="flex justify-between items-center"
>
<div className="flex gap-2 items-center">
<div className="w-8 h-8 rounded-full text-[10px] font-semibold flex items-center justify-center bg-[#E6ECF2]">
{manager?.name.split(" ")[0][0]}
</div>
<p className="text-sm">{manager.name}</p>
</div>
<p className="text-sm">{manager.name}</p>
{user?.role === "admin" && company && (
<div
onClick={(e) => {
const row = (e.currentTarget as HTMLElement).closest(
"[data-manager-row]"
) as HTMLElement;
setAnchorEl(row);
setOpenMenuManagerId((id) =>
id === manager.id ? null : manager.id
);
}}
>
<Button
variant="tertiary"
onlyIcon
icon={<MoreIcon />}
/>
</div>
)}
</div>
{/* {user?.role === "admin" && (
<Button
disabled
variant="tertiary"
onlyIcon
icon={<MoreIcon />}
/>
)} */}
</div>
))}
))
)}
</div>
{user?.role === "admin" &&
company &&
openMenuManagerId &&
managers.find((m) => m.id === openMenuManagerId) && (
<ManagerMoreMenu
shown
manager={managers.find((m) => m.id === openMenuManagerId)!}
companyId={company.id}
anchorEl={anchorEl}
onClose={() => {
setOpenMenuManagerId(null);
setAnchorEl(null);
}}
onManagerDeleted={() => {
const manager = managers.find(
(m) => m.id === openMenuManagerId
);
if (manager) {
setManagers(managers.filter((m) => m.id !== manager.id));
}
setOpenMenuManagerId(null);
setAnchorEl(null);
}}
/>
)}
{user?.role === "admin" && (
<Button
variant="secondary"
+22 -1
View File
@@ -22,6 +22,7 @@ import Label from "./Label";
import api from "../utils/api";
import useStore from "../stores/useStore";
import IScheduledSession from "../types/IScheduledSession";
import IUser from "../types/IUser";
import toast from "react-hot-toast";
import Select3 from "./Select3";
import DatePicker from "./DatePicker";
@@ -31,9 +32,24 @@ interface Props {
selectedDay: Date;
slots: number;
events: IScheduledSession[];
companyId: string;
managers: IUser[];
onScheduledSessionUpdate: (
scheduledSessionId: string,
managerId: string | null
) => void;
onScheduledSessionRemove?: () => void;
}
function Schedule({ selectedDay, slots, events }: Props) {
function Schedule({
selectedDay,
slots,
events,
companyId,
managers,
onScheduledSessionUpdate,
onScheduledSessionRemove,
}: Props) {
const { company, builds, selectedBuild, setSelectedBuild } = useStore();
const [draftMode, setDraftMode] = useState<boolean>(false);
const [slot, setSlot] = useState<number>(1);
@@ -261,6 +277,11 @@ function Schedule({ selectedDay, slots, events }: Props) {
onChangeDuration={handleChangeDuration}
startTime={company?.startTime}
endTime={company?.endTime}
companyId={companyId}
managers={managers}
builds={builds}
onScheduledSessionUpdate={onScheduledSessionUpdate}
onScheduledSessionRemove={onScheduledSessionRemove}
/>
))}
</div>
+73 -37
View File
@@ -1,64 +1,100 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from "react";
import { Transition } from "react-transition-group";
import CheckIcon from "./icons/CheckIcon";
interface SelectUserProps {
shown: boolean;
selectedManagerId?: string;
managers: any[];
loading?: boolean;
handleClick: (managerId: string | null) => void;
handleShown: () => void;
}
import { Transition } from "react-transition-group";
import CheckIcon from "./icons/CheckIcon";
import useOutsideClick from "../hooks/useOutsideClick";
function SelectUser({
shown,
selectedManagerId,
managers,
loading,
handleClick,
handleShown,
}: SelectUserProps) {
const selectUserRef = useOutsideClick(handleShown);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shown) return;
const handleMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
handleShown();
}
};
// defer to avoid catching the same click that opened the dropdown
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleMouseDown);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [shown, handleShown]);
return (
<Transition in={shown} timeout={150} mountOnEnter unmountOnExit>
{(state) => (
<div
ref={selectUserRef}
className={`bg-white w-[280px] max-h-[126px] overflow-auto rounded-lg py-2 flex flex-col gap-1 shadow transition-opacity ${state}`}
ref={ref}
className={`bg-white w-[280px] rounded-lg flex flex-col shadow-[0px_1px_4px_0px_rgba(0,0,0,0.16)] transition-opacity ${state}`}
>
{managers.map((manager) => (
<button
key={manager.id}
onClick={() => handleClick(manager.id)}
className="px-4 flex justify-between gap-2 w-full py-1 transition-colors hover:bg-[#E6ECF2]"
>
<div className="flex items-center gap-2">
<img
src={manager.avatar || "/images/no-avatar.png"}
alt=""
className="w-6 h-6 rounded-full"
/>
<p className="text-xs">{manager.name}</p>
<div className="py-2 flex flex-col gap-1 max-h-[160px] overflow-y-auto">
{loading ? (
<div className="flex items-center px-4 h-8">
<span className="text-[12px] text-[#77828C]">Загрузка...</span>
</div>
{manager.id === selectedManagerId && (
<div className="text-[#49A1F5]">
<CheckIcon />
</div>
)}
</button>
))}
) : (
<>
{managers.map((manager) => (
<button
key={manager.id}
onClick={() => handleClick(manager.id)}
className="px-4 flex items-center justify-between gap-2 w-full h-8 transition-colors hover:bg-[#E6ECF2]"
>
<div className="flex gap-2 items-center">
<div className="relative w-6 h-6 shrink-0">
<img
src={manager.avatar || "/images/no-avatar.png"}
alt=""
className="w-6 h-6 rounded-full object-cover bg-[#E6ECF2]"
/>
<div className="absolute left-4 top-4 w-2 h-2 rounded-full border border-white bg-[#49A1F5]" />
</div>
<span className="text-[12px] leading-[1.3] text-[#111C26]">
{manager.name}
</span>
</div>
{manager.id === selectedManagerId && (
<span className="w-4 h-4 text-[#49A1F5] shrink-0">
<CheckIcon />
</span>
)}
</button>
))}
<button
onClick={() => handleClick(null)}
className="px-4 flex justify-between gap-2 w-full py-1 transition-colors hover:bg-[#E6ECF2]"
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-[#E6ECF2]"></div>
<p className="text-xs">Без менеджера</p>
</div>
</button>
<button
onClick={() => handleClick(null)}
className="px-4 flex items-center gap-2 w-full h-8 transition-colors hover:bg-[#E6ECF2]"
>
<div className="w-6 h-6 rounded-full bg-[#E6ECF2] shrink-0" />
<span className="text-[12px] leading-[1.3] text-[#111C26]">
Без менеджера
</span>
</button>
</>
)}
</div>
</div>
)}
</Transition>
+80 -50
View File
@@ -13,10 +13,11 @@ import {
} from "date-fns";
import Button from "./Button";
import IScheduledSession from "../types/IScheduledSession";
import useStore from "../stores/useStore";
import IUser from "../types/IUser";
import CloseIcon from "./icons/CloseIcon";
import api from "../utils/api";
import toast from "react-hot-toast";
import Card from "./Card";
interface Props {
timelineEvents: IScheduledSession[];
@@ -28,6 +29,14 @@ interface Props {
onChangeStartAt: (startAt: Date) => void;
onChangeDuration: (duration: number) => void;
onChangeSlot: (slot: number) => void;
companyId: string;
managers: IUser[];
builds: { id: string; name: string }[];
onScheduledSessionUpdate: (
scheduledSessionId: string,
managerId: string | null
) => void;
onScheduledSessionRemove?: () => void;
}
const timelineSlotHeight = 180;
@@ -43,6 +52,11 @@ function Timeline({
onChangeStartAt,
onChangeDuration,
onChangeSlot,
companyId,
managers,
builds,
onScheduledSessionUpdate,
onScheduledSessionRemove,
}: Props) {
const [pressed, setPressed] = useState(false);
const [startPosY, setStartPosY] = useState<number>();
@@ -50,7 +64,6 @@ function Timeline({
const ref = useRef<HTMLDivElement>(null);
const [startAt, setStartAt] = useState<string>();
const [duration, setDuration] = useState<number>(30); // min
const { builds } = useStore();
function handleMouseDown(e: MouseEvent<HTMLDivElement>) {
if (draftMode) return;
@@ -119,6 +132,7 @@ function Timeline({
}
toast.success("Сеанс успешно удален!");
onScheduledSessionRemove?.();
} catch (error) {
toast.error("Что-то пошло не так");
}
@@ -192,56 +206,72 @@ function Timeline({
</div>
</div>
{timelineEvents.map((event) => (
<div
key={event.id}
data-type="event"
className="absolute w-full bg-white border-b border-r border-[#DAE0E5] p-3 pointer-events-auto"
style={{
top: `${
differenceInMinutes(
new Date(event.startAt),
startTime
? addHours(
startOfDay(new Date(event.startAt)),
getHours(parseISO(startTime))
)
: startOfDay(new Date(event.startAt))
) * minutePx
}px`,
height: `${
differenceInMinutes(
new Date(event.endAt),
new Date(event.startAt)
) * minutePx
}px`,
}}
>
<div className="relative flex flex-col justify-between h-full">
<div className="space-y-1">
<p>
{format(new Date(event.startAt), "HH:mm")} -{" "}
{format(new Date(event.endAt), "HH:mm")}
</p>
<p>{builds.find((build) => build.id === event.buildId)?.name}</p>
{timelineEvents.map((event) => {
const client = event.client as
| { name?: string; phone?: string; email?: string }
| undefined;
const manager = event.userId
? managers.find((m) => m.id === event.userId)
: undefined;
return (
<div
key={event.id}
data-type="event"
className="absolute w-full pointer-events-auto"
style={{
top: `${
differenceInMinutes(
new Date(event.startAt),
startTime
? addHours(
startOfDay(new Date(event.startAt)),
getHours(parseISO(startTime))
)
: startOfDay(new Date(event.startAt))
) * minutePx
}px`,
height: `${
differenceInMinutes(
new Date(event.endAt),
new Date(event.startAt)
) * minutePx
}px`,
}}
>
<div className="relative h-full">
<Card
companyId={companyId}
buildId={event.buildId}
buildName={builds.find((b) => b.id === event.buildId)?.name}
scheduledSessionId={event.id}
scheduleSessionStartAt={event.startAt}
scheduleSessionEndAt={event.endAt}
client={
client
? {
name: client.name ?? "",
phone: client.phone ?? "",
email: client.email ?? "",
}
: undefined
}
manager={manager}
managers={managers}
handleSelect={onScheduledSessionUpdate}
fitContainer
/>
<Button
variant="tertiary"
icon={<CloseIcon className="" />}
onlyIcon
onClick={() => handleClickRemove(event.id)}
className="absolute -top-2 -right-2 z-10"
/>
</div>
<a
href={`https://stream.graff.tech/scheduled/${event.id}?admin=true`}
target="_blank"
className="self-end"
>
<Button className="">Начать</Button>
</a>
<Button
variant="tertiary"
icon={<CloseIcon className="" />}
onlyIcon
onClick={() => handleClickRemove(event.id)}
className="absolute -top-2 -right-2"
/>
</div>
</div>
))}
);
})}
</div>
);
}
@@ -1,8 +1,6 @@
function EntryIcon() {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
-2
View File
@@ -1,8 +1,6 @@
function ExitIcon() {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
-2
View File
@@ -5,8 +5,6 @@ interface Props {
function MoreIcon({ className = "" }: Props) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+34
View File
@@ -0,0 +1,34 @@
function ShareIcon({ className = "" }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M10 4V9"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5 6.5L10 4L7.5 6.5"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 10V14C5 14.5523 5.44772 15 6 15H14C14.5523 15 15 14.5523 15 14V10"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default ShareIcon;
@@ -0,0 +1,87 @@
import Button from "../Button";
import CloseIcon from "../icons/CloseIcon";
import useModalStore from "../../stores/useModalStore";
import toast from "react-hot-toast";
interface Props {
scheduledSessionId: string;
}
function ShareModal({ scheduledSessionId }: Props) {
const { setModal } = useModalStore();
const streamUrl = import.meta.env.VITE_STREAM_URL || "";
const viewLink = `${streamUrl}/scheduled/${scheduledSessionId}`;
const conductLink = `${streamUrl}/scheduled/${scheduledSessionId}?admin=true`;
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
toast.success("Ссылка скопирована");
} catch {
toast.error("Не удалось скопировать");
}
}
return (
<div className="w-[352px] bg-white rounded-lg shadow-[0px_1px_4px_0px_rgba(0,0,0,0.16)]">
<div className="flex justify-between items-center px-4 py-2 border-b border-[#DAE0E5]">
<p className="text-sm font-semibold text-[#111C26]">Поделиться</p>
<Button
variant="tertiary"
icon={<CloseIcon />}
onlyIcon
onClick={() => setModal(null)}
/>
</div>
<div className="p-6 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-[#111C26]">
Ссылка для просмотра
</p>
<input
type="text"
readOnly
value={viewLink}
className="px-3 py-2 outline-none rounded-lg ring-1 ring-[#DAE0E5] text-sm text-[#77828C] bg-white w-full"
/>
<p className="text-xs text-[#77828C]">
Без возможности управления демонстрацией
</p>
<Button
size="medium"
widthFull
onClick={() => copyToClipboard(viewLink)}
>
Копировать
</Button>
</div>
<div className="flex flex-col gap-2 pt-6 border-t border-[#DAE0E5]">
<p className="text-xs font-semibold text-[#111C26]">
Ссылка для проведения
</p>
<input
type="text"
readOnly
value={conductLink}
className="px-3 py-2 outline-none rounded-lg ring-1 ring-[#DAE0E5] text-sm text-[#77828C] bg-white w-full"
/>
<p className="text-xs text-[#77828C]">
Полный доступ на управление демонстрацией
</p>
<Button
size="medium"
widthFull
onClick={() => copyToClipboard(conductLink)}
>
Копировать
</Button>
</div>
</div>
</div>
);
}
export default ShareModal;
+29
View File
@@ -131,6 +131,31 @@ function DashboardPage() {
if (useLoader) setIsLoadingScheduledSessions(false);
}
async function handleSelectSession(
scheduledSessionId: string,
managerId: string | null
) {
if (!company) return;
try {
await api
.put(
`companies/${company.id}/scheduledSessions/${scheduledSessionId}`,
{ json: { userId: managerId } }
)
.json();
await getScheduledSessions();
toast.success(
managerId
? "Менеджер назначен"
: "Менеджер снят с сессии"
);
} catch (error) {
toast.error((error as Error).message);
}
}
useEffect(() => {
getCompany();
@@ -212,6 +237,10 @@ function DashboardPage() {
selectedDay={selectedDay}
slots={company.sessionLimit}
events={scheduledSessions}
companyId={company.id}
managers={managers}
onScheduledSessionUpdate={handleSelectSession}
onScheduledSessionRemove={getScheduledSessions}
/>
)}
</div>
+11 -2
View File
@@ -9,6 +9,14 @@ const router = Router();
router.post("/", async (req, res) => {
const { companyId, username, name, role, buildIds } = req.body;
if (res.locals.user.role !== "admin") {
return res.status(403).json({ error: "Только администратор может добавлять менеджеров" });
}
if (companyId != res.locals.user.companyId) {
return res.status(403).json({ error: "Access denied" });
}
try {
const password = generate({
length: 8,
@@ -49,9 +57,10 @@ router.post("/", async (req, res) => {
await transporter.sendMail({
from: "stream@graff.tech", // sender address
to: username, // list of receivers
subject: "Данные аккаунта - stream.graff.tech", // Subject line
subject: "Данные аккаунта - crm.stream.graff.tech", // Subject line
html: `<div>
Пароль для входа в аккаунт: <b>${password}</b>
Пароль для входа в аккаунт: <b>${password}</b><br>
Ссылка для входа: <a href="https://crm.stream.graff.tech/" target="_blank">https://crm.stream.graff.tech/</a>
</div>`,
});
} catch (error) {
+115 -44
View File
@@ -4,6 +4,7 @@ import Company from "../models/Company.js";
import ScheduledSession from "../models/ScheduledSession.js";
import Schedule from "../models/Schedule.js";
import User from "../models/User.js";
import mongoose from "mongoose";
const router = Router();
@@ -15,8 +16,7 @@ const router = Router();
router.get("/:companyId", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const company = await Company.findById(req.params.companyId);
@@ -26,8 +26,7 @@ router.get("/:companyId", async (req, res) => {
router.put("/:companyId", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const company = await Company.findByIdAndUpdate(
@@ -43,8 +42,7 @@ router.put("/:companyId", async (req, res) => {
router.get("/:companyId/builds", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const company: any = await Company.findById(req.params.companyId).populate(
@@ -57,8 +55,7 @@ router.get("/:companyId/builds", async (req, res) => {
router.get("/:companyId/users", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const company: any = await Company.findById(req.params.companyId).populate(
@@ -71,8 +68,7 @@ router.get("/:companyId/users", async (req, res) => {
router.get("/:companyId/builds/:buildId/users", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const buildUsers: any = await User.find({
@@ -91,8 +87,7 @@ router.get("/:companyId/builds/:buildId/users", async (req, res) => {
router.get("/:companyId/scheduledSessions", async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const { startDate, endDate } = req.query;
@@ -117,71 +112,147 @@ router.get("/:companyId/scheduledSessions", async (req, res) => {
res.json(scheduledSessions);
});
router.get(
"/:companyId/builds/:buildId/scheduledSessions/:scheduledSessionId/availableManagers",
async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
return res.status(403).json({ error: "Access denied" });
}
const { companyId, buildId, scheduledSessionId } = req.params;
const { startAt } = req.query as { startAt: string };
try {
const startAtDate = parseISO(startAt);
// All managers of this company assigned to this build
const buildManagers = await User.find({
companyId,
role: "manager",
buildIds: new mongoose.Types.ObjectId(buildId),
});
// Find managers already busy at this time (excluding current session)
const busySessions = await ScheduledSession.find({
_id: { $ne: new mongoose.Types.ObjectId(scheduledSessionId) },
userId: { $in: buildManagers.map((m) => m._id) },
startAt: startAtDate,
});
const busyManagerIds = new Set(
busySessions.map((s) => s.userId?.toString())
);
const available = buildManagers
.filter((m) => !busyManagerIds.has(m._id.toString()))
.map((m) => m._id.toString());
res.json(available);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
}
);
router.put(
"/:companyId/scheduledSessions/:scheduledSessionId",
async (req, res) => {
if (req.params.companyId != res.locals.user.companyId) {
res.json({ error: "Access denied" });
return;
return res.status(403).json({ error: "Access denied" });
}
const { companyId, scheduledSessionId } = req.params;
const { userId: newManagerId } = req.body;
try {
const scheduledSession = await ScheduledSession.findById(
req.params.scheduledSessionId
);
const scheduledSession = await ScheduledSession.findById(scheduledSessionId);
if (!scheduledSession) {
return res.status(404).json({ error: "Сессия не найдена" });
}
if (scheduledSession.companyId.toString() !== companyId) {
return res.status(403).json({ error: "Access denied" });
}
if (res.locals.user.role === "manager") {
const currentUserId = res.locals.user._id.toString();
if (newManagerId !== null && newManagerId !== currentUserId) {
return res.status(403).json({
error: "Менеджер может назначать только себя на сессию",
});
}
}
const scheduledSessionAtSameTime = await ScheduledSession.findOne({
startAt: scheduledSession?.startAt,
userId: req.body.userId,
startAt: scheduledSession.startAt,
userId: newManagerId,
});
if (scheduledSessionAtSameTime && req.body.userId !== null) {
if (scheduledSessionAtSameTime && newManagerId !== null) {
await ScheduledSession.updateMany(
{
userId: req.body.userId,
startAt: scheduledSession?.startAt,
userId: newManagerId,
startAt: scheduledSession.startAt,
},
{
userId: null,
}
{ userId: null }
);
await ScheduledSession.findByIdAndUpdate(
req.params.scheduledSessionId,
{
userId: req.body.userId,
}
scheduledSessionId,
{ userId: newManagerId },
{ new: true }
);
res.json({ error: "Scheduled session at same time" });
return;
return res.json({ error: "Scheduled session at same time" });
}
const updatedScheduledSession = await ScheduledSession.findByIdAndUpdate(
req.params.scheduledSessionId,
req.body,
{
new: true,
upsert: true,
}
scheduledSessionId,
{ userId: newManagerId },
{ new: true }
);
res.json(updatedScheduledSession);
} catch (error) {
if (error instanceof Error) {
res.json({ error });
res.status(500).json({ error: error.message });
}
}
}
);
router.get("/:companyId/users", async (req, res) => {
try {
const companyId = req.params.companyId;
const users = await User.find({ companyId });
router.delete("/:companyId/users/:userId", async (req, res) => {
if (res.locals.user.role !== "admin") {
return res.status(403).json({ error: "Только администратор может удалять менеджеров" });
}
return res.json(users);
if (req.params.companyId != res.locals.user.companyId) {
return res.status(403).json({ error: "Access denied" });
}
const { companyId, userId } = req.params;
try {
const user = await User.findOne({ _id: userId, companyId });
if (!user) {
return res.status(404).json({ error: "Пользователь не найден" });
}
if (user.role === "admin") {
return res.status(403).json({ error: "Нельзя удалить администратора" });
}
await ScheduledSession.updateMany(
{ companyId, userId: new mongoose.Types.ObjectId(userId) },
{ userId: null }
);
await User.findByIdAndRemove(userId);
res.json({ success: true });
} catch (error) {
return res.json({ error: (error as Error).message });
res.status(500).json({ error: (error as Error).message });
}
});
+1 -1
View File
@@ -37,7 +37,7 @@ router.post("/", async (req, res) => {
await transporter.sendMail({
from: "stream@graff.tech", // sender address
to: username, // list of receivers
subject: "Сброс пароля - stream.graff.tech", // Subject line
subject: "Сброс пароля - crm.stream.graff.tech", // Subject line
html: `<div>
Ссылка для сброса пароля: <a href="${url}" target="_blank">${url}</a>
</div>`,
+11 -3
View File
@@ -1,12 +1,12 @@
import { Router } from "express";
import User from "../models/User.js";
// import User from "../models/User.js";
const router = Router();
router.get("/", async (req, res) => {
try {
const result = await User.find(req.query);
const companyId = res.locals.user.companyId;
const result = await User.find({ companyId, ...req.query });
res.json(result);
} catch (error) {
@@ -16,7 +16,15 @@ router.get("/", async (req, res) => {
router.get("/:id", async (req, res) => {
try {
const result = await User.findById(req.params);
const result = await User.findById(req.params.id);
if (!result) {
return res.status(404).json({ error: "Пользователь не найден" });
}
if (result.companyId.toString() !== res.locals.user.companyId) {
return res.status(403).json({ error: "Access denied" });
}
res.json(result);
} catch (error) {