Унификация сортировки и визуального стиля карточек: добавлены селекторы сортировки и убраны лишние тени в разделах заявлений, закупок и оборудования

This commit is contained in:
lizerk 2026-03-27 18:11:14 +03:00
parent 3a2d05a64d
commit decbadf47b
8 changed files with 1958 additions and 927 deletions

View File

@ -15,7 +15,7 @@ import {
Tags,
LayoutGrid,
CheckSquare,
SlidersHorizontal,
Filter,
CheckCircle,
AlertCircle,
Calendar,
@ -107,7 +107,7 @@ export default function DocumentsPage() {
console.log("📁 Загрузка документов с параметрами:", params);
const response = await apiClient.getDocuments(params);
console.log("📄 Получено документов:", response.results?.length || 0);
setDocuments(response.results || []);
setDocuments(response.results || response || []);
} catch (err) {
console.error("Ошибка загрузки документов:", err);
setError("Не удалось загрузить документы");
@ -226,91 +226,295 @@ export default function DocumentsPage() {
return selectedFolder.path.split(' / ');
}, [selectedFolder]);
const activeFilterCount = selectedTags.length + (dateFrom ? 1 : 0) + (dateTo ? 1 : 0);
return (
<AppShell>
<div className="space-y-4">
{/* Top Bar */}
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* Left Section: Navigation */}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
{/* Folder Filter */}
<div className="relative">
<button
onClick={() => setShowFolderDropdown(!showFolderDropdown)}
className={`flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition ${
selectedFolderId
? "border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
<FolderOpen size={16} />
<span className="max-w-[200px] truncate">
{selectedFolder ? selectedFolder.name : "Все папки"}
</span>
{selectedFolderId && (
<span
onClick={(e) => {
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Документы</p>
<button
onClick={() => setShowUploadForm(true)}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={14} /> Загрузить документ
</button>
</div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по документам"
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
className={`relative inline-flex items-center justify-center rounded-lg border p-2.5 transition ${
showFilters || activeFilterCount > 0
? 'border-sky-400 bg-sky-50 text-sky-600'
: 'border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100'
}`}
title="Фильтры"
>
<Filter size={16} />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">
{activeFilterCount}
</span>
)}
</button>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<div className="relative">
<button
onClick={() => setShowFolderDropdown(!showFolderDropdown)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
selectedFolderId
? 'bg-sky-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<FolderOpen size={14} />
<span className="max-w-[220px] truncate">
{selectedFolder ? selectedFolder.name : 'Все папки'}
</span>
{selectedFolderId ? (
<span
onClick={(e) => {
e.stopPropagation();
setSelectedFolderId(null);
}}
className={`rounded-full p-0.5 ${selectedFolderId ? 'hover:bg-sky-500' : 'hover:bg-gray-300'}`}
title="Сбросить фильтр"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setSelectedFolderId(null);
}}
className="rounded p-0.5 hover:bg-sky-200 cursor-pointer"
title="Сбросить фильтр"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setSelectedFolderId(null);
}
}}
>
<X size={14} />
</span>
)}
</button>
{showFolderDropdown && (
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<button
onClick={() => {
setShowCreateFolder(true);
setShowFolderDropdown(false);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-sky-600 transition hover:bg-sky-50"
>
<Plus size={16} />
Создать папку
</button>
</div>
<div className="max-h-96 overflow-y-auto p-2">
<FolderTree
folders={folders}
selectedFolderId={selectedFolderId}
onSelectFolder={(id) => {
console.log("🗂️ Выбрана папка:", id);
setSelectedFolderId(id);
setShowFolderDropdown(false);
}}
/>
</div>
</div>
}
}}
>
<X size={12} />
</span>
) : (
<ChevronDown size={12} className="opacity-70" />
)}
</div>
</div>
{/* Right Section: Actions */}
<div className="flex shrink-0">
<button
onClick={() => setShowUploadForm(true)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
>
<Plus size={16} />
Загрузить
</button>
{showFolderDropdown && (
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<button
onClick={() => {
setShowCreateFolder(true);
setShowFolderDropdown(false);
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-sky-600 transition hover:bg-sky-50"
>
<Plus size={16} />
Создать папку
</button>
</div>
<div className="max-h-96 overflow-y-auto p-2">
<FolderTree
folders={folders}
selectedFolderId={selectedFolderId}
onSelectFolder={(id) => {
console.log("🗂️ Выбрана папка:", id);
setSelectedFolderId(id);
setShowFolderDropdown(false);
}}
/>
</div>
</div>
)}
</div>
</div>
{/* Active Filters Tags */}
{(selectedTags.length > 0 || dateFrom || dateTo) && (
<div className="mt-3 flex flex-wrap gap-2">
{selectedTags.map(tagId => {
const tag = availableTags.find(t => t.id === tagId);
return tag ? (
<span
key={tagId}
className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 text-xs text-sky-700"
>
<Tags size={12} />
{tag.name}
<button
onClick={() => setSelectedTags(prev => prev.filter(id => id !== tagId))}
className="hover:text-sky-900"
>
<X size={12} />
</button>
</span>
) : null;
})}
{dateFrom && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1 text-xs text-purple-700">
<Calendar size={12} />
С: {new Date(dateFrom).toLocaleDateString('ru')}
<button
onClick={() => setDateFrom('')}
className="hover:text-purple-900"
>
<X size={12} />
</button>
</span>
)}
{dateTo && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1 text-xs text-purple-700">
<Calendar size={12} />
До: {new Date(dateTo).toLocaleDateString('ru')}
<button
onClick={() => setDateTo('')}
className="hover:text-purple-900"
>
<X size={12} />
</button>
</span>
)}
<button
onClick={() => {
setSelectedTags([]);
setDateFrom('');
setDateTo('');
}}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
Сбросить все
</button>
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div className="mb-3 flex flex-col gap-4 rounded-xl border border-gray-200 bg-gray-50 p-3">
{/* Tags Filter */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Теги</label>
<button
type="button"
onClick={() => setShowTagManagement(true)}
className="text-xs text-blue-600 hover:text-blue-700 hover:underline"
>
Управление
</button>
</div>
{availableTags.length > 0 ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{availableTags.map(tag => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm transition hover:border-sky-300 hover:bg-sky-50"
>
<input
type="checkbox"
checked={selectedTags.includes(tag.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedTags(prev => [...prev, tag.id]);
} else {
setSelectedTags(prev => prev.filter(id => id !== tag.id));
}
}}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-2 focus:ring-sky-100"
/>
<span className="truncate">{tag.name}</span>
</label>
))}
</div>
) : (
<p className="text-sm italic text-gray-500">Нет доступных тегов</p>
)}
</div>
{/* Date Range */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дата создания (с)
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дата создания (до)
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
{/* Sorting */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Сортировать по
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as "date" | "title")}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="date">Дата создания</option>
<option value="title">Название</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Порядок
</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="desc">По убыванию</option>
<option value="asc">По возрастанию</option>
</select>
</div>
</div>
{activeFilterCount > 0 && (
<button
type="button"
onClick={() => {
setSelectedTags([]);
setDateFrom('');
setDateTo('');
}}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100"
>
Очистить фильтры
</button>
)}
</div>
)}
</div>
{/* Main Content */}
@ -326,199 +530,6 @@ export default function DocumentsPage() {
</div>
) : (
<>
{/* Search & Filters */}
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
{/* Search Bar */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по документам"
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition ${
showFilters || selectedTags.length > 0 || dateFrom || dateTo
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
title="Фильтры"
>
<SlidersHorizontal size={16} />
{(selectedTags.length > 0 || dateFrom || dateTo) && (
<span className="rounded-full bg-sky-600 px-1.5 py-0.5 text-xs text-white">
{selectedTags.length + (dateFrom ? 1 : 0) + (dateTo ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Active Filters Tags */}
{(selectedTags.length > 0 || dateFrom || dateTo) && (
<div className="mt-3 flex flex-wrap gap-2">
{selectedTags.map(tagId => {
const tag = availableTags.find(t => t.id === tagId);
return tag ? (
<span
key={tagId}
className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 text-xs text-sky-700"
>
<Tags size={12} />
{tag.name}
<button
onClick={() => setSelectedTags(prev => prev.filter(id => id !== tagId))}
className="hover:text-sky-900"
>
<X size={12} />
</button>
</span>
) : null;
})}
{dateFrom && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1 text-xs text-purple-700">
<Calendar size={12} />
С: {new Date(dateFrom).toLocaleDateString('ru')}
<button
onClick={() => setDateFrom('')}
className="hover:text-purple-900"
>
<X size={12} />
</button>
</span>
)}
{dateTo && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-100 px-3 py-1 text-xs text-purple-700">
<Calendar size={12} />
До: {new Date(dateTo).toLocaleDateString('ru')}
<button
onClick={() => setDateTo('')}
className="hover:text-purple-900"
>
<X size={12} />
</button>
</span>
)}
<button
onClick={() => {
setSelectedTags([]);
setDateFrom('');
setDateTo('');
}}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
Сбросить все
</button>
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div className="mt-4 space-y-4 border-t pt-4">
{/* Tags Filter */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">Теги</label>
<button
onClick={() => setShowTagManagement(true)}
className="text-xs text-blue-600 hover:text-blue-700 hover:underline"
>
Управление
</button>
</div>
{availableTags.length > 0 ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{availableTags.map(tag => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm transition hover:border-sky-300 hover:bg-sky-50"
>
<input
type="checkbox"
checked={selectedTags.includes(tag.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedTags(prev => [...prev, tag.id]);
} else {
setSelectedTags(prev => prev.filter(id => id !== tag.id));
}
}}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-2 focus:ring-sky-100"
/>
<span className="truncate">{tag.name}</span>
</label>
))}
</div>
) : (
<p className="text-sm text-gray-500 italic">Нет доступных тегов</p>
)}
</div>
{/* Date Range */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дата создания (с)
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Дата создания (до)
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
</div>
{/* Sorting */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Сортировать по
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as "date" | "title")}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="date">Дата создания</option>
<option value="title">Название</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Порядок
</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "asc" | "desc")}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="desc">По убыванию</option>
<option value="asc">По возрастанию</option>
</select>
</div>
</div>
</div>
)}
</div>
{/* Bulk Actions Toolbar */}
{selection.selectedIds.length > 0 && (
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
@ -918,16 +929,6 @@ export default function DocumentsPage() {
</form>
</Modal>
{/* Acknowledgements Report Modal */}
{showAcknowledgementsReport && (
<DocumentAcknowledgementsReport
documentId={showAcknowledgementsReport.documentId}
documentTitle={showAcknowledgementsReport.documentTitle}
onClose={() => setShowAcknowledgementsReport(null)}
/>
)}
{/* Tag Management Modal */}
<TagManagementModal
isOpen={showTagManagement}
onClose={() => setShowTagManagement(false)}

File diff suppressed because it is too large Load Diff

View File

@ -417,6 +417,17 @@ export default function MessagesPage() {
</div>
) : (
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Сообщения</p>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-600"
>
<Plus size={14} /> Создать чат
</button>
</div>
{/* Поиск и кнопка фильтров */}
<div className="mb-4 flex gap-2">
<div className="relative flex-1">
@ -428,14 +439,6 @@ export default function MessagesPage() {
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center justify-center rounded-lg border border-sky-200 bg-sky-500 p-2.5 text-white transition hover:bg-sky-600"
title="Создать чат"
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}

View File

@ -16,6 +16,7 @@ import type {
} from "@/types/api";
import {
AlertTriangle,
ArrowUpDown,
Check,
ChevronDown,
ChevronUp,
@ -59,6 +60,14 @@ const urgencyMeta: Record<string, { label: string; cls: string }> = {
critical: { label: "Критическая", cls: "text-rose-600" },
};
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "urgency", label: "По срочности ↑" },
{ value: "-urgency", label: "По срочности ↓" },
];
type ScopeTab = "all" | "mine" | "pending_approvals" | "available";
const scopeTabs: { value: ScopeTab; label: string }[] = [
{ value: "all", label: "Все" },
@ -141,6 +150,7 @@ export default function ProcurementPage() {
const [urgencyFilter, setUrgencyFilter] = useState("");
const [departmentFilter, setDepartmentFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [ordering, setOrdering] = useState("-created_at");
/* ── form modal ── */
const [createOpen, setCreateOpen] = useState(false);
@ -279,9 +289,29 @@ export default function ProcurementPage() {
/* ══════════ client-side filter ══════════ */
const filteredRequests = useMemo(() => {
// already filtered server-side, but search may be local too
return [...requests].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}, [requests]);
const urgencyRank: Record<string, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4,
};
return [...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":
return String(a.title || "").localeCompare(String(b.title || ""), "ru", { sensitivity: "base" });
case "urgency":
return (urgencyRank[a.urgency || "medium"] || 0) - (urgencyRank[b.urgency || "medium"] || 0);
case "-urgency":
return (urgencyRank[b.urgency || "medium"] || 0) - (urgencyRank[a.urgency || "medium"] || 0);
case "-created_at":
default:
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
}
});
}, [ordering, requests]);
/* ══════════ form ══════════ */
@ -520,8 +550,8 @@ export default function ProcurementPage() {
{/* ── header ── */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">Закупки</p>
<button type="button" onClick={openCreate} className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white hover:bg-sky-600">
<Plus size={14} /> Новая заявка
<button type="button" onClick={openCreate} className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-sky-600">
<Plus size={14} /> Создать заявку
</button>
</div>
@ -529,20 +559,6 @@ export default function ProcurementPage() {
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
{actionSuccess && <p className="mb-3 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionSuccess}</p>}
{/* ── scope tabs ── */}
<div className="mb-4 flex gap-1 rounded-lg bg-gray-100 p-1">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition ${scope === tab.value ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`}
>
{tab.label}
</button>
))}
</div>
{/* ── search + filter ── */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
@ -566,6 +582,38 @@ export default function ProcurementPage() {
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
)}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(e) => setOrdering(e.target.value)}
className="w-full appearance-none rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-8 text-xs font-medium text-gray-700 transition hover:bg-gray-100 focus:border-sky-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
aria-label="Сортировка списка закупок"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
</div>
{/* ── scope tabs ── */}
<div className="mb-4 flex flex-wrap gap-2">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value
? "bg-sky-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── filters panel ── */}
@ -610,70 +658,158 @@ export default function ProcurementPage() {
const isInProgress = st === "in_progress";
const expanded = expandedIds.has(req.id);
const detail = detailsCache[req.id];
const resolvedDetail = detail || req;
const requestorName = displayUserName(req.requestor);
const executorName = req.executor ? displayUserName(req.executor) : "";
const requestorLink = userLink(req.requestor);
const executorLink = userLink(req.executor);
const itemsCount = detail?.items?.length ?? 0;
const approvalsCount = detail?.approvals?.length ?? 0;
return (
<article key={req.id} className="rounded-xl border border-gray-100 bg-white transition hover:shadow-sm">
{/* ── card head ── */}
<button type="button" onClick={() => toggleExpand(req.id)} className="flex w-full items-start gap-3 p-4 text-left">
<div className="flex-1 min-w-0">
{/* Title + status */}
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-semibold text-gray-900">{req.title || "Без названия"}</p>
<span className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs ring-1 ${sMeta.cls}`}>{sMeta.label}</span>
</div>
<article key={req.id} className="overflow-hidden rounded-xl border border-gray-200 bg-white transition hover:border-gray-300">
<div className="px-4 py-3 xl:hidden">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500 transition hover:bg-gray-100"
>
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
</button>
{/* Meta line */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span>{getDeptName(req)}</span>
<span className="text-gray-300">·</span>
<span className={urg.cls}>{urg.label} срочность</span>
<span className="text-gray-300">·</span>
<span>{fmt(req.created_at)}</span>
{req.total_estimated_cost && (
<>
<span className="text-gray-300">·</span>
<span className="font-medium text-gray-700">{money(req.total_estimated_cost)}</span>
</>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
<h3 className="truncate text-sm font-semibold text-gray-900">{req.title || "Без названия"}</h3>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span className="font-medium text-gray-700">{getDeptName(req)}</span>
<span>{fmt(req.created_at)}</span>
{req.total_estimated_cost && <span className="font-medium text-gray-700">{money(req.total_estimated_cost)}</span>}
</div>
</div>
{/* Requestor */}
<div className="mt-2 flex items-center gap-1.5 text-xs text-gray-500">
<span>Заявитель:</span>
{(() => {
const link = userLink(req.requestor);
const name = displayUserName(req.requestor);
return link
? <Link href={link} onClick={(e) => e.stopPropagation()} className="font-medium text-sky-700 hover:underline">{name}</Link>
: <span className="font-medium text-gray-700">{name}</span>;
})()}
{req.executor && (
<>
<span className="ml-2">Исполнитель:</span>
{(() => {
const link = userLink(req.executor);
const name = displayUserName(req.executor);
return link
? <Link href={link} onClick={(e) => e.stopPropagation()} className="font-medium text-sky-700 hover:underline">{name}</Link>
: <span className="font-medium text-gray-700">{name}</span>;
})()}
</>
)}
<div className="flex shrink-0 flex-col items-end gap-2">
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ${sMeta.cls}`}>{sMeta.label}</span>
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs text-gray-500 sm:grid-cols-2">
<div className="min-w-0">
<span className="text-gray-400">Заявитель:</span>{" "}
{requestorLink
? <Link href={requestorLink} className="font-medium text-sky-700 hover:text-sky-800">{requestorName}</Link>
: <span className="font-medium text-gray-700">{requestorName}</span>}
</div>
<div className="min-w-0">
<span className="text-gray-400">Исполнитель:</span>{" "}
{req.executor ? (
executorLink
? <Link href={executorLink} className="font-medium text-sky-700 hover:text-sky-800">{executorName}</Link>
: <span className="font-medium text-gray-700">{executorName}</span>
) : (
<span className="text-gray-400">не назначен</span>
)}
</div>
{(itemsCount > 0 || approvalsCount > 0) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{itemsCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200">
<Package size={11} /> {itemsCount} поз.
</span>
)}
{approvalsCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200">
<Check size={11} /> {approvalsCount} соглас.
</span>
)}
</div>
)}
</div>
</div>
</div>
<ChevronDown size={16} className={`mt-1 shrink-0 text-gray-400 transition ${expanded ? "rotate-180" : ""}`} />
</button>
</div>
<div className="hidden grid-cols-[minmax(0,2.2fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto] gap-3 px-4 py-3 xl:grid xl:items-center">
<div className="min-w-0">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500 transition hover:bg-gray-100"
>
<ChevronDown size={15} className={`transition ${expanded ? "rotate-180" : ""}`} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
<h3 className="truncate text-sm font-semibold text-gray-900">{req.title || "Без названия"}</h3>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span className="font-medium text-gray-700">{getDeptName(req)}</span>
<span>{fmt(req.created_at)}</span>
{itemsCount > 0 && <span>{itemsCount} поз.</span>}
{approvalsCount > 0 && <span>{approvalsCount} соглас.</span>}
</div>
</div>
</div>
</div>
<div className="min-w-0 text-sm">
<p className="text-xs text-gray-400">Заявитель</p>
{requestorLink
? <Link href={requestorLink} className="truncate font-medium text-sky-700 hover:text-sky-800">{requestorName}</Link>
: <p className="truncate font-medium text-gray-700">{requestorName}</p>}
<p className="mt-1 text-xs text-gray-400">Исполнитель</p>
{req.executor ? (
executorLink
? <Link href={executorLink} className="truncate font-medium text-sky-700 hover:text-sky-800">{executorName}</Link>
: <p className="truncate font-medium text-gray-700">{executorName}</p>
) : (
<p className="truncate text-sm text-gray-400">Не назначен</p>
)}
</div>
<div>
<div className="flex flex-wrap items-center gap-1.5">
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${sMeta.cls}`}>{sMeta.label}</span>
</div>
<p className={`mt-2 text-sm font-medium ${urg.cls}`}>{urg.label} срочность</p>
</div>
<div>
<p className="text-sm font-medium text-gray-700">{money(req.total_estimated_cost)}</p>
<p className="mt-1 text-xs text-gray-500">Создано {fmt(req.created_at) || "—"}</p>
</div>
<div className="flex items-center justify-end lg:justify-self-end">
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={expanded ? "Свернуть детали" : "Развернуть детали"}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 transition hover:bg-gray-50"
>
<ChevronDown size={16} className={`transition ${expanded ? "rotate-180" : ""}`} />
</button>
</div>
</div>
{/* ── expanded detail ── */}
{expanded && (
<div className="border-t border-gray-100 px-4 pb-4 pt-3">
{/* Description */}
<p className="mb-3 text-sm text-gray-700 whitespace-pre-line">{(detail || req).description || "—"}</p>
<div className="border-t border-gray-100 bg-gray-50/70 px-4 py-4">
<div className="mb-3 rounded-xl bg-white px-3 py-2.5 ring-1 ring-gray-100">
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">Описание</p>
<p className="mt-1 whitespace-pre-line text-sm leading-6 text-gray-700">{resolvedDetail.description || "—"}</p>
</div>
{/* Items table */}
{detail?.items && detail.items.length > 0 && (
<div className="mb-3">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">Позиции</p>
<div className="mb-3 rounded-xl border border-gray-200 bg-white p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-gray-400">Позиции</p>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-500">
@ -690,8 +826,8 @@ export default function ProcurementPage() {
<tr key={idx} className="text-gray-700">
<td className="px-3 py-2">
<p className="font-medium">{it.name}</p>
{it.description && <p className="text-gray-500 mt-0.5">{it.description}</p>}
{it.supplier_info && <p className="text-gray-400 mt-0.5">Поставщик: {it.supplier_info}</p>}
{it.description && <p className="mt-0.5 text-gray-500">{it.description}</p>}
{it.supplier_info && <p className="mt-0.5 text-gray-400">Поставщик: {it.supplier_info}</p>}
</td>
<td className="px-3 py-2 text-right">{it.quantity}</td>
<td className="px-3 py-2">{it.unit}</td>
@ -705,10 +841,9 @@ export default function ProcurementPage() {
</div>
)}
{/* Approvals */}
{detail?.approvals && detail.approvals.length > 0 && (
<div className="mb-3">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">Согласования</p>
<div className="mb-3 rounded-xl border border-gray-200 bg-white p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-gray-400">Согласования</p>
<div className="flex flex-col gap-1.5">
{detail.approvals.map((a) => {
const aSt = String(a.status).toLowerCase();
@ -726,29 +861,24 @@ export default function ProcurementPage() {
</div>
)}
{/* Workflow action buttons */}
<div className="flex flex-wrap items-center gap-1.5">
{/* Draft → Submit */}
{isDraft && isAuthor && (
<button type="button" onClick={() => handleSubmit(req.id)} disabled={busyKey === `submit-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60">
<Send size={13} /> На согласование
</button>
)}
{/* Draft → Edit */}
{isDraft && isAuthor && (
<button type="button" onClick={() => openEdit(req)} className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50">
<Pencil size={13} /> Редактировать
</button>
)}
{/* Draft → Delete */}
{isDraft && (isAuthor || canManage) && (
<button type="button" onClick={() => handleDelete(req.id)} disabled={busyKey === `delete-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-600 hover:bg-rose-100 disabled:opacity-60">
<Trash2 size={13} /> Удалить
</button>
)}
{/* Pending → Approve / Reject (for approvers; simplified) */}
{isPending && !isAuthor && (
<>
<button type="button" onClick={() => handleApprove(req.id)} disabled={busyKey === `approve-${req.id}`}
@ -761,21 +891,18 @@ export default function ProcurementPage() {
</button>
</>
)}
{/* Approved → Start work */}
{isApproved && !req.executor && (
<button type="button" onClick={() => handleStart(req.id)} disabled={busyKey === `start-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60">
<Play size={13} /> Взять в работу
</button>
)}
{/* In progress → Complete */}
{isInProgress && isExecutor && (
<button type="button" onClick={() => handleComplete(req.id)} disabled={busyKey === `complete-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg bg-teal-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-teal-600 disabled:opacity-60">
<ClipboardCheck size={13} /> Завершить
</button>
)}
{/* Cancel (author, not final) */}
{isAuthor && !isFinal(st) && st !== "draft" && (
<button type="button" onClick={() => handleCancel(req.id)} disabled={busyKey === `cancel-${req.id}`}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-60">

View File

@ -7,7 +7,7 @@ import { canManageRequests, canProcessRequests } from "@/lib/permissions";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import type { Request, RequestComment, User, Department } from "@/types/api";
import { Ban, Check, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X } from "lucide-react";
import { ArrowUpDown, Ban, Check, ChevronDown, FileSignature, Filter, MessageSquare, Paperclip, Pencil, Plus, Search, ThumbsDown, ThumbsUp, Trash2, X } from "lucide-react";
type RequestFormState = {
type: string;
@ -75,6 +75,14 @@ const requestTypeLabels: Record<string, string> = {
other: "Другое",
};
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "date_from", label: "По периоду ↑" },
{ value: "-date_from", label: "По периоду ↓" },
];
const defaultStatusMeta = {
label: "Неизвестный статус",
className: "bg-gray-50 text-gray-700 ring-gray-200",
@ -97,6 +105,7 @@ export default function RequestsPage() {
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>>({});
@ -114,6 +123,7 @@ export default function RequestsPage() {
const [periodFromFilter, setPeriodFromFilter] = useState("");
const [periodToFilter, setPeriodToFilter] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [ordering, setOrdering] = useState("-created_at");
const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null);
const [loading, setLoading] = useState(true);
@ -223,9 +233,22 @@ export default function RequestsPage() {
const filteredRequests = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...requests].sort((a, b) => {
const aTime = new Date(a.created_at).getTime() || 0;
const bTime = new Date(b.created_at).getTime() || 0;
return bTime - aTime;
switch (ordering) {
case "created_at":
return (new Date(a.created_at).getTime() || 0) - (new Date(b.created_at).getTime() || 0);
case "title": {
const leftTitle = String(a.display_title || a.title || "").trim();
const rightTitle = String(b.display_title || b.title || "").trim();
return leftTitle.localeCompare(rightTitle, "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);
case "-created_at":
default:
return (new Date(b.created_at).getTime() || 0) - (new Date(a.created_at).getTime() || 0);
}
});
if (!q) return sorted;
@ -237,7 +260,7 @@ export default function RequestsPage() {
const author = displayUserName(item.employee || item.created_by).toLowerCase();
return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q);
});
}, [requests, search]);
}, [ordering, requests, search]);
const resetForm = () => {
setForm(emptyForm);
@ -402,6 +425,10 @@ export default function RequestsPage() {
}
};
const toggleRow = (requestId: number) => {
setExpandedRows((prev) => ({ ...prev, [requestId]: !prev[requestId] }));
};
const handleAddComment = async (requestId: number) => {
const text = (commentDrafts[requestId] || "").trim();
if (!text) return;
@ -603,6 +630,20 @@ export default function RequestsPage() {
</span>
)}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(e) => setOrdering(e.target.value)}
className="w-full appearance-none rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-8 text-xs font-medium text-gray-700 transition hover:bg-gray-100 focus:border-sky-400 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
aria-label="Сортировка списка заявлений"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<ChevronDown size={14} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
</div>
{filtersOpen && (
@ -728,258 +769,309 @@ export default function RequestsPage() {
const canEditThis = isAuthor && !isFinal(statusKey);
const canCancelThis = isAuthor && !isFinal(statusKey);
const canDeleteThis = (isAuthor && !isFinal(statusKey)) || canManage;
const rowOpen = Boolean(expandedRows[item.id]);
const comments = commentsMap[item.id] || [];
const commentsOpen = Boolean(expandedComments[item.id]);
const departmentLabels = (item.departments || [])
.map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`)
.join(", ");
const recipients = item.recipients || [];
const ccUsers = item.cc_users || [];
const visibleRecipients = recipients.slice(0, 2);
const visibleCcUsers = ccUsers.slice(0, 2);
const hiddenRecipientsCount = Math.max(0, recipients.length - visibleRecipients.length);
const hiddenCcCount = Math.max(0, ccUsers.length - visibleCcUsers.length);
const summaryText = item.comment || item.description;
return (
<article key={item.id} className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50">
<div className="mb-2 flex items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="flex items-center gap-2 group">
{requestAuthor?.avatar ? (
<img src={requestAuthor.avatar} alt={authorName} className="h-8 w-8 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
{(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="text-sm font-medium text-gray-800 group-hover:text-sky-700">{authorName}</span>
</Link>
) : (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-xs font-semibold text-gray-500 ring-1 ring-gray-200">
?
</span>
<span className="text-sm font-medium text-gray-800">{authorName}</span>
</div>
)}
</div>
<article key={item.id} className="overflow-hidden rounded-xl border border-gray-200 bg-white transition hover:border-gray-300">
<div className="p-4">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => toggleRow(item.id)}
aria-label={rowOpen ? "Свернуть детали" : "Развернуть детали"}
className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500 transition hover:bg-gray-100"
>
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
</button>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{item.display_title || item.title || "Без заголовка"}</p>
<p className="mt-1 text-xs text-gray-500">Тип: {requestTypeLabel}</p>
</div>
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
</span>
</div>
{(item.comment || item.description) ? (
<p className="mt-3 text-sm text-gray-700">{item.comment || item.description}</p>
) : null}
<div className="mt-3 flex items-center gap-2 text-xs text-gray-500">
<span>Решающий:</span>
{(() => {
const approver = item.approver || item.assigned_to;
const aLink = userProfileLink(approver);
const aName = displayUserName(approver);
if (!approver) return <span className="text-gray-400">—</span>;
const avatarEl = approver.avatar ? (
<img src={approver.avatar} alt={aName} className="h-6 w-6 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-semibold text-sky-700 ring-1 ring-sky-200">
{(approver.first_name?.[0] || approver.last_name?.[0] || "?").toUpperCase()}
</span>
);
return aLink ? (
<Link href={aLink} className="flex items-center gap-1.5 group">
{avatarEl}
<span className="font-medium text-gray-800 group-hover:text-sky-700">{aName}</span>
</Link>
) : (
<div className="flex items-center gap-1.5">
{avatarEl}
<span className="font-medium text-gray-800">{aName}</span>
</div>
);
})()}
</div>
<div className="mt-3 flex flex-col gap-1.5 text-xs text-gray-500">
<div className="flex items-center gap-1.5">
<span>Получатели:</span>
{(item.recipients && item.recipients.length > 0) ? item.recipients.map((r) => {
const rLink = userProfileLink(r);
const rName = displayUserName(r);
const avatarEl = r.avatar ? (
<img src={r.avatar} alt={rName} className="h-6 w-6 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-semibold text-sky-700 ring-1 ring-sky-200">
{(r.first_name?.[0] || r.last_name?.[0] || "?").toUpperCase()}
</span>
);
return (
<span key={r.id} className="inline-flex flex-1 items-center justify-between">
<span className="inline-flex items-center gap-1">
{rLink ? (
<Link href={rLink} className="flex items-center gap-1 group" title={rName}>
{avatarEl}
<span className="font-medium text-gray-800 group-hover:text-sky-700">{rName}</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
{requestAuthor?.avatar ? (
<img src={requestAuthor.avatar} alt={authorName} className="h-8 w-8 shrink-0 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-sky-100 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
{(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="truncate text-sm font-medium text-gray-800 group-hover:text-sky-700">{authorName}</span>
</Link>
) : (
<span className="flex items-center gap-1" title={rName}>
{avatarEl}
<span className="font-medium text-gray-800">{rName}</span>
</span>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-semibold text-gray-500 ring-1 ring-gray-200">
?
</span>
<span className="truncate text-sm font-medium text-gray-800">{authorName}</span>
</div>
)}
</span>
</span>
);
}) : <span className="text-gray-400">{item.recipient_count ?? 0}</span>}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span>В копии:</span>
{(item.cc_users && item.cc_users.length > 0) ? item.cc_users.map((c) => {
const cLink = userProfileLink(c);
const cName = displayUserName(c);
const avatarEl = c.avatar ? (
<img src={c.avatar} alt={cName} className="h-6 w-6 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-[10px] font-semibold text-sky-700 ring-1 ring-sky-200">
{(c.first_name?.[0] || c.last_name?.[0] || "?").toUpperCase()}
</span>
);
return cLink ? (
<Link key={c.id} href={cLink} className="flex items-center gap-1 group" title={cName}>
{avatarEl}
<span className="font-medium text-gray-800 group-hover:text-sky-700">{cName}</span>
</Link>
) : (
<div key={c.id} className="flex items-center gap-1" title={cName}>
{avatarEl}
<span className="font-medium text-gray-800">{cName}</span>
</div>
<h3 className="truncate text-sm font-semibold text-gray-900">{item.display_title || item.title || "Без заголовка"}</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
<span>Тип: {requestTypeLabel}</span>
<span>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</span>
</div>
</div>
);
}) : <span className="text-gray-400">—</span>}
</div>
<p>Период: {item.date_from ? formatDate(item.date_from) : "—"}{item.date_to ? ` — ${formatDate(item.date_to)}` : ""}</p>
<p>Создано: {formatDate(item.created_at)}</p>
<p>Обновлено: {formatDate(item.updated_at)}</p>
{departmentLabels ? <p>Отделы: {departmentLabels}</p> : null}
{(item.attachment || item.attachment_url) && (
<div className="flex items-center gap-1.5 min-w-0">
<button
type="button"
onClick={() => {
const url = item.attachment_url || item.attachment || "";
const name = decodeURIComponent(url.split("/").pop() || "Вложение");
setAttachmentPreview({ url, name });
}}
className="inline-flex items-center gap-1.5 min-w-0 max-w-full text-sky-700 hover:text-sky-800"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate font-medium underline decoration-sky-300 underline-offset-2">
{(() => {
const url = item.attachment_url || item.attachment || "";
return decodeURIComponent(url.split("/").pop() || "Вложение");
})()}
<span className={`inline-flex shrink-0 rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
</span>
</button>
</div>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{canCancelThis ? (
<button type="button" title="Отменить" onClick={() => handleCancel(item.id)} disabled={busyKey === `cancel-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50 disabled:opacity-60">
<Ban size={15} />
</button>
) : null}
{summaryText ? (
<p className="mt-3 text-sm text-gray-700">{summaryText}</p>
) : null}
<button
type="button"
title={`Комментарии (${item.comments_count ?? comments.length})`}
onClick={() => toggleComments(item.id)}
className="relative inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50"
>
<MessageSquare size={15} />
{(item.comments_count ?? comments.length) > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">
{item.comments_count ?? comments.length}
</span>
)}
</button>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{canCancelThis ? (
<button type="button" title="Отменить" onClick={() => handleCancel(item.id)} disabled={busyKey === `cancel-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50 disabled:opacity-60">
<Ban size={15} />
</button>
) : null}
{canEditThis ? (
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50">
<Pencil size={15} />
</button>
) : null}
{canDeleteThis ? (
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-rose-200 bg-rose-50 p-1.5 text-rose-600 hover:bg-rose-100 disabled:opacity-60">
<Trash2 size={15} />
</button>
) : null}
{canProcessThis && (
<span className="inline-flex items-center gap-10 ml-auto">
<button type="button" title={`Одобрить`} onClick={() => handleApprove(item.id)} disabled={busyKey === `approve-${item.id}`} className="text-emerald-500 hover:text-emerald-600 disabled:opacity-60">
<ThumbsUp size={30} />
</button>
<button type="button" title={`Отклонить`} onClick={() => handleReject(item.id)} disabled={busyKey === `reject-${item.id}`} className="text-rose-500 hover:text-rose-600 disabled:opacity-60">
<ThumbsDown size={30} />
</button>
<button
type="button"
title={`Комментарии (${item.comments_count ?? comments.length})`}
onClick={() => toggleComments(item.id)}
className="relative inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50"
>
<MessageSquare size={15} />
{(item.comments_count ?? comments.length) > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">
{item.comments_count ?? comments.length}
</span>
)}
</button>
</div>
{canEditThis ? (
<button type="button" title="Редактировать" onClick={() => openEdit(item)} className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50">
<Pencil size={15} />
</button>
) : null}
{commentsOpen ? (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="space-y-2">
{(commentsMap[item.id] || []).length === 0 ? (
<p className="text-xs text-gray-500">Комментариев пока нет</p>
) : (
(commentsMap[item.id] || []).map((c) => {
const canDeleteComment = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser));
return (
<div key={c.id} className="rounded-lg bg-white px-3 py-2 text-xs text-gray-700 ring-1 ring-gray-100">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(c.author)}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500">{formatDate(c.created_at)}</span>
{canDeleteComment ? (
<button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="text-rose-600 hover:text-rose-700">
удалить
</button>
) : null}
</div>
</div>
<p>{c.text}</p>
</div>
);
})
)}
</div>
{canDeleteThis ? (
<button type="button" title="Удалить" onClick={() => handleDelete(item.id)} disabled={busyKey === `delete-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-rose-200 bg-rose-50 p-1.5 text-rose-600 hover:bg-rose-100 disabled:opacity-60">
<Trash2 size={15} />
</button>
) : null}
<div className="mt-2 flex items-center gap-2">
<input
value={commentDrafts[item.id] || ""}
onChange={(e) => setCommentDrafts((prev) => ({ ...prev, [item.id]: e.target.value }))}
placeholder="Добавить комментарий"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => handleAddComment(item.id)}
disabled={busyKey === `comment-${item.id}`}
className="rounded-lg bg-sky-500 px-3 py-2 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
Отправить
</button>
{canProcessThis && (
<span className="ml-auto inline-flex items-center gap-2">
<button type="button" title="Одобрить" onClick={() => handleApprove(item.id)} disabled={busyKey === `approve-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-emerald-200 bg-emerald-50 p-2 text-emerald-600 hover:bg-emerald-100 disabled:opacity-60">
<ThumbsUp size={18} />
</button>
<button type="button" title="Отклонить" onClick={() => handleReject(item.id)} disabled={busyKey === `reject-${item.id}`} className="inline-flex items-center justify-center rounded-lg border border-rose-200 bg-rose-50 p-2 text-rose-600 hover:bg-rose-100 disabled:opacity-60">
<ThumbsDown size={18} />
</button>
</span>
)}
</div>
</div>
</div>
) : null}
{(rowOpen || commentsOpen) ? (
<div className="mt-4 rounded-xl border border-gray-100 bg-gray-50/80 p-4">
{rowOpen ? (
<div className="space-y-3 text-xs text-gray-500">
<div className="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div className="min-w-0">
<span className="text-gray-400">Решающий:</span>{" "}
{(() => {
const approver = item.approver || item.assigned_to;
const aLink = userProfileLink(approver);
const aName = displayUserName(approver);
if (!approver) return <span className="text-gray-400">—</span>;
return aLink ? (
<Link href={aLink} className="font-medium text-sky-700 hover:text-sky-800">{aName}</Link>
) : (
<span className="font-medium text-gray-700">{aName}</span>
);
})()}
</div>
<div>
<span className="text-gray-400">Создано:</span>{" "}
<span className="font-medium text-gray-700">{formatDate(item.created_at) || "—"}</span>
</div>
<div>
<span className="text-gray-400">Обновлено:</span>{" "}
<span className="font-medium text-gray-700">{formatDate(item.updated_at) || "—"}</span>
</div>
{departmentLabels ? (
<div className="sm:col-span-2">
<span className="text-gray-400">Отделы:</span>{" "}
<span className="font-medium text-gray-700">{departmentLabels}</span>
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2">
<span className="pt-1 text-gray-400">Получатели:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{visibleRecipients.length > 0 ? visibleRecipients.map((recipient) => {
const recipientLink = userProfileLink(recipient);
const recipientName = displayUserName(recipient);
const chipContent = (
<>
{recipient.avatar ? (
<img src={recipient.avatar} alt={recipientName} className="h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-sky-100 text-[10px] font-semibold text-sky-700 ring-1 ring-sky-200">
{(recipient.first_name?.[0] || recipient.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="max-w-[140px] truncate">{recipientName}</span>
</>
);
return recipientLink ? (
<Link key={recipient.id} href={recipientLink} className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-700 ring-1 ring-gray-200 hover:bg-gray-200">
{chipContent}
</Link>
) : (
<span key={recipient.id} className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-700 ring-1 ring-gray-200">
{chipContent}
</span>
);
}) : (
<span className="pt-1 text-gray-400">{item.recipient_count ?? 0}</span>
)}
{hiddenRecipientsCount > 0 && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-600 ring-1 ring-gray-200">
+{hiddenRecipientsCount}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
<span className="pt-1 text-gray-400">В копии:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{visibleCcUsers.length > 0 ? visibleCcUsers.map((ccUser) => {
const ccLink = userProfileLink(ccUser);
const ccName = displayUserName(ccUser);
const chipContent = (
<>
{ccUser.avatar ? (
<img src={ccUser.avatar} alt={ccName} className="h-5 w-5 shrink-0 rounded-full object-cover ring-1 ring-gray-200" />
) : (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-sky-100 text-[10px] font-semibold text-sky-700 ring-1 ring-sky-200">
{(ccUser.first_name?.[0] || ccUser.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<span className="max-w-[140px] truncate">{ccName}</span>
</>
);
return ccLink ? (
<Link key={ccUser.id} href={ccLink} className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-700 ring-1 ring-gray-200 hover:bg-gray-200">
{chipContent}
</Link>
) : (
<span key={ccUser.id} className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-700 ring-1 ring-gray-200">
{chipContent}
</span>
);
}) : (
<span className="pt-1 text-gray-400">—</span>
)}
{hiddenCcCount > 0 && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 font-medium text-gray-600 ring-1 ring-gray-200">
+{hiddenCcCount}
</span>
)}
</div>
</div>
{(item.attachment || item.attachment_url) && (
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
onClick={() => {
const url = item.attachment_url || item.attachment || "";
const name = decodeURIComponent(url.split("/").pop() || "Вложение");
setAttachmentPreview({ url, name });
}}
className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-sky-50 px-2.5 py-1 text-sky-700 ring-1 ring-sky-100 hover:bg-sky-100"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate font-medium">
{(() => {
const url = item.attachment_url || item.attachment || "";
return decodeURIComponent(url.split("/").pop() || "Вложение");
})()}
</span>
</button>
</div>
)}
</div>
</div>
) : null}
{commentsOpen ? (
<div className={rowOpen ? "mt-3 rounded-lg border border-gray-200 bg-white p-3" : "rounded-lg border border-gray-200 bg-white p-3"}>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="text-xs text-gray-500">Комментариев пока нет</p>
) : (
comments.map((c) => {
const canDeleteComment = Boolean(c.author?.id && (user?.id === c.author.id || auth?.is_staff || auth?.is_superuser));
return (
<div key={c.id} className="rounded-lg bg-white px-3 py-2 text-xs text-gray-700 ring-1 ring-gray-100">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(c.author)}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500">{formatDate(c.created_at)}</span>
{canDeleteComment ? (
<button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="text-rose-600 hover:text-rose-700">
удалить
</button>
) : null}
</div>
</div>
<p>{c.text}</p>
</div>
);
})
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={commentDrafts[item.id] || ""}
onChange={(e) => setCommentDrafts((prev) => ({ ...prev, [item.id]: e.target.value }))}
placeholder="Добавить комментарий"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => handleAddComment(item.id)}
disabled={busyKey === `comment-${item.id}`}
className="rounded-lg bg-sky-500 px-3 py-2 text-xs font-medium text-white hover:bg-sky-600 disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>
</article>
);
})

View File

@ -1704,82 +1704,49 @@ class ApiClient {
return this.request(url);
}
async createEquipment(data: any): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, String(data[key]));
}
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/procurement/equipment/', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
async getEquipmentDetail(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/`);
}
async updateEquipment(equipmentId: number, data: any): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachment' && data[key] instanceof File) {
formData.append('attachment', data[key]);
} else if (Array.isArray(data[key])) {
data[key].forEach((val: any) => {
formData.append(key, String(val));
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, String(data[key]));
}
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
async getMyEquipment(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/equipment/my_equipment/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/`, {
async getWarrantyExpiringEquipment(): Promise<any> {
return this.request('/api/v1/procurement/equipment/warranty_expiring/');
}
async generateEquipmentInventoryNumber(): Promise<{ inventory_number: string }> {
return this.request('/api/v1/procurement/equipment/generate-inventory-number/');
}
async getEquipmentCreateOptions(): Promise<any> {
return this.request('/api/v1/procurement/equipment/create-options/');
}
async createEquipment(data: Record<string, any>): Promise<any> {
return this.request('/api/v1/procurement/equipment/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async updateEquipment(equipmentId: number, data: Record<string, any>): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/`, {
method: 'PATCH',
headers,
body: formData,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
detail = JSON.stringify(body);
} catch {}
throw new Error(detail);
}
return response.json();
}
async deleteEquipment(equipmentId: number): Promise<void> {
@ -1788,24 +1755,72 @@ class ApiClient {
});
}
async approveEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/approve/`, {
async transferEquipment(equipmentId: number, data: { to_department?: number | null; to_person?: number | null; reason?: string }): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/transfer/`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async rejectEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/reject/`, {
async writeOffEquipment(equipmentId: number, reason: string): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/write_off/`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
async cancelEquipment(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/cancel/`, {
async addEquipmentMaintenance(equipmentId: number, data: { type: string; description?: string; cost?: string | number; date?: string }): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/add_maintenance/`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async getEquipmentTransferHistory(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/transfer_history/`);
}
async getMaintenanceRecords(params?: Record<string, string | number>): Promise<any> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
}
const url = `/api/v1/procurement/maintenance/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getEquipmentCategoryTree(): Promise<any> {
return this.request('/api/v1/procurement/equipment-categories/tree/');
}
async getEquipmentCategoryChildren(categoryId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment-categories/${categoryId}/children/`);
}
async getEquipmentQrCodeBlobUrl(equipmentId: number): Promise<string> {
const headers: Record<string, string> = {};
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/procurement/equipment/${equipmentId}/qr_code/`, {
method: 'GET',
headers,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
async getEquipmentComments(equipmentId: number): Promise<any> {
return this.request(`/api/v1/procurement/equipment/${equipmentId}/comments/`);
}

View File

@ -82,3 +82,50 @@ export function canManageRequests(user?: User | null): boolean {
return false;
}
/**
* Проверяет, может ли пользователь управлять оборудованием.
*
* Соответствует backend CanManageEquipment из procurement/permissions.py
*
* Возвращает true если:
* - Пользователь является staff или superuser
* - Имеет модельное право procurement.add_equipment / change_equipment / delete_equipment
* - Является руководителем хотя бы одного отдела
* - Имеет департаментное право MANAGE_EQUIPMENT
*/
export function canManageEquipment(user?: User | null): boolean {
if (!user) return false;
const auth = user.auth;
if (!auth) return false;
if (auth.is_staff || auth.is_superuser) {
return true;
}
const perms = auth.permissions || [];
if (
perms.includes("procurement.add_equipment") ||
perms.includes("procurement.change_equipment") ||
perms.includes("procurement.delete_equipment")
) {
return true;
}
const procPerms = auth.permissions_by_app?.["procurement"] || [];
if (
procPerms.includes("add_equipment") ||
procPerms.includes("change_equipment") ||
procPerms.includes("delete_equipment")
) {
return true;
}
const departments = user.departments || [];
if (departments.some((dept) => dept.is_head)) {
return true;
}
return false;
}

View File

@ -268,31 +268,37 @@ export interface RequestComment {
export interface EquipmentCategory {
id: number;
name: string;
parent?: number | null;
description?: string;
icon?: string;
full_path?: string;
children_count?: number;
created_at?: string;
children?: EquipmentCategory[];
}
export interface Equipment {
id: number;
name: string;
description?: string;
category: number | EquipmentCategory;
inventory_number?: string;
serial_number?: string;
category: number;
category_name?: string;
category_icon?: string;
department: number;
department_name?: string;
serial_number?: string;
inventory_number?: string;
status?: string;
condition?: string;
status_display?: string;
responsible_person?: number | null;
responsible_name?: string;
location?: string;
purchase_date: string;
purchase_cost: string | number;
assigned_to?: User | number | null;
assigned_to_details?: User;
location?: string;
warranty_until?: string | null;
notes?: string;
image?: string | null;
attachment?: string | null;
attachment_url?: string | null;
created_by?: User;
is_under_warranty?: boolean;
comments_count?: number;
maintenance_count?: number;
created_at: string;
updated_at: string;
}
@ -305,6 +311,41 @@ export interface EquipmentComment {
created_at: string;
}
export interface EquipmentCreateOptions {
allowed_departments: Array<Pick<Department, 'id' | 'name'>>;
can_choose_department: boolean;
can_choose_responsible: boolean;
default_responsible: { id: number; name: string } | null;
permission_level: 'full' | 'dept_head' | 'scoped' | null;
}
export interface EquipmentTransferHistoryEntry {
id: number;
from_department: string | null;
to_department: string | null;
from_person: string | null;
to_person: string | null;
reason?: string;
created_by?: string | null;
date: string;
}
export interface MaintenanceRecord {
id: number;
equipment: number;
equipment_name?: string;
equipment_inventory?: string;
date: string;
type: string;
type_display?: string;
description?: string;
cost?: string | number | null;
performed_by?: number;
performed_by_name?: string;
next_maintenance_date?: string | null;
created_at: string;
}
// Procurement Request types
export type ProcurementStatus = 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'rejected' | 'cancelled';
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';