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:
+160
-127
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user