Update environment configuration and enhance Card and Timeline components; change API URL for development, integrate Kebab menu for session management in Card, and streamline Timeline session handling by removing unnecessary button.

This commit is contained in:
2026-03-17 17:28:11 +05:00
parent 9f828473f6
commit 27317124aa
8 changed files with 414 additions and 157 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_API_URL=http://localhost:3001
VITE_API_URL=http://192.168.1.53:3001
# VITE_API_URL=https://crm.stream.graff.tech/api
VITE_STREAM_URL=https://stream.graff.tech
+128 -130
View File
@@ -3,15 +3,14 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { isAfter, isBefore, subMinutes } from "date-fns";
import api from "../utils/api";
import Button from "./Button";
import SelectUser from "./SelectUser";
import ManagerSelector from "./ManagerSelector";
import CardKebabMenu from "./CardKebabMenu";
import EntryIcon from "./icons/EntryIcon";
import MoreIcon from "./icons/MoreIcon";
import ShareIcon from "./icons/ShareIcon";
import ShareModal from "./modals/ShareModal";
import KebabIcon from "./icons/KebabIcon";
import useAuthStore from "../stores/useAuthStore";
import useModalStore from "../stores/useModalStore";
import IUser from "../types/IUser";
import Button from "./Button";
interface CardProps {
companyId: string;
@@ -24,16 +23,33 @@ interface CardProps {
manager?: IUser;
managers: IUser[];
handleSelect: (scheduledSessionId: string, managerId: string | null) => void;
onDelete?: (scheduledSessionId: string) => void;
fitContainer?: boolean;
}
const STREAM_URL = import.meta.env.VITE_STREAM_URL as string;
const cn = {
border: "border-[#DAE0E6]",
contact: "text-[10px] font-semibold leading-[1.2] text-[#77828C]",
mutedBtn: "text-[#77828C] hover:text-[#4C5359] transition-colors",
statusLabel: "text-[10px] font-semibold leading-[1.2]",
textPrimary: "text-[#111C26]",
} as const;
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: "Запланировано" },
noManager: {
dot: "bg-[#EB5757]",
text: "text-[#EB5757]",
label: "Нет менеджера",
},
ready: { dot: "bg-[#49A1F5]", text: "text-[#49A1F5]", label: "Готов" },
done: { dot: "bg-[#77828C]", text: "text-[#77828C]", label: "Проведена" },
scheduled: {
dot: "bg-[#77828C]",
text: "text-[#77828C]",
label: "Запланировано",
},
} as const;
function getStatus(
@@ -58,11 +74,12 @@ function Card({
manager,
managers,
handleSelect,
onDelete,
fitContainer = false,
}: CardProps) {
const { user } = useAuthStore();
const setModal = useModalStore((state) => state.setModal);
const [isShowManagerSelect, setIsShowManagerSelect] = useState(false);
const [isKebabMenuOpen, setIsKebabMenuOpen] = useState(false);
const [availableManagers, setAvailableManagers] = useState<IUser[]>();
useEffect(() => {
@@ -73,12 +90,16 @@ function Card({
`companies/${companyId}/builds/${buildId}/scheduledSessions/${scheduledSessionId}/availableManagers?startAt=${scheduleSessionStartAt}`
)
.json<string[]>()
.then((ids) => setAvailableManagers(managers.filter((m) => ids.includes(m.id))));
.then((ids) =>
setAvailableManagers(managers.filter((m) => ids.includes(m.id)))
);
}, [isShowManagerSelect]);
const now = new Date();
const canStart = !!manager && isAfter(now, subMinutes(new Date(scheduleSessionStartAt), 10));
const isCompleted = !!scheduleSessionEndAt && isBefore(new Date(scheduleSessionEndAt), now);
const canStart =
!!manager && isAfter(now, subMinutes(new Date(scheduleSessionStartAt), 10));
const isCompleted =
!!scheduleSessionEndAt && isBefore(new Date(scheduleSessionEndAt), now);
const status = getStatus(manager, canStart, isCompleted);
const sessionUrl = `${STREAM_URL}/scheduled/${scheduledSessionId}?admin=true`;
@@ -86,132 +107,109 @@ function Card({
return (
<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" : ""
}`}
className={`box-border flex flex-col items-start p-3 gap-3 w-[264px] bg-white ${
fitContainer ? "h-full min-h-[144px]" : "h-[144px]"
} ${fitContainer && !isShowManagerSelect && !isKebabMenuOpen ? "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]">
{/* ── SessionInfo: клиент + ЖК, gap 8px ── */}
<div
className={`relative w-full flex flex-col items-start gap-2 ${cn.border}`}
>
{/* Frame 2702: имя + контакты, gap 4px */}
<div className="flex flex-col gap-1 items-start">
<p
className={`text-[14px] font-normal leading-[1.4] ${cn.textPrimary}`}
>
{client?.name || "Имя не указано"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[12px] leading-[1.3] text-[#77828C]">
{client?.phone || "Телефон не указан"}
</span>
<span className="text-[12px] leading-[1.3] text-[#77828C]">
{client?.email || "Email не указан"}
</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>
{/* 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]" />
)}
</p>
{/* Contacts: column, gap 4px */}
<div className="flex flex-col gap-1 items-start">
<span className={cn.contact}>
{client?.phone || "Телефон не указан"}
</span>
<span className={cn.contact}>
{client?.email || "Email не указан"}
</span>
</div>
<span className="text-[12px] leading-[1.3] text-[#111C26] truncate">
{manager ? manager.name : "Не назначен"}
</span>
{/* Kebab — absolute right-0 top-0, 32×32, p-1, rounded-lg */}
{user?.role === "admin" && (
<div className="absolute top-0 right-0">
<div className="relative">
<Button
onlyIcon
variant="tertiary"
type="button"
onClick={() => setIsKebabMenuOpen((v) => !v)}
>
<KebabIcon className="w-5 h-5" />
</Button>
<CardKebabMenu
shown={isKebabMenuOpen}
scheduledSessionId={scheduledSessionId}
onClose={() => setIsKebabMenuOpen(false)}
onDelete={onDelete ? () => onDelete(scheduledSessionId) : undefined}
/>
</div>
</div>
)}
</div>
<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 && !canStart && manager.id === user.id && (
<Button variant="secondary" onClick={() => handleSelect(scheduledSessionId, null)}>
Отменить
</Button>
)}
</>
)}
<div className="flex gap-1 justify-between items-center w-full">
{/* ЖК — order 2, gap 8 от Frame 2702 */}
<p className="text-[12px] font-normal leading-[1.3] text-[#77828C]">
{buildName ? `ЖК «${buildName}»` : "ЖК"}
</p>
{user?.role === "admin" && (
<>
<Link to={sessionUrl} target="_blank">
<Button variant="tertiary" icon={<EntryIcon />} onlyIcon />
</Link>
<Button
variant="tertiary"
icon={<MoreIcon />}
onlyIcon
onClick={() => setIsShowManagerSelect((v) => !v)}
/>
</>
)}
{/* Indicator — absolute right-0 top-[46px], gap 4px, dot 6×6 */}
<div className={`flex items-center gap-1 ${status.text}`}>
<span
className={`w-[6px] h-[6px] rounded-full shrink-0 ${status.dot}`}
/>
<span className={cn.statusLabel}>{status.label}</span>
</div>
</div>
</div>
</div>
<div className="absolute z-10 pl-[calc(264px+8px)]">
<SelectUser
shown={isShowManagerSelect}
selectedManagerId={manager?.id}
managers={availableManagers ?? []}
loading={isShowManagerSelect && !availableManagers}
handleClick={(managerId) => {
handleSelect(scheduledSessionId, managerId);
setIsShowManagerSelect(false);
}}
handleShown={() => setIsShowManagerSelect(false)}
/>
{/* ── MasterPerson/Small: gap 8px, h-8 ── */}
<div className="flex gap-2 items-center w-full h-8">
<div className="relative flex-1 min-w-0">
<ManagerSelector
selectedManager={manager}
canStart={canStart}
isAdmin={user?.role === "admin"}
onTriggerClick={() => setIsShowManagerSelect((v) => !v)}
sessionUrl={sessionUrl}
currentUserId={user?.id}
onSelectSelf={() => handleSelect(scheduledSessionId, user!.id)}
onCancel={() => handleSelect(scheduledSessionId, null)}
/>
{/* Попап под селектором */}
<div className="absolute left-0 top-full z-20 mt-1">
<SelectUser
shown={isShowManagerSelect}
selectedManagerId={manager?.id}
managers={availableManagers ?? []}
loading={isShowManagerSelect && !availableManagers}
handleClick={(managerId) => {
handleSelect(scheduledSessionId, managerId);
setIsShowManagerSelect(false);
}}
handleShown={() => setIsShowManagerSelect(false)}
/>
</div>
</div>
{/* Button/Secondary — 32×32, bg #F0F1F2, icon #77828C */}
{user?.role === "admin" && (
<Link to={sessionUrl} target="_blank" className="shrink-0">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#F0F1F2] hover:bg-[#E6ECF2] transition-colors text-[#77828C]">
<span className="w-5 h-5">
<EntryIcon />
</span>
</span>
</Link>
)}
</div>
</div>
</div>
);
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from "react";
import { Transition } from "react-transition-group";
import useModalStore from "../stores/useModalStore";
import ShareModal from "./modals/ShareModal";
import ShareIcon from "./icons/ShareIcon";
import TrashIcon from "./icons/TrashIcon";
interface CardKebabMenuProps {
shown: boolean;
scheduledSessionId: string;
onClose: () => void;
onDelete?: () => void;
}
function CardKebabMenu({
shown,
scheduledSessionId,
onClose,
onDelete,
}: CardKebabMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const { setModal } = useModalStore();
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]);
function handleShare() {
onClose();
setModal(<ShareModal scheduledSessionId={scheduledSessionId} />);
}
function handleDelete() {
if (!confirm("Вы уверены, что хотите удалить сеанс?")) return;
onClose();
onDelete?.();
}
return (
<Transition in={shown} timeout={150} mountOnEnter unmountOnExit>
{(state) => (
<div
ref={ref}
className={`absolute left-full top-0 ml-2 z-50 bg-white w-[232px] rounded-lg flex flex-col shadow-[0px_1px_4px_0px_rgba(0,0,0,0.16)] transition-opacity py-2 ${state}`}
>
<button
onClick={handleShare}
className="px-4 flex items-center gap-2 w-full h-8 transition-colors hover:bg-[#E6ECF2] text-left text-[#111C26]"
>
<ShareIcon className="w-5 h-5 shrink-0 text-[#77828C]" />
<span className="text-[12px] leading-[1.3]">Поделиться</span>
</button>
{onDelete && (
<button
onClick={handleDelete}
className="px-4 flex items-center gap-2 w-full h-8 transition-colors hover:bg-[#F2DADA] text-left text-[#EB5757]"
>
<TrashIcon className="w-5 h-5 shrink-0" />
<span className="text-[12px] leading-[1.3]">Удалить</span>
</button>
)}
</div>
)}
</Transition>
);
}
export default CardKebabMenu;
+132
View File
@@ -0,0 +1,132 @@
import { Link } from "react-router-dom";
import ChevronDownIcon from "./icons/ChevronDownIcon";
import IUser from "../types/IUser";
const cn = {
borderLight: "border-[#F0F1F2]",
textPrimary: "text-[#111C26]",
avatar: "w-6 h-6 rounded-full object-cover bg-[#E6ECF2]",
avatarPlaceholder: "block w-6 h-6 rounded-full bg-[#E6ECF2]",
} as const;
interface ManagerSelectorProps {
selectedManager?: IUser;
canStart: boolean;
isAdmin: boolean;
onTriggerClick: () => void;
sessionUrl: string;
currentUserId?: string;
onSelectSelf: () => void;
onCancel: () => void;
}
function ManagerSelector({
selectedManager,
canStart,
isAdmin,
onTriggerClick,
sessionUrl,
currentUserId,
onSelectSelf,
onCancel,
}: ManagerSelectorProps) {
const isManager = !isAdmin;
const showSelect = isManager && !selectedManager;
const showStart = isManager && selectedManager && canStart;
const showCancel =
isManager &&
selectedManager &&
!canStart &&
selectedManager.id === currentUserId;
return (
<div
role={isAdmin ? "button" : undefined}
tabIndex={isAdmin ? 0 : undefined}
onClick={isAdmin ? onTriggerClick : undefined}
onKeyDown={
isAdmin
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTriggerClick();
}
}
: undefined
}
className={`flex-1 min-w-0 flex items-center gap-2 h-8 py-1.5 px-2 rounded-lg border hover:bg-[#E6ECF2] transition-colors ${
cn.borderLight
} ${isAdmin ? "cursor-pointer" : ""}`}
aria-label={isAdmin ? "Выбрать менеджера" : undefined}
>
{/* Аватар */}
<div className="relative w-6 h-6 shrink-0">
{selectedManager ? (
<>
<img
src={selectedManager.avatar || "/images/no-avatar.png"}
alt=""
className={cn.avatar}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-white ${
canStart ? "bg-[#49A1F5]" : "bg-[#77828C]"
}`}
/>
</>
) : (
<span className={cn.avatarPlaceholder} />
)}
</div>
{/* Имя менеджера */}
<span
className={`text-[12px] leading-[1.3] truncate flex-1 ${cn.textPrimary}`}
>
{selectedManager ? selectedManager.name : "Не назначен"}
</span>
{/* Admin: chevron — индикатор выпадающего списка */}
{isAdmin && (
<span
className={`w-5 h-5 shrink-0 flex items-center justify-center text-[#77828C]`}
>
<ChevronDownIcon />
</span>
)}
{/* Manager: действия */}
{isManager && (
<>
{showSelect && (
<button
type="button"
onClick={onSelectSelf}
className="shrink-0 text-[11px] font-semibold text-[#77828C] hover:text-[#4C5359] transition-colors"
>
Выбрать
</button>
)}
{showStart && (
<Link to={sessionUrl} target="_blank">
<span className="shrink-0 text-[11px] font-semibold text-[#49A1F5] hover:text-[#4190DB] transition-colors">
Начать
</span>
</Link>
)}
{showCancel && (
<button
type="button"
onClick={onCancel}
className="shrink-0 text-[11px] font-semibold text-[#77828C] hover:text-[#4C5359] transition-colors"
>
Отменить
</button>
)}
</>
)}
</div>
);
}
export default ManagerSelector;
+1 -9
View File
@@ -11,10 +11,8 @@ import {
parseISO,
startOfDay,
} from "date-fns";
import Button from "./Button";
import IScheduledSession from "../types/IScheduledSession";
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";
@@ -259,15 +257,9 @@ function Timeline({
manager={manager}
managers={managers}
handleSelect={onScheduledSessionUpdate}
onDelete={handleClickRemove}
fitContainer
/>
<Button
variant="tertiary"
icon={<CloseIcon className="" />}
onlyIcon
onClick={() => handleClickRemove(event.id)}
className="absolute -top-2 -right-2 z-10"
/>
</div>
</div>
);
+38
View File
@@ -0,0 +1,38 @@
interface Props {
className?: string;
}
function KebabIcon({ className = "" }: Props) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<circle
cx="12"
cy="5"
r="1.5"
transform="rotate(90 12 5)"
fill="currentColor"
/>
<circle
cx="12"
cy="12"
r="1.5"
transform="rotate(90 12 12)"
fill="currentColor"
/>
<circle
cx="12"
cy="19"
r="1.5"
transform="rotate(90 12 19)"
fill="currentColor"
/>
</svg>
);
}
export default KebabIcon;
+7 -17
View File
@@ -1,27 +1,17 @@
function ShareIcon({ className = "" }: { className?: string }) {
interface Props {
className?: string;
}
function ShareIcon({ className = "" }: Props) {
return (
<svg
viewBox="0 0 20 20"
viewBox="0 0 24 24"
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"
d="M12 4v11m4-7-4-4-4 4m-4 4v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
+24
View File
@@ -0,0 +1,24 @@
interface Props {
className?: string;
}
function TrashIcon({ className = "" }: Props) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M10.5 18v-6m3 6v-6M6 4h12m-8-1h4M7.931 21h8.138a1 1 0 0 0 .997-.929l.858-12A1 1 0 0 0 16.926 7H7.074a1 1 0 0 0-.997 1.071l.857 12A1 1 0 0 0 7.93 21"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default TrashIcon;