This commit is contained in:
2025-03-20 14:26:57 +05:00
commit eb552cbdc8
55 changed files with 2212 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
import { clsx as cn } from "clsx";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: "link" | "primary" | "secondary" | "tertiary";
className?: string;
size?: "small" | "medium" | "large";
onlyIcon?: boolean;
}
function Button({
children,
variant = "primary",
size = "medium",
onlyIcon,
className,
...props
}: ButtonProps) {
return (
<button
{...props}
className={cn(
"transition-all rounded-lg flex items-center justify-center",
variant !== "link" && [
size === "small" && (onlyIcon ? "p-2" : "px-3 py-2 gap-2"),
size === "medium" && (onlyIcon ? "p-3.5" : "px-5 py-3.5 gap-3.5"),
size === "large" && (onlyIcon ? "p-4" : "px-6 py-4 gap-4"),
],
variant === "link" && "text-sm text-black/50 w-fit",
variant === "primary" && "bg-[#1E1E1E] text-white",
variant === "secondary" && "bg-white",
variant === "tertiary" && "bg-transparent text-[#767676] hover:bg-black/5",
className
)}
>
{children}
</button>
);
}
export default Button;
+72
View File
@@ -0,0 +1,72 @@
import Button from "./Button";
import SessionIcon from "./icons/SessionIcon";
import Input from "./Input";
import useQueryApps from "../queries/useQueryApps.ts";
import useQueryServers from "../queries/useQueryServers.ts";
import Select from "./Select.tsx";
export default function CreateSessionModal() {
const { data: apps } = useQueryApps();
const { data: servers } = useQueryServers();
return (
<div className="w-[27.222vw] h-full rounded-[0.833vw] bg-white p-[1.667vw] flex flex-col justify-between gap-[1.111vw]">
<div className="gap-y-[1.111vw] flex flex-col justify-between">
<div className="space-y-[0.556vw]">
<div className="rounded-[0.556vw] w-fit p-[0.833vw] bg-[#2D68F60D]">
<div className="min-w-[1.389vw] min-h-[1.389vw] text-[#2D68F6]">
<SessionIcon />
</div>
</div>
<p className="text-[1.389vw]">Создание сеанса</p>
<p className="text-[0.833vw] text-black/20">
Укажите данные клиента, выберите менеджера и стол
</p>
</div>
<hr className="border-black/10" />
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">
Имя <span className="text-[#C6C6C699]">*</span>
</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="Константин" required />
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">
Номер <span className="text-[#C6C6C699]">*</span>
</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="+ 7 (999) 99 99 99" required type="tel" />
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Электронная почта</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="sample@mail.ru" type="email" />
</div>
</div>
{apps && (
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Приложение</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Select
options={apps.map(({ id, name }) => ({ id, value: name }))}
/>
</div>
</div>
)}
<hr className="border-black/10" />
</div>
<div className="flex justify-between">
<Button className="bg-[#F9F9F9] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-black font-medium text-[0.972vw]">Отменить</p>
</Button>
<Button className="bg-[#2D68F6] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-[0.972vw] font-medium">Запустить сеанс</p>
</Button>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Button from "./Button";
import MoreIcon from "./icons/MoreIcon";
import api from "../utils/api";
import PlusIcon from "./icons/PlusIcon";
import { IServer } from "../types/IServer";
interface IDesktopCardProps {
server: IServer;
}
export default function DesktopCard({ server }: IDesktopCardProps) {
const queryClient = useQueryClient();
const { mutate: createSession } = useMutation({
mutationFn: () =>
api.post(`sessions`, {
json: {
serverId: server.id,
clientId: "abcfd570-2fa8-4f55-957b-5007f84f8f96",
appId: "b8a9995c-a799-4593-8f96-03942050cb21",
},
}),
onMutate: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
});
const { mutate: endSession } = useMutation({
mutationKey: ["sessions", server.sessions?.[0]?.id],
mutationFn: () =>
api.put(`sessions/${server.sessions?.[0]?.id}`, {
json: { status: "ending" },
}),
onMutate: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
});
return (
<div className="p-[1.111vw] rounded-[0.833vw] flex flex-col justify-between gap-[1.111vw] aspect-[272/149] w-[25.208vw] border border-[#00000014]">
<div className="flex justify-between items-start">
<div className="space-y-[0.278vw]">
<p className="leading-none text-[1.111vw]">{server.name}</p>
<p className="opacity-50 leading-none text-[0.694vw]">
{server.location}
</p>
</div>
<Button
onlyIcon
size="small"
className="rounded-[0.278vw] bg-[#F5F5F5]"
>
<div className="min-w-[0.833vw] min-h-[0.833vw] text-black">
<MoreIcon />
</div>
</Button>
</div>
<div className="flex justify-between items-center">
<div className="space-y-[0.556vw]">
<div className="px-[0.556vw] py-[0.278vw] flex gap-[0.278vw] items-center bg-[#108C330D] rounded-[0.278vw] w-fit">
{server.sessions?.[0]?.status === "started" && (
<div className="rounded-full w-[0.278vw] h-[0.278vw] bg-[#108C33]" />
)}
<p className="font-medium leading-none text-[#108C33] text-[0.556vw]">
{server.sessions?.[0]?.status === "started"
? "Сеанс запущен"
: "Свободен"}
</p>
</div>
{server.sessions?.[0]?.status === "started" && (
<>
<p className="leading-none">
{server.sessions?.[0]?.client?.fullname}
</p>
<p className="opacity-40 leading-none">
{server.sessions?.[0]?.app?.name}
</p>
</>
)}
</div>
<p className="opacity-40">0 мин</p>
</div>
<div className="flex gap-[0.278vw]">
{server.sessions?.[0]?.status === "started" ? (
<div className="flex items-center gap-[0.278vw]">
{/* <Button
variant="secondary"
className="py-[0.694vw] border rounded-lg border-[#2D68F6] w-full"
>
<p className="text-center leading-none font-medium text-[#2D68F6]">
Подробнее о сеансе
</p>
</Button> */}
<Button
variant="secondary"
className="py-[0.694vw] border rounded-lg border-[#DC0404] w-full"
onClick={() => endSession()}
>
<p className="text-center leading-none font-medium text-[#DC0404]">
Завершить сеанс
</p>
</Button>
</div>
) : (
<Button
variant="primary"
className="bg-[#798FFF] w-full rounded-[0.556vw]"
onClick={() => createSession()}
>
<div className="flex gap-1 items-center">
<span className="w-[1.389vw] h-[1.389vw]">
<PlusIcon />
</span>
<p className="font-medium leading-[115%]">Начать сеанс</p>
</div>
</Button>
)}
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useQueryClient } from "@tanstack/react-query";
import ChevronDownIcon from "./icons/ChevronDownIcon";
import { IUser } from "../types/IUser";
import SearchInput from "./SearchInput";
function Header() {
const queryClient = useQueryClient();
const me = queryClient.getQueryData<IUser>(["me"]);
return (
<div className="h-[66px] bg-white">
<div className="w-[952px] mx-auto flex items-center justify-between h-full">
<div className="flex items-center gap-5">
<div className="w-[134px]">
<img src="/logo-mate.svg" alt="logo" />
</div>
<SearchInput placeholder="Поиск по клиентам" />
</div>
<div className="flex items-center gap-2 cursor-pointer">
<div className="flex flex-col gap-1.5 justify-between h-9">
<p className="text-sm leading-none">{me?.fullname}</p>
<p className="text-[10px] leading-none text-black/40">
Старший менеджер
</p>
</div>
<div className="text-[#767676]">
<ChevronDownIcon />
</div>
</div>
</div>
</div>
);
}
export default Header;
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
function Input({ label, ...props }: InputProps) {
return (
<label className="space-y-2">
{label && <p className="text-xs text-black/50">{label}</p>}
<input
{...props}
className="bg-white rounded-lg px-5 py-3.5 outline-none ring-1 ring-transparent focus:ring-[#363636] transition-[ring-color] inline-block w-full"
/>
</label>
);
}
export default Input;
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect, useRef } from "react";
import useModalStore from "../stores/useModalStore";
import { AnimatePresence, motion } from "motion/react";
import CloseIcon from "./icons/CloseIcon";
import Button from "./Button";
const duration = 300;
function ModalContainer() {
const { modal, setModal, isOpen, setIsOpen } = useModalStore();
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
function handleResize() {
if (!popoverRef.current) return;
if (divRef.current!.clientHeight > popoverRef.current!.clientHeight) {
buttonRef.current!.style.height = `${divRef.current!.clientHeight}px`;
} else {
buttonRef.current!.style.height = `100%`;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
setIsOpen(false);
}
useEffect(() => setIsOpen(!!modal), [modal]);
useEffect(() => {
if (isOpen) return;
setTimeout(() => {
setModal(null);
}, duration);
}, [isOpen]);
useEffect(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("keydown", handleKeydown);
};
}, []);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={`h-full transition-opacity duration-300`}
>
<div
ref={popoverRef}
className="fixed top-0 left-0 w-full h-full bg-black/70 overflow-y-auto flex flex-col items-center justify-center"
>
<div className="max-h-full">
<div ref={divRef} className="p-[0.972vw]">
<div
ref={buttonRef}
className="absolute top-0 left-0 w-full h-full cursor-pointer"
onClick={() => setIsOpen(false)}
/>
<div className="relative w-full">
{modal}
<Button
onlyIcon
className="absolute top-[1.667vw] right-[1.667vw] p-[0.556vw] !rounded-full bg-[#F9F9F9]"
onClick={() => setIsOpen(false)}
>
<span className="w-[1.389vw] h-[1.389vw] text-black">
<CloseIcon />
</span>
</Button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export default ModalContainer;
+25
View File
@@ -0,0 +1,25 @@
import React, { useState } from "react";
import SearchIcon from "./icons/SearchIcon";
function SearchInput(props: React.InputHTMLAttributes<HTMLInputElement>) {
const [value, setValue] = useState("");
return (
<div className="relative">
<input
type="text"
className="outline-none pl-2.5 pr-8 pt-[7px] pb-2 bg-[#F6F6F6] rounded-lg leading-none text-sm"
value={value}
onChange={(e) => setValue(e.target.value)}
{...props}
/>
{!value && (
<div className="text-[#858585] absolute top-0 right-0 p-2">
<SearchIcon />
</div>
)}
</div>
);
}
export default SearchInput;
+28
View File
@@ -0,0 +1,28 @@
interface IOption {
id: string;
value: string;
}
interface ISelectProps<T extends IOption>
extends React.SelectHTMLAttributes<HTMLSelectElement> {
multiple?: boolean;
options: T[];
selected?: T;
// onClose: () => void;
}
export default function Select<T extends IOption>({
multiple,
options,
selected,
onClose,
...props
}: ISelectProps<T>) {
return (
<select {...props}>
{options.map(({ id, value }) => (
<option key={id}>{value}</option>
))}
</select>
);
}
+45
View File
@@ -0,0 +1,45 @@
import AddressBookIcon from "./icons/AddressBookIcon";
import DisplayIcon from "./icons/DisplayIcon";
import HomeIcon from "./icons/HomeIcon";
import LibIcon from "./icons/LibIcon";
import PeoplesIcon from "./icons/PeopleIcon";
import SidebarButton from "./SidebarButton";
function Sidebar() {
return (
<div className="space-y-[0.278vw]">
<SidebarButton active>
<span className="w-[1.111vw] h-[1.111vw]">
<HomeIcon />
</span>{" "}
Главная
</SidebarButton>
<SidebarButton>
<span className="w-[1.111vw] h-[1.111vw]">
<DisplayIcon />
</span>{" "}
Сеансы
</SidebarButton>
<SidebarButton>
<span className="w-[1.111vw] h-[1.111vw]">
<PeoplesIcon />
</span>{" "}
Менеджеры
</SidebarButton>
<SidebarButton>
<span className="w-[1.111vw] h-[1.111vw]">
<AddressBookIcon />
</span>{" "}
Клиенты
</SidebarButton>
<SidebarButton>
<span className="w-[1.111vw] h-[1.111vw]">
<LibIcon />
</span>{" "}
Контент
</SidebarButton>
</div>
);
}
export default Sidebar;
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import { clsx as cn } from "clsx";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: "link" | "primary" | "secondary" | "tertiary";
className?: string;
size?: "small" | "medium" | "large";
onlyIcon?: boolean;
active?: boolean;
}
function SidebarButton({ children, className, active, ...props }: ButtonProps) {
return (
<button
{...props}
className={cn(
"transition-all flex items-center gap-[0.833vw] px-[1.111vw] py-[0.833vw] text-[0.972vw] rounded-[0.556vw] hover:bg-[#7B60F3]/10 leading-[115%] font-medium tracking-tight w-full",
active ? "bg-[#7B60F3]/10 text-[#7B60F3]" : "text-[#242424]",
className
)}
>
{children}
</button>
);
}
export default SidebarButton;
+22
View File
@@ -0,0 +1,22 @@
function AddressBookIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2.667 11.999v2.666h10.666V1.332H2.667v2.667M2 10h1.333M2 8h1.333M2 6h1.333"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M8 6.999a1.333 1.333 0 1 0 0-2.667 1.333 1.333 0 0 0 0 2.667Zm2.667 4.668a2.667 2.667 0 1 0-5.334 0"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="square"
strokeLinejoin="round"
/>
</svg>
);
}
export default AddressBookIcon;
+21
View File
@@ -0,0 +1,21 @@
function ChevronDownIcon() {
return (
<svg
width={20}
height={20}
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"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default ChevronDownIcon;
+13
View File
@@ -0,0 +1,13 @@
export default function CloseIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="m10 10 4.167-4.166M10.001 10 5.834 5.834M10.001 10l4.166 4.167M10.001 10l-4.167 4.167"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
function CogIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.095 14.39a6.67 6.67 0 0 1-2.899-1.768 2 2 0 0 0-1.727-3.28A6.7 6.7 0 0 1 1.639 6h.028a2 2 0 0 0 1.795-2.883 6.66 6.66 0 0 1 2.755-1.543 2 2 0 0 0 3.566 0 6.66 6.66 0 0 1 2.756 1.543A2 2 0 0 0 14.362 6a6.7 6.7 0 0 1 .17 3.343 2 2 0 0 0-1.727 3.28 6.67 6.67 0 0 1-2.9 1.767 2.001 2.001 0 0 0-3.81 0Z"
stroke="currentColor"
strokeWidth={1.2}
strokeLinejoin="round"
/>
<path
d="M8 10.333a2.333 2.333 0 1 0 0-4.666 2.333 2.333 0 0 0 0 4.666Z"
stroke="currentColor"
strokeWidth={1.2}
strokeLinejoin="round"
/>
</svg>
);
}
export default CogIcon;
+20
View File
@@ -0,0 +1,20 @@
export default function DesktopIcon() {
return (
<svg
width={20}
height={20}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.083 2.5H2.917c-.69 0-1.25.56-1.25 1.25v10c0 .69.56 1.25 1.25 1.25h14.166c.69 0 1.25-.56 1.25-1.25v-10c0-.69-.56-1.25-1.25-1.25ZM10 15v2.917m-5.833 0h11.666"
opacity={0.8}
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="square"
strokeLinejoin="round"
/>
</svg>
);
}
+15
View File
@@ -0,0 +1,15 @@
function DisplayIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.632 3H3.368c-.32 0-.578.26-.578.579V8.21c0 .32.259.58.578.58h9.264a.58.58 0 0 0 .579-.58V3.58a.58.58 0 0 0-.58-.579M3.368 13.999l1.416-1.737h6.304l1.544 1.736M8 8.79V14"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default DisplayIcon;
+14
View File
@@ -0,0 +1,14 @@
function HomeIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.333 6.645v5.022a1 1 0 0 1-1 1H10.6a1 1 0 0 1-1-1v-1.2a1 1 0 0 0-1-1H7.4a1 1 0 0 0-1 1v1.2a1 1 0 0 1-1 1H3.667a1 1 0 0 1-1-1V6.645a1 1 0 0 1 .385-.788l4.333-3.378a1 1 0 0 1 1.23 0l4.333 3.378a1 1 0 0 1 .385.788Z"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="square"
/>
</svg>
);
}
export default HomeIcon;
+14
View File
@@ -0,0 +1,14 @@
function LibIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.688 9.371h2.295c.38 0 .688.308.688.688v2.295c0 .38-.308.689-.688.689H3.688A.69.69 0 0 1 3 12.354V10.06c0-.38.308-.689.688-.689Zm5.829 0h2.295c.38 0 .688.308.688.688v2.295c0 .38-.308.689-.688.689H9.517a.69.69 0 0 1-.689-.689V10.06a.69.69 0 0 1 .689-.689Zm0-5.414h2.295c.38 0 .688.308.688.688V6.94c0 .38-.308.689-.688.689H9.517a.69.69 0 0 1-.689-.689V4.645c0-.38.309-.688.689-.688Zm-5.829 0h2.295c.38 0 .688.308.688.688V6.94c0 .38-.308.689-.688.689H3.688A.69.69 0 0 1 3 6.94V4.645c0-.38.308-.688.688-.688Z"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="round"
/>
</svg>
);
}
export default LibIcon;
+32
View File
@@ -0,0 +1,32 @@
export default function MoreIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx={1}
cy={1}
transform="matrix(1 0 0 -1 2.333 9)"
fill="currentColor"
r={1}
/>
<circle
cx={1}
cy={1}
r={1}
transform="matrix(1 0 0 -1 7 9)"
fill="currentColor"
/>
<circle
cx={1}
cy={1}
r={1}
transform="matrix(1 0 0 -1 11.667 9)"
fill="currentColor"
/>
</svg> )
}
+15
View File
@@ -0,0 +1,15 @@
function PeopleIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.611 6.889a1.944 1.944 0 1 0 0-3.889 1.944 1.944 0 0 0 0 3.889Zm3.779-3.612a1.943 1.943 0 0 1 0 3.334m-7.945 6.054v.333h8.333v-.333c0-1.245 0-1.867-.242-2.342a2.22 2.22 0 0 0-.972-.971c-.475-.243-1.097-.243-2.342-.243H6c-1.244 0-1.867 0-2.342.243a2.22 2.22 0 0 0-.971.97c-.242.476-.242 1.098-.242 2.343Zm11.11.333v-.333c0-1.245 0-1.867-.242-2.342a2.22 2.22 0 0 0-.971-.971"
stroke="currentColor"
strokeWidth={1.2}
strokeLinecap="square"
strokeLinejoin="round"
/>
</svg>
);
}
export default PeopleIcon;
+17
View File
@@ -0,0 +1,17 @@
export default function PlusIcon() {
return (
<svg
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.5 8h4m-4 0V4m0 4v4m0-4h-4"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+20
View File
@@ -0,0 +1,20 @@
function SearchIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 12.667A5.667 5.667 0 1 0 7 1.333a5.667 5.667 0 0 0 0 11.334Zm4.074-1.593 2.828 2.828"
stroke="currentColor"
strokeWidth={1.2}
strokeLinejoin="round"
/>
</svg>
);
}
export default SearchIcon;
@@ -0,0 +1,72 @@
import Button from "../Button.tsx";
import SessionIcon from "../icons/SessionIcon.tsx";
import Input from "../Input.tsx";
import useQueryApps from "../../queries/useQueryApps.ts";
import useQueryServers from "../../queries/useQueryServers.ts";
import Select from "../Select.tsx";
import DisplayIcon from "../icons/DisplayIcon.tsx";
export default function CreateSessionModal() {
const { data: apps } = useQueryApps();
const { data: servers } = useQueryServers();
return (
<div className="w-[34.375vw] h-full rounded-[0.833vw] bg-white p-[1.667vw] flex flex-col justify-between gap-[1.111vw]">
<div className="gap-y-[1.111vw] flex flex-col justify-between">
<div className="space-y-[0.556vw]">
<div className="p-[0.833vw] ring-1 w-fit rounded-[0.556vw]">
<div className="w-[1.389vw] h-[1.389vw]">
<DisplayIcon />
</div>
</div>
<p className="text-[1.389vw]">Создание сеанса</p>
<p className="text-[0.833vw] text-black/20">
Укажите данные клиента, выберите менеджера и стол
</p>
</div>
<hr className="border-black/10" />
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">
Имя <span className="text-[#C6C6C699]">*</span>
</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="Константин" required />
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">
Номер <span className="text-[#C6C6C699]">*</span>
</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="+ 7 (999) 99 99 99" required type="tel" />
</div>
</div>
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Электронная почта</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Input placeholder="sample@mail.ru" type="email" />
</div>
</div>
{apps && (
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Приложение</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
<Select
options={apps.map(({ id, name }) => ({ id, value: name }))}
/>
</div>
</div>
)}
<hr className="border-black/10" />
</div>
<div className="flex justify-between">
<Button className="bg-[#F9F9F9] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-black font-medium text-[0.972vw]">Отменить</p>
</Button>
<Button className="bg-[#2D68F6] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-[0.972vw] font-medium">Запустить сеанс</p>
</Button>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import "tailwindcss";
body {
font-family: "Inter", sans-serif;
background-color: #f2f0fe;
}
button {
cursor: pointer;
}
+24
View File
@@ -0,0 +1,24 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router";
import routes from "./routes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
import ModalContainer from "./components/ModalContainer";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
},
},
});
const router = createBrowserRouter(routes);
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
<ModalContainer />
</QueryClientProvider>
);
+86
View File
@@ -0,0 +1,86 @@
import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/IUser";
import api from "../utils/api";
import Sidebar from "../components/Sidebar";
import { IServer } from "../types/IServer";
import DesktopCard from "../components/DesktopCard";
import Button from "../components/Button";
import useModalStore from "../stores/useModalStore";
import CreateSessionModal from "../components/modals/CreateSessionModal";
function DashboardPage() {
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get("users/me").json<IUser>(),
});
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: () => api.get("servers?withLastSession=true").json<IServer[]>(),
enabled: !!me,
refetchInterval: 1000,
});
const { setModal } = useModalStore();
// async function logout() {
// return await api.get("auth/logout").json();
// }
// async function handleClickLogout() {
// try {
// await logout();
// setToken(null);
// } catch (error) {
// toast.error((await (error as HTTPError).response.json<IError>()).error);
// }
// }
return (
// <div className="space-y-6">
// <Header />
// <div className="w-[952px] min-w-[952px] mx-auto">
// <div className="flex gap-5">
// <Sidebar />
// <div className="main-content flex-1 bg-white rounded-xl p-8 w-full space-y-5">
// {/* Main content */}
// {servers?.map((server) => (
// <DesktopCard key={server.id} server={server} />
// ))}
// </div>
// </div>
// </div>
// </div>
<div className="p-[0.833vw] flex gap-[0.833vw] h-dvh">
<div className="w-[3.333vw] flex flex-col justify-between"></div>
<div className="bg-white w-full rounded-[1.667vw] border border-black/5 p-[1.667vw] flex justify-between">
<div className="w-[13.889vw] space-y-[1.667vw]">
<div className="px-[1.111vw] py-[0.556vw]">
<img
src="/logo-mate.svg"
alt="logo"
className="w-[4.569vw] h-[1.944vw]"
/>
</div>
<Sidebar />
</div>
<div className="border w-[38.889vw]">
<div className="space-y-4">
{servers?.map((server) => (
<DesktopCard key={server.id} server={server} />
))}
</div>
</div>
<div className="bg-green-200 w-[13.889vw]">
<Button onClick={() => setModal(<CreateSessionModal />)}>
открыть
</Button>
</div>
</div>
</div>
);
}
export default DashboardPage;
+93
View File
@@ -0,0 +1,93 @@
import toast from "react-hot-toast";
import Button from "../components/Button";
import Input from "../components/Input";
import useAuthStore from "../stores/useAuthStore";
import api from "../utils/api";
import { useState } from "react";
import { HTTPError } from "ky";
import { IError } from "../types/IError";
import { useNavigate } from "react-router";
function LoginPage() {
const navigate = useNavigate();
const { token, setToken } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function login() {
return await api
.post("auth/login", {
json: {
email,
password,
},
})
.json<{ token: string }>();
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
try {
const { token } = await login();
setToken(token);
toast.success("Вы успешно вошли в систему");
navigate("/");
} catch (error) {
toast.error((await (error as HTTPError).response.json<IError>()).error);
}
}
return (
<div className="h-dvh flex flex-col items-center p-8 bg-[#F6F6F6]">
<div className="flex-1 flex flex-col gap-4 justify-between w-[380px]">
<div className="flex flex-col items-center justify-between gap-4">
<img src="/logo-mate.svg" alt="logo" />
<p>{token}</p>
</div>
<div>
<form onSubmit={handleSubmit}>
<div>
<Input
label="Email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="mt-6">
<Input
label="Пароль"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="mt-3">
<Button type="button" variant="link">
Забыли пароль?
</Button>
</div>
<div className="mt-6">
<Button type="submit" className="w-full">
Войти
</Button>
</div>
<div className="mt-6">
<Button type="button" variant="link" className="mx-auto">
Нет данных для входа?
</Button>
</div>
</form>
</div>
<div className="h-[30px]"></div>
</div>
</div>
);
}
export default LoginPage;
+10
View File
@@ -0,0 +1,10 @@
import { Navigate, Outlet } from "react-router";
import useAuthStore from "../stores/useAuthStore";
function ProtectedPage() {
const { token } = useAuthStore();
return token ? <Outlet /> : <Navigate to="/login" />;
}
export default ProtectedPage;
+10
View File
@@ -0,0 +1,10 @@
import { IApp } from "../types/IApp.ts";
import api from "../utils/api.ts";
import { useQuery } from "@tanstack/react-query";
export default function useQueryApps() {
return useQuery({
queryKey: ["apps"],
queryFn: () => api.get("apps").json<IApp[]>(),
});
}
+10
View File
@@ -0,0 +1,10 @@
import { IServer } from "../types/IServer.ts";
import api from "../utils/api.ts";
import { useQuery } from "@tanstack/react-query";
export default function useQueryServers() {
return useQuery({
queryKey: ["servers"],
queryFn: () => api.get("servers").json<IServer[]>(),
});
}
+20
View File
@@ -0,0 +1,20 @@
import DashboardPage from "./pages/DashboardPage";
import LoginPage from "./pages/LoginPage";
import ProtectedPage from "./pages/ProtectedPage";
export default [
{
path: "/",
element: <ProtectedPage />,
children: [
{
index: true,
element: <DashboardPage />,
},
],
},
{
path: "/login",
element: <LoginPage />,
},
];
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
// import { IUser } from "../types/IUser";
interface State {
token: string | null;
setToken: (token: string | null) => void;
// user: IUser | null;
// setUser: (user: IUser | null) => void;
}
const useAuthStore = create<State>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
// user: null,
// setUser: (user) => set({ user }),
}),
{
name: "auth", // name of the item in the storage (must be unique)
}
)
);
export default useAuthStore;
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
import { ReactNode } from "react";
import { devtools } from "zustand/middleware";
interface State {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
modal: ReactNode | null;
setModal: (modal: ReactNode | null) => void;
}
const useModalStore = create<State>()(
devtools(
(set) => ({
isOpen: false,
setIsOpen: (isOpen) => set({ isOpen }),
modal: null,
setModal: (modal) => set({ modal }),
}),
{
name: "modal",
}
)
);
export default useModalStore;
+8
View File
@@ -0,0 +1,8 @@
export interface IApp {
id: string;
name: string;
fileName: string;
companyId: string;
createdAt: Date;
updatedAt: Date;
}
+7
View File
@@ -0,0 +1,7 @@
export interface IClient {
id: string;
fullname: string;
email: string;
phone: string;
companyId: string;
}
+3
View File
@@ -0,0 +1,3 @@
export interface IError {
error: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { IApp } from "./IApp";
import { IClient } from "./IClient";
import { ISession } from "./ISession";
export interface IServer {
id: string;
hostname: string;
name: string;
location: string;
companyId: string;
sessions?: ISession[];
client?: IClient;
app?: IApp;
}
+15
View File
@@ -0,0 +1,15 @@
import { IApp } from "./IApp";
import { IServer } from "./IServer";
import { IUser } from "./IUser";
export interface ISession {
id: string;
ownerId: string;
serverId: string;
clientId: string;
companyId: string;
status: "starting" | "started" | "restarted" | "ending" | "ended";
server: IServer;
client: IUser;
app: IApp
}
+6
View File
@@ -0,0 +1,6 @@
export interface IUser {
id: string;
email: string;
fullname: string;
}
+20
View File
@@ -0,0 +1,20 @@
import ky from "ky";
import useAuthStore from "../stores/useAuthStore";
const api = ky.create({
prefixUrl: import.meta.env.VITE_API_URL,
retry: 0,
hooks: {
beforeRequest: [
(request) => {
const token = useAuthStore.getState().token;
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
],
},
});
export default api;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />