This commit is contained in:
2025-06-10 15:56:21 +05:00
27 changed files with 204 additions and 66 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ function ClientCard({ client }: { client: IUser }) {
<NewButton variant="secondary" className="w-full">
<div className="flex flex-col gap-[0.278vw] w-full text-left h-[2.222vw]">
<p className="caption-s font-medium text-[#BDBDBD]">Клиент</p>
<p className="text-s font-medium">{client.name}</p>
<p className="text-s font-medium">{client.fullname}</p>
</div>
<div className="flex gap-[0.556vw] items-center">
{!client.email && (
+2 -2
View File
@@ -1,5 +1,5 @@
import FlashIcon from "./icons/FlashIcon";
import { ISession } from "../types/ISession";
import { Session } from "../types/ISession";
import NewButton from "./NewButton";
import ChevronRightIcon from "./icons/ChevronRightIcon";
import { motion } from "motion/react";
@@ -11,7 +11,7 @@ function CurrentSessionCard({
session,
index,
}: {
session: ISession;
session: Session;
index: number;
}) {
const { setModal } = useModalStore();
+2 -2
View File
@@ -1,4 +1,4 @@
import { IServer } from "../types/IServer";
import { Server } from "../types/IServer";
import useModalStore from "../stores/useModalStore";
import CreateSessionModal from "./modals/CreateSessionModal";
import NewButton from "./NewButton";
@@ -13,7 +13,7 @@ import CurrentSessionModal from "./modals/CurrentSessionModal";
import SpinIcon from "./icons/SpinIcon";
interface IDesktopCardProps {
server: IServer;
server: Server;
}
export default function DesktopCard({ server }: IDesktopCardProps) {
+4 -4
View File
@@ -1,13 +1,13 @@
import { useState } from "react";
import { IServer } from "../types/IServer";
import { Server } from "../types/IServer";
import Button from "./Button";
import { useClickAway } from "@uidotdev/usehooks";
import ArrowDownIcon from "./icons/ArrowDownIcon";
interface Props {
servers: IServer[] | undefined;
value: IServer | undefined;
onChange: (server: IServer) => void;
servers: Server[] | undefined;
value: Server | undefined;
onChange: (server: Server) => void;
}
export default function DesktopSelect({ servers, value, onChange }: Props) {
+2 -2
View File
@@ -3,7 +3,7 @@ import Navbar from "./Navbar";
import { useQuery } from "@tanstack/react-query";
import api from "../utils/api";
import CurrentSessionCard from "./CurrentSessionCard";
import { ISession } from "../types/ISession";
import { Session } from "../types/ISession";
import { AnimatePresence } from "motion/react";
import NewButton from "./NewButton";
import PlusIcon from "./icons/PlusIcon";
@@ -18,7 +18,7 @@ function Layout() {
.get("sessions", {
searchParams: { limit: 3, status: "started" },
})
.json<ISession[]>(),
.json<Session[]>(),
refetchInterval: 1000,
});
+1 -1
View File
@@ -21,7 +21,7 @@ function Navbar() {
<span className="2xl:text-[0.972vw] text-sm font-medium">Главная</span>
</NavLink>
<NavLink
to="/"
to="sessions"
className={({ isActive }) =>
clsx(
"2xl:p-[1.493vw] 2xl:py-[1.111vw] px-[21.5px] py-4 transition-colors flex 2xl:gap-[0.556vw] gap-2 items-center text-[#7D7D7D] hover:text-[#7B60F3]",
+1 -1
View File
@@ -51,7 +51,7 @@ export default function Select({ options, onChange }: Props) {
</div>
</NewButton>
{isOpen && (
<div className="absolute top-full w-full bg-white rounded-[0.556vw] outline outline-black/10">
<div className="absolute top-full w-full bg-white rounded-[0.556vw] outline outline-black/10 z-1">
{options.map((option) => (
<NewButton
key={option}
+4 -4
View File
@@ -1,18 +1,18 @@
import useModalStore from "../stores/useModalStore";
import { ISession } from "../types/ISession";
import { Session } from "../types/ISession";
import SessionModal from "./modals/SessionModal";
function SessionCard({ session }: { session: ISession }) {
function SessionCard({ session }: { session: Session }) {
const { setModal, setPosition } = useModalStore();
return (
<div
className="w-full h-[4.444vw] border-1 border-l-0 border-r-0 border-t-0 border-b-[#F6F6F6] flex py-[0.278vw] items-center gap-[0.556vw] cursor-pointer group"
className="w-full h-[4.444vw] border-b-1 first:border-t-1 border-[#F6F6F6] flex py-[0.278vw] items-center gap-[0.556vw] cursor-pointer group"
onClick={() => {
setModal(<SessionModal session={session} />);
setPosition("right");
}}
>
<div className="rounded-xl w-full h-full flex items-center gap-[0.556vw] group-hover:bg-[#F6F6F6] transition-colors duration-200">
<div className="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="flex flex-col w-full gap-[0.278vw]">
<p className="button-m font-medium">{session.owner.fullname}</p>
+2 -2
View File
@@ -1,8 +1,8 @@
import { motion } from "motion/react";
import { IComment } from "../types/IComments";
import { Comment } from "../types/IComments";
import { format } from "date-fns";
function SessionCommentItem({ comment }: { comment: IComment }) {
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-xl w-full rounded-br-none">
+2 -2
View File
@@ -3,7 +3,7 @@ import SendIcon from "./icons/SendIcon";
import NewButton from "./NewButton";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import api from "../utils/api";
import { IComment } from "../types/IComments";
import { Comment } from "../types/IComments";
import SessionCommentItem from "./SessionCommentItem";
import { AnimatePresence } from "motion/react";
@@ -51,7 +51,7 @@ function SessionComments({ sessionId }: { sessionId: string }) {
const { data: comments } = useQuery({
queryKey: ["sessions", "comments", sessionId],
queryFn: () => api.get(`comments/${sessionId}`).json<IComment[]>(),
queryFn: () => api.get(`comments/${sessionId}`).json<Comment[]>(),
});
const { mutate: createComment } = useMutation({
+4 -4
View File
@@ -1,11 +1,11 @@
import clsx from "clsx";
import { IServer } from "../types/IServer";
import { Server } from "../types/IServer";
import LightningIcon from "./icons/LightningIcon";
interface TableSelectorProps {
tables: IServer[];
selectedTable: IServer | null;
onSelect: (table: IServer) => void;
tables: Server[];
selectedTable: Server | null;
onSelect: (table: Server) => void;
}
function TableSelector({
+7 -7
View File
@@ -1,9 +1,9 @@
import { IServer } from "../../types/IServer.ts";
import { Server } from "../../types/IServer.ts";
import { useEffect, useRef, useState } from "react";
import { IApp } from "../../types/IApp.ts";
import api from "../../utils/api.ts";
import { ISession } from "../../types/ISession.ts";
import { IClient } from "../../types/IClient.ts";
import { Session } from "../../types/ISession.ts";
import { Client } from "../../types/IClient.ts";
import useModalStore from "../../stores/useModalStore.ts";
import TableSelector from "../TableSelector.tsx";
import NewInput from "../NewInput.tsx";
@@ -27,7 +27,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: () => api.get("servers?withLastSession=true").json<IServer[]>(),
queryFn: () => api.get("servers?withLastSession=true").json<Server[]>(),
refetchInterval: 1000,
});
@@ -35,7 +35,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
? servers?.find((server) => server.id === targetServerId) || null
: null;
const [selectedServer, setSelectedServer] = useState<IServer | null>(
const [selectedServer, setSelectedServer] = useState<Server | null>(
targetServer
);
const [selectedApp, setSelectedApp] = useState<IApp | null>(null);
@@ -56,7 +56,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
email,
},
})
.json<IClient>();
.json<Client>();
},
});
@@ -79,7 +79,7 @@ export default function CreateSessionModal({ targetServerId }: Props) {
appId,
},
})
.json<ISession>(),
.json<Session>(),
onMutate: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
@@ -3,12 +3,13 @@ import FlashIcon from "../icons/FlashIcon";
import NewButton from "../NewButton";
import ChevronRightIcon from "../icons/ChevronRightIcon";
import useModalStore from "../../stores/useModalStore";
import { ISession } from "../../types/ISession";
import { Session } from "../../types/ISession";
import { useEffect, useState } from "react";
import EndSessionModal from "./EndSessionModal";
function CurrentSessionModal({ session }: { session: ISession }) {
function CurrentSessionModal({ session }: { session: Session }) {
// const queryClient = useQueryClient();
const { setModal } = useModalStore();
// const { mutate: endSession } = useMutation({
@@ -85,7 +86,7 @@ function CurrentSessionModal({ session }: { session: ISession }) {
<NewButton variant="secondary" className="w-full">
<div className="flex flex-col gap-[0.278vw] w-full text-left h-[2.222vw]">
<p className="caption-s font-medium text-[#BDBDBD]">Клиент</p>
<p className="text-s font-medium">{session.client.name}</p>
<p className="text-s font-medium">{session.client.fullname}</p>
</div>
<div className="flex gap-[0.556vw] items-center">
{!session.client.email && (
+2 -2
View File
@@ -2,12 +2,12 @@ import { useState } from "react";
import NewInput from "../NewInput";
import NewButton from "../NewButton";
import useModalStore from "../../stores/useModalStore";
import { IServer } from "../../types/IServer";
import { Server } from "../../types/IServer";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import api from "../../utils/api";
function EditTable({ table }: { table: IServer }) {
function EditTable({ table }: { table: Server }) {
const [tableName, setTableName] = useState(table.name);
const [tableDescription, setTableDescription] = useState(table.location);
const { setModal } = useModalStore();
+2 -2
View File
@@ -1,4 +1,4 @@
import { ISession } from "../../types/ISession";
import { Session } from "../../types/ISession";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import getIntervalDuration from "../../utils/interval-duration";
@@ -11,7 +11,7 @@ import DownloadIcon from "../icons/DownloadIcon";
import ShareIcon from "../icons/ShareIcon";
import SessionComments from "../SessionComments";
function SessionModal({ session }: { session: ISession }) {
function SessionModal({ session }: { session: Session }) {
return (
<div className="bg-[#FFFFFF] w-[49.722vw] rounded-4xl">
<div className="w-full flex justify-center items-center h-[4.861vw]">
+4 -4
View File
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/IUser";
import api from "../utils/api";
import { IServer } from "../types/IServer";
import { Server } from "../types/IServer";
import DesktopCard from "../components/DesktopCard";
import Badge from "../components/Badge";
import { ISession } from "../types/ISession";
import { Session } from "../types/ISession";
import SessionCard from "../components/SessionCard";
import NewButton from "../components/NewButton";
import ChevronRightIcon from "../components/icons/ChevronRightIcon";
@@ -17,7 +17,7 @@ function DashboardPage() {
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: () => api.get("servers?withLastSession=true").json<IServer[]>(),
queryFn: () => api.get("servers?withLastSession=true").json<Server[]>(),
enabled: !!me,
refetchInterval: 1000,
});
@@ -25,7 +25,7 @@ function DashboardPage() {
const { data: sessions } = useQuery({
queryKey: ["last-sessions"],
queryFn: () =>
api.get("sessions", { searchParams: { limit: 5 } }).json<ISession[]>(),
api.get("sessions", { searchParams: { limit: 5 } }).json<Session[]>(),
enabled: !!me,
});
+124
View File
@@ -0,0 +1,124 @@
import { useQuery } from "@tanstack/react-query";
import api from "../utils/api";
import { IUser } from "../types/IUser";
import { Session } from "../types/ISession";
import Input from "../components/Input";
import Select from "../components/Select";
import { useState } from "react";
import { IApp } from "../types/IApp";
import { useDebounce } from "@uidotdev/usehooks";
import SessionCard from "../components/SessionCard";
import { groupByCreatedAt } from "../utils/groupByCreatedAt";
import { format, isToday } from "date-fns";
import { ru } from "date-fns/locale";
function SessionsPage() {
const [search, setSearch] = useState<string | null>(null);
const [managerId, setManagerId] = useState<string | null>(null);
const [appId, setAppId] = useState<string | null>(null);
const debouncedSearch = useDebounce(search, 500);
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(),
});
const { data: managers } = useQuery({
queryKey: ["managers"],
queryFn: () => api.get("users").json<IUser[]>(),
enabled: !!me,
});
const { data: apps } = useQuery({
queryKey: ["apps"],
queryFn: () => api.get("apps").json<IApp[]>(),
enabled: !!me,
});
const { data: grouppedSessions } = useQuery({
queryKey: ["sessions", managerId, appId, debouncedSearch],
queryFn: () =>
api
.get(
`sessions?${managerId ? `ownerId=${managerId}` : ""}${
appId ? `&appId=${appId}` : ""
}${debouncedSearch ? `&clientSearch=${debouncedSearch}` : ""}`
)
.json<Session[]>(),
enabled: !!me,
select: groupByCreatedAt,
});
const { data: count } = useQuery({
queryKey: ["sessions", "count", managerId, appId, debouncedSearch],
queryFn: () =>
api
.get(
`sessions/count?${managerId ? `ownerId=${managerId}` : ""}${
appId ? `&appId=${appId}` : ""
}${debouncedSearch ? `&clientSearch=${debouncedSearch}` : ""}`
)
.json<number>(),
enabled: !!me,
});
return (
<div className="py-[1.667vw] 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]">
<div className="flex flex-col gap-[0.556vw]">
<Input
placeholder="Поиск по имени клиента"
value={search || ""}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-[0.556vw]">
<Select
options={managers?.map((manager) => manager.fullname) || []}
onChange={(option) => {
setManagerId(
managers?.find((manager) => manager.fullname === option)
?.id || null
);
}}
/>
<Select
options={apps?.map((app) => app.name) || []}
onChange={(option) => {
setAppId(
apps?.find((app) => app.name === option)?.id || null
);
}}
/>
</div>
</div>
{!!count && (
<p className="caption-m font-medium opacity-40">
Найдено {count} сеансов
</p>
)}
</div>
</div>
<div className="space-y-[1.667vw]">
{Object.entries(grouppedSessions || {}).map(([timestamp, sessions]) => (
<div key={timestamp} className="space-y-[0.833vw]">
<p className="caption-m font-medium opacity-40">
{isToday(new Date(timestamp))
? "Сегодня"
: format(new Date(timestamp), "d MMMM", { locale: ru })}
</p>
<div className="space-y-[0.278vw]">
{sessions.map((session) => (
<SessionCard key={session.id} session={session} />
))}
</div>
</div>
))}
</div>
</div>
);
}
export default SessionsPage;
+2 -2
View File
@@ -1,10 +1,10 @@
import { IServer } from "../types/IServer.ts";
import { Server } from "../types/IServer.ts";
import api from "../utils/api.ts";
import { useQuery } from "@tanstack/react-query";
export default function useQueryServers() {
return useQuery({
queryKey: ["servers"],
queryFn: () => api.get("servers").json<IServer[]>(),
queryFn: () => api.get("servers").json<Server[]>(),
});
}
+5
View File
@@ -2,6 +2,7 @@ import Layout from "./components/Layout";
import DashboardPage from "./pages/DashboardPage";
import LoginPage from "./pages/LoginPage";
import ProtectedPage from "./pages/ProtectedPage";
import SessionsPage from "./pages/SessionsPage";
export default [
{
@@ -15,6 +16,10 @@ export default [
index: true,
element: <DashboardPage />,
},
{
path: "sessions",
element: <SessionsPage />,
},
],
},
],
+1 -1
View File
@@ -1,4 +1,4 @@
export interface IClient {
export interface Client {
id: string;
name: string;
email: string;
+1 -1
View File
@@ -1,4 +1,4 @@
export interface IComment {
export interface Comment {
id: string;
text: string;
createdAt: Date;
+2 -2
View File
@@ -1,11 +1,11 @@
import { IApp } from "./IApp";
import { IServer } from "./IServer";
import { Server } from "./IServer";
import { IUser } from "./IUser";
export interface ICompany {
id: string;
name: string;
apps?: IApp[];
servers?: IServer[];
servers?: Server[];
users?: IUser[];
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { IApp } from "./IApp";
import { ISession } from "./ISession";
import { IApp as App } from "./IApp";
import { Session } from "./ISession";
export interface IServer {
export interface Server {
id: string;
hostname: string;
name: string;
location: string;
companyId: string;
sessions?: ISession[];
apps?: IApp[];
sessions?: Session[];
apps?: App[];
status: "online" | "offline";
}
+11 -11
View File
@@ -1,21 +1,21 @@
import { IApp } from "./IApp";
import { IComment } from "./IComments";
import { IOwner } from "./IOwner";
import { IServer } from "./IServer";
import { IUser } from "./IUser";
import { IApp as App } from "./IApp";
import { Comment } from "./IComments";
import { IOwner as Owner } from "./IOwner";
import { Server } from "./IServer";
import { Client } from "./IClient";
export interface ISession {
export interface Session {
id: string;
ownerId: string;
serverId: string;
clientId: string;
companyId: string;
comments: IComment[];
comments: Comment[];
status: "starting" | "started" | "restarted" | "ending" | "ended";
server: IServer;
client: IUser;
app: IApp;
owner: IOwner;
server: Server;
client: Client;
app: App;
owner: Owner;
createdAt: Date;
updatedAt: Date;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { ICompany } from "./ICompany";
export interface IUser {
id: string;
email: string;
name: string;
fullname: string;
companyId: string;
company?: ICompany;
}
+8
View File
@@ -0,0 +1,8 @@
export function groupByCreatedAt<T extends { createdAt: Date }>(items: T[]) {
return items.reduce((acc, session) => {
const date = session.createdAt.toString().split("T")[0];
acc[date] = acc[date] || [];
acc[date].push(session);
return acc;
}, {} as Record<string, T[]>);
}