summary modal

This commit is contained in:
2025-07-22 18:11:57 +05:00
parent 41b8ab969d
commit 20a56d4078
10 changed files with 417 additions and 49 deletions
+59
View File
@@ -0,0 +1,59 @@
import { useState } from "react";
import ChevronDownIcon from "./icons/ChevronDownIcon";
import clsx from "clsx";
import { AnimatePresence, motion } from "motion/react";
import { useClickAway } from "@uidotdev/usehooks";
function Accordion({ text, title }: { title: string; text: string }) {
const [isOpen, setIsOpen] = useState(false);
const [initialHeight, setInitialHeight] = useState(0);
const [textHeight, setTextHeight] = useState(0);
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
return (
<motion.div
ref={(el) => {
if (el) {
ref.current = el;
setInitialHeight(el?.clientHeight || 0);
}
}}
animate={{
height: isOpen ? initialHeight + textHeight + 12 : initialHeight,
}}
className="p-[1.111vw] space-y-[0.833vw] bg-[#F6F6F6] rounded-[0.833vw] overflow-hidden cursor-pointer select-none"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center justify-between">
<p className="button-m font-medium">{title}</p>
<div
className={clsx(
"text-[#7D7D7D] size-[1.389vw] transition-transform duration-300",
isOpen && "rotate-180"
)}
>
<ChevronDownIcon />
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
ref={(el) => setTextHeight(el?.clientHeight || 0)}
className="text-s text-[#7D7D7D]"
>
{text}
</motion.p>
)}
</AnimatePresence>
</motion.div>
);
}
export default Accordion;
+5
View File
@@ -12,6 +12,7 @@ import ChevronRightIcon from "./icons/ChevronRightIcon";
import CurrentSessionModal from "./modals/CurrentSessionModal";
import SpinIcon from "./icons/SpinIcon";
import { useIsMutating } from "@tanstack/react-query";
import { useEffect } from "react";
interface IDesktopCardProps {
server: Server;
@@ -29,6 +30,10 @@ export default function DesktopCard({ server }: IDesktopCardProps) {
setModal(<CreateSessionModal targetServerId={server.id} />);
}
useEffect(() => {
console.log(server.sessions?.[0]?.status);
}, [server.sessions]);
return (
<div className="flex flex-col gap-[0.833vw] aspect-[300/211] w-[20.833vw] h-[14.653vw]">
<div
+90
View File
@@ -0,0 +1,90 @@
import React, { useState } from "react";
import ScaleIndicator from "./ScaleIndicator";
const ScaleDemo: React.FC = () => {
const [currentValue, setCurrentValue] = useState(5);
return (
<div className="p-8 space-y-8">
<h2 className="text-2xl font-bold mb-6">Компонент Шкалы</h2>
{/* Интерактивный пример */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Интерактивный пример</h3>
<div className="flex items-center gap-4">
<ScaleIndicator value={currentValue} />
<div className="space-y-2">
<label className="block text-sm font-medium">
Значение: {currentValue}
</label>
<input
type="range"
min="1"
max="10"
value={currentValue}
onChange={(e) => setCurrentValue(Number(e.target.value))}
className="w-40"
/>
</div>
</div>
</div>
{/* Различные размеры */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Различные размеры</h3>
<div className="flex items-center gap-6">
<ScaleIndicator value={3} size={48} />
<ScaleIndicator value={6} size={64} />
<ScaleIndicator value={9} size={80} />
</div>
</div>
{/* Различные цвета */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Различные цвета</h3>
<div className="flex items-center gap-6">
<ScaleIndicator
value={4}
fillColor="#EF4444"
backgroundColor="#FEE2E2"
/>
<ScaleIndicator
value={7}
fillColor="#3B82F6"
backgroundColor="#DBEAFE"
/>
<ScaleIndicator
value={10}
fillColor="#8B5CF6"
backgroundColor="#EDE9FE"
/>
</div>
</div>
{/* Без текста */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Без отображения значения</h3>
<div className="flex items-center gap-6">
<ScaleIndicator value={2} showValue={false} />
<ScaleIndicator value={5} showValue={false} />
<ScaleIndicator value={8} showValue={false} />
</div>
</div>
{/* Все значения от 1 до 10 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Все значения от 1 до 10</h3>
<div className="grid grid-cols-5 gap-4">
{Array.from({ length: 10 }, (_, i) => i + 1).map((value) => (
<div key={value} className="text-center">
<ScaleIndicator value={value} size={64} />
<p className="mt-2 text-sm text-gray-600">Значение: {value}</p>
</div>
))}
</div>
</div>
</div>
);
};
export default ScaleDemo;
+144
View File
@@ -0,0 +1,144 @@
import { motion } from "motion/react";
import React from "react";
interface ScaleIndicatorProps {
value: number; // значение от 1 до 10
size?: number; // размер в пикселях
strokeWidth?: number; // толщина обводки
backgroundColor?: string; // цвет фона
fillColor?: string; // цвет заполнения
showValue?: boolean; // показывать ли цифру в центре
}
const ScaleIndicator: React.FC<ScaleIndicatorProps> = ({
value,
size = 64,
strokeWidth = 8,
backgroundColor = "#F0F0F0",
fillColor = "#29AF61",
showValue = true,
}) => {
// Ограничиваем значение от 1 до 10
const clampedValue = Math.max(1, Math.min(10, value));
// Рассчитываем процент заполнения (от 0% до 100%)
const percentage = (clampedValue / 10) * 100;
// Параметры для дуги
const center = size / 2;
const radius = center - strokeWidth / 2;
// Функция для конвертации полярных координат в декартовы
const polarToCartesian = (
centerX: number,
centerY: number,
radius: number,
angleInDegrees: number
) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
// Функция для создания пути дуги
const createArcPath = (
startAngle: number,
endAngle: number,
radius: number
) => {
const start = polarToCartesian(center, center, radius, endAngle);
const end = polarToCartesian(center, center, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
largeArcFlag,
0,
end.x,
end.y,
].join(" ");
};
// Углы для дуги (270 градусов, как в примере)
const startAngle = 135; // начинаем снизу слева
const totalAngle = 270; // общий угол дуги
const endAngle = startAngle + totalAngle;
// Рассчитываем угол заполнения
const fillAngle = (percentage / 100) * totalAngle;
const currentEndAngle = startAngle + fillAngle;
// Пути для фоновой и заполненной дуги
const backgroundPath = createArcPath(startAngle, endAngle, radius);
const fillPath =
fillAngle > 0 ? createArcPath(startAngle, currentEndAngle, radius) : "";
// ID для градиента
const gradientId = `gradient-${Math.random().toString(36).substring(2, 9)}`;
return (
<div className="inline-flex items-center justify-center rotate-90">
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
>
<defs>
{/* Конический градиент */}
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={fillColor} stopOpacity="1" />
<stop offset="100%" stopColor={fillColor} stopOpacity="0.3" />
</linearGradient>
</defs>
{/* Фоновая дуга */}
<path
d={backgroundPath}
stroke={backgroundColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
/>
{/* Заполненная дуга */}
{fillPath && (
<motion.path
d={fillPath}
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
fill="none"
/>
)}
{/* Текст со значением в центре */}
{showValue && (
<text
x={center}
y={center + size * 0.05}
textAnchor="middle"
dominantBaseline="middle"
className="font-bold -rotate-90 origin-center"
style={{
fontSize: size * 0.35,
fill: "#141414",
}}
>
{clampedValue}
</text>
)}
</svg>
</div>
);
};
export default ScaleIndicator;
+1 -1
View File
@@ -2,7 +2,7 @@ function ChevronDownIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.833 8.333 10 12.5l4.167-4.167"
d="m5 7.917 5 5 5-5"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
+10 -37
View File
@@ -1,33 +1,18 @@
import { intervalToDuration } from "date-fns";
import FlashIcon from "../icons/FlashIcon";
import Button from "../Button";
import ChevronRightIcon from "../icons/ChevronRightIcon";
import useModalStore from "../../stores/useModalStore";
import { Session } from "../../types/Session";
import { useEffect, useState } from "react";
import EndSessionModal from "./EndSessionModal";
import ClientCard from "../ClientCard";
import { useQuery } from "@tanstack/react-query";
import api from "../../utils/api";
import { Client } from "../../types/Client";
function CurrentSessionModal({ session }: { session: Session }) {
// const queryClient = useQueryClient();
const { setModal } = useModalStore();
// const { mutate: endSession } = useMutation({
// mutationKey: ["sessions", session.id],
// mutationFn: () =>
// api.put(`sessions/${session.id}`, {
// json: { status: "ending" },
// }),
// onMutate: () =>
// queryClient.invalidateQueries({
// queryKey: ["sessions"],
// }),
// onSuccess: () => {
// queryClient.invalidateQueries({
// queryKey: ["last-sessions"],
// });
// },
// });
const [now, setNow] = useState(Date.now());
useEffect(() => {
@@ -37,6 +22,11 @@ function CurrentSessionModal({ session }: { session: Session }) {
return () => clearInterval(interval);
}, []);
const { data: client } = useQuery({
queryKey: ["client", session.clientId],
queryFn: () => api.get(`clients/${session.clientId}`).json<Client>(),
});
if (!session) return null;
return (
@@ -82,24 +72,7 @@ function CurrentSessionModal({ session }: { session: Session }) {
</div>
<div className="flex flex-col gap-[0.833vw]">
<h2 className="title-s font-medium">Параметры сеанса</h2>
<div>
<Button variant="secondary" className="w-full">
<div className="flex flex-col gap-[0.278vw] w-full text-left h-[2.222vw]">
<p className="caption-s font-medium text-[#BDBDBD]">Клиент</p>
<p className="text-s font-medium">{session.client.name}</p>
</div>
<div className="flex gap-[0.556vw] items-center">
{!session.client.email && (
<p className="caption-s font-medium text-[#7B60F3] whitespace-nowrap">
Добавьте email
</p>
)}
<span className="w-[1.389vw] h-[1.389vw] flex items-center justify-center text-[#7B60F3]">
<ChevronRightIcon />
</span>
</div>
</Button>
</div>
{client && <ClientCard client={client} />}
</div>
<div className="flex flex-col gap-[0.833vw]">
<h2 className="title-s font-medium">Детали</h2>
+17 -6
View File
@@ -14,8 +14,12 @@ import SessionFiles from "../SessionFiles";
import DownloadIcon from "../icons/DownloadIcon";
import ShareIcon from "../icons/ShareIcon";
import { Client } from "../../types/Client";
import useModalStore from "../../stores/useModalStore";
import SummaryModal from "./SummaryModal";
function SessionModal({ session }: { session: Session }) {
const { setModal } = useModalStore();
const { data: files } = useQuery({
queryKey: ["file-list", session.id],
queryFn: () =>
@@ -113,16 +117,23 @@ function SessionModal({ session }: { session: Session }) {
<span className="caption-s font-medium text-[#BDBDBD]">
Бюджет клиента:
</span>
<span className="caption-s font-medium">8 500 000 </span>
<span className="caption-s font-medium">
{/* {session.summary.budget}₽ */}
</span>
</p>
</div>
<div className="bg-[#F6F6F6] rounded-[0.833vw] px-[1.111vw] py-[0.833vw] text-xs tracking-[-0.02em] leading-[110%]">
Клиент проявил высокий интерес к объекту, особенно к варианту с
улучшенной отделкой. Основной вопрос для принятия решения
согласование с семьей и выбор этажа. Необходимо подготовить
предварительный договор к следующей встрече.
{/* {session.summary.introduction} */}
</div>
<Button variant="primary" size="large">
<Button
variant="primary"
size="large"
onClick={() => {
// if (session.summary) {
setModal(<SummaryModal />);
// }
}}
>
Весь отчет по встрече
<span className="size-[1.111vw] text-[#7B60F3]">
<ChevronRightIcon />
+69
View File
@@ -0,0 +1,69 @@
import Accordion from "../Accordion";
import ScaleIndicator from "../ScaleIndicator";
function SummaryModal() {
return (
<div className="rounded-[2.222vw] bg-[#F0F0F0] overflow-hidden w-[24.861vw]">
<div className="py-[1.806vw] bg-white flex items-center relative outline-[0.069vw] outline-[#f6f6f6]">
<p className="title-s font-medium text-center w-full">Резюме встречи</p>
</div>
<div className="p-[1.111vw] flex flex-col gap-[0.833vw] overflow-y-auto h-[calc(100vh-8.861vw)]">
<div className="rounded-3xl p-[1.111vw] space-y-[1.111vw] w-full bg-white">
<div className="flex flex-col gap-y-[0.556vw] items-center">
<ScaleIndicator value={5} />
<div className="space-y-[0.278vw] text-center">
<p className="title-s font-medium">Эффективность встречи</p>
<p className="caption-s font-medium text-[#BDBDBD]">
Общая оценка работы менеджера
</p>
</div>
</div>
<div className="p-[0.833vw] rounded-[0.833vw] bg-[#F6F6F6] flex items-center gap-[0.278vw]">
<div className="text-center space-y-[0.278vw] flex-1">
<p className="caption-xs font-medium text-[#7D7D7D]">
Бюджет покупателя:
</p>
<p className="title-xs">8 500 000 </p>
</div>
<div className="h-[1.389vw] border border-[#D6D6D6]" />
<div className="text-center space-y-[0.278vw] flex-1">
<p className="caption-xs font-medium text-[#7D7D7D]">
Длительность
</p>
<p className="title-xs">43:57 мин</p>
</div>
</div>
</div>
<div className="bg-white p-[1.111vw] space-y-[1.111vw] rounded-[1.111vw]">
<div className="space-y-[0.556vw]">
<p className="title-s font-medium">Подробности встречи</p>
<p className="text-s text-[#7D7D7D]">
Клиент проявил высокий интерес к объекту, особенно к варианту с
улучшенной отделкой. Основной вопрос для принятия решения
согласование с семьей и выбор этажа. Необходимо подготовить
предварительный договор к следующей встрече.
</p>
</div>
<Accordion
title="Подробности встречи"
text="Клиент проявил высокий интерес к объекту, особенно к варианту с улучшенной отделкой. Основной вопрос для принятия решения — согласование с семьей и выбор этажа. Необходимо подготовить предварительный договор к следующей встрече."
/>
<Accordion
title="Подробности встречи"
text="Клиент проявил высокий интерес к объекту, особенно к варианту с улучшенной отделкой. Основной вопрос для принятия решения — согласование с семьей и выбор этажа. Необходимо подготовить предварительный договор к следующей встрече."
/>
<Accordion
title="Подробности встречи"
text="Клиент проявил высокий интерес к объекту, особенно к варианту с улучшенной отделкой. Основной вопрос для принятия решения — согласование с семьей и выбор этажа. Необходимо подготовить предварительный договор к следующей встрече."
/>
<Accordion
title="Подробности встречи"
text="Клиент проявил высокий интерес к объекту, особенно к варианту с улучшенной отделкой. Основной вопрос для принятия решения — согласование с семьей и выбор этажа. Необходимо подготовить предварительный договор к следующей встрече."
/>
</div>
</div>
</div>
);
}
export default SummaryModal;
+6 -5
View File
@@ -22,13 +22,14 @@ function useClientSearch(phone: string | null) {
queryFn: () =>
api
.get("clients/by-phone", {
searchParams:
debouncedPhone && debouncedPhone.replace(/\D/g, "").length === 11
? { phone: debouncedPhone.replace(/\D/g, "") }
: {},
searchParams: { phone: debouncedPhone?.replace(/\D/g, "") || "" },
})
.json<Client>(),
enabled: Boolean(debouncedPhone && isPhoneComplete),
enabled: Boolean(
debouncedPhone &&
debouncedPhone.replace(/\D/g, "").length === 11 &&
isPhoneComplete
),
});
useEffect(() => {
+16
View File
@@ -4,6 +4,21 @@ import { Server } from "./Server";
import { Client } from "./Client";
import { Manager } from "./Manager";
export interface Summary {
efficiency: number;
duration: number;
budget: number;
introduction: string;
resume: string;
goal: string[];
presentation: string[];
finance: string[];
discussionTone: string
questions: string[];
nextSteps: string;
conclusion: string;
}
export interface Session {
id: string;
managerId: string;
@@ -18,4 +33,5 @@ export interface Session {
manager: Manager;
createdAt: Date;
updatedAt: Date;
summary?: Summary;
}