refactor: extract shared utils/components, split requests page data into hook

- Create frontend/src/lib/shared/ with formatters, userUtils, paginationUtils
  (formatDate, formatMoney, displayUserName, userProfileLink, extractNextPage, loadAllPages)
- Create frontend/src/components/shared/SearchableSelect.tsx
  (SearchableSelectSingle + SearchableSelectMulti, replaces 3 inline copies)
- Extract requests page data/state/actions into useRequestsPage hook
  (requests/page.tsx: 1580 → ~280 lines of pure JSX)
- Replace duplicated helpers in equipment/page.tsx with shared imports
  (~80 lines removed: formatDate, formatMoney, displayUserName, extractNextPage,
   loadAllPages, inline SearchableSelectSingle)
- Replace duplicated helpers in procurement/page.tsx with shared imports
  (~90 lines removed: fmt, money, displayUserName, extractNextPage,
   inline departments loader, inline SearchableSelectSingle)
- 609 backend tests pass, TypeScript typecheck clean, ESLint clean
This commit is contained in:
lizerk 2026-04-01 16:21:02 +03:00
parent 3ca4d108b6
commit 0a80eb86cf
9 changed files with 868 additions and 1670 deletions

View File

@ -17,6 +17,8 @@ import type {
User,
} from "@/types/api";
import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench, X } from "lucide-react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate as sharedFormatDate, formatMoney as sharedFormatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared";
/* ──── form state ──── */
type EquipmentFormState = {
@ -117,19 +119,8 @@ const defaultStatusMeta = {
surfaceClass: "border-gray-200",
};
function formatDate(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function formatMoney(value?: string | number | null): string {
if (value === null || value === undefined || value === "") return "—";
const amount = Number(value);
if (!Number.isFinite(amount)) return "—";
return `${amount.toLocaleString("ru-RU")} ₽`;
}
const formatDate = sharedFormatDate;
const formatMoney = sharedFormatMoney;
/* ──── main page ──── */
export default function EquipmentPage() {
@ -188,22 +179,9 @@ export default function EquipmentPage() {
const isCreateMode = createOpen && editingId === null;
/* ──── helpers ──── */
const displayUserName = (person?: User | null) => {
if (!person) return "—";
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
return full || (person as any)?.full_name || (person as any)?.display_name || person.email || "Пользователь";
};
const displayUserName = (person?: User | null) => sharedDisplayUserName(person);
const extractNextPage = (nextUrl?: string | null): number | null => {
if (!nextUrl) return null;
try {
const parsed = new URL(nextUrl, window.location.origin);
const num = Number(parsed.searchParams.get("page"));
return Number.isFinite(num) && num > 0 ? num : null;
} catch {
return null;
}
};
const extractNextPage = sharedExtractNextPage;
const buildParams = (page: number): Record<string, string | number> => {
const p: Record<string, string | number> = { page };
@ -437,19 +415,6 @@ export default function EquipmentPage() {
]);
useEffect(() => {
async function loadAllPages<T extends { id: number }>(fetcher: (params: any) => Promise<any>): Promise<T[]> {
const all: T[] = [];
let page = 1;
while (true) {
const res = await fetcher({ page, limit: 200 });
const results = Array.isArray(res) ? res : (res.results || []);
all.push(...results);
if (Array.isArray(res) || !res.next) break;
page++;
}
return all;
}
(async () => {
try {
const [allEmployees, allDepartments, allCategories] = await Promise.all([
@ -759,76 +724,6 @@ export default function EquipmentPage() {
}
};
/* ──── SearchableSelect (single) ──── */
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder, disabled = false }: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
placeholder?: string;
disabled?: boolean;
}) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400"
>
<span className="truncate">
{selectedName ? selectedName : <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">
Сбросить
</button>
)}
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : (
filtered.map((item) => (
<button
key={item.id}
type="button"
onClick={() => { onSelect(item.id); setOpen(false); }}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}
>
{item.name}
</button>
))
)}
</div>
</div>
)}
</div>
);
};
const modalMode: "create" | "edit" = editingId ? "edit" : "create";
const isModalOpen = createOpen || editingId !== null;

View File

@ -35,6 +35,8 @@ import {
X,
XCircle,
} from "lucide-react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate, formatMoney, displayUserName as sharedDisplayUserName, extractNextPage as sharedExtractNextPage, loadAllPages } from "@/lib/shared";
/* ══════════════════════════════════════════════════════
Constants & helpers
@ -84,16 +86,8 @@ const periodOptions = [
{ value: "quarter", label: "90 дней" },
];
function fmt(d?: string | null) {
if (!d) return "";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? "" : dt.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function money(v?: string | number | null) {
if (v === null || v === undefined || v === "") return "—";
return Number(v).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₽";
}
const fmt = formatDate;
const money = formatMoney;
/* ══════════════════════════════════════════════════════
Item row (позиция заявки) — for inline editing
@ -181,13 +175,7 @@ export default function ProcurementPage() {
return typeof person === "number" ? person : person.id;
};
const displayUserName = (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => {
if (fallbackName) return fallbackName;
if (!person) return fallbackEmail || "—";
if (typeof person === "number") return fallbackEmail || `Пользователь #${person}`;
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
return full || (person as any)?.full_name || person.email || fallbackEmail || "Пользователь";
};
const displayUserName = (person?: User | number | null, fallbackName?: string | null, fallbackEmail?: string | null) => sharedDisplayUserName(person, fallbackName, fallbackEmail);
const userLink = (person?: User | number | null) => {
const personId = resolveUserId(person);
@ -195,14 +183,7 @@ export default function ProcurementPage() {
return user?.id && personId === user.id ? "/profile" : `/users/${personId}`;
};
const extractNextPage = (nextUrl?: string | null): number | null => {
if (!nextUrl) return null;
try {
const u = new URL(nextUrl, window.location.origin);
const n = Number(u.searchParams.get("page"));
return Number.isFinite(n) && n > 0 ? n : null;
} catch { return null; }
};
const extractNextPage = sharedExtractNextPage;
const getDeptName = (req: ProcurementRequest) => {
if (req.department_name) return req.department_name;
@ -257,15 +238,7 @@ export default function ProcurementPage() {
useEffect(() => {
(async () => {
try {
const all: Department[] = [];
let pg = 1;
while (true) {
const res = await apiClient.getDepartments({ page: pg, limit: 200 });
const chunk = Array.isArray(res) ? res : (res.results || []);
all.push(...chunk);
if (Array.isArray(res) || !res.next) break;
pg++;
}
const all = await loadAllPages<Department>((p) => apiClient.getDepartments(p));
setDepartments(all);
} catch { /* silent */ }
})();
@ -513,58 +486,6 @@ export default function ProcurementPage() {
const updateItemRow = (idx: number, patch: Partial<ItemDraft>) =>
setForm((f) => ({ ...f, items: f.items.map((it, i) => (i === idx ? { ...it, ...patch } : it)) }));
/* ══════════ SearchableSelect ══════════ */
const SearchableSelectSingle = ({ label, items: selectItems, selectedId, onSelect, placeholder }: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
placeholder?: string;
}) => {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = selectItems.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = selectItems.find((i) => i.id === selectedId)?.name;
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button type="button" onClick={() => setOpen((v) => !v)} className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800">
<span className="truncate">{selectedName || <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Поиск..." className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none" autoFocus />
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
<button type="button" onClick={() => { onSelect(null); setOpen(false); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50">Сбросить</button>
)}
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : filtered.map((item) => (
<button key={item.id} type="button" onClick={() => { onSelect(item.id); setOpen(false); }} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 text-sky-700 font-medium" : ""}`}>
{item.name}
</button>
))}
</div>
</div>
)}
</div>
);
};
const activeFilterCount = [statusFilter, urgencyFilter, departmentFilter, periodFilter].filter(Boolean).length;
const isFinal = (s?: string) => ["completed", "rejected", "cancelled"].includes(String(s || "").toLowerCase());

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
/* ── Single-select dropdown with search ── */
export function SearchableSelectSingle({
label,
items,
selectedId,
onSelect,
placeholder,
disabled = false,
}: {
label: string;
items: { id: number; name: string }[];
selectedId: number | null;
onSelect: (id: number | null) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedName = items.find((i) => i.id === selectedId)?.name;
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-400"
>
<span className="truncate">
{selectedName || <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Поиск..."
className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-40 overflow-y-auto p-1">
{selectedId && (
<button
type="button"
onClick={() => { onSelect(null); setOpen(false); }}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-gray-400 hover:bg-gray-50"
>
Сбросить
</button>
)}
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : (
filtered.map((item) => (
<button
key={item.id}
type="button"
onClick={() => { onSelect(item.id); setOpen(false); }}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 ${selectedId === item.id ? "bg-sky-50 font-medium text-sky-700" : ""}`}
>
{item.name}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
/* ── Multi-select dropdown with search ── */
export function SearchableSelectMulti({
label,
items,
selectedIds,
onToggle,
placeholder,
}: {
label: string;
items: { id: number; name: string }[];
selectedIds: number[];
onToggle: (id: number) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = items.filter((i) => i.name.toLowerCase().includes(q.toLowerCase()));
const selectedNames = items.filter((i) => selectedIds.includes(i.id)).map((i) => i.name);
return (
<div ref={ref} className="relative">
<label className="mb-1 block text-xs font-medium text-gray-500">{label}</label>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-800"
>
<span className="truncate">
{selectedNames.length > 0 ? selectedNames.join(", ") : <span className="text-gray-400">{placeholder || "Выбрать..."}</span>}
</span>
<ChevronDown size={14} className={`ml-2 shrink-0 text-gray-400 transition ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute z-50 mt-1 max-h-56 w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Поиск..."
className="w-full rounded border border-gray-200 px-2 py-1.5 text-sm focus:border-sky-400 focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-40 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-gray-400">Ничего не найдено</p>
) : (
filtered.map((item) => (
<label key={item.id} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50">
<input
type="checkbox"
checked={selectedIds.includes(item.id)}
onChange={() => onToggle(item.id)}
className="rounded border-gray-300"
/>
<span className="truncate">{item.name}</span>
</label>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,426 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api";
import type { Request, RequestComment, User, Department } from "@/types/api";
import { extractNextPage, loadAllPages, displayUserName } from "@/lib/shared";
/* ── constants ── */
export type RequestFormState = {
type: string;
title: string;
date_from: string;
date_to: string;
comment: string;
department_ids: number[];
recipient_ids: number[];
cc_user_ids: number[];
sent_to_all_department: boolean;
attachment: File | null;
};
export const emptyForm: RequestFormState = {
type: "",
title: "",
date_from: "",
date_to: "",
comment: "",
department_ids: [],
recipient_ids: [],
cc_user_ids: [],
sent_to_all_department: false,
attachment: null,
};
export const statusMeta: Record<string, { label: string; className: string }> = {
draft: { label: "Черновик", className: "bg-slate-100 text-slate-700 ring-slate-200" },
pending: { label: "На рассмотрении", className: "bg-amber-50 text-amber-700 ring-amber-100" },
approved: { label: "Одобрено", className: "bg-emerald-50 text-emerald-700 ring-emerald-100" },
rejected: { label: "Отклонено", className: "bg-rose-50 text-rose-700 ring-rose-100" },
cancelled: { label: "Отменено", className: "bg-gray-100 text-gray-700 ring-gray-200" },
in_progress: { label: "В работе", className: "bg-sky-50 text-sky-700 ring-sky-100" },
completed: { label: "Завершено", className: "bg-violet-50 text-violet-700 ring-violet-100" },
};
export const defaultStatusMeta = { label: "Неизвестный статус", className: "bg-gray-50 text-gray-700 ring-gray-200" };
export const requestTypeLabels: Record<string, string> = {
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
};
export const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "date_from", label: "По периоду ↑" },
{ value: "-date_from", label: "По периоду ↓" },
];
/* ── hook ── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useRequestsPage(_userId: number | null | undefined) {
/* data */
const [requests, setRequests] = useState<Request[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedComments, setExpandedComments] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
/* form */
const [createOpen, setCreateOpen] = useState(false);
const [editingRequestId, setEditingRequestId] = useState<number | null>(null);
const [form, setForm] = useState<RequestFormState>(emptyForm);
/* filters */
const [search, setSearch] = useState("");
const [view, setView] = useState<"" | "mine" | "addressed">("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [employeeFilter, setEmployeeFilter] = useState("");
const [createdFromFilter, setCreatedFromFilter] = useState("");
const [createdToFilter, setCreatedToFilter] = useState("");
const [periodFromFilter, setPeriodFromFilter] = useState("");
const [periodToFilter, setPeriodToFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [ordering, setOrdering] = useState("-created_at");
const [swipeMode, setSwipeMode] = useState(false);
/* UI */
const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null);
const [detailsRequest, setDetailsRequest] = useState<Request | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [nextPage, setNextPage] = useState<number | null>(null);
const departmentNameMap = useMemo(
() => new Map((departments || []).map((d) => [d.id, d.name])),
[departments],
);
/* ── params ── */
const buildRequestParams = useCallback(
(page: number): Record<string, string | number> => {
const params: Record<string, string | number> = { page, limit: 20 };
if (view === "mine") params.view = "mine";
if (view === "addressed") params.addressed_to_me = "true";
if (typeFilter) params.type = typeFilter;
if (statusFilter) params.status = statusFilter;
if (employeeFilter) params.employee_id = employeeFilter;
if (createdFromFilter) params.created_from = createdFromFilter;
if (createdToFilter) params.created_to = createdToFilter;
if (periodFromFilter) params.date_from = periodFromFilter;
if (periodToFilter) params.date_to = periodToFilter;
return params;
},
[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter],
);
/* ── load data ── */
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.getRequests(buildRequestParams(1));
if (cancelled) return;
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (err) {
if (!cancelled) {
console.error("Ошибка загрузки заявлений:", err);
setError("Не удалось загрузить заявления");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [buildRequestParams]);
useEffect(() => {
(async () => {
try {
const [allEmployees, allDepartments] = await Promise.all([
loadAllPages<User>((p) => apiClient.getEmployees(p)),
loadAllPages<Department>((p) => apiClient.getDepartments(p)),
]);
setEmployees(allEmployees);
setDepartments(allDepartments);
} catch {
setEmployees([]);
setDepartments([]);
}
})();
}, []);
/* ── filtered + sorted ── */
const filteredRequests = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...requests].sort((a, b) => {
switch (ordering) {
case "created_at":
return (new Date(a.created_at).getTime() || 0) - (new Date(b.created_at).getTime() || 0);
case "title": {
const l = String(a.display_title || a.title || "").trim();
const r = String(b.display_title || b.title || "").trim();
return l.localeCompare(r, "ru", { sensitivity: "base" });
}
case "date_from":
return (new Date(a.date_from || 0).getTime() || 0) - (new Date(b.date_from || 0).getTime() || 0);
case "-date_from":
return (new Date(b.date_from || 0).getTime() || 0) - (new Date(a.date_from || 0).getTime() || 0);
default:
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
}
});
if (!q) return sorted;
return sorted.filter((item) => {
const title = (item.display_title || item.title || "").toLowerCase();
const description = (item.comment || item.description || "").toLowerCase();
const type = String(item.type || item.request_type || "").toLowerCase();
const author = displayUserName(item.employee || item.created_by).toLowerCase();
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [ordering, requests, search]);
/* ── form ── */
const resetForm = () => setForm(emptyForm);
const openEdit = (req: Request) => {
setEditingRequestId(req.id);
setCreateOpen(false);
setActionError(null);
setActionSuccess(null);
setForm({
type: req.type || req.request_type || "",
title: req.title || "",
date_from: req.date_from || "",
date_to: req.date_to || "",
comment: req.comment || req.description || "",
department_ids: (req.departments || []).map(Number).filter((n) => Number.isFinite(n)),
recipient_ids: (req.recipients || []).map((u: User) => u.id).filter(Boolean),
cc_user_ids: (req.cc_users || []).map((u: User) => u.id).filter(Boolean),
sent_to_all_department: Boolean(req.sent_to_all_department),
attachment: null,
});
};
const openCreate = () => {
setEditingRequestId(null);
resetForm();
setActionError(null);
setActionSuccess(null);
setCreateOpen(true);
};
const closeModal = () => {
setCreateOpen(false);
setEditingRequestId(null);
resetForm();
setActionError(null);
};
/* ── CRUD ── */
const handleCreateOrUpdate = async (mode: "create" | "edit", saveAs: "draft" | "submitted") => {
try {
setBusyKey(`${mode}-${saveAs}`);
setActionError(null);
setActionSuccess(null);
if (saveAs === "submitted" && !form.sent_to_all_department && form.department_ids.length === 0 && form.recipient_ids.length === 0) {
setActionError("Укажите получателей или отделы.");
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: Record<string, any> = {
type: form.type,
title: form.title,
date_from: form.date_from || null,
date_to: form.date_to || null,
comment: form.comment,
sent_to_all_department: form.sent_to_all_department,
};
if (form.department_ids.length > 0) payload.department_ids = form.department_ids;
if (form.recipient_ids.length > 0) payload.recipient_ids = form.recipient_ids;
if (form.cc_user_ids.length > 0) payload.cc_user_ids = form.cc_user_ids;
if (form.attachment) payload.attachment = form.attachment;
if (mode === "create") {
await apiClient.createRequest(payload, saveAs);
setActionSuccess(saveAs === "draft" ? "Черновик сохранён." : "Заявление создано.");
setCreateOpen(false);
} else if (editingRequestId) {
await apiClient.updateRequest(editingRequestId, payload, saveAs);
setActionSuccess("Заявление обновлено.");
setEditingRequestId(null);
}
resetForm();
const response = await apiClient.getRequests(buildRequestParams(1));
setRequests(response.results || []);
setNextPage(extractNextPage(response.next));
} catch (e: unknown) {
const raw = String((e as Error)?.message || "Не удалось сохранить заявление");
let readable = raw;
try { const parsed = JSON.parse(raw); readable = Object.values(parsed).flat().join(". "); } catch { /* keep raw */ }
setActionError(readable);
} finally {
setBusyKey(null);
}
};
const handleLoadMore = useCallback(async () => {
if (!nextPage || loadingMore) return;
try {
setLoadingMore(true);
setError(null);
const response = await apiClient.getRequests(buildRequestParams(nextPage));
const chunk = response.results || [];
setRequests((prev) => {
const known = new Set(prev.map((r: Request) => r.id));
return [...prev, ...chunk.filter((r: Request) => !known.has(r.id))];
});
setNextPage(extractNextPage(response.next));
} catch (e: unknown) {
setError(String((e as Error)?.message || "Не удалось загрузить ещё заявления"));
} finally {
setLoadingMore(false);
}
}, [nextPage, loadingMore, buildRequestParams]);
const handleApprove = async (id: number) => {
try { setBusyKey(`approve-${id}`); setActionError(null); await apiClient.approveRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "approved" } : r))); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось одобрить")); }
finally { setBusyKey(null); }
};
const handleReject = async (id: number) => {
try { setBusyKey(`reject-${id}`); setActionError(null); await apiClient.rejectRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "rejected" } : r))); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отклонить")); }
finally { setBusyKey(null); }
};
const handleCancel = async (id: number) => {
try { setBusyKey(`cancel-${id}`); setActionError(null); await apiClient.cancelRequest(id); setRequests((p) => p.map((r) => (r.id === id ? { ...r, status: "cancelled" } : r))); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось отменить")); }
finally { setBusyKey(null); }
};
const handleDelete = async (id: number) => {
try { setBusyKey(`delete-${id}`); setActionError(null); await apiClient.deleteRequest(id); setRequests((p) => p.filter((r) => r.id !== id)); }
catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить")); }
finally { setBusyKey(null); }
};
/* ── comments ── */
const toggleComments = async (requestId: number) => {
const isOpen = Boolean(expandedComments[requestId]);
setExpandedComments((p) => ({ ...p, [requestId]: !isOpen }));
if (!isOpen && !commentsMap[requestId]) {
try { const c = await apiClient.getRequestComments(requestId); setCommentsMap((p) => ({ ...p, [requestId]: c })); }
catch { setCommentsMap((p) => ({ ...p, [requestId]: [] })); }
}
};
const toggleRow = (requestId: number) => setExpandedRows((p) => ({ ...p, [requestId]: !p[requestId] }));
const handleAddComment = async (requestId: number) => {
const text = (commentDrafts[requestId] || "").trim();
if (!text) return;
try {
setBusyKey(`comment-${requestId}`);
const saved = await apiClient.addRequestComment(requestId, text);
setCommentsMap((p) => ({ ...p, [requestId]: [...(p[requestId] || []), saved] }));
setCommentDrafts((p) => ({ ...p, [requestId]: "" }));
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось добавить комментарий")); }
finally { setBusyKey(null); }
};
const handleDeleteComment = async (requestId: number, commentId: number) => {
try {
setBusyKey(`comment-delete-${commentId}`);
await apiClient.deleteRequestComment(requestId, commentId);
setCommentsMap((p) => ({ ...p, [requestId]: (p[requestId] || []).filter((c) => c.id !== commentId) }));
} catch (e: unknown) { setActionError(String((e as Error)?.message || "Не удалось удалить комментарий")); }
finally { setBusyKey(null); }
};
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && nextPage && !loadingMore && !loading) handleLoadMore(); },
{ rootMargin: "100px" },
);
const el = loadMoreRef.current;
if (el) observer.observe(el);
return () => { if (el) observer.unobserve(el); };
}, [nextPage, loadingMore, loading, handleLoadMore]);
return {
/* data */
requests: filteredRequests,
employees,
departments,
departmentNameMap,
commentsMap,
expandedRows,
expandedComments,
commentDrafts,
setCommentDrafts,
/* form */
form,
setForm,
createOpen,
editingRequestId,
modalMode: (editingRequestId ? "edit" : "create") as "create" | "edit",
isModalOpen: createOpen || editingRequestId !== null,
openCreate,
openEdit,
closeModal,
handleCreateOrUpdate,
/* filters */
search, setSearch,
view, setView,
typeFilter, setTypeFilter,
statusFilter, setStatusFilter,
employeeFilter, setEmployeeFilter,
createdFromFilter, setCreatedFromFilter,
createdToFilter, setCreatedToFilter,
periodFromFilter, setPeriodFromFilter,
periodToFilter, setPeriodToFilter,
filtersOpen, setFiltersOpen,
ordering, setOrdering,
swipeMode, setSwipeMode,
/* UI */
attachmentPreview, setAttachmentPreview,
detailsRequest, setDetailsRequest,
loading, loadingMore, error,
actionError, actionSuccess,
busyKey, nextPage,
loadMoreRef,
/* actions */
handleApprove, handleReject, handleCancel, handleDelete,
toggleRow, toggleComments, handleAddComment, handleDeleteComment,
isFinal,
};
}

View File

@ -0,0 +1,21 @@
/**
* Shared formatters used across list pages (requests, equipment, procurement).
*/
export function formatDate(value?: string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export function formatMoney(value?: string | number | null): string {
if (value === null || value === undefined || value === "") return "—";
const amount = Number(value);
if (!Number.isFinite(amount)) return "—";
return `${amount.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽`;
}

View File

@ -0,0 +1,3 @@
export { formatDate, formatMoney } from "./formatters";
export { displayUserName, userProfileLink } from "./userUtils";
export { extractNextPage, loadAllPages } from "./paginationUtils";

View File

@ -0,0 +1,29 @@
/**
* Shared pagination helpers.
*/
export function extractNextPage(nextUrl?: string | null): number | null {
if (!nextUrl) return null;
try {
const parsed = new URL(nextUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost");
const num = Number(parsed.searchParams.get("page"));
return Number.isFinite(num) && num > 0 ? num : null;
} catch {
return null;
}
}
type PageFetcher<T> = (params: Record<string, number>) => Promise<{ results?: T[]; next?: string | null } | T[]>;
export async function loadAllPages<T extends { id: number }>(fetcher: PageFetcher<T>): Promise<T[]> {
const all: T[] = [];
let page = 1;
for (;;) {
const res = await fetcher({ page, limit: 200 });
const results = Array.isArray(res) ? res : (res.results || []);
all.push(...results);
if (Array.isArray(res) || !(res as { next?: string | null }).next) break;
page++;
}
return all;
}

View File

@ -0,0 +1,32 @@
/**
* Shared user display helpers used across list pages.
*/
import type { User } from "@/types/api";
export function displayUserName(
person?: User | number | null,
fallbackName?: string | null,
fallbackEmail?: string | null,
): string {
if (fallbackName) return fallbackName;
if (!person) return fallbackEmail || "—";
if (typeof person === "number") return fallbackEmail || `Пользователь #${person}`;
const full = `${person.last_name || ""} ${person.first_name || ""}`.trim();
return (
full ||
(person as unknown as Record<string, string>)?.full_name ||
(person as unknown as Record<string, string>)?.display_name ||
person.email ||
fallbackEmail ||
"Пользователь"
);
}
export function userProfileLink(
person: User | null | undefined,
currentUserId: number | null | undefined,
): string {
if (!person?.id) return "";
if (currentUserId && person.id === currentUserId) return "/profile";
return `/users/${person.id}`;
}