Compare commits
22 Commits
fa75d98302
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d433bc0d8c | |||
| 20a56d4078 | |||
| d49ae4878f | |||
| 41b8ab969d | |||
| ad5b6c9234 | |||
| a881f315c2 | |||
| 1a8c25f092 | |||
| c9357962c6 | |||
| a0e737aa11 | |||
| fc1858a6ed | |||
| e8060c594d | |||
| fc7d55b10f | |||
| c82f1dfbb5 | |||
| ee837e5930 | |||
| 6ea1e84414 | |||
| 4c2369ce88 | |||
| 3f11fcd829 | |||
| 2e5790d246 | |||
| e635b7ef0a | |||
| 8bdf63eb57 | |||
| 4fcb334d69 | |||
| f2002125a5 |
@@ -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=="],
|
||||
|
||||
Generated
+3491
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -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;
|
||||
@@ -27,7 +27,7 @@ function Button({
|
||||
onClick?.(e);
|
||||
}}
|
||||
className={clsx(
|
||||
"transition-all flex outline-none 2xl:gap-[0.556vw] gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:text-[#D6D6D6]",
|
||||
"transition-all flex outline-none 2xl:gap-[0.556vw] gap-2 items-center justify-center font-medium disabled:bg-[#F6F6F6] disabled:!text-[#D6D6D6]",
|
||||
variant === "critical" &&
|
||||
"text-[#FF4517] bg-[#FEF3F2] hover:bg-[#FEE4E2]",
|
||||
variant === "secondary" &&
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Client } from "../types/Client";
|
||||
import ChevronRightIcon from "./icons/ChevronRightIcon";
|
||||
import Button from "./Button";
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import ClientModal from "./modals/ClientModal";
|
||||
|
||||
function ClientCard({ client }: { client: Client }) {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" className="w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setModal(<ClientModal client={client} />)}
|
||||
>
|
||||
<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>
|
||||
@@ -16,7 +24,7 @@ function ClientCard({ client }: { client: Client }) {
|
||||
Добавьте email
|
||||
</p>
|
||||
)}
|
||||
<span className="w-[1.389vw] h-[1.389vw] flex items-center justify-center text-[#7B60F3]">
|
||||
<span className="size-[1.389vw] flex items-center justify-center text-[#7B60F3]">
|
||||
<ChevronRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ function CurrentSessionCard({
|
||||
</p>
|
||||
<div className="size-[0.139vw] bg-[#7D7D7D] rounded-full" />
|
||||
<p className="caption-s font-medium text-[#7D7D7D]">
|
||||
{session.owner.fullname}
|
||||
{session.manager.fullname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -24,15 +25,15 @@ export default function DesktopCard({ server }: IDesktopCardProps) {
|
||||
mutationKey: ["create-session", server.id],
|
||||
});
|
||||
|
||||
const isEnding = useIsMutating({
|
||||
mutationKey: ["end-session", server.sessions?.[0]?.id],
|
||||
});
|
||||
|
||||
async function handleClickCreateSession() {
|
||||
setPosition("right");
|
||||
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
|
||||
@@ -45,7 +46,11 @@ export default function DesktopCard({ server }: IDesktopCardProps) {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="absolute top-[0.556vw] right-[0.556vw] cursor-pointer flex items-center justify-center"
|
||||
onClick={() => setModal(<EditTable table={server} />)}
|
||||
onClick={() => {
|
||||
if (server.status !== "offline") {
|
||||
setModal(<EditTable table={server} />);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-[#7D7D7D] w-[0.972vw] h-[0.972vw]">
|
||||
<CogIcon />
|
||||
|
||||
+18
-10
@@ -1,11 +1,14 @@
|
||||
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;
|
||||
isError?: boolean;
|
||||
errorMessage?: string;
|
||||
isLoading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
mask?: string;
|
||||
}
|
||||
|
||||
function Input({
|
||||
@@ -13,20 +16,25 @@ function Input({
|
||||
isError,
|
||||
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
|
||||
className="absolute caption-m font-medium text-[#7D7D7D] left-[1.111vw] top-1/2 -translate-y-1/2 pointer-events-none transition-all duration-300
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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>(null);
|
||||
const [position, setPosition] = useState<"top" | "bottom">("bottom");
|
||||
const selectRef = useClickAway<HTMLDivElement>(() => setIsOpen(false));
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && !selectedManager) {
|
||||
setSelectedManager(data[0]);
|
||||
}
|
||||
}, [data, selectedManager]);
|
||||
|
||||
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;
|
||||
@@ -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 />
|
||||
|
||||
@@ -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[];
|
||||
@@ -20,129 +21,106 @@ function ProjectSelector({
|
||||
setSelectedProject,
|
||||
activeProject,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
console.log(projects);
|
||||
}, [projects]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -8,9 +8,10 @@ function SearchInput(
|
||||
setSearch: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
) {
|
||||
// const [value, setValue] = useState();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { setSearch, ...inputProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -23,24 +24,24 @@ function SearchInput(
|
||||
</span>
|
||||
<input
|
||||
className="outline-none focus:outline-none placeholder:button-m placeholder:font-medium placeholder:text-[#7D7D7D] button-m font-medium flex-1"
|
||||
{...props}
|
||||
{...inputProps}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-[0.566vw] items-center transition-opacity",
|
||||
!props.value && "opacity-0"
|
||||
!inputProps.value && "opacity-0"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
disabled={!props.value}
|
||||
disabled={!inputProps.value}
|
||||
className="outline-none cursor-pointer disabled:cursor-default"
|
||||
onClick={() => {
|
||||
if (ref.current) {
|
||||
ref.current.value = "";
|
||||
ref.current.focus();
|
||||
props.setSearch(ref.current.value);
|
||||
setSearch(ref.current.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SessionModal from "./modals/SessionModal";
|
||||
|
||||
function SessionCard({ session }: { session: Session }) {
|
||||
const { setModal, setPosition } = useModalStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-[4.444vw] not-last:border-b-1 border-[#F6F6F6] flex py-[0.278vw] items-center gap-[0.556vw] cursor-pointer group"
|
||||
@@ -13,9 +14,9 @@ function SessionCard({ session }: { session: Session }) {
|
||||
}}
|
||||
>
|
||||
<div className="rounded-[1.111vw] w-full h-full flex items-center gap-[0.556vw] group-hover:bg-[#F6F6F6] transition-colors duration-200 px-[1.111vw] py-[0.972vw]">
|
||||
<div className="size-[2.5vw] bg-[#F6F6F6] rounded-full"></div>
|
||||
<div className="size-[2.5vw] bg-[#F6F6F6] rounded-full bg-[url(/images/mock_manager_photo_1_c.png)] bg-cover bg-no-repeat bg-center"></div>
|
||||
<div className="flex flex-col w-full gap-[0.278vw]">
|
||||
<p className="button-m font-medium">{session.owner.fullname}</p>
|
||||
<p className="button-m font-medium">{session.manager.fullname}</p>
|
||||
<p className="caption-s font-medium text-[#7D7D7D]">
|
||||
Клиент: {session.client.name} •
|
||||
{session.app.name}
|
||||
|
||||
@@ -1,12 +1,65 @@
|
||||
import { motion } from "motion/react";
|
||||
import { Comment } from "../types/Comment";
|
||||
import { format } from "date-fns";
|
||||
import { format, isToday } from "date-fns";
|
||||
import { Session } from "../types/Session";
|
||||
import { ru } from "date-fns/locale";
|
||||
import ChevronRightIcon from "./icons/ChevronRightIcon";
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import SessionModal from "./modals/SessionModal";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "../utils/api";
|
||||
|
||||
function SessionCommentItem({
|
||||
comment,
|
||||
session,
|
||||
}: {
|
||||
comment: Comment;
|
||||
session?: Session;
|
||||
}) {
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const { data: files } = useQuery({
|
||||
queryKey: ["file-list", comment.sessionId],
|
||||
enabled: !!session,
|
||||
queryFn: () =>
|
||||
api
|
||||
.get("files", {
|
||||
searchParams: { sessionId: comment.sessionId },
|
||||
})
|
||||
.json<{ filename: string; size: number }[]>(),
|
||||
});
|
||||
|
||||
function SessionCommentItem({ comment }: { comment: Comment }) {
|
||||
return (
|
||||
<motion.div layout className="flex gap-[0.833vw] items-end">
|
||||
<div className="relative flex flex-col gap-[0.556vw] p-[0.833vw] bg-white rounded-[0.833vw] w-full rounded-br-none">
|
||||
<p className="button-m font-medium">{comment.owner.fullname}</p>
|
||||
{session && (
|
||||
<div
|
||||
className="relative bg-[#E1DEFC] rounded-[0.556vw] self-stretch p-[0.556vw] flex justify-between items-center cursor-pointer overflow-hidden"
|
||||
onClick={() => setModal(<SessionModal session={session} />)}
|
||||
>
|
||||
<div className="h-full w-[0.139vw] bg-[#7B60F3] left-0 absolute" />
|
||||
<div className="space-y-[0.278vw]">
|
||||
<p className="text-[#7B60F3] caption-m font-medium">
|
||||
Сеанс{" "}
|
||||
{isToday(new Date(session.createdAt))
|
||||
? "Сегодня"
|
||||
: `от ${format(new Date(session.createdAt), "dd MMMM", {
|
||||
locale: ru,
|
||||
})}`}
|
||||
</p>
|
||||
{files && (
|
||||
<p className="text-s">
|
||||
{files?.length}{" "}
|
||||
{files?.length === 1 ? "документ" : "документов"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="size-[1.389vw] text-[#7B60F3]">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="button-m font-medium">{comment.manager.fullname}</p>
|
||||
<div className="flex flex-col max-w-[19.583vw]">
|
||||
<p className="caption-s break-words whitespace-pre-wrap overflow-hidden">
|
||||
{comment.text}
|
||||
@@ -18,7 +71,7 @@ function SessionCommentItem({ comment }: { comment: Comment }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white size-[2.222vw] rounded-full flex-shrink-0" />
|
||||
<div className="bg-[url(/images/mock_manager_photo_c.png)] bg-cover bg-no-repeat bg-center size-[2.222vw] rounded-full flex-shrink-0" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ function SessionComments({ sessionId }: { sessionId: string }) {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button variant="cta" size="large" type="submit" disabled={!value}>
|
||||
<span className="size-[1.111vw] text-white">
|
||||
<span className="size-[1.111vw]">
|
||||
<SendIcon />
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M6 5.725V4.03C6 3.461 6.462 3 7.031 3h8.938C16.539 3 17 3.462 17 4.031v8.938A1.03 1.03 0 0 1 15.969 14h-1.713"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.969 6H4.03A1.03 1.03 0 0 0 3 7.031v8.938C3 16.539 3.462 17 4.031 17h8.938A1.03 1.03 0 0 0 14 15.969V7.03A1.03 1.03 0 0 0 12.969 6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyIcon;
|
||||
@@ -3,13 +3,13 @@ function SendIcon() {
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m16 4-4.2 12-2.4-5.4L4 8.2z"
|
||||
stroke="#fff"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m15 5-5.5 5.5"
|
||||
stroke="#fff"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { format, isToday } from "date-fns";
|
||||
import { Client } from "../../types/Client";
|
||||
import Badge from "../Badge";
|
||||
import Button from "../Button";
|
||||
import PeopleIcon from "../icons/PeopleIcon";
|
||||
import PlusIcon from "../icons/PlusIcon";
|
||||
import Input from "../Input";
|
||||
import { ru } from "date-fns/locale";
|
||||
import ChevronRightIcon from "../icons/ChevronRightIcon";
|
||||
import CopyIcon from "../icons/CopyIcon";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "../../utils/api";
|
||||
import SpinIcon from "../icons/SpinIcon";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
import CreateSessionModal from "./CreateSessionModal";
|
||||
import SessionModal from "./SessionModal";
|
||||
import SessionCommentItem from "../SessionCommentItem";
|
||||
|
||||
function ClientModal({ client }: { client: Client }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const [clientData, setClientData] = useState(client);
|
||||
|
||||
const { mutate: updateClientData, isPending } = useMutation({
|
||||
mutationKey: ["clients", client.id],
|
||||
mutationFn: () =>
|
||||
api.put(`clients/${client.id}`, {
|
||||
json: {
|
||||
name: clientData.name,
|
||||
phone: clientData.phone.replace(/\D/g, ""),
|
||||
email: clientData.email,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
updateClientData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[49.722vw] bg-[#FFFFFF] rounded-[2.222vw] overflow-hidden">
|
||||
<div className="flex justify-center items-center py-[1.806vw] border-b border-[#D6D6D6]">
|
||||
<p className="title-s font-medium">{client.name}</p>
|
||||
</div>
|
||||
<div className="flex bg-[#F0F0F0] h-[calc(100vh-8.861vw)] rounded-b-[2.222vw] overflow-hidden">
|
||||
<div className="flex flex-col gap-[1.111vw] p-[1.111vw] flex-1 overflow-y-auto [scrollbar-width:thin]">
|
||||
<div className="flex flex-col gap-[1.111vw] rounded-[1.667vw] bg-white p-[1.111vw]">
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<p className="title-s font-medium">Данные клиента</p>
|
||||
<p className="caption-s text-[#BDBDBD] font-medium">
|
||||
Вы можете изменить данные клиента
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<Input
|
||||
placeholder="Имя"
|
||||
defaultValue={clientData.name || ""}
|
||||
onChange={(e) => {
|
||||
setClientData({ ...clientData, name: e.target.value });
|
||||
}}
|
||||
required
|
||||
>
|
||||
<span
|
||||
className="absolute z-10 top-[1.25vw] left-[17.917vw] size-[1.389vw] text-[#7D7D7D] cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(clientData.name);
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</span>
|
||||
</Input>
|
||||
<Input
|
||||
placeholder="Номер телефона"
|
||||
defaultValue={clientData.phone || ""}
|
||||
onChange={(e) => {
|
||||
setClientData({ ...clientData, phone: e.target.value });
|
||||
}}
|
||||
required
|
||||
mask="+7 (999) 999-99-99"
|
||||
>
|
||||
<span
|
||||
className="absolute top-[1.25vw] left-[17.917vw] size-[1.389vw] text-[#7D7D7D] cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(clientData.phone);
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</span>
|
||||
</Input>
|
||||
<Input
|
||||
placeholder="Эл. почта"
|
||||
defaultValue={clientData.email || ""}
|
||||
onChange={(e) => {
|
||||
setClientData({ ...clientData, email: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-[1.111vw]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
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">
|
||||
<SpinIcon />
|
||||
</span>
|
||||
) : (
|
||||
"Сохранить изменения"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[1.111vw] rounded-[1.667vw] bg-white p-[1.111vw]">
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<p className="title-s font-medium">Управление доступом</p>
|
||||
<p className="caption-s text-[#BDBDBD] font-medium">
|
||||
Выберите, кто может видеть и редактировать данные этого клиента
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-[0.556vw]">
|
||||
{client.managers.map((manager) => (
|
||||
<div
|
||||
key={manager.id}
|
||||
className="size-[2.222vw] rounded-full bg-[#F0F0F0] bg-[url(/images/mock_manager_photo_c.png)] bg-cover bg-no-repeat bg-center"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button className="button-m text-[#7B60F3] font-medium flex items-center gap-[0.278vw]">
|
||||
<span className="size-[0.972vw]">
|
||||
<PeopleIcon />
|
||||
</span>
|
||||
Настроить доступ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[1.111vw] rounded-[1.667vw] bg-white p-[1.111vw]">
|
||||
<div className="flex items-center gap-[0.556vw]">
|
||||
<p className="title-s font-medium">История сеансов</p>
|
||||
{client.sessions.length > 0 && (
|
||||
<Badge count={client.sessions.length} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.556vw]">
|
||||
{client.sessions.length === 0 && (
|
||||
<p className="caption-s text-[#BDBDBD] font-medium text-center">
|
||||
Пока не было сеансов
|
||||
</p>
|
||||
)}
|
||||
{client.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="p-[0.278vw] border-b border-[#F6F6F6] cursor-pointer"
|
||||
onClick={() => setModal(<SessionModal session={session} />)}
|
||||
>
|
||||
<div className="p-[0.833vw] flex justify-between items-center">
|
||||
<div className="flex gap-[0.556vw] items-center">
|
||||
<div className="size-[2.5vw] rounded-full bg-[#F0F0F0] bg-[url(/images/mock_manager_photo_c.png)] bg-cover bg-no-repeat bg-center" />
|
||||
<div className="flex flex-col gap-[0.278vw]">
|
||||
<p className="button-m font-medium">
|
||||
{session.manager.fullname}
|
||||
</p>
|
||||
<p className="caption-s text-[#BDBDBD] font-medium">
|
||||
{isToday(new Date(session.updatedAt))
|
||||
? "Сегодня"
|
||||
: format(
|
||||
new Date(session.updatedAt),
|
||||
"dd.MM.yyyy",
|
||||
{
|
||||
locale: ru,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="size-[1.389vw] text-[#7D7D7D]">
|
||||
<ChevronRightIcon />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="cta"
|
||||
size="large"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setModal(
|
||||
<CreateSessionModal targetServerId={null} client={client} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="size-[1.111vw]">
|
||||
<PlusIcon />
|
||||
</span>
|
||||
Новый сеанс с клиентом
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-[1.111vw] py-[1.667vw] [scrollbar-width:thin] flex flex-col-reverse">
|
||||
{client.sessions
|
||||
.filter((session) => session.comments.length)
|
||||
.map((session) => (
|
||||
<div key={session.id} className="space-y-[1.111vw]">
|
||||
<p className="text-center text-[#BDBDBD] caption-s font-medium">
|
||||
{isToday(new Date(session.createdAt))
|
||||
? "Сегодня"
|
||||
: format(new Date(session.createdAt), "dd MMMM", {
|
||||
locale: ru,
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-[0.833vw]">
|
||||
{session.comments.map((comment) => (
|
||||
<SessionCommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(client.sessions.length === 0 ||
|
||||
client.sessions.filter((session) => session.comments.length)
|
||||
.length === 0) && (
|
||||
<div className="flex flex-col items-center gap-[1.111vw] w-[18.333vw] m-auto">
|
||||
<div className="w-[13.889vw]">
|
||||
<img src="/images/empty_ghost.png" alt="" />
|
||||
</div>
|
||||
<div className="space-y-[0.556vw]">
|
||||
<p className="text-center title-m font-medium">
|
||||
Пока что пусто
|
||||
</p>
|
||||
<p className="caption-s text-[#BDBDBD] font-medium text-center">
|
||||
Здесь отображаются все комментарии по сеансам с текущим
|
||||
клиентом
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClientModal;
|
||||
@@ -11,19 +11,23 @@ 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;
|
||||
client?: Client | null;
|
||||
}
|
||||
|
||||
export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
export default function CreateSessionModal({ targetServerId, client }: Props) {
|
||||
const { setModal } = useModalStore();
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [phone, setPhone] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
// const [isSessionExists, setIsSessionExists] = useState(false);
|
||||
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 [isFullPhone, setIsFullPhone] = useState(false);
|
||||
const [isSessionExists, setIsSessionExists] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -33,6 +37,11 @@ export default function CreateSessionModal({ targetServerId }: 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;
|
||||
@@ -42,34 +51,20 @@ export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
);
|
||||
const [selectedApp, setSelectedApp] = useState<App | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useClientSearch(phone);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedApp(
|
||||
selectedServer?.sessions?.[0]?.app ||
|
||||
selectedServer?.apps?.[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]);
|
||||
|
||||
@@ -79,7 +74,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
.post("clients", {
|
||||
json: {
|
||||
name,
|
||||
phone,
|
||||
phone: phone?.replace(/\D/g, ""),
|
||||
email,
|
||||
},
|
||||
})
|
||||
@@ -113,70 +108,74 @@ export default function CreateSessionModal({ targetServerId }: 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);
|
||||
|
||||
@@ -201,13 +200,19 @@ export default function CreateSessionModal({ targetServerId }: 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 }}
|
||||
@@ -242,23 +247,31 @@ export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-[0.833vw]">
|
||||
<p className="title-s font-medium">Выберите параметры сеанса</p>
|
||||
|
||||
{selectedServer &&
|
||||
selectedServer?.apps &&
|
||||
selectedServer?.apps?.length > 0 && (
|
||||
<ProjectSelector
|
||||
activeProject={
|
||||
selectedServer?.sessions?.[0]?.status === "started"
|
||||
? selectedApp
|
||||
: null
|
||||
}
|
||||
projects={selectedServer?.apps.map(({ app }) => app)}
|
||||
selectedProject={selectedApp}
|
||||
setSelectedProject={setSelectedApp}
|
||||
/>
|
||||
selectedServer?.appsToServers &&
|
||||
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"
|
||||
@@ -271,8 +284,8 @@ export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
При запуске нового текущий будет завершен.`}
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
{/* )} */}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -287,7 +300,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
|
||||
variant="cta"
|
||||
size="large"
|
||||
>
|
||||
<div className="size-[1.111vw]">
|
||||
<div className="size-[1.111vw] text-[#9184F6]">
|
||||
<StartSessionIcon />
|
||||
</div>
|
||||
<span>Запустить сеанс</span>
|
||||
|
||||
@@ -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,31 +72,16 @@ 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>
|
||||
<div className="flex flex-col gap-[0.556vw]">
|
||||
<div className="flex gap-[0.556vw]">
|
||||
<p className="caption-s font-medium text-[#BDBDBD]">Менеджер:</p>
|
||||
<p className="caption-s font-medium">{session.owner.fullname}</p>
|
||||
<p className="caption-s font-medium">
|
||||
{session.manager.fullname}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-[0.556vw]">
|
||||
<p className="caption-s font-medium text-[#BDBDBD]">
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
import { Session } from "../../types/Session";
|
||||
import Button from "../Button";
|
||||
import CurrentSessionModal from "./CurrentSessionModal";
|
||||
import api from "../../utils/api";
|
||||
import SpinIcon from "../icons/SpinIcon";
|
||||
import SessionModal from "./SessionModal";
|
||||
|
||||
function EndSessionModal({ session }: { session: Session }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const { mutate: endSession, isPending } = useMutation({
|
||||
const { setModal, setPosition } = useModalStore();
|
||||
|
||||
const {
|
||||
mutate: endSession,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationKey: ["sessions", session.id],
|
||||
mutationFn: () =>
|
||||
api.put(`sessions/${session.id}`, { json: { status: "ending" } }),
|
||||
onMutate: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["last-started"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
},
|
||||
api
|
||||
.put(`sessions/${session.id}`, { json: { status: "ending" } })
|
||||
.json<Session>(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["last-sessions"] });
|
||||
setModal(null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,31 +50,46 @@ 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) {
|
||||
setPosition("right");
|
||||
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>
|
||||
|
||||
@@ -10,14 +10,18 @@ import ClientCard from "../ClientCard";
|
||||
import SessionComments from "../SessionComments";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "../../utils/api";
|
||||
import { useEffect } from "react";
|
||||
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 { data } = useQuery({
|
||||
queryKey: ["file-list"],
|
||||
const { setModal } = useModalStore();
|
||||
|
||||
const { data: files } = useQuery({
|
||||
queryKey: ["file-list", session.id],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get("files", {
|
||||
@@ -28,9 +32,11 @@ function SessionModal({ session }: { session: Session }) {
|
||||
.json<{ filename: string; size: number }[]>(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log(data);
|
||||
}, [data]);
|
||||
const { data: client } = useQuery({
|
||||
queryKey: ["clients", session.client.id],
|
||||
queryFn: () => api.get(`clients/${session.client.id}`).json<Client>(),
|
||||
enabled: !!session.client.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-[#FFFFFF] w-[49.722vw] rounded-[2.222vw]">
|
||||
@@ -43,9 +49,9 @@ function SessionModal({ session }: { session: Session }) {
|
||||
<div className="bg-[#F0F0F0] flex h-[calc(100vh-8.861vw)] overflow-hidden rounded-b-[2.222vw]">
|
||||
<div className="flex-1 flex flex-col gap-[0.833vw] px-[1.111vw] overflow-y-auto pr-[0.556vw] pb-[1.111vw] [scrollbar-width:thin]">
|
||||
<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="size-[3.333vw] rounded-full bg-white bg-[url(/images/mock_manager_photo_1_c.png)] bg-cover bg-no-repeat bg-center"></div>
|
||||
<div className="flex flex-col gap-[0.278vw] items-center">
|
||||
<p className="title-s font-medium">{session.owner.fullname}</p>
|
||||
<p className="title-s font-medium">{session.manager.fullname}</p>
|
||||
<p className="caption-s text-[#BDBDBD] font-medium">
|
||||
Продолжительность:{" "}
|
||||
{getIntervalDuration(session.createdAt, session.updatedAt)}
|
||||
@@ -66,7 +72,7 @@ function SessionModal({ session }: { session: Session }) {
|
||||
Интерактивный стол:
|
||||
</span>
|
||||
<span className="caption-s font-medium">
|
||||
{session.server.name}
|
||||
{session.server?.name}
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex gap-[0.556vw]">
|
||||
@@ -86,7 +92,7 @@ function SessionModal({ session }: { session: Session }) {
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<ClientCard client={session.client} />
|
||||
{client && <ClientCard client={client} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-[1.111vw] bg-white rounded-[1.667vw] p-[1.111vw]">
|
||||
<h3 className="title-s flex font-medium">
|
||||
@@ -111,29 +117,36 @@ 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 />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
{files && (
|
||||
<div className="flex flex-col gap-[1.111vw] bg-white rounded-[1.667vw] p-[1.111vw]">
|
||||
<h3 className="title-s flex items-center font-medium gap-[0.556vw]">
|
||||
<span>Документы по сеансу</span>
|
||||
<Badge count={data?.length} />
|
||||
<Badge count={files?.length} />
|
||||
</h3>
|
||||
<SessionFiles files={data} session={session} />
|
||||
<SessionFiles files={files} session={session} />
|
||||
<div className="flex w-full gap-[0.556vw]">
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<span className="size-[1.111vw] text-[#7B60F3]">
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,48 @@
|
||||
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: { phone: debouncedPhone?.replace(/\D/g, "") || "" },
|
||||
})
|
||||
.json<Client>(),
|
||||
enabled: Boolean(
|
||||
debouncedPhone &&
|
||||
debouncedPhone.replace(/\D/g, "").length === 11 &&
|
||||
isPhoneComplete
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isSearching) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [isLoading, isSearching]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: isSearching || isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export default useClientSearch;
|
||||
+33
-25
@@ -1,39 +1,42 @@
|
||||
import api from "../utils/api";
|
||||
import Button from "../components/Button";
|
||||
import CloseIcon from "../components/icons/CloseIcon";
|
||||
import SpinIcon from "../components/icons/SpinIcon";
|
||||
import MultySelect from "../components/MultySelect";
|
||||
import SearchInput from "../components/SearchInput";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User } from "../types/User";
|
||||
import { Manager } from "../types/Manager";
|
||||
import { Client } from "../types/Client";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import pluralize from "../utils/pluralize";
|
||||
import ChevronRightIcon from "../components/icons/ChevronRightIcon";
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import ClientModal from "../components/modals/ClientModal";
|
||||
import clsx from "clsx";
|
||||
|
||||
function ClientsPage() {
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [limit, setLimit] = useState(5);
|
||||
const [search, setSearch] = useState<string | null>(null);
|
||||
const { setModal, setPosition } = useModalStore();
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get("auth/me").json<User>(),
|
||||
queryFn: () => api.get("auth/me").json<Manager>(),
|
||||
});
|
||||
|
||||
const { data: clients, isLoading } = useQuery({
|
||||
queryKey: ["clients", debouncedSearch],
|
||||
queryKey: ["clients", debouncedSearch, limit],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get("clients", {
|
||||
searchParams: debouncedSearch
|
||||
? { search: debouncedSearch, limit }
|
||||
: {},
|
||||
: { limit },
|
||||
})
|
||||
.json<Client[]>(),
|
||||
enabled: !!me,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const { data: count } = useQuery({
|
||||
@@ -44,14 +47,9 @@ function ClientsPage() {
|
||||
searchParams: debouncedSearch ? { search: debouncedSearch } : {},
|
||||
})
|
||||
.json<number>(),
|
||||
|
||||
enabled: !!me,
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[1.667vw] h-full">
|
||||
<h1 className="title-l font-medium">Клиенты</h1>
|
||||
@@ -64,7 +62,7 @@ function ClientsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
<div className="flex gap-[0.556vw]">
|
||||
{/* <div className="flex gap-[0.556vw]">
|
||||
<MultySelect
|
||||
data={[]}
|
||||
isGrid={false}
|
||||
@@ -79,20 +77,20 @@ function ClientsPage() {
|
||||
resetTitle={"Все сценарии"}
|
||||
onSelect={() => console.log(1)}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="caption-m font-medium opacity-40">
|
||||
Найдено {count ? pluralize(count, "клиент") : "0 клиентов"}
|
||||
</p>
|
||||
<button className="flex gap-[0.278vw] items-center" onClick={reset}>
|
||||
{/* <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>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,17 +100,27 @@ function ClientsPage() {
|
||||
<SpinIcon />
|
||||
</div>
|
||||
) : clients?.length ? (
|
||||
clients?.map(({ name, email, phone }) => (
|
||||
<div className="p-[0.278vw] border-b border-[#F6F6F6]">
|
||||
clients?.map((client, index) => (
|
||||
<div
|
||||
key={client.id}
|
||||
className={clsx(
|
||||
"p-[0.278vw]",
|
||||
clients.length - 1 !== index && "border-b border-[#F6F6F6]"
|
||||
)}
|
||||
onClick={() => {
|
||||
setPosition("right");
|
||||
setModal(<ClientModal client={client} />);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={name}
|
||||
className="flex justify-between items-center cursor-pointer aspace-y-[0.833vw] p-[1.111vw] hover:bg-[#F6F6F6] rounded-[0.833vw]"
|
||||
key={client.id}
|
||||
className="flex justify-between items-center cursor-pointer p-[1.111vw] hover:bg-[#F6F6F6] rounded-[0.833vw]"
|
||||
>
|
||||
<div className="flex flex-col gap-[0.556vw]">
|
||||
<p className="button-m font-medium">{name}</p>
|
||||
<p className="button-m font-medium">{client.name}</p>
|
||||
<div className="flex gap-[0.278vw] caption-s font-medium text-[#7D7D7D]">
|
||||
<p>{phone}</p>
|
||||
<p>{email ? "• " + email : ""}</p>
|
||||
<p>{client.phone}</p>
|
||||
<p>{client.email ? "• " + client.email : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-[1.389vw] text-[#7D7D7D]">
|
||||
@@ -138,8 +146,8 @@ function ClientsPage() {
|
||||
size="large"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setLimit((prev) => prev + 10)}
|
||||
disabled={!!clients?.length && limit >= clients.length}
|
||||
onClick={() => setLimit((prev) => prev + 5)}
|
||||
disabled={!!clients?.length && limit > clients.length}
|
||||
>
|
||||
Показать еще
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User } from "../types/User";
|
||||
import { Manager } from "../types/Manager";
|
||||
import api from "../utils/api";
|
||||
import { Server } from "../types/Server";
|
||||
import DesktopCard from "../components/DesktopCard";
|
||||
@@ -13,7 +13,7 @@ import { useNavigate } from "react-router";
|
||||
function DashboardPage() {
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get("auth/me").json<User>(),
|
||||
queryFn: () => api.get("auth/me").json<Manager>(),
|
||||
});
|
||||
|
||||
const { data: servers } = useQuery({
|
||||
@@ -30,19 +30,6 @@ function DashboardPage() {
|
||||
enabled: !!me,
|
||||
});
|
||||
|
||||
// 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);
|
||||
// }
|
||||
// }
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
||||
@@ -71,7 +71,7 @@ function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button type="button" variant="secondary">
|
||||
<Button type="submit" variant="secondary">
|
||||
Забыли пароль?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Navigate, Outlet } from "react-router";
|
||||
import useAuthStore from "../stores/useAuthStore";
|
||||
import api from "../utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User } from "../types/User";
|
||||
import { Manager } from "../types/Manager";
|
||||
|
||||
function ProtectedPage() {
|
||||
const { token } = useAuthStore();
|
||||
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get("auth/me").json<User>(),
|
||||
queryFn: () => api.get("auth/me").json<Manager>(),
|
||||
enabled: !!token,
|
||||
});
|
||||
|
||||
|
||||
+19
-13
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "../utils/api";
|
||||
import { User } from "../types/User";
|
||||
import { Manager } from "../types/Manager";
|
||||
import { Session } from "../types/Session";
|
||||
import { useState } from "react";
|
||||
import { App } from "../types/App";
|
||||
@@ -28,12 +28,12 @@ function SessionsPage() {
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get("auth/me").json<User>(),
|
||||
queryFn: () => api.get("auth/me").json<Manager>(),
|
||||
});
|
||||
|
||||
const { data: managers } = useQuery({
|
||||
queryKey: ["managers"],
|
||||
queryFn: () => api.get("users").json<User[]>(),
|
||||
queryFn: () => api.get("users").json<Manager[]>(),
|
||||
enabled: !!me,
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ function SessionsPage() {
|
||||
.json<Session[]>(),
|
||||
enabled: !!me,
|
||||
select: groupByCreatedAt,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
const { data: count } = useQuery({
|
||||
@@ -83,7 +84,7 @@ function SessionsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className=" flex flex-col gap-[1.667vw]">
|
||||
<div className="flex flex-col gap-[1.667vw]">
|
||||
<h1 className="title-l font-medium">Сеансы</h1>
|
||||
<div className="p-[1.389vw] rounded-[2.222vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.05),0_2px_2px_0_rgba(15,16,17,0.05)] w-full">
|
||||
<div className="space-y-[1.111vw]">
|
||||
@@ -122,14 +123,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 +147,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 })}
|
||||
|
||||
@@ -14,10 +14,7 @@ const useModalStore = create<State>()(
|
||||
(set) => ({
|
||||
modal: null,
|
||||
setModal: (modal) => {
|
||||
if (!modal) {
|
||||
set({ position: "center" });
|
||||
}
|
||||
|
||||
if (!modal) set({ position: "center" });
|
||||
set({ modal });
|
||||
},
|
||||
position: "center",
|
||||
|
||||
+5
-1
@@ -1,3 +1,6 @@
|
||||
import { Manager } from "./Manager";
|
||||
import { Session } from "./Session";
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -5,6 +8,7 @@ export interface Client {
|
||||
phone: string;
|
||||
companyId: string;
|
||||
createdAt: string;
|
||||
ownerId: string;
|
||||
updatedAt: string;
|
||||
sessions: Session[];
|
||||
managers: Manager[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Manager } from "./Manager";
|
||||
import { Session } from "./Session";
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: string;
|
||||
managerId: string;
|
||||
sessionId: string;
|
||||
owner: {
|
||||
ownerId: string;
|
||||
fullname: string;
|
||||
};
|
||||
manager: Manager;
|
||||
session: Session;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { App } from "./App";
|
||||
import { Server } from "./Server";
|
||||
import { User } from "./User";
|
||||
import { Manager } from "./Manager";
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
apps?: App[];
|
||||
servers?: Server[];
|
||||
users?: User[];
|
||||
managers?: Manager[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Company } from "./Company";
|
||||
|
||||
export interface User {
|
||||
export interface Manager {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface IOwner {
|
||||
fullname: string;
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ export interface Server {
|
||||
description: string;
|
||||
companyId: string;
|
||||
sessions?: Session[];
|
||||
apps?: { app: App }[];
|
||||
appsToServers?: { app: App }[];
|
||||
status: "online" | "offline";
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
+19
-3
@@ -1,12 +1,27 @@
|
||||
import { App as App } from "./App";
|
||||
import { Comment } from "./Comment";
|
||||
import { IOwner as Owner } from "./Owner";
|
||||
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;
|
||||
ownerId: string;
|
||||
managerId: string;
|
||||
serverId: string;
|
||||
clientId: string;
|
||||
companyId: string;
|
||||
@@ -15,7 +30,8 @@ export interface Session {
|
||||
server: Server;
|
||||
client: Client;
|
||||
app: App;
|
||||
owner: Owner;
|
||||
manager: Manager;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
summary?: Summary;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user