Various fixes, add pagination

- add sending token when app is loading if present
- set gcTime to Infinity in LoadingState to prevent rerender every 3 minutes
- add pagination
- add attaching users to address
- add detaching users from address
This commit is contained in:
2025-06-10 20:18:34 +04:00
parent 5a37c9f8af
commit 49bf644589
24 changed files with 978 additions and 174 deletions

43
package-lock.json generated
View File

@@ -24,12 +24,15 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tanstack/react-table": "^8.21.3",
"@types/lodash": "^4.17.17",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lodash": "^4.17.21",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
@@ -3365,6 +3368,39 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -3504,6 +3540,12 @@
"@types/geojson": "*" "@types/geojson": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
"integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==",
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -8760,7 +8802,6 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {

View File

@@ -37,12 +37,15 @@
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tanstack/react-table": "^8.21.3",
"@types/lodash": "^4.17.17",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-store": "^8.2.0", "electron-store": "^8.2.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lodash": "^4.17.21",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",

View File

@@ -3,6 +3,7 @@ import { join } from 'path';
import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import './tokenIpc'; import './tokenIpc';
import WebSocket from 'ws' import WebSocket from 'ws'
import { getToken } from './store';
const WS_URL = import.meta.env.MAIN_VITE_WS_URL; const WS_URL = import.meta.env.MAIN_VITE_WS_URL;
let ws: WebSocket; let ws: WebSocket;
@@ -17,6 +18,10 @@ function connect(mainWindow: BrowserWindow) {
ws.on("open", () => { ws.on("open", () => {
console.log("Connected"); console.log("Connected");
reconnectAttempts = 0; reconnectAttempts = 0;
const token = getToken();
if (token)
ws.send(token);
}); });
ws.on("error", () => { ws.on("error", () => {
@@ -29,9 +34,10 @@ function connect(mainWindow: BrowserWindow) {
ws.on("message", (msg: any) => { ws.on("message", (msg: any) => {
const str: string = msg.toString(); const str: string = msg.toString();
console.log("Message received: ", str); console.log("Message received:", str);
const splitted = str.split("::"); const splitted = str.split("::");
const type = splitted[0]; const type = splitted[0];
switch (type) { switch (type) {
case "msg": case "msg":
mainWindow.webContents.send('ws-message-chat', [...splitted.slice(1)]); mainWindow.webContents.send('ws-message-chat', [...splitted.slice(1)]);

36
src/main/store.ts Normal file
View File

@@ -0,0 +1,36 @@
import { safeStorage } from 'electron';
import Store from 'electron-store';
type Token = {
access_token: string;
}
const store = new Store<Token>({
defaults: {
access_token: '',
},
});
export function setToken(token: string) {
if (!safeStorage.isEncryptionAvailable())
return;
const encrypted = safeStorage.encryptString(token);
store.set('access_token', encrypted.toString('base64'));
}
export function getToken() {
if (!safeStorage.isEncryptionAvailable())
return null;
const encryptedBase64 = store.get('access_token');
if (!encryptedBase64)
return null;
const buffer = Buffer.from(encryptedBase64, 'base64');
return safeStorage.decryptString(buffer);
}
export function clearToken() {
store.delete('access_token');
}

View File

@@ -1,37 +1,14 @@
import { ipcMain, safeStorage } from 'electron'; import { ipcMain } from "electron";
import Store from 'electron-store'; import { clearToken, getToken, setToken } from "./store";
type Token = {
access_token: string;
}
const store = new Store<Token>({
defaults: {
access_token: '',
},
});
ipcMain.handle('set-token', (_, token: string) => { ipcMain.handle('set-token', (_, token: string) => {
if (!safeStorage.isEncryptionAvailable()) setToken(token);
return;
const encrypted = safeStorage.encryptString(token);
store.set('access_token', encrypted.toString('base64'));
}); });
ipcMain.handle('get-token', () => { ipcMain.handle('get-token', () => {
if (!safeStorage.isEncryptionAvailable()) return getToken();
return null;
const encryptedBase64 = store.get('access_token');
if (!encryptedBase64)
return null;
const buffer = Buffer.from(encryptedBase64, 'base64');
return safeStorage.decryptString(buffer);
}); });
ipcMain.handle('clear-token', () => { ipcMain.handle('clear-token', () => {
store.delete('access_token'); clearToken();
}); });

View File

@@ -29,7 +29,8 @@ const LoadingState = () => {
queryKey: ["isAlive"], queryKey: ["isAlive"],
queryFn: () => { queryFn: () => {
return checkServer(); return checkServer();
} },
gcTime: Infinity
} }
); );
@@ -81,7 +82,6 @@ const Layout = () => {
return; return;
} }
await window.api.sendWsMessage(token);
const data = await getEmployeeData(token); const data = await getEmployeeData(token);
if (data instanceof Error) { if (data instanceof Error) {

View File

@@ -43,7 +43,7 @@ function LocationMarker({ initalMarker, onLocationSelect }: LocationMarkerProps)
export const MapPicker = ({ center, zoom, className, initalMarker, onLocationSelect }: MapPickerComponentProps) => { export const MapPicker = ({ center, zoom, className, initalMarker, onLocationSelect }: MapPickerComponentProps) => {
return ( return (
<MapContainer center={center} zoom={zoom} className={`h-[24rem] w-full ${className ?? ""}`}> <MapContainer center={center} zoom={zoom} className={`h-[28rem] w-full ${className ?? ""}`}>
<TileLayer attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' <TileLayer attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"/> url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"/>
<LocationMarker onLocationSelect={onLocationSelect} initalMarker={initalMarker}/> <LocationMarker onLocationSelect={onLocationSelect} initalMarker={initalMarker}/>

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "./ui/pagination";
import { range } from "lodash";
interface PaginationButtonsProps {
currentPage: number;
totalPages: number;
handleOnPageClick: (page: number) => void;
className?: string
}
const PaginationButtons = ({ currentPage, totalPages, handleOnPageClick, className }: PaginationButtonsProps) => {
const toShowButtons = 5;
const [showStartEllipsis, setShowStartEllipsis] = useState(false);
const [showEndEllipsis, setShowEndEllipsis] = useState(false);
const [currButtons, setCurrButtons] = useState<number[]>([]);
useEffect(() => {
if (currentPage === 1)
setShowStartEllipsis(false);
if (totalPages <= toShowButtons) {
setCurrButtons(range(1, totalPages + 1));
}
else if (currentPage + toShowButtons - 1 < totalPages) {
if (currentPage !== 1)
setShowStartEllipsis(true);
setShowEndEllipsis(true);
setCurrButtons(range(currentPage, toShowButtons + currentPage));
}
else {
setCurrButtons(range(totalPages - toShowButtons, totalPages + 1));
setShowEndEllipsis(false);
}
}, [currentPage, totalPages]);
return (
<Pagination className={className ?? ""}>
<PaginationContent>
<PaginationItem>
<Button variant="ghost" onClick={() => handleOnPageClick(currentPage - 1)} disabled={currentPage <= 1}>Назад</Button>
</PaginationItem>
{
showStartEllipsis &&
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
}
{
currButtons.map((val) => (
<PaginationItem key={val + 1}>
<Button variant={currentPage === val ? "outline" : "ghost"} onClick={() => handleOnPageClick(val)}>{val}</Button>
</PaginationItem>
))
}
{
showEndEllipsis &&
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
}
<PaginationItem>
<Button variant="ghost" onClick={() => handleOnPageClick(currentPage + 1)} disabled={currentPage >= totalPages}>Далее</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
);
};
export default PaginationButtons;

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@renderer/lib/utils"
import { Button, buttonVariants } from "@renderer/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Вернутся назад"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Назад</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Следующая страница"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Вперед</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@renderer/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -19,7 +19,7 @@ export const UserDto = z.object({
middleName: z.string(), middleName: z.string(),
creationDate: z.date(), creationDate: z.date(),
phone: z.string(), phone: z.string(),
}) });
export const UserLoginDto = z.object({ export const UserLoginDto = z.object({
login: z.string() login: z.string()

View File

@@ -0,0 +1,8 @@
export interface PaginationDto<T> {
items: T[];
totalItems: number;
totalPages: number;
page: number;
pageSize: number;
hasNext: boolean;
};

View File

@@ -0,0 +1,157 @@
import { Button } from "@renderer/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTrigger } from "@renderer/components/ui/dialog";
import { Checkbox } from "@renderer/components/ui/checkbox"
import { HiPlus } from "react-icons/hi2";
import UserDataTable from "./DataTable";
import { ColumnDef } from "@tanstack/react-table";
import { UserDto } from "@renderer/models/user/types";
import { useEffect, useState } from "react";
import { useUserStore } from "@renderer/hooks/userStore";
import { getUnattachedUsers } from "@renderer/services/user";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Skeleton } from "@renderer/components/ui/skeleton";
import PaginationButtons from "@renderer/components/PaginationButtons";
import { useSearchParams } from "react-router-dom";
import { DialogTitle } from "@radix-ui/react-dialog";
import { attachUser } from "@renderer/services/address";
const columns: ColumnDef<UserDto>[] = [
{
id: "select",
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "login",
header: "Логин",
},
{
accessorKey: "lastName",
header: "Фамилия",
},
{
accessorKey: "firstName",
header: "Имя",
},
{
accessorKey: "middleName",
header: "Отчество",
},
{
accessorKey: "phone",
header: "Телефон",
},
];
interface AddressAttachUserModalProps {
addressId: string
}
const AddressAttachUserModal = ( { addressId }: AddressAttachUserModalProps) => {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const token = useUserStore((s) => s.token);
const [searchParams, setSearchParams] = useSearchParams();
const [selected, setSelected] = useState<UserDto | null>(null);
const currentPage = Number(searchParams.get("usersPage") ?? 1);
const { data: unattachedUsers, isLoading } = useQuery(
{
queryKey: ["unattachedUsers", currentPage],
queryFn: () => {
if (!token)
return null;
return getUnattachedUsers(token, currentPage);
}
}
);
useEffect(() => {
if (!isLoading) {
if (unattachedUsers instanceof Error) {
toast.error("Произошла ошибка при загрузке пользователей", { description: unattachedUsers.message });
}
}
}, [unattachedUsers]);
const handleOpenChange = () => {
setOpen(!open);
searchParams.delete("usersPage");
setSearchParams(searchParams);
};
const handlePageChange = (page) => {
searchParams.set("usersPage", page);
setSearchParams(searchParams);
};
const handleSelectedChange = (user: UserDto) => {
setSelected(user ?? null);
};
const handleSubmit = async () => {
if (!selected) {
toast.error("Выберите пользователя");
return;
}
if (!token)
return;
const res = await attachUser(token, String(selected.id), addressId);
if (res instanceof Error) {
toast.error("Произошла ошибка при присоединении пользователя", { description: res.message });
return;
}
toast.success("Пользователь успешно присоединен");
queryClient.invalidateQueries({ queryKey: ["unattachedUsers"] });
queryClient.invalidateQueries({ queryKey: ["address_users"] });
setOpen(false);
};
const isDisabled = !selected
|| isLoading
|| unattachedUsers instanceof Error
|| !unattachedUsers
|| unattachedUsers.items.length === 0;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button><HiPlus /></Button>
</DialogTrigger>
<DialogContent className="min-w-3xl">
<DialogHeader>
<DialogTitle asChild>
<h3 className="text-2xl">Присоединение пользователя</h3>
</DialogTitle>
<p className="text-muted-foreground text-sm">Выберите пользователя из таблицы ниже</p>
<p className="text-muted-foreground text-sm">Всего пользователей: {(unattachedUsers instanceof Error) ? 0 : unattachedUsers?.totalItems}</p>
</DialogHeader>
{
(unattachedUsers instanceof Error) || isLoading || !unattachedUsers ?
<Skeleton className="h-56"/> :
<>
<UserDataTable columns={columns} data={unattachedUsers.items} onSelectRow={handleSelectedChange}/>
{unattachedUsers.totalItems > 0 && <PaginationButtons currentPage={currentPage} totalPages={unattachedUsers.totalPages} handleOnPageClick={handlePageChange}/>}
</>
}
<DialogFooter><Button disabled={isDisabled} onClick={handleSubmit}>Присоединить</Button></DialogFooter>
</DialogContent>
</Dialog>
)
};
export default AddressAttachUserModal;

View File

@@ -2,22 +2,27 @@ import { Button } from "@renderer/components/ui/button";
import { Separator } from "@renderer/components/ui/separator"; import { Separator } from "@renderer/components/ui/separator";
import { Skeleton } from "@renderer/components/ui/skeleton"; import { Skeleton } from "@renderer/components/ui/skeleton";
import { useUserStore } from "@renderer/hooks/userStore"; import { useUserStore } from "@renderer/hooks/userStore";
import { deleteAddress, getAddressWithUsers } from "@renderer/services/address"; import { deleteAddress, getAddress, getAddressUsers } from "@renderer/services/address";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, } from "react"; import { useEffect, } from "react";
import { HiArrowPath, HiTrash } from "react-icons/hi2"; import { HiArrowPath, HiTrash } from "react-icons/hi2";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import UserCard from "./UserCard"; import UserCard from "./UserCard";
import { MapWithMarker } from "@renderer/components/Map"; import { MapWithMarker } from "@renderer/components/Map";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@renderer/components/ui/alert-dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@renderer/components/ui/alert-dialog";
import AddressUpdateModal from "./AddressUpdateModal"; import AddressUpdateModal from "./AddressUpdateModal";
import PaginationButtons from "@renderer/components/PaginationButtons";
import AddressAttachUserModal from "./AddressAttachUserModal";
const AddressPage = () => { const AddressPage = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const params = useParams(); const params = useParams();
const token = useUserStore((s) => s.token); const token = useUserStore((s) => s.token);
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page") ?? 1);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -25,34 +30,57 @@ const AddressPage = () => {
} }
}, []); }, []);
const { data: address, isLoading, isFetching, error } = useQuery({ const { data: address, isLoading: isLoadingAddress, isFetching: isFetchingAddress, error: addressError } = useQuery({
queryKey: ["address", params.id!], queryKey: ["address", params.id!],
queryFn: () => { queryFn: () => {
if (!token) if (!token)
return null; return null;
if (!params.id) if (!params.id)
return null; return null;
return getAddressWithUsers(token, params.id); return getAddress(token, params.id);
} }
}); });
const { data: users, isLoading, isFetching, error } = useQuery({
queryKey: ["address_users", params.id, currentPage],
queryFn: () => {
if (!token)
return null;
if (!params.id)
return null;
return getAddressUsers(token, params.id, currentPage);
}
});
useEffect(() => { useEffect(() => {
if (!isLoading || !isFetching) { if (!isLoading || !isFetching) {
if (!users) {
queryClient.refetchQueries({ queryKey: ["address_users", params.id] });
}
}
if (!isLoadingAddress || !isFetchingAddress) {
if (!address) { if (!address) {
queryClient.refetchQueries({ queryKey: ["address", params.id] }); queryClient.refetchQueries({ queryKey: ["address", params.id] });
} }
} }
if (!isLoading) { if (!isLoading || !isLoadingAddress) {
if (users instanceof Error) {
toast.error("Произошла ошибка во время загрузки пользователей адреса", { description: users.message });
}
if (address instanceof Error) { if (address instanceof Error) {
toast.error("Произошла ошибка во время загрузки адреса", { description: address.message }); toast.error("Произошла ошибка во время загрузки адреса", { description: address.message });
} }
if (error) { if (error) {
toast.error("Произошла ошибка во время загрузки адреса", { description: error.message }); toast.error("Произошла ошибка во время загрузки пользователей адреса", { description: error.message });
}
if (addressError) {
toast.error("Произошла ошибка во время загрузки адреса", { description: addressError.message });
} }
} }
}, [address]); }, [address]);
const handleRefresh = async () => { const handleRefresh = async () => {
await queryClient.refetchQueries({ queryKey: ["address", params.id] }); await queryClient.refetchQueries({ queryKey: ["address", params.id] });
await queryClient.refetchQueries({ queryKey: ["address_users", params.id] });
}; };
const handleDelete = async () => { const handleDelete = async () => {
@@ -69,6 +97,11 @@ const AddressPage = () => {
navigate('/addresses'); navigate('/addresses');
} }
const handlePageChange = (page) => {
searchParams.set("page", page);
setSearchParams(searchParams);
};
return ( return (
<div className="w-full px-16 mt-12 space-y-6"> <div className="w-full px-16 mt-12 space-y-6">
{ {
@@ -112,21 +145,33 @@ const AddressPage = () => {
<MapWithMarker center={[address.latitude, address.longitude]} zoom={16} markerCoords={[address.latitude, address.longitude]} className="rounded-lg z-5"/> <MapWithMarker center={[address.latitude, address.longitude]} zoom={16} markerCoords={[address.latitude, address.longitude]} className="rounded-lg z-5"/>
</div> </div>
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-4 mb-8">
<div className="flex items-center space-x-2 mb-6"> <div className="flex items-center space-x-4 mb-6">
<p className="text-xl font-semibold">Пользователи: {address.users?.length}</p> <p className="text-xl font-semibold">Пользователи</p>
<AddressAttachUserModal addressId={params.id!} />
</div> </div>
{ {
address.users?.map((user) => ( !(users instanceof Error) && users &&
<UserCard <>
key={user.id} {
id={user.id} users.items.length === 0 ?
firstName={user.firstName} <p className="text-lg font-semibold text-center text-muted-foreground">По данному адресу нет привязанных пользователей</p> :
lastName={user.lastName} <PaginationButtons className="mb-8" currentPage={currentPage} totalPages={users.totalPages} handleOnPageClick={handlePageChange}/>
middleName={user.middleName} }
phone={user.phone} {
creationDate={user.creationDate.toLocaleString()} /> users.items.map((user) => (
)) <UserCard
key={user.id}
id={user.id}
firstName={user.firstName}
lastName={user.lastName}
middleName={user.middleName}
phone={user.phone}
creationDate={user.creationDate.toLocaleString()} />
))
}
</>
} }
</div> </div>
</> </>

View File

@@ -3,9 +3,8 @@ import { MapPicker } from "@renderer/components/Map";
import { Button } from "@renderer/components/ui/button"; import { Button } from "@renderer/components/ui/button";
import { DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTrigger } from "@renderer/components/ui/dialog"; import { DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTrigger } from "@renderer/components/ui/dialog";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useReducer, useState } from "react"; import { useEffect, useState } from "react";
import { HiPencilSquare } from "react-icons/hi2"; import { HiPencilSquare } from "react-icons/hi2";
import { addressReducer, State as AddressState } from "../addresses/AddressReducer";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { AddressNamesDto } from "@renderer/models/address/types"; import { AddressNamesDto } from "@renderer/models/address/types";
import { getAddressByLocation, updateAddress } from "@renderer/services/address"; import { getAddressByLocation, updateAddress } from "@renderer/services/address";
@@ -28,28 +27,9 @@ interface AddressUpdateModalProps {
const AddressUpdateModal = ({ id, token, city, cityDistrict, road, houseNumber, latitude, longitude }: AddressUpdateModalProps) => { const AddressUpdateModal = ({ id, token, city, cityDistrict, road, houseNumber, latitude, longitude }: AddressUpdateModalProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const initState: AddressState = {
values: {
city: city,
cityDistrict: cityDistrict,
houseNumber: houseNumber,
road: road,
latitude: latitude,
longitude: longitude
},
errors: {
city: [],
cityDistrict: [],
houseNumber: [],
road: [],
}
};
const [state, dispatch] = useReducer(addressReducer, initState);
const [latLng, setLatLng] = useState<[number, number] | null>(null); const [latLng, setLatLng] = useState<[number, number] | null>(null);
const [debouncedLatLng] = useDebounce(latLng, 300); const [debouncedLatLng] = useDebounce(latLng, 300);
const [nominatimAddress, setNominatimAddress] = useState<AddressNamesDto | Error>(state.values); const [nominatimAddress, setNominatimAddress] = useState<AddressNamesDto | Error>({ city, cityDistrict, road, houseNumber });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -60,29 +40,28 @@ const AddressUpdateModal = ({ id, token, city, cityDistrict, road, houseNumber,
setNominatimAddress(address); setNominatimAddress(address);
} }
); );
dispatch({ type: "UPDATE_FIELD", field: "latitude", value: debouncedLatLng[0] });
dispatch({ type: "UPDATE_FIELD", field: "longitude", value: debouncedLatLng[1] });
} }
}, [debouncedLatLng]); }, [debouncedLatLng]);
useEffect(() => { useEffect(() => {
if (nominatimAddress && !(nominatimAddress instanceof Error) && debouncedLatLng) { if (nominatimAddress && debouncedLatLng) {
if (nominatimAddress.city !== city || nominatimAddress.cityDistrict !== cityDistrict || nominatimAddress.road !== road || nominatimAddress.houseNumber !== houseNumber) { if (!(nominatimAddress instanceof Error)) {
setNominatimAddress(new Error("Невозможно изменить метку на другой дом")); if (nominatimAddress.city !== city || nominatimAddress.cityDistrict !== cityDistrict || nominatimAddress.road !== road || nominatimAddress.houseNumber !== houseNumber) {
setNominatimAddress(new Error("Невозможно изменить метку на другой дом"));
}
} }
dispatch({ type: "UPDATE_FIELD", field: "city", value: nominatimAddress.city });
dispatch({ type: "UPDATE_FIELD", field: "cityDistrict", value: nominatimAddress.cityDistrict });
dispatch({ type: "UPDATE_FIELD", field: "houseNumber", value: nominatimAddress.houseNumber });
dispatch({ type: "UPDATE_FIELD", field: "road", value: nominatimAddress.road });
setIsLoading(false); setIsLoading(false);
} }
}, [nominatimAddress]); }, [nominatimAddress]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!debouncedLatLng)
return;
const body = AddressUpdateDto.safeParse({ const body = AddressUpdateDto.safeParse({
latitude: state.values.latitude, latitude: debouncedLatLng[0],
longitude: state.values.longitude longitude: debouncedLatLng[1]
}); });
if (!body.success) { if (!body.success) {
@@ -102,15 +81,14 @@ const AddressUpdateModal = ({ id, token, city, cityDistrict, road, houseNumber,
setIsLoading(false); setIsLoading(false);
}; };
const doesHaveErrors = Object.values(state.errors).some((errs) => errs.length > 0) || (nominatimAddress instanceof Error); const doesHaveErrors = nominatimAddress instanceof Error;
const doesNotHasValues = Object.values(state.values).some((value) => value === "");
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button><HiPencilSquare />Изменить метку</Button> <Button><HiPencilSquare />Изменить метку</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-3xl"> <DialogContent className="min-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle asChild> <DialogTitle asChild>
<h3 className="text-2xl">Изменение метки адреса</h3> <h3 className="text-2xl">Изменение метки адреса</h3>
@@ -124,7 +102,7 @@ const AddressUpdateModal = ({ id, token, city, cityDistrict, road, houseNumber,
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="outline">Отмена</Button> <Button type="button" variant="outline">Отмена</Button>
</DialogClose> </DialogClose>
<Button type="submit" onClick={handleSubmit} disabled={isLoading || doesHaveErrors || doesNotHasValues}>{isLoading && <Loader2Icon className="animate-spin" />}Сохранить</Button> <Button type="submit" onClick={handleSubmit} disabled={isLoading || doesHaveErrors}>{isLoading && <Loader2Icon className="animate-spin" />}Сохранить</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,75 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@renderer/components/ui/table";
import { UserDto } from "@renderer/models/user/types";
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useEffect } from "react";
interface UserDataTableProps {
onSelectRow: (user: UserDto) => void
columns: ColumnDef<UserDto>[]
data: UserDto[]
}
const UserDataTable = ( { columns, data, onSelectRow } : UserDataTableProps) => {
const table = useReactTable({
enableMultiRowSelection: false,
data,
columns,
getCoreRowModel: getCoreRowModel()
});
useEffect(() => {
onSelectRow(table.getSelectedRowModel().rows[0]?.original);
}, [table.getState().rowSelection]);
return (
<div className="rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{
headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{
header.isPlaceholder ? null :
flexRender(
header.column.columnDef.header,
header.getContext()
)
}
</TableHead>
)
})
}
</TableRow>
))}
</TableHeader>
<TableBody>
{
table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Пользователей нет
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export default UserDataTable;

View File

@@ -1,6 +1,11 @@
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@renderer/components/ui/alert-dialog";
import { Button } from "@renderer/components/ui/button"; import { Button } from "@renderer/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@renderer/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@renderer/components/ui/card";
import { useUserStore } from "@renderer/hooks/userStore";
import { detachUser } from "@renderer/services/address";
import { useQueryClient } from "@tanstack/react-query";
import { HiCalendarDays, HiMiniPhone, HiMinus } from "react-icons/hi2"; import { HiCalendarDays, HiMiniPhone, HiMinus } from "react-icons/hi2";
import { toast } from "sonner";
interface UserCardProps { interface UserCardProps {
id: number; id: number;
@@ -12,6 +17,23 @@ interface UserCardProps {
} }
const UserCard = ({ id, firstName, lastName, middleName, phone, creationDate }: UserCardProps) => { const UserCard = ({ id, firstName, lastName, middleName, phone, creationDate }: UserCardProps) => {
const queryClient = useQueryClient();
const token = useUserStore((s) => s.token);
const handleDetach = async () => {
if (!token)
return;
const res = await detachUser(token, String(id));
if (res instanceof Error) {
toast.error("Произошла ошибка при отвязке пользователя", { description: res.message });
return;
}
toast.success("Пользователь успешно отвязан");
queryClient.invalidateQueries({ queryKey: ["unattachedUsers"] });
queryClient.invalidateQueries({ queryKey: ["address_users"] });
};
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
@@ -28,7 +50,21 @@ const UserCard = ({ id, firstName, lastName, middleName, phone, creationDate }:
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button variant="destructive"><HiMinus />Отвязать</Button> <AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive"><HiMinus />Отвязать</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Вы уверены?</AlertDialogTitle>
<AlertDialogDescription>При отвязке пользователя чат с ним будет удален</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отменить</AlertDialogCancel>
<AlertDialogAction onClick={handleDetach}>Отвязать</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -62,11 +62,13 @@ const AddressCreateModalButton = ({ token }: AddressCreateModalButtonProps) => {
}, [debouncedLatLng]); }, [debouncedLatLng]);
useEffect(() => { useEffect(() => {
if (address && !(address instanceof Error) && debouncedLatLng) { if (address && debouncedLatLng) {
dispatch({ type: "UPDATE_FIELD", field: "city", value: address.city }); if (!(address instanceof Error)) {
dispatch({ type: "UPDATE_FIELD", field: "cityDistrict", value: address.cityDistrict }); dispatch({ type: "UPDATE_FIELD", field: "city", value: address.city });
dispatch({ type: "UPDATE_FIELD", field: "houseNumber", value: address.houseNumber }); dispatch({ type: "UPDATE_FIELD", field: "cityDistrict", value: address.cityDistrict });
dispatch({ type: "UPDATE_FIELD", field: "road", value: address.road }); dispatch({ type: "UPDATE_FIELD", field: "houseNumber", value: address.houseNumber });
dispatch({ type: "UPDATE_FIELD", field: "road", value: address.road });
}
setIsLoading(false); setIsLoading(false);
} }
}, [address]); }, [address]);
@@ -112,7 +114,7 @@ const AddressCreateModalButton = ({ token }: AddressCreateModalButtonProps) => {
toast.success("Адрес успешно добавлен"); toast.success("Адрес успешно добавлен");
setIsLoading(false); setIsLoading(false);
setOpen(false); setOpen(false);
await queryClient.invalidateQueries({ queryKey: ["addresses"] }); await queryClient.refetchQueries({ queryKey: ["addresses"] });
} }
const doesHaveErrors = Object.values(state.errors).some((errs) => errs.length > 0) || (address instanceof Error); const doesHaveErrors = Object.values(state.errors).some((errs) => errs.length > 0) || (address instanceof Error);
@@ -123,7 +125,7 @@ const AddressCreateModalButton = ({ token }: AddressCreateModalButtonProps) => {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button><HiPlus />Добавить</Button> <Button><HiPlus />Добавить</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-3xl"> <DialogContent className="min-w-3xl">
<DialogHeader> <DialogHeader>
<h3 className="text-2xl">Добавить адрес</h3> <h3 className="text-2xl">Добавить адрес</h3>
</DialogHeader> </DialogHeader>

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { useUserStore } from "@renderer/hooks/userStore"; import { useUserStore } from "@renderer/hooks/userStore";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -10,44 +10,56 @@ import { Skeleton } from "@renderer/components/ui/skeleton";
import { Button } from "@renderer/components/ui/button"; import { Button } from "@renderer/components/ui/button";
import { HiArrowPath } from "react-icons/hi2"; import { HiArrowPath } from "react-icons/hi2";
import AddressCreateModalButton from "./AddressCreateModal"; import AddressCreateModalButton from "./AddressCreateModal";
import PaginationButtons from "@renderer/components/PaginationButtons";
const AddressesPage = () => { const AddressesPage = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const token = useUserStore((s) => s.token); const token = useUserStore((s) => s.token);
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page") ?? 1);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
navigate('/login'); navigate('/login');
} }
}, []); }, []);
const { data: addresses, isLoading, isFetching, error } = useQuery(
const { data: page, isLoading, isFetching, error } = useQuery(
{ {
queryKey: ["addresses"], queryKey: ["addresses", currentPage],
queryFn: () => { queryFn: () => {
if (!token) if (!token)
return null; return null;
return getAddresses(token); return getAddresses(token, currentPage);
}, },
},); },);
useEffect(() => { useEffect(() => {
if (!isLoading || !isFetching) { if (!isLoading || !isFetching) {
if (!addresses) { if (!page) {
queryClient.refetchQueries({ queryKey: ["addresses"] }); queryClient.refetchQueries({ queryKey: ["addresses", currentPage] });
} }
} }
if (!isLoading) { if (!isLoading) {
if (error) { if (error) {
toast.error("Произошла ошибка во время загрузки адресов", { description: error.message }); toast.error("Произошла ошибка во время загрузки адресов", { description: error.message });
} }
} }
}, [addresses]); }, [page]);
const handleRefresh = async () => { const handleRefresh = async () => {
await queryClient.refetchQueries({ queryKey: ["addresses"] }); await queryClient.refetchQueries({ queryKey: ["addresses", currentPage] });
}; };
const handlePageChange = (page) => {
searchParams.set("page", page);
setSearchParams(searchParams);
};
return ( return (
<div className="w-full px-16 mt-12 mb-6 space-y-4"> <div className="w-full px-16 mt-12 mb-6 space-y-4">
<div className="space-y-6 mb-6"> <div className="space-y-6 mb-6">
@@ -56,29 +68,31 @@ const AddressesPage = () => {
<Button size="icon" variant="ghost" onClick={handleRefresh}><HiArrowPath /></Button> <Button size="icon" variant="ghost" onClick={handleRefresh}><HiArrowPath /></Button>
</div> </div>
<AddressCreateModalButton token={token!}/> <AddressCreateModalButton token={token!}/>
{
!(page instanceof Error) && page &&
<PaginationButtons currentPage={currentPage} totalPages={page.totalPages} handleOnPageClick={handlePageChange}/>
}
</div> </div>
{
{isLoading || isFetching ? isLoading || isFetching ?
<> <>
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
<Skeleton className="h-52 w-full" /> <Skeleton className="h-52 w-full" />
</> : </> :
addresses instanceof Error ? !(page instanceof Error) && page &&
null <>
: {page?.items?.map((address) => (
<> <AddressCard key={address?.id}
{addresses?.map((address) => ( id={address?.id}
<AddressCard key={address?.id} city={address?.city}
id={address?.id} cityDistrict={address?.cityDistrict}
city={address?.city} road={address?.road}
cityDistrict={address?.cityDistrict} houseNumber={address?.houseNumber} />
road={address?.road} ))}
houseNumber={address?.houseNumber} /> </>
))}
</>
} }
</div> </div>
) )

View File

@@ -37,6 +37,7 @@ const LoginPage = () => {
} }
await window.api.setToken(res.access_token); await window.api.setToken(res.access_token);
setToken(res.access_token); setToken(res.access_token);
await window.api.sendWsMessage(res.access_token);
toast.success("Вы успешно вошли в аккаунт"); toast.success("Вы успешно вошли в аккаунт");
navigate('/'); navigate('/');

View File

@@ -47,10 +47,6 @@ const ProfilePage = () => {
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading) {
if (!companyData) {
toast.error("Произошла ошибка во время загрузки данных УК");
return;
}
if (companyData instanceof Error) { if (companyData instanceof Error) {
toast.error("Произошла ошибка во время загрузки данных УК", { description: companyData.message }); toast.error("Произошла ошибка во время загрузки данных УК", { description: companyData.message });
} }

View File

@@ -5,26 +5,30 @@ import { getAllRequests } from "@renderer/services/address";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { HiArrowPath } from "react-icons/hi2"; import { HiArrowPath } from "react-icons/hi2";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import RequestCard from "./RequestCard"; import RequestCard from "./RequestCard";
import PaginationButtons from "@renderer/components/PaginationButtons";
const RequestsPage = () => { const RequestsPage = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const token = useUserStore((s) => s.token); const token = useUserStore((s) => s.token);
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page") ?? 1);
useEffect(() => { useEffect(() => {
if (!token) if (!token)
navigate('/login'); navigate('/login');
}, []); }, []);
const { data: requests, isLoading, isFetching } = useQuery( const { data: page, isLoading, isFetching } = useQuery(
{ {
queryKey: ["requests"], queryKey: ["requests", currentPage],
queryFn: () => { queryFn: () => {
if (!token) if (!token)
return null; return null;
return getAllRequests(token); return getAllRequests(token, currentPage);
}, },
}, },
); );
@@ -33,6 +37,11 @@ const RequestsPage = () => {
await queryClient.refetchQueries({ queryKey: ["requests"] }); await queryClient.refetchQueries({ queryKey: ["requests"] });
}; };
const handlePageChange = (page) => {
searchParams.set("page", page);
setSearchParams(searchParams);
};
return ( return (
<div className="w-full px-16 mt-12 mb-6 space-y-4"> <div className="w-full px-16 mt-12 mb-6 space-y-4">
<div className="space-y-6 mb-6"> <div className="space-y-6 mb-6">
@@ -40,6 +49,16 @@ const RequestsPage = () => {
<h1 className="text-3xl font-semibold">Заявки на присоединение</h1> <h1 className="text-3xl font-semibold">Заявки на присоединение</h1>
<Button size="icon" variant="ghost" onClick={handleRefresh}><HiArrowPath /></Button> <Button size="icon" variant="ghost" onClick={handleRefresh}><HiArrowPath /></Button>
</div> </div>
{
!(page instanceof Error) && page &&
<>
{
page.totalItems === 0 ?
<p className="text-lg font-semibold text-center text-muted-foreground">Новых заявок на присоединение пока что нет</p>:
<PaginationButtons currentPage={currentPage} totalPages={page.totalPages} handleOnPageClick={handlePageChange}/>
}
</>
}
</div> </div>
{ {
isLoading || isFetching ? isLoading || isFetching ?
@@ -50,15 +69,20 @@ const RequestsPage = () => {
<Skeleton className="h-53 w-full" /> <Skeleton className="h-53 w-full" />
<Skeleton className="h-53 w-full" /> <Skeleton className="h-53 w-full" />
</> : </> :
requests instanceof Error ? null : !(page instanceof Error) && page &&
requests?.map((request) => ( <>
<RequestCard {
key={request.id} page?.items?.map((request) => (
address={request.address!} <RequestCard
user={request.user!} key={request.id}
timestamp={request.timestamp} address={request.address!}
/> user={request.user!}
)) timestamp={request.timestamp}
/>
))
}
</>
} }
</div> </div>
); );

View File

@@ -5,6 +5,8 @@ import { AddressCreateDto, AddressDto as AddressDtoType, AddressNamesDto, Addres
import { UserDto } from "@renderer/models/user/dtos"; import { UserDto } from "@renderer/models/user/dtos";
import { AttachRequestDto } from "@renderer/models/attachrequest/dtos"; import { AttachRequestDto } from "@renderer/models/attachrequest/dtos";
import { AttachRequestDto as AttachRequestDtoType } from "@renderer/models/attachrequest/types"; import { AttachRequestDto as AttachRequestDtoType } from "@renderer/models/attachrequest/types";
import { PaginationDto } from "@renderer/models/utils";
import { UserDto as UserDtoType } from "@renderer/models/user/types";
const API_URL = `${BASE_URL}/address`; const API_URL = `${BASE_URL}/address`;
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse?format=jsonv2" const NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse?format=jsonv2"
@@ -26,8 +28,9 @@ export async function getAddressByLocation(location: [number, number]) : Promise
} }
} }
export async function getAllRequests(token: string): Promise<Error | AttachRequestDtoType[]> { export async function getAllRequests(token: string, page: number): Promise<Error | PaginationDto<AttachRequestDtoType>> {
const data = await requestWrapper(`${API_URL}/attach_requests`, { const params = new URLSearchParams({ page: page.toString() }).toString();
const data = await requestWrapper(`${API_URL}/attach_requests?${params}`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -38,7 +41,13 @@ export async function getAllRequests(token: string): Promise<Error | AttachReque
if (data instanceof Error) { if (data instanceof Error) {
return data; return data;
} }
const requests = data.map((item: any) => { const totalItems = data.total_items;
const totalPages = data.total_pages;
const pageSize = data.page_size;
const hasNext = data.has_next;
const items = data.items;
const requests = items.map((item: any) => {
const a = AddressDto.safeParse({ const a = AddressDto.safeParse({
id: item.address.id, id: item.address.id,
city: item.address.city, city: item.address.city,
@@ -75,11 +84,19 @@ export async function getAllRequests(token: string): Promise<Error | AttachReque
if (requests === undefined) { if (requests === undefined) {
return new Error("Произошла ошибка при получении заявок"); return new Error("Произошла ошибка при получении заявок");
} }
return requests; return {
totalItems,
totalPages,
page,
pageSize,
hasNext,
items: requests
};
} }
export async function getAddresses(token: string): Promise<Error | AddressDtoType[]> { export async function getAddresses(token: string, page: number): Promise<Error | PaginationDto<AddressDtoType>> {
const data = await requestWrapper(`${API_URL}/company_addresses`, { const params = new URLSearchParams({ page: page.toString() }).toString();
const data = await requestWrapper(`${API_URL}/company_addresses?${params}`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -96,7 +113,13 @@ export async function getAddresses(token: string): Promise<Error | AddressDtoTyp
} }
return new Error(data.detail); return new Error(data.detail);
} }
const addresses = data.map((item: any) => { const totalItems = data.total_items;
const totalPages = data.total_pages;
const pageSize = data.page_size;
const hasNext = data.has_next;
const items = data.items;
const addresses = items.map((item: any) => {
const a = AddressDto.safeParse({ const a = AddressDto.safeParse({
id: item.id, id: item.id,
city: item.city, city: item.city,
@@ -112,10 +135,17 @@ export async function getAddresses(token: string): Promise<Error | AddressDtoTyp
if (addresses === undefined) { if (addresses === undefined) {
return new Error("Произошла ошибка при получении адресов"); return new Error("Произошла ошибка при получении адресов");
} }
return addresses; return {
totalItems,
totalPages,
page,
pageSize,
hasNext,
items: addresses
};
} }
export async function getAddressWithUsers(token: string, address_id: string): Promise<Error | AddressDtoType> { export async function getAddress(token: string, address_id: string): Promise<Error | AddressDtoType> {
const params = new URLSearchParams({ address_id }).toString(); const params = new URLSearchParams({ address_id }).toString();
const data = await requestWrapper(`${API_URL}/get?${params}`, { const data = await requestWrapper(`${API_URL}/get?${params}`, {
method: "GET", method: "GET",
@@ -124,7 +154,6 @@ export async function getAddressWithUsers(token: string, address_id: string): Pr
"Authorization": `Bearer ${token}` "Authorization": `Bearer ${token}`
} }
}); });
if (data instanceof Error) { if (data instanceof Error) {
return data; return data;
} }
@@ -134,7 +163,38 @@ export async function getAddressWithUsers(token: string, address_id: string): Pr
} }
return new Error(data.detail); return new Error(data.detail);
} }
const users = data.users.map((item: any) => { const a = AddressDto.safeParse({
id: data.id,
city: data.city,
cityDistrict: data.city_district,
road: data.road,
houseNumber: data.house_number,
latitude: data.latitude,
longitude: data.longitude,
});
if (!a.success) {
return new Error("Произошла ошибка при получении адреса", { cause: a.error });
}
return a.data;
}
export async function getAddressUsers(token: string, address_id: string, page: number): Promise<Error | PaginationDto<UserDtoType>> {
const params = new URLSearchParams({ address_id: address_id, page: page.toString() }).toString();
const usersData = await requestWrapper(`${API_URL}/get/users?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
});
const totalItems = usersData.total_items;
const totalPages = usersData.total_pages;
const pageSize = usersData.page_size;
const hasNext = usersData.has_next;
const items = usersData.items;
const users = items.map((item: any) => {
const u = UserDto.safeParse({ const u = UserDto.safeParse({
id: item.id, id: item.id,
login: item.login, login: item.login,
@@ -149,20 +209,14 @@ export async function getAddressWithUsers(token: string, address_id: string): Pr
if (users === undefined) { if (users === undefined) {
return new Error("Произошла ошибка при получении пользователей"); return new Error("Произошла ошибка при получении пользователей");
} }
const a = AddressDto.safeParse({ return {
id: data.id, totalItems,
city: data.city, totalPages,
cityDistrict: data.city_district, page,
road: data.road, pageSize,
houseNumber: data.house_number, hasNext,
latitude: data.latitude, items: users
longitude: data.longitude, };
users: users
});
if (a.data === undefined) {
return new Error("Произошла ошибка при получении адреса");
}
return a.data;
} }
export async function createAddress(token: string, body: AddressCreateDto) { export async function createAddress(token: string, body: AddressCreateDto) {
@@ -209,7 +263,7 @@ export async function updateAddress(token: string, body: AddressUpdateDto, addre
longitude: body.longitude longitude: body.longitude
}) })
}); });
console.log(data);
if (data instanceof Error) { if (data instanceof Error) {
return data; return data;
} }

View File

@@ -1,9 +1,49 @@
import requestWrapper from "@renderer/utils/requestWrapper"; import requestWrapper from "@renderer/utils/requestWrapper";
import { BASE_URL } from "@renderer/utils/constants"; import { BASE_URL } from "@renderer/utils/constants";
import { UserEmployeeDto, EmployeeUpdateDto } from "@renderer/models/user/types"; import { UserEmployeeDto, EmployeeUpdateDto, UserDto } from "@renderer/models/user/types";
import { PaginationDto } from "@renderer/models/utils";
const API_URL = `${BASE_URL}/user`; const API_URL = `${BASE_URL}/user`;
export async function getUnattachedUsers(token: string, page: number): Promise<PaginationDto<UserDto> | Error> {
const params = new URLSearchParams({ page: page.toString() }).toString();
const data = await requestWrapper(`${API_URL}/all_unattached?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
});
if (data instanceof Error) {
return data;
}
const totalItems = data.total_items;
const totalPages = data.total_pages;
const pageSize = data.page_size;
const hasNext = data.has_next;
const items = data.items;
const users = items.map((user: any) => ({
id: user.id,
login: user.login,
firstName: user.first_name,
lastName: user.last_name,
middleName: user.middle_name,
creationDate: new Date(user.creation_date),
phone: user.phone,
}));
return {
page,
totalItems,
totalPages,
pageSize,
hasNext,
items: users
};
}
export async function getEmployeeData(token: string): Promise<UserEmployeeDto | Error>{ export async function getEmployeeData(token: string): Promise<UserEmployeeDto | Error>{
const data = await requestWrapper(`${API_URL}/profile_data`, { const data = await requestWrapper(`${API_URL}/profile_data`, {
method: "GET", method: "GET",
@@ -16,7 +56,7 @@ export async function getEmployeeData(token: string): Promise<UserEmployeeDto |
return data; return data;
} }
if (data.detail) { if (data?.detail) {
if (data.detail.error === "NotExists" && data.detail.class_name === "User") { if (data.detail.error === "NotExists" && data.detail.class_name === "User") {
return new Error("Пользователь не найден"); return new Error("Пользователь не найден");
} }
@@ -55,7 +95,7 @@ export async function updateEmployeeData(token: string, body: EmployeeUpdateDto)
if (data instanceof Error) { if (data instanceof Error) {
return data; return data;
} }
if (data.detail) { if (data?.detail) {
if (data.detail.error === "DatabaseError") { if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных"); return new Error("Произошла ошибка в базе данных");
} }