This commit is contained in:
2025-06-18 18:36:23 +05:00
16 changed files with 142 additions and 85 deletions
+10 -2
View File
@@ -1,19 +1,22 @@
import clsx from "clsx"; import clsx from "clsx";
import SpinIcon from "./icons/SpinIcon";
interface NewInputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface NewInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
placeholder?: string; placeholder?: string;
isError?: boolean; isError?: boolean;
errorMessage?: string; errorMessage?: string;
isLoading?: boolean;
} }
function Input({ function Input({
placeholder, placeholder,
isError, isError,
errorMessage, errorMessage,
isLoading,
...props ...props
}: NewInputProps) { }: NewInputProps) {
return ( return (
<div className="relative"> <div className={clsx("relative", props.disabled && "opacity-40")}>
<input <input
{...props} {...props}
placeholder="" placeholder=""
@@ -30,7 +33,7 @@ function Input({
peer-focus:caption-xs peer-focus:top-[0.556vw] peer-focus:translate-y-0 peer-focus:caption-xs peer-focus:top-[0.556vw] peer-focus:translate-y-0
peer-[:not(:placeholder-shown)]:caption-xs peer-[:not(:placeholder-shown)]:top-[0.556vw] peer-[:not(:placeholder-shown)]:translate-y-0" peer-[:not(:placeholder-shown)]:caption-xs peer-[:not(:placeholder-shown)]:top-[0.556vw] peer-[:not(:placeholder-shown)]:translate-y-0"
> >
{placeholder} {placeholder + (props.required ? "*" : "")}
</span> </span>
)} )}
{isError && ( {isError && (
@@ -38,6 +41,11 @@ function Input({
{errorMessage} {errorMessage}
</p> </p>
)} )}
{isLoading && (
<div className="size-[1.389vw] text-[#7B60F3] animate-spin z-1 absolute right-[1.111vw] top-1/2 -translate-y-1/2">
<SpinIcon />
</div>
)}
</div> </div>
); );
} }
+3 -5
View File
@@ -45,9 +45,9 @@ function MultySelect<T extends { name: string; id: string }>({
const isItemSelected = selectedValues.some((val) => val.id === item.id); const isItemSelected = selectedValues.some((val) => val.id === item.id);
if (isItemSelected) { if (isItemSelected) {
setSelectedValues(selectedValues.filter((value) => value.id !== item.id)); setSelectedValues((prev) => prev.filter((value) => value.id !== item.id));
} else { } else {
setSelectedValues([...selectedValues, item]); setSelectedValues((prev) => [...prev, item]);
} }
}; };
@@ -59,9 +59,7 @@ function MultySelect<T extends { name: string; id: string }>({
<div <div
className={clsx( className={clsx(
"flex items-center justify-between px-[1.111vw] py-[1.285vw] hover:bg-[#F0F0F0] rounded-[0.833vw] cursor-pointer", "flex items-center justify-between px-[1.111vw] py-[1.285vw] hover:bg-[#F0F0F0] rounded-[0.833vw] cursor-pointer",
isSelectVisible isSelectVisible && "!bg-[#E1DEFC] !text-[#7B60F3] hover:bg-[#E1DEFC]"
? "!bg-[#E1DEFC] !text-[#7B60F3] hover:bg-[#E1DEFC]"
: "text-[#141414]"
)} )}
onClick={() => setIsSelectVisible(!isSelectVisible)} onClick={() => setIsSelectVisible(!isSelectVisible)}
> >
+6 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { IApp } from "../types/App"; import { App } from "../types/App";
import ChevronLeftIcon from "./icons/ChevronLeftIcon"; import ChevronLeftIcon from "./icons/ChevronLeftIcon";
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import LightningIcon from "./icons/LightningIcon"; import LightningIcon from "./icons/LightningIcon";
@@ -8,10 +8,10 @@ import CheckIcon from "./icons/CheckIcon";
import ChevronDownIcon from "./icons/ChevronDownIcon"; import ChevronDownIcon from "./icons/ChevronDownIcon";
interface Props { interface Props {
projects: IApp[]; projects: App[];
selectedProject: IApp | null; selectedProject: App | null;
setSelectedProject: (project: IApp | null) => void; setSelectedProject: (project: App | null) => void;
activeProject: IApp | null; activeProject: App | null;
} }
function ProjectSelector({ function ProjectSelector({
@@ -26,7 +26,7 @@ function ProjectSelector({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [pointedProject, setPointedProject] = useState<IApp | null>(null); const [pointedProject, setPointedProject] = useState<App | null>(null);
useEffect(() => { useEffect(() => {
setPointedProject(selectedProject); setPointedProject(selectedProject);
-2
View File
@@ -15,8 +15,6 @@ function SpinIcon() {
> >
<div <div
style={{ style={{
background:
"conic-gradient(from 90deg,rgba(255,255,255,.0195) 0deg,#fff 314.826deg,rgba(255,255,255,0) 353.741deg,rgba(255,255,255,.0195) 360deg)",
height: "100%", height: "100%",
width: "100%", width: "100%",
opacity: 1, opacity: 1,
+67 -22
View File
@@ -1,6 +1,6 @@
import { Server } from "../../types/Server.ts"; import { Server } from "../../types/Server.ts";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { IApp } from "../../types/App.ts"; import { App } from "../../types/App.ts";
import api from "../../utils/api.ts"; import api from "../../utils/api.ts";
import { Session } from "../../types/Session.ts"; import { Session } from "../../types/Session.ts";
import { Client } from "../../types/Client.ts"; import { Client } from "../../types/Client.ts";
@@ -11,6 +11,8 @@ import StartSessionIcon from "../icons/StartSessionIcon.tsx";
import Button from "../Button.tsx"; import Button from "../Button.tsx";
import ProjectSelector from "../ProjectSelector.tsx"; import ProjectSelector from "../ProjectSelector.tsx";
import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query"; import { useQueryClient, useMutation, useQuery } from "@tanstack/react-query";
import { useDebounce } from "@uidotdev/usehooks";
import { AnimatePresence, motion } from "motion/react";
interface Props { interface Props {
targetServerId: string | null; targetServerId: string | null;
@@ -38,7 +40,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
const [selectedServer, setSelectedServer] = useState<Server | null>( const [selectedServer, setSelectedServer] = useState<Server | null>(
targetServer targetServer
); );
const [selectedApp, setSelectedApp] = useState<IApp | null>(null); const [selectedApp, setSelectedApp] = useState<App | null>(null);
useEffect(() => { useEffect(() => {
setSelectedApp( setSelectedApp(
@@ -48,9 +50,32 @@ export default function CreateSessionModal({ targetServerId }: Props) {
); );
}, [selectedServer]); }, [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]);
const { mutate: createClient } = useMutation({ const { mutate: createClient } = useMutation({
mutationFn: () => { mutationFn: () =>
return api api
.post("clients", { .post("clients", {
json: { json: {
name, name,
@@ -58,8 +83,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
email, email,
}, },
}) })
.json<Client>(); .json<Client>(),
},
}); });
const { mutate: createSession } = useMutation({ const { mutate: createSession } = useMutation({
@@ -158,7 +182,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
return ( return (
<form <form
className="relative rounded-[2.222vw] w-[25vw] min-h-[calc(100dvh-2.222vw)] bg-[#F0F0F0] flex flex-col overflow-hidden" className="relative rounded-[2.222vw] w-[25vw] bg-[#F0F0F0] flex flex-col overflow-hidden"
onSubmit={handleClickCreateSession} onSubmit={handleClickCreateSession}
ref={ref} ref={ref}
> >
@@ -166,7 +190,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
<p className="title-s font-medium">Новый сеанс</p> <p className="title-s font-medium">Новый сеанс</p>
</div> </div>
<div className="w-full h-[6.944vw] bg-[url(/images/Table.png)] bg-no-repeat bg-top bg-[length:9.306vw]" /> <div className="w-full h-[6.944vw] bg-[url(/images/Table.png)] bg-no-repeat bg-top bg-[length:9.306vw]" />
<div className="bg-white rounded-t-[2.222vw] p-[1.389vw] flex flex-col gap-y-[1.667vw] flex-1 overflow-y-auto"> <div className="bg-white rounded-t-[2.222vw] p-[1.389vw] flex flex-col gap-y-[1.667vw] flex-1moverflow-y-auto">
<TableSelector <TableSelector
tables={servers || []} tables={servers || []}
selectedTable={selectedServer} selectedTable={selectedServer}
@@ -180,19 +204,40 @@ export default function CreateSessionModal({ targetServerId }: Props) {
onChange={(e) => setPhone(e.target.value)} onChange={(e) => setPhone(e.target.value)}
placeholder="Номер телефона" placeholder="Номер телефона"
required required
isLoading={isLoading}
/> />
<Input <AnimatePresence>
value={name || ""} {phone && (
onChange={(e) => setName(e.target.value)} <>
placeholder="Имя" <motion.div
required initial={{ opacity: 0 }}
/> animate={{ opacity: 1 }}
<Input exit={{ opacity: 0 }}
type="email" >
value={email || ""} <Input
onChange={(e) => setEmail(e.target.value)} value={name || ""}
placeholder="Электронная почта" disabled={isLoading}
/> onChange={(e) => setName(e.target.value)}
placeholder="Имя"
required
/>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Input
disabled={isLoading}
type="email"
value={email || ""}
onChange={(e) => setEmail(e.target.value)}
placeholder="Электронная почта"
/>
</motion.div>
</>
)}
</AnimatePresence>
</div> </div>
</div> </div>
<div className="flex flex-col gap-y-[0.833vw]"> <div className="flex flex-col gap-y-[0.833vw]">
@@ -232,7 +277,8 @@ export default function CreateSessionModal({ targetServerId }: Props) {
<Button <Button
type="submit" type="submit"
disabled={ disabled={
!ref.current?.checkValidity() || !phone ||
!name ||
!selectedServer || !selectedServer ||
!selectedApp || !selectedApp ||
servers?.find((server) => server.id === selectedServer?.id) servers?.find((server) => server.id === selectedServer?.id)
@@ -240,7 +286,6 @@ export default function CreateSessionModal({ targetServerId }: Props) {
} }
variant="cta" variant="cta"
size="large" size="large"
className="sticky bottom-0"
> >
<div className="size-[1.111vw]"> <div className="size-[1.111vw]">
<StartSessionIcon /> <StartSessionIcon />
+2 -2
View File
@@ -7,12 +7,12 @@ body {
color: #141414; color: #141414;
} }
button { button:enabled {
cursor: pointer; cursor: pointer;
} }
button:disabled { button:disabled {
color: default; cursor: default;
} }
@layer utilities { @layer utilities {
+31 -24
View File
@@ -1,3 +1,4 @@
import api from "../utils/api";
import Button from "../components/Button"; import Button from "../components/Button";
import CloseIcon from "../components/icons/CloseIcon"; import CloseIcon from "../components/icons/CloseIcon";
import SpinIcon from "../components/icons/SpinIcon"; import SpinIcon from "../components/icons/SpinIcon";
@@ -5,25 +6,44 @@ import MultySelect from "../components/MultySelect";
import SearchInput from "../components/SearchInput"; import SearchInput from "../components/SearchInput";
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/User"; import { User } from "../types/User";
import api from "../utils/api";
import { Client } from "../types/Client"; import { Client } from "../types/Client";
import { useDebounce } from "@uidotdev/usehooks";
import pluralize from "../utils/pluralize"; import pluralize from "../utils/pluralize";
function ClientsPage() { function ClientsPage() {
const [limit, setLimit] = useState(10); const [limit, setLimit] = useState(10);
const [search, setSearch] = useState<string | null>(null); const [search, setSearch] = useState<string | null>(null);
// const debouncedSearch = useDebounce(search, 500); const debouncedSearch = useDebounce(search, 500);
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(), queryFn: () => api.get("auth/me").json<User>(),
}); });
const { data: clients, isLoading } = useQuery({ const { data: clients, isLoading } = useQuery({
queryKey: ["clients"], queryKey: ["clients", debouncedSearch],
queryFn: () => api.get("clients").json<Client[]>(), queryFn: () =>
api
.get("clients", {
searchParams: debouncedSearch
? { search: debouncedSearch, limit }
: {},
})
.json<Client[]>(),
enabled: !!me,
});
const { data: count } = useQuery({
queryKey: ["clients", "count", debouncedSearch],
queryFn: () =>
api
.get(`clients/count`, {
searchParams: debouncedSearch ? { search: debouncedSearch } : {},
})
.json<number>(),
enabled: !!me, enabled: !!me,
}); });
@@ -32,11 +52,11 @@ function ClientsPage() {
} }
return ( return (
<div className=" flex flex-col gap-[1.667vw]"> <div className="flex flex-col gap-[1.667vw] h-full">
<h1 className="title-l font-medium">Клиенты</h1> <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="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]"> <div className="space-y-[1.111vw]">
<div className="flex flex-col gap-[0.556vw]"> <div className="flex flex-col gap-[0.556vw] h-full">
<SearchInput <SearchInput
placeholder="Поиск клиентов" placeholder="Поиск клиентов"
value={search || ""} value={search || ""}
@@ -45,17 +65,7 @@ function ClientsPage() {
/> />
<div className="flex gap-[0.556vw]"> <div className="flex gap-[0.556vw]">
<MultySelect <MultySelect
data={[ data={[]}
{ name: "С бронью", id: "1" },
{
name: "С избранными лотами",
id: "2",
},
{
name: "Без отправленных КП",
id: "3",
},
]}
isGrid={false} isGrid={false}
placeholder={"Все встречи"} placeholder={"Все встречи"}
resetTitle={"Все встречи"} resetTitle={"Все встречи"}
@@ -72,10 +82,7 @@ function ClientsPage() {
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<p className="caption-m font-medium opacity-40"> <p className="caption-m font-medium opacity-40">
Найдено{" "} Найдено {count ? pluralize(count, "клиент") : "0 клиентов"}
{clients?.length
? pluralize(clients?.length, "клиент")
: "0 клиентов"}
</p> </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]"> <div className="size-[1.111vw] text-[#7D7D7D]">
@@ -95,7 +102,7 @@ function ClientsPage() {
</div> </div>
) : clients?.length ? ( ) : clients?.length ? (
clients?.map(({ name }) => ( clients?.map(({ name }) => (
<div key={name} className="space-y-[0.833vw]"> <div key={name} className="aspace-y-[0.833vw]">
<p className="caption-m font-medium opacity-40">{name}</p> <p className="caption-m font-medium opacity-40">{name}</p>
</div> </div>
)) ))
+2 -2
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/User"; import { User } from "../types/User";
import api from "../utils/api"; import api from "../utils/api";
import { Server } from "../types/Server"; import { Server } from "../types/Server";
import DesktopCard from "../components/DesktopCard"; import DesktopCard from "../components/DesktopCard";
@@ -13,7 +13,7 @@ import { useNavigate } from "react-router";
function DashboardPage() { function DashboardPage() {
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(), queryFn: () => api.get("auth/me").json<User>(),
}); });
const { data: servers } = useQuery({ const { data: servers } = useQuery({
+2 -2
View File
@@ -2,14 +2,14 @@ import { Navigate, Outlet } from "react-router";
import useAuthStore from "../stores/useAuthStore"; import useAuthStore from "../stores/useAuthStore";
import api from "../utils/api"; import api from "../utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/User"; import { User } from "../types/User";
function ProtectedPage() { function ProtectedPage() {
const { token } = useAuthStore(); const { token } = useAuthStore();
const { data: user, isLoading } = useQuery({ const { data: user, isLoading } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(), queryFn: () => api.get("auth/me").json<User>(),
enabled: !!token, enabled: !!token,
}); });
+6 -5
View File
@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import api from "../utils/api"; import api from "../utils/api";
import { IUser } from "../types/User"; import { User } from "../types/User";
import { Session } from "../types/Session"; import { Session } from "../types/Session";
import { useState } from "react"; import { useState } from "react";
import { IApp } from "../types/App"; import { App } from "../types/App";
import { useDebounce } from "@uidotdev/usehooks"; import { useDebounce } from "@uidotdev/usehooks";
import SessionCard from "../components/SessionCard"; import SessionCard from "../components/SessionCard";
import { groupByCreatedAt } from "../utils/groupByCreatedAt"; import { groupByCreatedAt } from "../utils/groupByCreatedAt";
@@ -19,6 +19,7 @@ import pluralize from "../utils/pluralize";
function SessionsPage() { function SessionsPage() {
const [limit, setLimit] = useState(10); const [limit, setLimit] = useState(10);
const [search, setSearch] = useState<string | null>(null); const [search, setSearch] = useState<string | null>(null);
const [managerIds, setManagerIds] = useState<string[]>([]); const [managerIds, setManagerIds] = useState<string[]>([]);
const [appIds, setAppIds] = useState<string[]>([]); const [appIds, setAppIds] = useState<string[]>([]);
const [shouldReset, setShouldReset] = useState(false); const [shouldReset, setShouldReset] = useState(false);
@@ -27,18 +28,18 @@ function SessionsPage() {
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(), queryFn: () => api.get("auth/me").json<User>(),
}); });
const { data: managers } = useQuery({ const { data: managers } = useQuery({
queryKey: ["managers"], queryKey: ["managers"],
queryFn: () => api.get("users").json<IUser[]>(), queryFn: () => api.get("users").json<User[]>(),
enabled: !!me, enabled: !!me,
}); });
const { data: apps } = useQuery({ const { data: apps } = useQuery({
queryKey: ["apps"], queryKey: ["apps"],
queryFn: () => api.get("apps").json<IApp[]>(), queryFn: () => api.get("apps").json<App[]>(),
enabled: !!me, enabled: !!me,
}); });
+2 -2
View File
@@ -1,10 +1,10 @@
import { IApp } from "../types/App.ts"; import { App } from "../types/App.ts";
import api from "../utils/api.ts"; import api from "../utils/api.ts";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
export default function useQueryApps() { export default function useQueryApps() {
return useQuery({ return useQuery({
queryKey: ["apps"], queryKey: ["apps"],
queryFn: () => api.get("apps").json<IApp[]>(), queryFn: () => api.get("apps").json<App[]>(),
}); });
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export interface IApp { export interface App {
id: string; id: string;
name: string; name: string;
fileName: string; fileName: string;
+5 -5
View File
@@ -1,11 +1,11 @@
import { IApp } from "./App"; import { App } from "./App";
import { Server } from "./Server"; import { Server } from "./Server";
import { IUser } from "./User"; import { User } from "./User";
export interface ICompany { export interface Company {
id: string; id: string;
name: string; name: string;
apps?: IApp[]; apps?: App[];
servers?: Server[]; servers?: Server[];
users?: IUser[]; users?: User[];
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { IApp as App } from "./App"; import { App as App } from "./App";
import { Session } from "./Session"; import { Session } from "./Session";
export interface Server { export interface Server {
+1 -1
View File
@@ -1,4 +1,4 @@
import { IApp as App } from "./App"; import { App as App } from "./App";
import { Comment } from "./Comment"; import { Comment } from "./Comment";
import { IOwner as Owner } from "./Owner"; import { IOwner as Owner } from "./Owner";
import { Server } from "./Server"; import { Server } from "./Server";
+3 -3
View File
@@ -1,9 +1,9 @@
import { ICompany } from "./Company"; import { Company } from "./Company";
export interface IUser { export interface User {
id: string; id: string;
email: string; email: string;
fullname: string; fullname: string;
companyId: string; companyId: string;
company?: ICompany; company?: Company;
} }