summary modal
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user