From 27317124aa688bcb0a63b772246a65909bc3d956 Mon Sep 17 00:00:00 2001 From: inmake Date: Tue, 17 Mar 2026 17:28:11 +0500 Subject: [PATCH] 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. --- client/.env.development | 2 +- client/src/components/Card.tsx | 258 +++++++++++----------- client/src/components/CardKebabMenu.tsx | 83 +++++++ client/src/components/ManagerSelector.tsx | 132 +++++++++++ client/src/components/Timeline.tsx | 10 +- client/src/components/icons/KebabIcon.tsx | 38 ++++ client/src/components/icons/ShareIcon.tsx | 24 +- client/src/components/icons/TrashIcon.tsx | 24 ++ 8 files changed, 414 insertions(+), 157 deletions(-) create mode 100644 client/src/components/CardKebabMenu.tsx create mode 100644 client/src/components/ManagerSelector.tsx create mode 100644 client/src/components/icons/KebabIcon.tsx create mode 100644 client/src/components/icons/TrashIcon.tsx diff --git a/client/.env.development b/client/.env.development index 79cdb72..2182fb4 100644 --- a/client/.env.development +++ b/client/.env.development @@ -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 diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx index 7cc4ce6..b3857fa 100644 --- a/client/src/components/Card.tsx +++ b/client/src/components/Card.tsx @@ -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(); useEffect(() => { @@ -73,12 +90,16 @@ function Card({ `companies/${companyId}/builds/${buildId}/scheduledSessions/${scheduledSessionId}/availableManagers?startAt=${scheduleSessionStartAt}` ) .json() - .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 (
- {/* SessionInfo */} -
-

- {buildName ? `ЖК «${buildName}»` : "ЖК"} -

- -
- - Клиент - - + {/* ── SessionInfo: клиент + ЖК, gap 8px ── */} +
+ {/* Frame 2702: имя + контакты, gap 4px */} +
+

{client?.name || "Имя не указано"} - -

- -
- - {client?.phone || "Телефон не указан"} - - - {client?.email || "Email не указан"} - -
- - {/* Status indicator */} -
-
- - {status.label} - -
- - {/* Share button */} -
-
-
- - {/* Manager row */} -
-
-
- {manager ? ( - <> - - {/* Online dot */} -
- - ) : ( -
- )} +

+ {/* Contacts: column, gap 4px */} +
+ + {client?.phone || "Телефон не указан"} + + + {client?.email || "Email не указан"} +
- - {manager ? manager.name : "Не назначен"} - + {/* Kebab — absolute right-0 top-0, 32×32, p-1, rounded-lg */} + {user?.role === "admin" && ( +
+
+ + setIsKebabMenuOpen(false)} + onDelete={onDelete ? () => onDelete(scheduledSessionId) : undefined} + /> +
+
+ )}
-
- {user?.role === "manager" && ( - <> - {!manager && ( - - )} - {manager && canStart && ( - - - - )} - {manager && !canStart && manager.id === user.id && ( - - )} - - )} +
+ {/* ЖК — order 2, gap 8 от Frame 2702 */} +

+ {buildName ? `ЖК «${buildName}»` : "ЖК"} +

- {user?.role === "admin" && ( - <> - -
-
-
- { - handleSelect(scheduledSessionId, managerId); - setIsShowManagerSelect(false); - }} - handleShown={() => setIsShowManagerSelect(false)} - /> + {/* ── MasterPerson/Small: gap 8px, h-8 ── */} +
+
+ setIsShowManagerSelect((v) => !v)} + sessionUrl={sessionUrl} + currentUserId={user?.id} + onSelectSelf={() => handleSelect(scheduledSessionId, user!.id)} + onCancel={() => handleSelect(scheduledSessionId, null)} + /> + {/* Попап под селектором */} +
+ { + handleSelect(scheduledSessionId, managerId); + setIsShowManagerSelect(false); + }} + handleShown={() => setIsShowManagerSelect(false)} + /> +
+
+ + {/* Button/Secondary — 32×32, bg #F0F1F2, icon #77828C */} + {user?.role === "admin" && ( + + + + + + + + )} +
); diff --git a/client/src/components/CardKebabMenu.tsx b/client/src/components/CardKebabMenu.tsx new file mode 100644 index 0000000..0731df4 --- /dev/null +++ b/client/src/components/CardKebabMenu.tsx @@ -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(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(); + } + + function handleDelete() { + if (!confirm("Вы уверены, что хотите удалить сеанс?")) return; + onClose(); + onDelete?.(); + } + + return ( + + {(state) => ( +
+ + {onDelete && ( + + )} +
+ )} +
+ ); +} + +export default CardKebabMenu; diff --git a/client/src/components/ManagerSelector.tsx b/client/src/components/ManagerSelector.tsx new file mode 100644 index 0000000..ca023ec --- /dev/null +++ b/client/src/components/ManagerSelector.tsx @@ -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 ( +
{ + 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} + > + {/* Аватар */} +
+ {selectedManager ? ( + <> + + + + ) : ( + + )} +
+ + {/* Имя менеджера */} + + {selectedManager ? selectedManager.name : "Не назначен"} + + + {/* Admin: chevron — индикатор выпадающего списка */} + {isAdmin && ( + + + + )} + + {/* Manager: действия */} + {isManager && ( + <> + {showSelect && ( + + )} + {showStart && ( + + + Начать + + + )} + {showCancel && ( + + )} + + )} +
+ ); +} + +export default ManagerSelector; diff --git a/client/src/components/Timeline.tsx b/client/src/components/Timeline.tsx index 112ec70..4fa297e 100644 --- a/client/src/components/Timeline.tsx +++ b/client/src/components/Timeline.tsx @@ -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 /> -
); diff --git a/client/src/components/icons/KebabIcon.tsx b/client/src/components/icons/KebabIcon.tsx new file mode 100644 index 0000000..7de50ee --- /dev/null +++ b/client/src/components/icons/KebabIcon.tsx @@ -0,0 +1,38 @@ +interface Props { + className?: string; +} + +function KebabIcon({ className = "" }: Props) { + return ( + + + + + + ); +} + +export default KebabIcon; diff --git a/client/src/components/icons/ShareIcon.tsx b/client/src/components/icons/ShareIcon.tsx index 4225b83..d942dc7 100644 --- a/client/src/components/icons/ShareIcon.tsx +++ b/client/src/components/icons/ShareIcon.tsx @@ -1,27 +1,17 @@ -function ShareIcon({ className = "" }: { className?: string }) { +interface Props { + className?: string; +} + +function ShareIcon({ className = "" }: Props) { return ( - - + + + ); +} + +export default TrashIcon;