Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/metersApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { RawArea, RawMeter, PagedResponse } from './types';

export type { RawArea, RawMeter, PagedResponse };

const BASE_URL = '/api';

export const metersApi = {
async fetchMeters(
page: number,
pageSize: number
): Promise<PagedResponse<RawMeter>> {
const params = new URLSearchParams({
limit: String(pageSize),
offset: String((page - 1) * pageSize),
});
const response = await fetch(`${BASE_URL}/meters/?${params}`);
if (!response.ok)
throw new Error(`Ошибка загрузки счётчиков: ${response.status}`);
return response.json() as Promise<PagedResponse<RawMeter>>;
},

async fetchAreas(ids: string[]): Promise<PagedResponse<RawArea>> {
const params = new URLSearchParams();
ids.forEach((id) => params.append('id__in', id));
const response = await fetch(`${BASE_URL}/areas/?${params}`);
if (!response.ok)
throw new Error(`Ошибка загрузки адресов: ${response.status}`);
return response.json() as Promise<PagedResponse<RawArea>>;
},

async deleteMeter(id: string): Promise<void> {
const response = await fetch(`${BASE_URL}/meters/${id}/`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`Ошибка удаления: ${response.status}`);
},
};
28 changes: 28 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export interface RawArea {
id: string;
number: number | null;
str_number_full: string | null;
str_number: string | null;
house: { id: string; address: string; fias_addrobjs: string[] } | null;
}

export interface RawMeter {
id: string;
_type: string[];
area: { id: string };
is_automatic: boolean | null;
description: string | null;
installation_date: string | null;
initial_values: number[];
serial_number: string;
model_name: string | null;
brand_name: string | null;
communication: string;
}

export interface PagedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
141 changes: 92 additions & 49 deletions src/components/MetersTable/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,50 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}

interface PageButtonProps {
page: number;
isActive: boolean;
disabled: boolean;
onPageChange: (page: number) => void;
}

interface GapPopupProps {
gap: Gap;
disabled: boolean;
onSelect: (page: number) => void;
}

interface GapItemProps {
gap: Gap;
isOpen: boolean;
disabled: boolean;
onToggle: () => void;
onSelect: (page: number) => void;
}

function PageButton({
page,
isActive,
disabled,
onPageChange,
}: PageButtonProps) {
const className = `pagination__btn${isActive ? ' pagination__btn--active' : ''}`;

return (
<li>
<button
className={className}
onClick={() => onPageChange(page)}
disabled={disabled || isActive}
aria-label={`Страница ${page}`}
aria-current={isActive ? 'page' : undefined}
>
{page}
</button>
</li>
);
}

function GapPopup({ gap, disabled, onSelect }: GapPopupProps) {
return (
<div
Expand All @@ -38,6 +76,31 @@ function GapPopup({ gap, disabled, onSelect }: GapPopupProps) {
);
}

function GapItem({ gap, isOpen, disabled, onToggle, onSelect }: GapItemProps) {
const className = `pagination__ellipsis${isOpen ? ' pagination__ellipsis--open' : ''}`;

return (
<li className="pagination__gap-item">
<button
className={className}
onClick={onToggle}
aria-label="Показать скрытые страницы"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
...
</button>

{isOpen && (
<>
<div className="pagination__backdrop" onPointerDown={onToggle} />
<GapPopup gap={gap} disabled={disabled} onSelect={onSelect} />
</>
)}
</li>
);
}

export function Pagination({
current,
total,
Expand All @@ -50,58 +113,38 @@ export function Pagination({

const pages = buildPages(current, total);

function handleGapToggle(idx: number) {
setOpenGapIdx(openGapIdx === idx ? null : idx);
}

function handleGapSelect(page: number) {
onPageChange(page);
setOpenGapIdx(null);
}

return (
<nav className="pagination" aria-label="Пагинация">
<ol className="pagination__list">
{pages.map((item, idx) => {
if (typeof item === 'number') {
return (
<li key={item}>
<button
className={`pagination__btn${current === item ? ' pagination__btn--active' : ''}`}
onClick={() => onPageChange(item)}
disabled={disabled || current === item}
aria-label={`Страница ${item}`}
aria-current={current === item ? 'page' : undefined}
>
{item}
</button>
</li>
);
}

const isOpen = openGapIdx === idx;
return (
<li key={`gap-${idx}`} className="pagination__gap-item">
<button
className={`pagination__ellipsis${isOpen ? ' pagination__ellipsis--open' : ''}`}
onClick={() => setOpenGapIdx(isOpen ? null : idx)}
aria-label={`Показать скрытые страницы `}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
...
</button>

{isOpen && (
<>
<div
className="pagination__backdrop"
onPointerDown={() => setOpenGapIdx(null)}
/>
<GapPopup
gap={item}
disabled={disabled}
onSelect={(p) => {
onPageChange(p);
setOpenGapIdx(null);
}}
/>
</>
)}
</li>
);
})}
{pages.map((item, idx) =>
typeof item === 'number' ? (
<PageButton
key={item}
page={item}
isActive={current === item}
disabled={disabled}
onPageChange={onPageChange}
/>
) : (
<GapItem
key={`gap-${idx}`}
gap={item}
isOpen={openGapIdx === idx}
disabled={disabled}
onToggle={() => handleGapToggle(idx)}
onSelect={handleGapSelect}
/>
)
)}
</ol>
</nav>
);
Expand Down
3 changes: 2 additions & 1 deletion src/components/MetersTable/Pagination/buildPages.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
export type Gap = { type: 'gap'; pages: number[] };
export type PageItem = number | Gap;

const WINDOW = 2;

export const range = (from: number, to: number): number[] =>
Array.from({ length: to - from + 1 }, (_, i) => from + i);

export function buildPages(current: number, total: number): PageItem[] {
if (total <= 1) return [];

const WINDOW = 2;
const visible = new Set([
1,
total,
Expand Down
6 changes: 2 additions & 4 deletions src/components/MetersTable/TableRow/TableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { observer } from 'mobx-react-lite';
import type { Instance } from 'mobx-state-tree';
import { metersStore } from '@/stores/MetersStore';
import type { MeterType } from '@/stores/MetersStore';
import { MeterTypeIcon } from '@/components/MetersTable/icons/MeterTypeIcon';
import TrashIcon from '@/assets/TrashIcon.svg?react';
import './TableRow.css';

type MeterInstance = Instance<typeof metersStore.meters>[number];

interface TableRowProps {
meter: MeterInstance;
meter: MeterType;
rowNum: number;
}

Expand Down
75 changes: 11 additions & 64 deletions src/stores/MetersStore.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,9 @@
import { types, flow, cast } from 'mobx-state-tree';
import type { Instance } from 'mobx-state-tree';
import { metersApi } from '../api/metersApi';
import type { RawArea, RawMeter, PagedResponse } from '../api/types';

const PAGE_SIZE = 20;
const BASE_URL = '/api';

interface RawArea {
id: string;
number: number | null;
str_number_full: string | null;
str_number: string | null;
house: { id: string; address: string; fias_addrobjs: string[] } | null;
}

interface RawMeter {
id: string;
_type: string[];
area: { id: string };
is_automatic: boolean | null;
description: string | null;
installation_date: string | null;
initial_values: number[];
serial_number: string;
model_name: string | null;
brand_name: string | null;
communication: string;
}

interface PagedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}

const HouseModel = types.model('House', {
id: types.string,
Expand All @@ -47,15 +19,11 @@ const AreaModel = types.model('Area', {
house: types.maybeNull(HouseModel),
});

const MeterAreaRefModel = types.model('MeterAreaRef', {
id: types.string,
});

const MeterModel = types
.model('Meter', {
id: types.identifier,
_type: types.array(types.string),
area: MeterAreaRefModel,
area: types.model('MeterAreaRef', { id: types.string }),
is_automatic: types.maybeNull(types.boolean),
description: types.maybeNull(types.string),
installation_date: types.maybeNull(types.string),
Expand Down Expand Up @@ -122,14 +90,8 @@ const MetersStoreModel = types
);
if (!unknownIds.length) return;

const params = new URLSearchParams();
unknownIds.forEach((id) => params.append('id__in', id));

const response: Response = yield fetch(`${BASE_URL}/areas/?${params}`);
if (!response.ok)
throw new Error(`Ошибка загрузки адресов: ${response.status}`);

const data: PagedResponse<RawArea> = yield response.json();
const data: PagedResponse<RawArea> =
yield metersApi.fetchAreas(unknownIds);
data.results.forEach((area) =>
self.areas.set(area.id, area as Parameters<typeof self.areas.set>[1])
);
Expand All @@ -139,17 +101,10 @@ const MetersStoreModel = types
self.isLoading = true;
self.error = null;
try {
const offset = (page - 1) * PAGE_SIZE;
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
});

const response: Response = yield fetch(`${BASE_URL}/meters/?${params}`);
if (!response.ok)
throw new Error(`Ошибка загрузки счётчиков: ${response.status}`);

const data: PagedResponse<RawMeter> = yield response.json();
const data: PagedResponse<RawMeter> = yield metersApi.fetchMeters(
page,
PAGE_SIZE
);
self.meters = cast(data.results);
self.totalCount = data.count;
self.currentPage = page;
Expand All @@ -165,16 +120,7 @@ const MetersStoreModel = types
const deleteMeter = flow(function* (meterId: string) {
self.deletingId = meterId;
try {
const response: Response = yield fetch(
`${BASE_URL}/meters/${meterId}/`,
{
method: 'DELETE',
}
);

if (!response.ok) {
throw new Error(`Ошибка удаления: ${response.status}`);
}
yield metersApi.deleteMeter(meterId);

const newTotal = self.totalCount - 1;
const targetPage =
Expand All @@ -198,5 +144,6 @@ const MetersStoreModel = types
});

export type MetersStoreType = Instance<typeof MetersStoreModel>;
export type MeterType = MetersStoreType['meters'][number];

export const metersStore = MetersStoreModel.create({});
Loading