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:
parent
3ca4d108b6
commit
0a80eb86cf
@ -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;
|
||||
|
||||
|
||||
@ -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
167
frontend/src/components/shared/SearchableSelect.tsx
Normal file
167
frontend/src/components/shared/SearchableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
frontend/src/hooks/useRequestsPage.ts
Normal file
426
frontend/src/hooks/useRequestsPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
frontend/src/lib/shared/formatters.ts
Normal file
21
frontend/src/lib/shared/formatters.ts
Normal 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 })} ₽`;
|
||||
}
|
||||
3
frontend/src/lib/shared/index.ts
Normal file
3
frontend/src/lib/shared/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { formatDate, formatMoney } from "./formatters";
|
||||
export { displayUserName, userProfileLink } from "./userUtils";
|
||||
export { extractNextPage, loadAllPages } from "./paginationUtils";
|
||||
29
frontend/src/lib/shared/paginationUtils.ts
Normal file
29
frontend/src/lib/shared/paginationUtils.ts
Normal 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;
|
||||
}
|
||||
32
frontend/src/lib/shared/userUtils.ts
Normal file
32
frontend/src/lib/shared/userUtils.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user