This commit is contained in:
2025-07-07 11:13:35 +05:00
18 changed files with 3976 additions and 247 deletions
+15
View File
@@ -5,6 +5,7 @@
"name": "graff-mate-client",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@mona-health/react-input-mask": "^3.0.3",
"@react-router/dev": "^7.3.0",
"@tailwindcss/vite": "^4.0.13",
"@tanstack/react-query": "^5.67.3",
@@ -186,6 +187,8 @@
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
"@mona-health/react-input-mask": ["@mona-health/react-input-mask@3.0.3", "", { "dependencies": { "invariant": "^2.2.4", "prop-types": "^15.7.2", "warning": "^4.0.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-E8t4ZAaHjmPT1NS/9nlIGR7OsyshP7hs5OwqEYnVslm/eaWrQg5ZziB6r5IOxGmFUyB+G1muFPobVPA08L2sJg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -488,6 +491,8 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -556,6 +561,8 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -590,6 +597,8 @@
"npm-pick-manifest": ["npm-pick-manifest@8.0.2", "", { "dependencies": { "npm-install-checks": "^6.0.0", "npm-normalize-package-bin": "^3.0.0", "npm-package-arg": "^10.0.0", "semver": "^7.3.5" } }, "sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -624,6 +633,8 @@
"promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -634,6 +645,8 @@
"react-hot-toast": ["react-hot-toast@2.5.2", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-router": ["react-router@7.3.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-466f2W7HIWaNXTKM5nHTqNxLrHTyXybm7R0eBlVSt0k/u55tTCDO194OIx/NrYD4TS5SXKTNekXfT37kMKUjgw=="],
@@ -726,6 +739,8 @@
"vite-node": ["vite-node@3.0.0-beta.2", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-ofTf6cfRdL30Wbl9n/BX81EyIR5s4PReLmSurrxQ+koLaWUNOEo8E0lCM53OJkb8vpa2URM2nSrxZsIFyvY1rg=="],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+3491
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@mona-health/react-input-mask": "^3.0.3",
"@react-router/dev": "^7.3.0",
"@tailwindcss/vite": "^4.0.13",
"@tanstack/react-query": "^5.67.3",
+15 -10
View File
@@ -1,5 +1,6 @@
import clsx from "clsx";
import SpinIcon from "./icons/SpinIcon";
import InputMask from "@mona-health/react-input-mask";
interface NewInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
placeholder?: string;
@@ -7,6 +8,7 @@ interface NewInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
errorMessage?: string;
isLoading?: boolean;
children?: React.ReactNode;
mask?: string;
}
function Input({
@@ -15,20 +17,23 @@ function Input({
errorMessage,
isLoading,
children,
mask,
...props
}: NewInputProps) {
return (
<div className={clsx("relative", props.disabled && "opacity-40")}>
<input
{...props}
placeholder=""
className={clsx(
isError
? "hover:ring-[#FF4517] focus:ring-[#FF4517]"
: "hover:ring-[#7B60F3] focus:ring-[#7B60F3]",
"peer bg-[#F6F6F6] rounded-[0.833vw] px-[1.111vw] pt-[19px] pb-[11px] outline-none ring-1 ring-transparent transition-all inline-block w-full h-[3.889vw] text-m"
)}
/>
<InputMask mask={mask || ""} {...props}>
<input
placeholder=""
className={clsx(
isError
? "hover:ring-[#FF4517] focus:ring-[#FF4517]"
: "hover:ring-[#7B60F3] focus:ring-[#7B60F3]",
"peer bg-[#F6F6F6] rounded-[0.833vw] px-[1.111vw] pt-[19px] pb-[11px] outline-none ring-1 ring-transparent transition-all inline-block w-full h-[3.889vw] text-m"
)}
/>
</InputMask>
{children}
{placeholder && (
<span
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { Manager } from "../types/Manager";
import SortIcon from "./icons/SortIcon";
import clsx from "clsx";
import CheckIcon from "./icons/CheckIcon";
import { useClickAway } from "@uidotdev/usehooks";
import { AnimatePresence, motion } from "motion/react";
import { getPositionAbove } from "../utils/getPositionAbove";
function ManagerSelect({
placeholder,
data,
}: {
placeholder: string;
data: Manager[];
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedManager, setSelectedManager] = useState<Manager | null>(
data[0]
);
const [position, setPosition] = useState<"top" | "bottom">("bottom");
const selectRef = useClickAway<HTMLDivElement>(() => setIsOpen(false));
useEffect(() => {
if (!isOpen || !selectRef.current) return;
setPosition(getPositionAbove(selectRef) ? "top" : "bottom");
}, [isOpen, selectRef]);
useEffect(() => {
const handleScroll = () => {
if (isOpen) {
setPosition(getPositionAbove(selectRef) ? "top" : "bottom");
}
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [isOpen, selectRef]);
const handleToggle = () => {
setIsOpen(!isOpen);
};
return (
<div
ref={selectRef}
className={clsx(
"relative w-full rounded-[0.833vw] p-[1.111vw] bg-[#F6F6F6] cursor-pointer flex items-center justify-between select-none",
isOpen && "outline outline-[#7B60F3]"
)}
style={{ boxShadow: "0px 2px 2px 0px #0000000D" }}
onClick={handleToggle}
>
<div className="flex flex-col gap-[0.278vw]">
<div className="caption-s font-medium text-[#7D7D7D]">
{placeholder}
</div>
<div className="flex items-center gap-[0.556vw]">
<div className="size-[1.111vw] rounded-full bg-[#7D7D7D]" />
<div className="text-s">{selectedManager?.fullname}</div>
</div>
</div>
<span className="size-[1.389vw] text-[#7D7D7D]">
<SortIcon />
</span>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={clsx(
"absolute left-0 w-full z-10",
position === "top"
? "bottom-[calc(100%+4px)]"
: "top-[calc(100%+4px)]"
)}
>
<div
className="w-full rounded-[0.833vw] p-[0.833vw] max-h-[11.389vw] bg-white overflow-y-auto [scrollbar-width:thin]"
style={{ boxShadow: "0px 4px 40px 0px #0000000D" }}
>
{data.map((item) => (
<div
key={item.id}
className="p-[0.833vw] flex items-center gap-[0.556vw] hover:bg-[#F6F6F6] rounded-[0.278vw]"
onClick={(e) => {
e.stopPropagation();
setSelectedManager(item);
setIsOpen(false);
}}
>
<div className="size-[1.111vw] rounded-full text-[#7B60F3] flex items-center justify-center">
{selectedManager?.id === item.id && <CheckIcon />}
</div>
<div className="size-[1.111vw] rounded-full bg-[#7D7D7D]" />
<div className="text-s">{item.fullname}</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default ManagerSelect;
+4 -1
View File
@@ -96,7 +96,10 @@ function MultySelect<T extends { name: string; id: string }>({
<Button
variant="secondary"
className="!justify-start w-full text-s font-medium p-[0.833vw] text-[#7D7D7D] flex items-center gap-[0.278vw] cursor-pointer rounded-[0.278vw] hover:bg-[#F6F6F6] bg-white"
onClick={() => setSelectedValues([])}
onClick={() => {
setSelectedValues([]);
setIsSelectVisible(false);
}}
>
<span className="size-[1.111vw] flex items-center justify-center">
<CloseIcon />
+90 -108
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { App } from "../types/App";
import ChevronLeftIcon from "./icons/ChevronLeftIcon";
import CloseIcon from "./icons/CloseIcon";
import LightningIcon from "./icons/LightningIcon";
import Button from "./Button";
import CheckIcon from "./icons/CheckIcon";
import ChevronDownIcon from "./icons/ChevronDownIcon";
import { useClickAway } from "@uidotdev/usehooks";
import { AnimatePresence, motion } from "motion/react";
import clsx from "clsx";
import FlashIcon from "./icons/FlashIcon";
import SortIcon from "./icons/SortIcon";
import { getPositionAbove } from "../utils/getPositionAbove";
interface Props {
projects: App[];
@@ -21,124 +22,105 @@ function ProjectSelector({
activeProject,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [pointedProject, setPointedProject] = useState<App | null>(null);
const [position, setPosition] = useState<"top" | "bottom">("bottom");
const selectRef = useClickAway<HTMLDivElement>(() => setIsOpen(false));
useEffect(() => {
setPointedProject(selectedProject);
}, [selectedProject]);
setPosition(getPositionAbove(selectRef) ? "top" : "bottom");
}, [isOpen, selectRef]);
useEffect(() => {
const handleScroll = () => {
if (isOpen) {
setPosition(getPositionAbove(selectRef) ? "top" : "bottom");
}
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [isOpen, selectRef]);
const handleToggle = () => {
setIsOpen(!isOpen);
};
return (
<>
<button
className="p-[1.111vw] rounded-[0.833vw] bg-[#F6F6F6] flex justify-between items-center gap-[0.833vw]"
onClick={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
<div className="space-y-[0.278vw]">
<p className="caption-s text-[#7D7D7D] w-fit font-medium">Проект</p>
<p className="text-s font-medium w-fit">{selectedProject?.name}</p>
</div>
<div
ref={selectRef}
className={clsx(
"relative w-full rounded-[0.833vw] p-[1.111vw] bg-[#F6F6F6] cursor-pointer flex items-center justify-between select-none",
isOpen && "outline outline-[#7B60F3]"
)}
style={{ boxShadow: "0px 2px 2px 0px #0000000D" }}
onClick={handleToggle}
>
<div className="flex flex-col gap-[0.278vw]">
<div className="caption-s font-medium text-[#7D7D7D]">Проект</div>
<div className="flex items-center gap-[0.556vw]">
<img src="/images/app_image.png" className="size-[2.222vw]" alt="" />
<div className="size-[1.389vw] text-[#7D7D7D]">
<ChevronDownIcon />
</div>
<div className="text-s">{selectedProject?.name}</div>
</div>
</button>
{isOpen && (
<div className="fixed z-1 top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 rounded-[2.222vw] bg-white p-[1.389vw] flex flex-col gap-[2.778vw] w-[25vw]">
<div className="flex justify-between items-center">
<Button variant="secondary" size="small">
<div className="text-[#7D7D7D] size-[0.972vw]">
<ChevronLeftIcon />
</div>
</Button>
<p className="title-s font-medium">Смена проекта</p>
<Button
variant="secondary"
size="small"
onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
</div>
<div className="flex items-center gap-[0.556vw]">
<img
src="/images/app_image.png"
className="size-[2.222vw]"
alt="app_image"
/>
<span className="size-[1.389vw] text-[#7D7D7D]">
<SortIcon />
</span>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={clsx(
"absolute left-0 w-full z-10",
position === "top"
? "bottom-[calc(100%+4px)]"
: "top-[calc(100%+4px)]"
)}
>
<div
className="w-full rounded-[0.833vw] p-[0.833vw] max-h-[11.389vw] bg-white overflow-y-auto [scrollbar-width:thin]"
style={{ boxShadow: "0px 4px 40px 0px #0000000D" }}
>
<span className="text-[#7D7D7D] size-[0.972vw]">
<CloseIcon />
</span>
</Button>
</div>
<div className="flex flex-col gap-[1.667vw]">
<div className="flex flex-col">
{projects.map((project) => (
<button
<div
key={project.id}
className="p-[0.833vw] flex items-center gap-[0.556vw] hover:bg-[#F6F6F6] rounded-[0.278vw]"
onClick={(e) => {
e.preventDefault();
setPointedProject(project);
e.stopPropagation();
setSelectedProject(project);
setIsOpen(false);
}}
className="flex justify-between items-center not-last:border-b py-[0.883vw] border-[#F6F6F6] cursor-pointer"
>
<div className="flex items-center gap-[0.556vw]">
<img
src="/images/app_image.png"
className="size-[2.222vw] object-cover"
alt=""
/>
<div className="space-y-[0.278vw]">
<div className="flex items-center gap-[0.278vw]">
<p className="text-s font-medium">{project.name}</p>
{activeProject &&
project.name === activeProject.name && (
<span className="size-[0.972vw] text-[#7B60F3]">
<LightningIcon />
</span>
)}
</div>
<p className="caption-s text-[#7D7D7D] font-medium">
Доступно 128 квартир
</p>
</div>
<div className="size-[1.111vw] rounded-full text-[#7B60F3] flex items-center justify-center">
{selectedProject?.id === project.id && <CheckIcon />}
</div>
{pointedProject?.name === project.name ? (
<div className="size-[1.389vw] flex items-center justify-center rounded-full bg-[#7B60F3]">
<div className="size-[0.833vw] text-white">
<CheckIcon />
</div>
</div>
) : (
<div className="rounded-full bg-[#F6F6F6] ring ring-[#F0F0F0] size-[1.389vw]" />
)}
</button>
<img
src="/images/app_image.png"
className="size-[1.389vw]"
alt=""
/>
<div className="flex items-center gap-[0.278vw]">
<div className="text-s">{project.name}</div>
{activeProject && project.name === activeProject.name && (
<span className="size-[0.972vw] text-[#7B60F3]">
<FlashIcon />
</span>
)}
</div>
</div>
))}
</div>
<div className="flex flex-col gap-y-[0.556vw]">
<Button
variant="cta"
size="large"
onClick={() => {
setSelectedProject(pointedProject);
setIsOpen(false);
}}
>
Переключиться
</Button>
<Button
variant="primary"
size="large"
onClick={() => {
setIsOpen(false);
}}
>
Отменить
</Button>
</div>
</div>
</div>
)}
</>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+10 -1
View File
@@ -1,11 +1,12 @@
import clsx from "clsx";
import { Server } from "../types/Server";
import LightningIcon from "./icons/LightningIcon";
import { useEffect } from "react";
interface TableSelectorProps {
tables: Server[];
selectedTable: Server | null;
onSelect: (table: Server) => void;
onSelect: (table: Server | null) => void;
}
function TableSelector({
@@ -13,6 +14,14 @@ function TableSelector({
selectedTable,
onSelect,
}: TableSelectorProps) {
useEffect(() => {
if (selectedTable !== null) {
onSelect(selectedTable);
} else {
onSelect(tables.find((table) => table.status === "online") || null);
}
}, [onSelect, selectedTable, tables]);
return (
<div className="flex gap-[0.556vw] overflow-x-auto -mx-[1.111vw] pl-[1.111vw] [scrollbar-width:none]">
{tables.map((table) => (
+16
View File
@@ -0,0 +1,16 @@
function SortIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.8571 11.5257L9.99943 14.3905L7.14179 11.5257C7.01635 11.4002 6.84623 11.3297 6.66884 11.3297C6.49146 11.3297 6.32133 11.4002 6.1959 11.5257C6.07047 11.6511 6 11.8213 6 11.9987C6 12.1761 6.07047 12.3463 6.1959 12.4717L9.52649 15.8029C9.58841 15.8654 9.66209 15.9149 9.74326 15.9488C9.82443 15.9826 9.9115 16 9.99943 16C10.0874 16 10.1744 15.9826 10.2556 15.9488C10.3368 15.9149 10.4105 15.8654 10.4724 15.8029L13.803 12.4717C13.8651 12.4096 13.9143 12.3359 13.948 12.2547C13.9816 12.1736 13.9989 12.0866 13.9989 11.9987C13.9989 11.9109 13.9816 11.8239 13.948 11.7427C13.9143 11.6615 13.8651 11.5878 13.803 11.5257C13.7409 11.4636 13.6671 11.4143 13.586 11.3807C13.5048 11.3471 13.4179 11.3297 13.33 11.3297C13.2422 11.3297 13.1552 11.3471 13.0741 11.3807C12.9929 11.4143 12.9192 11.4636 12.8571 11.5257ZM7.14179 8.47432L9.99943 5.6095L12.8571 8.47432C12.919 8.53676 12.9927 8.58633 13.0738 8.62015C13.155 8.65397 13.2421 8.67139 13.33 8.67139C13.418 8.67139 13.505 8.65397 13.5862 8.62015C13.6674 8.58633 13.741 8.53676 13.803 8.47432C13.8654 8.41238 13.915 8.33869 13.9488 8.25751C13.9826 8.17632 14 8.08924 14 8.00129C14 7.91334 13.9826 7.82626 13.9488 7.74507C13.915 7.66388 13.8654 7.59019 13.803 7.52826L10.4724 4.19707C10.4105 4.13463 10.3368 4.08506 10.2556 4.05124C10.1744 4.01741 10.0874 4 9.99943 4C9.9115 4 9.82443 4.01741 9.74326 4.05124C9.66209 4.08506 9.58841 4.13463 9.52649 4.19707L6.1959 7.52826C6.13379 7.59038 6.08453 7.66412 6.05091 7.74529C6.0173 7.82645 6 7.91344 6 8.00129C6 8.17871 6.07047 8.34886 6.1959 8.47432C6.32133 8.59977 6.49146 8.67025 6.66884 8.67025C6.84623 8.67025 7.01635 8.59977 7.14179 8.47432Z"
fill="currentColor"
/>
</svg>
);
}
export default SortIcon;
+1 -1
View File
@@ -4,7 +4,7 @@ function StartSessionIcon() {
<rect width={20} height={20} rx={10} fill="#fff" fillOpacity={0.1} />
<path
d="M13.438 9.595a.5.5 0 0 1 0 .81l-4.645 3.353A.5.5 0 0 1 8 13.353V6.647a.5.5 0 0 1 .793-.405z"
fill="currentColor"
fill="#fff"
/>
</svg>
);
+18 -4
View File
@@ -26,7 +26,14 @@ function ClientModal({ client }: { client: Client }) {
const { mutate: updateClientData, isPending } = useMutation({
mutationKey: ["clients", client.id],
mutationFn: () => api.put(`clients/${client.id}`, { json: clientData }),
mutationFn: () =>
api.put(`clients/${client.id}`, {
json: {
name: clientData.name,
phone: clientData.phone.replace(/\D/g, ""),
email: clientData.email,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
},
@@ -59,7 +66,6 @@ function ClientModal({ client }: { client: Client }) {
onChange={(e) => {
setClientData({ ...clientData, name: e.target.value });
}}
className="relative"
required
>
<span
@@ -77,8 +83,8 @@ function ClientModal({ client }: { client: Client }) {
onChange={(e) => {
setClientData({ ...clientData, phone: e.target.value });
}}
className="relative"
required
mask="+7 (999) 999-99-99"
>
<span
className="absolute top-[1.25vw] left-[17.917vw] size-[1.389vw] text-[#7D7D7D] cursor-pointer"
@@ -103,7 +109,15 @@ function ClientModal({ client }: { client: Client }) {
size="large"
className="w-full"
type="submit"
disabled={!clientData.name || !clientData.phone || isPending}
disabled={
!clientData.name ||
!clientData.phone ||
isPending ||
(clientData.name === client.name &&
clientData.phone.replace(/\D/g, "") ===
client.phone.replace(/\D/g, "") &&
clientData.email === client.email)
}
>
{isPending ? (
<span className="size-[1.111vw] animate-spin text-[#7B60F3] flex items-center justify-center">
+104 -92
View File
@@ -11,8 +11,10 @@ import StartSessionIcon from "../icons/StartSessionIcon.tsx";
import Button from "../Button.tsx";
import ProjectSelector from "../ProjectSelector.tsx";
import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { useDebounce } from "@uidotdev/usehooks";
import { AnimatePresence, motion } from "motion/react";
import useClientSearch from "../../hooks/useClientSearch.tsx";
import ManagerSelect from "../ManagerSelect.tsx";
import { Manager } from "../../types/Manager.ts";
interface Props {
targetServerId: string | null;
@@ -24,7 +26,8 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
const [name, setName] = useState<string | null>(client?.name || null);
const [phone, setPhone] = useState<string | null>(client?.phone || null);
const [email, setEmail] = useState<string | null>(client?.email || null);
// const [isSessionExists, setIsSessionExists] = useState(false);
const [isFullPhone, setIsFullPhone] = useState(false);
const [isSessionExists, setIsSessionExists] = useState(false);
const queryClient = useQueryClient();
@@ -34,6 +37,11 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
refetchInterval: 1000,
});
const { data: managers } = useQuery({
queryKey: ["managers"],
queryFn: () => api.get("users").json<Manager[]>(),
});
const targetServer = targetServerId
? servers?.find((server) => server.id === targetServerId) || null
: null;
@@ -43,34 +51,20 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
);
const [selectedApp, setSelectedApp] = useState<App | null>(null);
const { data, isLoading, error } = useClientSearch(phone);
useEffect(() => {
setSelectedApp(
selectedServer?.sessions?.[0]?.app ||
selectedServer?.appsToServers?.[0].app ||
selectedServer?.appsToServers?.[0]?.app ||
null
);
}, [selectedServer]);
const debouncedPhone = useDebounce(phone, 500);
const { data, isLoading, error } = useQuery({
queryKey: ["get-user-by-phone", debouncedPhone],
queryFn: () =>
api
.get("clients/by-phone", {
searchParams: debouncedPhone ? { phone: debouncedPhone } : {},
})
.json<Client>(),
enabled: !!debouncedPhone,
});
useEffect(() => {
if (!error && data) {
setName(data.name);
setEmail(data.email);
} else {
setName(null);
setEmail(null);
}
}, [data, error]);
@@ -80,7 +74,7 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
.post("clients", {
json: {
name,
phone,
phone: phone?.replace(/\D/g, ""),
email,
},
})
@@ -114,70 +108,74 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
},
});
// const { mutate: endSession } = useMutation({
// mutationKey: ["end-session", selectedServer?.sessions?.[0]?.id],
// mutationFn: () =>
// api.put(`sessions/${selectedServer?.sessions?.[0]?.id}`, {
// json: { status: "ending" },
// }),
// onMutate: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
// });
const { mutate: endSession } = useMutation({
mutationKey: ["end-session", selectedServer?.sessions?.[0]?.id],
mutationFn: () =>
api.put(`sessions/${selectedServer?.sessions?.[0]?.id}`, {
json: { status: "ending" },
}),
onMutate: () => queryClient.invalidateQueries({ queryKey: ["sessions"] }),
});
async function handleClickCreateSession(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!name || !phone || !selectedServer || !selectedApp) return;
// if (selectedServer?.sessions?.[0]?.status !== "started") {
createClient(undefined, {
onSuccess: (client) => {
createSession({
clientId: client.id,
serverId: selectedServer.id,
appId: selectedApp.id,
});
if (selectedServer?.sessions?.[0]?.status !== "started") {
createClient(undefined, {
onSuccess: (client) => {
createSession({
clientId: client.id,
serverId: selectedServer.id,
appId: selectedApp.id,
});
},
});
return;
}
if (!isSessionExists) {
setIsSessionExists(true);
return;
}
endSession(undefined, {
onError: (error) => {
console.log("Ошибка при завершении сессии:", error);
},
});
// return;
// }
// if (!isSessionExists) {
// setIsSessionExists(true);
// return;
// }
// endSession(undefined, {
// onError: (error) => {
// console.log("Ошибка при завершении сессии:", error);
// },
// });
}
// useEffect(() => {
// if (
// selectedServer &&
// servers?.find((server) => server.id === selectedServer?.id)?.sessions?.[0]
// ?.status === "ended" &&
// selectedApp
// // && isSessionExists
// )
// createClient(undefined, {
// onSuccess: (client) => {
// createSession({
// clientId: client.id,
// serverId: selectedServer?.id,
// appId: selectedApp.id,
// });
// },
// });
// }, [
// selectedApp,
// servers,
// createClient,
// createSession,
// // isSessionExists,
// selectedServer,
// ]);
useEffect(() => {
if (
selectedServer &&
servers?.find((server) => server.id === selectedServer?.id)?.sessions?.[0]
?.status === "ended" &&
selectedApp &&
isSessionExists
)
createClient(undefined, {
onSuccess: (client) => {
createSession({
clientId: client.id,
serverId: selectedServer?.id,
appId: selectedApp.id,
});
},
});
}, [
selectedApp,
servers,
createClient,
createSession,
isSessionExists,
selectedServer,
]);
useEffect(() => {
console.log(managers);
}, [managers]);
const ref = useRef<HTMLFormElement>(null);
@@ -202,13 +200,19 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
<div className="flex flex-col gap-y-[0.556vw]">
<Input
value={phone || ""}
onChange={(e) => setPhone(e.target.value)}
onChange={(e) => {
setPhone(e.target.value);
if (e.target.value.replace(/\D/g, "").length === 11) {
setIsFullPhone(true);
}
}}
placeholder="Номер телефона"
mask="+7 (999) 999-99-99"
required
isLoading={isLoading}
/>
<AnimatePresence>
{phone && (
{isFullPhone && (
<>
<motion.div
initial={{ opacity: 0 }}
@@ -243,23 +247,31 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
</div>
<div className="flex flex-col gap-y-[0.833vw]">
<p className="title-s font-medium">Выберите параметры сеанса</p>
{selectedServer &&
selectedServer?.appsToServers &&
selectedServer?.appsToServers?.length > 0 && (
<ProjectSelector
activeProject={
selectedServer?.sessions?.[0]?.status === "started"
? selectedApp
: null
}
projects={selectedServer?.appsToServers.map(({ app }) => app)}
selectedProject={selectedApp}
setSelectedProject={setSelectedApp}
/>
selectedServer.appsToServers?.length > 0 && (
<>
<ManagerSelect
placeholder="Менеджер сеанса"
data={managers || []}
/>
<ProjectSelector
activeProject={
selectedServer?.sessions?.[0]?.status === "started"
? selectedApp
: null
}
projects={selectedServer?.appsToServers.map(({ app }) => app)}
selectedProject={selectedApp}
setSelectedProject={setSelectedApp}
/>
</>
)}
</div>
{/* {isSessionExists && ( */}
{/* <div className="absolute inset-0 top-[11.806vw] bg-[#FFFFFF] flex flex-col gap-[1.111vw] items-center justify-center h-[31.458vw]">
{isSessionExists && (
<div className="absolute inset-0 top-[11.806vw] bg-[#FFFFFF] flex flex-col gap-[1.111vw] items-center justify-center h-[31.458vw]">
<img
src="/images/ghost.png"
alt="ghost error"
@@ -272,8 +284,8 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
При запуске нового текущий будет завершен.`}
</p>
</div>
</div> */}
{/* )} */}
</div>
)}
<div className="flex-1 flex flex-col justify-end">
<Button
type="submit"
@@ -288,7 +300,7 @@ export default function CreateSessionModal({ targetServerId, client }: Props) {
variant="cta"
size="large"
>
<div className="size-[1.111vw]">
<div className="size-[1.111vw] text-[#9184F6]">
<StartSessionIcon />
</div>
<span>Запустить сеанс</span>
+28 -20
View File
@@ -6,14 +6,17 @@ import CurrentSessionModal from "./CurrentSessionModal";
import api from "../../utils/api";
import SpinIcon from "../icons/SpinIcon";
import SessionModal from "./SessionModal";
import { Server } from "../../types/Server";
function EndSessionModal({ session }: { session: Session }) {
const queryClient = useQueryClient();
const { setModal, setPosition } = useModalStore();
const { mutate: endSession, isPending } = useMutation({
const {
mutate: endSession,
isPending,
isSuccess,
} = useMutation({
mutationKey: ["sessions", session.id],
mutationFn: () =>
api
@@ -28,15 +31,6 @@ function EndSessionModal({ session }: { session: Session }) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["servers"] });
queryClient.invalidateQueries({ queryKey: ["last-sessions"] });
queryClient.invalidateQueries({ queryKey: ["sessions"] });
const servers = queryClient.getQueryData<Server[]>(["servers"]);
const updatedSession = servers
?.find((s) => s.id === session.serverId)
?.sessions?.find((s) => s.id === session.id);
if (updatedSession) {
setPosition("right");
setModal(<SessionModal session={updatedSession} />);
}
},
});
@@ -62,31 +56,45 @@ function EndSessionModal({ session }: { session: Session }) {
<div className="p-[1.389vw] flex flex-col gap-[1.667vw]">
<div className="flex flex-col gap-[0.556vw]">
<p className="title-m font-medium">
Точно хотите завершить сеанс?
{isSuccess
? "Доступен отчет по встрече"
: "Точно хотите завершить сеанс?"}
</p>
<p className="caption-m font-medium text-[#BDBDBD]">
Текущий сеанс будет завершен немедленно
{isSuccess
? "Вы можете просмотреть отчет или создать новый сеанс, перейдя на главную страницу"
: "Текущий сеанс будет завершен немедленно"}
</p>
</div>
<div className="flex flex-col gap-[0.556vw]">
<Button
size="large"
variant="critical"
variant={isSuccess ? "cta" : "critical"}
className="w-full"
onClick={() => endSession()}
onClick={() => {
if (isSuccess) {
setModal(<SessionModal session={session} />);
} else {
endSession();
}
}}
disabled={isPending}
>
Завершить сеанс
{isSuccess ? "Перейти к отчету" : "Завершить сеанс"}
</Button>
<Button
size="large"
variant="primary"
className="w-full"
onClick={() =>
setModal(<CurrentSessionModal session={session} />)
}
onClick={() => {
if (isSuccess) {
setModal(null);
} else {
setModal(<CurrentSessionModal session={session} />);
}
}}
>
Отменить
{isSuccess ? "На главную" : "Отменить"}
</Button>
</div>
</div>
+47
View File
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "@uidotdev/usehooks";
import { useEffect, useState } from "react";
import { Client } from "../types/Client";
import api from "../utils/api";
function useClientSearch(phone: string | null) {
const [isSearching, setIsSearching] = useState(false);
const isPhoneComplete = Boolean(
phone && phone.replace(/\D/g, "").length === 11
);
useEffect(() => {
setIsSearching(isPhoneComplete);
}, [isPhoneComplete]);
const debouncedPhone = useDebounce(phone, 500);
const { data, isLoading, error } = useQuery({
queryKey: ["get-user-by-phone", debouncedPhone],
queryFn: () =>
api
.get("clients/by-phone", {
searchParams:
debouncedPhone && debouncedPhone.replace(/\D/g, "").length === 11
? { phone: debouncedPhone.replace(/\D/g, "") }
: {},
})
.json<Client>(),
enabled: Boolean(debouncedPhone && isPhoneComplete),
});
useEffect(() => {
if (!isLoading && isSearching) {
setIsSearching(false);
}
}, [isLoading, isSearching]);
return {
data,
isLoading: isSearching || isLoading,
error,
};
}
export default useClientSearch;
+1
View File
@@ -60,6 +60,7 @@ function DashboardPage() {
</div>
</div>
</div>
<div className="w-full">
<div className="flex flex-col gap-[1.667vw]">
<h1 className="title-l font-medium">Последние сеансы</h1>
+1 -1
View File
@@ -71,7 +71,7 @@ function LoginPage() {
/>
</div>
<div className="mt-3">
<Button type="button" variant="secondary">
<Button type="submit" variant="secondary">
Забыли пароль?
</Button>
</div>
+14 -9
View File
@@ -122,14 +122,19 @@ function SessionsPage() {
<p className="caption-m font-medium opacity-40">
Найдено {count ? pluralize(count, "сеанс") : "0 сеансов"}
</p>
<button className="flex gap-[0.278vw] items-center" onClick={reset}>
<div className="size-[1.111vw] text-[#7D7D7D]">
<CloseIcon />
</div>
<p className="text-[#7D7D7D] text-[0.972vw] font-medium">
Сбросить все
</p>
</button>
{managerIds.length === 0 && appIds.length === 0 ? null : (
<button
className="flex gap-[0.278vw] items-center"
onClick={reset}
>
<div className="size-[1.111vw] text-[#7D7D7D]">
<CloseIcon />
</div>
<p className="text-[#7D7D7D] text-[0.972vw] font-medium">
Сбросить все
</p>
</button>
)}
</div>
</div>
</div>
@@ -141,7 +146,7 @@ function SessionsPage() {
) : grouppedSessions?.length ? (
grouppedSessions?.map(([timestamp, sessions]) => (
<div key={timestamp} className="space-y-[0.833vw]">
<p className="caption-m font-medium opacity-40">
<p className="caption-s font-medium opacity-40">
{isToday(new Date(timestamp))
? "Сегодня"
: format(new Date(timestamp), "d MMMM", { locale: ru })}
+11
View File
@@ -0,0 +1,11 @@
export function getPositionAbove(selectRef: React.RefObject<HTMLDivElement>) {
const rect = selectRef.current.getBoundingClientRect();
const dropdownHeight = 200;
const margin = 8;
const spaceBelow = window.innerHeight - rect.bottom - margin;
const spaceAbove = rect.top - margin;
return spaceBelow < dropdownHeight && spaceAbove >= dropdownHeight;
}