feat: add ClientCard and SessionModal components, enhance session handling with comments and duration utilities

This commit is contained in:
2025-06-06 18:35:12 +05:00
parent 8af9ad59b3
commit a4a3fde940
15 changed files with 279 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+28
View File
@@ -0,0 +1,28 @@
import { IUser } from "../types/IUser";
import ChevronRightIcon from "./icons/ChevronRightIcon";
import NewButton from "./NewButton";
function ClientCard({ client }: { client: IUser }) {
return (
<>
<NewButton 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">{client.name}</p>
</div>
<div className="flex gap-[0.556vw] items-center">
{!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>
</NewButton>
</>
);
}
export default ClientCard;
+3
View File
@@ -28,6 +28,9 @@ function CurrentSessionCard({
queryClient.invalidateQueries({ queryKey: ["last-started"] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["last-sessions"] });
},
});
return (
+1 -1
View File
@@ -84,7 +84,7 @@ export default function DesktopCard({ server }: IDesktopCardProps) {
</NewButton>
</div>
) : server.status === "offline" ? (
<NewButton variant="critical">
<NewButton variant="critical" className="hover:bg-[#FEF3F2]">
<span className="text-[#FF4517] size-[0.972vw]">
<UnlinkIcon />
</span>
+10 -1
View File
@@ -1,8 +1,17 @@
import useModalStore from "../stores/useModalStore";
import { ISession } from "../types/ISession";
import SessionModal from "./modals/SessionModal";
function SessionCard({ session }: { session: ISession }) {
const { setModal, setPosition } = useModalStore();
return (
<div className="w-full h-[4.444vw] border-1 border-l-0 border-r-0 border-t-0 border-b-[#F6F6F6] flex py-[0.278vw] items-center gap-[0.556vw] cursor-pointer group">
<div
className="w-full h-[4.444vw] border-1 border-l-0 border-r-0 border-t-0 border-b-[#F6F6F6] flex py-[0.278vw] items-center gap-[0.556vw] cursor-pointer group"
onClick={() => {
setModal(<SessionModal session={session} />);
setPosition("right");
}}
>
<div className="rounded-xl w-full h-full flex items-center gap-[0.556vw] group-hover:bg-[#F6F6F6] transition-colors duration-200">
<div className="size-[2.5vw] bg-[#F6F6F6] rounded-full"></div>
<div className="flex flex-col w-full gap-[0.278vw]">
+15
View File
@@ -0,0 +1,15 @@
function DownloadIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.5 17.5h15M13.75 10 10 13.75 6.25 10m3.746-7.5v10.833"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default DownloadIcon;
+12
View File
@@ -0,0 +1,12 @@
function MagicIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
d="M12.434 8.242a.83.83 0 0 1-.158.508.83.83 0 0 1-.434.317l-1.425.475a3 3 0 0 0-1.166.716 2.9 2.9 0 0 0-.717 1.167l-.5 1.417a.83.83 0 0 1-.3.425.9.9 0 0 1-.517.158.834.834 0 0 1-.833-.592l-.475-1.425a2.9 2.9 0 0 0-.717-1.166 3.2 3.2 0 0 0-1.166-.725L2.6 9.05a.9.9 0 0 1-.425-.308A.9.9 0 0 1 2 8.225a.83.83 0 0 1 .591-.833l1.434-.475a2.95 2.95 0 0 0 1.9-1.9L6.4 3.608A.83.83 0 0 1 7.192 3c.18 0 .355.05.509.142a.9.9 0 0 1 .333.425l.483 1.45a2.95 2.95 0 0 0 1.9 1.9l1.417.5a.83.83 0 0 1 .425.316.83.83 0 0 1 .175.509M16 14.51a.53.53 0 0 1-.09.29.53.53 0 0 1-.248.184l-.664.221a1.3 1.3 0 0 0-.496.306c-.138.14-.243.309-.306.494l-.227.653a.47.47 0 0 1-.185.247.53.53 0 0 1-.295.095.53.53 0 0 1-.301-.1.53.53 0 0 1-.18-.247l-.216-.658a1.3 1.3 0 0 0-.306-.49 1.16 1.16 0 0 0-.49-.305l-.66-.221a.5.5 0 0 1-.253-.184.525.525 0 0 1 .253-.774l.66-.216a1.32 1.32 0 0 0 .807-.805l.216-.647a.53.53 0 0 1 .169-.248.53.53 0 0 1 .29-.105.53.53 0 0 1 .3.084c.089.06.157.144.196.242l.222.674a1.31 1.31 0 0 0 .807.805l.66.227a.5.5 0 0 1 .242.184.5.5 0 0 1 .095.295M14.907 3.784A.55.55 0 0 0 15 3.505c0-.1-.033-.197-.093-.278a.55.55 0 0 0-.223-.163l-.344-.115a.6.6 0 0 1-.175-.109.5.5 0 0 1-.109-.18l-.114-.35a.55.55 0 0 0-.175-.223.5.5 0 0 0-.289-.087.55.55 0 0 0-.267.104.55.55 0 0 0-.153.218l-.114.338a.5.5 0 0 1-.11.18.5.5 0 0 1-.18.11l-.332.108a.5.5 0 0 0-.23.164A.5.5 0 0 0 12 3.5c0 .1.03.196.087.278.06.076.14.134.23.17l.338.108a.4.4 0 0 1 .174.11q.079.072.11.174l.114.338c.032.096.093.18.174.24a.6.6 0 0 0 .268.082.45.45 0 0 0 .283-.098.5.5 0 0 0 .158-.213l.12-.344a.46.46 0 0 1 .284-.283l.338-.115a.45.45 0 0 0 .23-.163"
fill="currentColor"
/>
</svg>
);
}
export default MagicIcon;
+15
View File
@@ -0,0 +1,15 @@
function ShareIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13 8.19231H14.5C14.8978 8.19231 15.2794 8.34426 15.5607 8.61475C15.842 8.88523 16 9.25209 16 9.63462V16.5577C16 16.9402 15.842 17.3071 15.5607 17.5776C15.2794 17.848 14.8978 18 14.5 18H5.5C5.10218 18 4.72064 17.848 4.43934 17.5776C4.15804 17.3071 4 16.9402 4 16.5577V9.63462C4 9.25209 4.15804 8.88523 4.43934 8.61475C4.72064 8.34426 5.10218 8.19231 5.5 8.19231H7M13 5.88462L10 3M10 3L7 5.88462M10 3V12.8437"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default ShareIcon;
+1 -1
View File
@@ -144,7 +144,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
return (
<form
className="relative rounded-[2.222vw] w-[25vw] min-h-[calc(100dvh-2.222vw)] bg-[#F0F0F0] flex flex-col overflow-hidden"
className="relative rounded-[2.222vw] w-[25vw] min-h-[calc(100dvh-2.222vw)] bg-[#F0F0F0] flex flex-col overflow-hidden "
onSubmit={handleClickCreateSession}
ref={ref}
>
@@ -18,7 +18,15 @@ function CurrentSessionModal({ session }: { session: ISession }) {
api.put(`sessions/${session.id}`, {
json: { status: "ending" },
}),
onMutate: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
onMutate: () =>
queryClient.invalidateQueries({
queryKey: ["sessions"],
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["last-sessions"],
});
},
});
const [now, setNow] = useState(Date.now());
+150
View File
@@ -0,0 +1,150 @@
import { ISession } from "../../types/ISession";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import getIntervalDuration from "../../utils/interval-duration";
import MagicIcon from "../icons/MagicIcon";
import NewButton from "../NewButton";
import ChevronRightIcon from "../icons/ChevronRightIcon";
import Badge from "../Badge";
import ClientCard from "../ClientCard";
import DownloadIcon from "../icons/DownloadIcon";
import ShareIcon from "../icons/ShareIcon";
function SessionModal({ session }: { session: ISession }) {
console.log(session);
return (
<div className="bg-[#FFFFFF] w-[49.722vw] rounded-4xl">
<div className="w-full flex justify-center items-center h-[4.861vw]">
<div className="title-s font-medium flex">
<p>{format(session.createdAt, "dd MMMM yyyy", { locale: ru })}</p>
<p>,&nbsp;{format(session.createdAt, "HH:mm")}</p>
</div>
</div>
<div className="bg-[#F0F0F0] flex h-[calc(100vh-8.861vw)] overflow-hidden rounded-b-4xl">
<div className="flex-1 flex flex-col gap-[0.833vw] px-[1.111vw] overflow-y-auto pr-[0.556vw] pb-[1.111vw]">
<div className="flex flex-col gap-[0.556vw] justify-center items-center pt-[1.111vw]">
<div className="size-[3.333vw] rounded-full bg-white"></div>
<div className="flex flex-col gap-[0.278vw] items-center">
<p className="title-s font-medium">{session.owner.fullname}</p>
<p className="caption-s text-[#BDBDBD] font-medium">
Продолжительность:{" "}
{getIntervalDuration(session.createdAt, session.updatedAt)}
</p>
</div>
</div>
<div className="flex flex-col gap-[1.111vw] bg-white rounded-3xl p-[1.111vw]">
<h3 className="title-s font-medium">Информация о сеансе</h3>
<div className="flex flex-col gap-[0.556vw]">
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Сценарии:
</span>
<span className="caption-s font-medium">Добавить в бэкенд</span>
</p>
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Интерактивный стол:
</span>
<span className="caption-s font-medium">
{session.server.name}
</span>
</p>
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Продолжительность сеанса:
</span>
<span className="caption-s font-medium">
{getIntervalDuration(session.createdAt, session.updatedAt)}
</span>
</p>
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Проект:
</span>
<span className="caption-s font-medium">
{session.app.name}
</span>
</p>
</div>
<ClientCard client={session.client} />
</div>
<div className="flex flex-col gap-[1.111vw] bg-white rounded-3xl p-[1.111vw]">
<h3 className="title-s font-medium flex">
Речевая&nbsp;
<span className="text-[#7B60F3] flex">
аналитика&nbsp;
<span className="w-[1.389vw] h-[1.389vw] text-[#7B60F3]">
<MagicIcon />
</span>
</span>
</h3>
<div className="flex flex-col gap-[0.556vw]">
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Эффективность встречи:
</span>
<span className="caption-s font-medium text-[#29AF61]">
Высокая
</span>
</p>
<p className="flex gap-[0.556vw]">
<span className="caption-s font-medium text-[#BDBDBD]">
Бюджет клиента:
</span>
<span className="caption-s font-medium">8 500 000 </span>
</p>
</div>
<div className="bg-[#F6F6F6] rounded-xl px-[1.111vw] py-[0.833vw] text-xs tracking-[-0.02em]">
Клиент проявил высокий интерес к объекту, особенно к варианту с
улучшенной отделкой. Основной вопрос для принятия решения
согласование с семьей и выбор этажа. Необходимо подготовить
предварительный договор к следующей встрече.
</div>
<NewButton variant="primary" size="large">
Весь отчёт по встречи <ChevronRightIcon />
</NewButton>
</div>
<div className="flex flex-col gap-[1.111vw] bg-white rounded-3xl p-[1.111vw]">
<h3 className="title-s font-medium flex">
Документы по сеансу <Badge count={4} />
</h3>
<div className="flex w-full gap-[0.556vw]">
<NewButton variant="primary" size="large" className="w-full">
<span className="w-[1.111vw] h-[1.111vw] text-[#7B60F3]">
<DownloadIcon />
</span>
Скачать архивом
</NewButton>
<NewButton variant="primary" size="large">
<span className="w-[1.111vw] h-[1.111vw] text-[#7B60F3]">
<ShareIcon />
</span>
</NewButton>
</div>
</div>
</div>
<div className="flex-1 flex flex-col">
<div className="relative h-full">
{session.comments.length > 0 ? (
<div>Комменты</div>
) : (
<div className="flex flex-col gap-[1.111vw] items-center justify-center h-full">
<img src="/images/smile-ghost.png" alt="ghost" />
<div className="flex flex-col gap-[0.556vw] items-center">
<h3 className="title-m font-medium">Оставьте заметку</h3>
<p className="caption-s font-medium text-[#BDBDBD] text-center whitespace-pre-line">
{`В дальнейшем это поможет быстро найти
клиента и не запутаться.`}
</p>
</div>
</div>
)}
</div>
<div className="h-[5.556vw] border-black border-t"></div>
</div>
</div>
</div>
);
}
export default SessionModal;
+10 -2
View File
@@ -8,6 +8,7 @@ import { ISession } from "../types/ISession";
import SessionCard from "../components/SessionCard";
import NewButton from "../components/NewButton";
import ChevronRightIcon from "../components/icons/ChevronRightIcon";
import { useEffect } from "react";
function DashboardPage() {
const { data: me } = useQuery({
@@ -23,7 +24,7 @@ function DashboardPage() {
});
const { data: sessions } = useQuery({
queryKey: ["sessions", { limit: 5 }],
queryKey: ["last-sessions"],
queryFn: () =>
api.get("sessions", { searchParams: { limit: 5 } }).json<ISession[]>(),
enabled: !!me,
@@ -42,6 +43,10 @@ function DashboardPage() {
// }
// }
useEffect(() => {
console.log(sessions);
}, [sessions]);
return (
<div className="flex flex-col gap-[5vw] min-h-dvh">
<div className="w-full flex justify-between">
@@ -63,7 +68,10 @@ function DashboardPage() {
<div className="w-full flex flex-col gap-[0.833vw]">
<div className="flex flex-col gap-[0.278vw]">
{sessions
?.filter((session) => session.status === "ended")
?.filter(
(session) =>
session.status === "ended" || session.status === "ending"
)
.map((session) => (
<SessionCard key={session.id} session={session} />
))}
+8
View File
@@ -0,0 +1,8 @@
export interface IComment {
id: string;
text: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
sessionId: string;
}
+3
View File
@@ -1,4 +1,5 @@
import { IApp } from "./IApp";
import { IComment } from "./IComments";
import { IOwner } from "./IOwner";
import { IServer } from "./IServer";
import { IUser } from "./IUser";
@@ -9,10 +10,12 @@ export interface ISession {
serverId: string;
clientId: string;
companyId: string;
comments: IComment[];
status: "starting" | "started" | "restarted" | "ending" | "ended";
server: IServer;
client: IUser;
app: IApp;
owner: IOwner;
createdAt: Date;
updatedAt: Date;
}
+14
View File
@@ -0,0 +1,14 @@
import { intervalToDuration } from "date-fns";
function getIntervalDuration(start: Date, end: Date) {
const duration = intervalToDuration({
start: start,
end: end,
});
const hours = (duration.hours || 0).toString().padStart(2, "0");
const minutes = (duration.minutes || 0).toString().padStart(2, "0");
const seconds = (duration.seconds || 0).toString().padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
export default getIntervalDuration;