Унификация сортировки и визуального стиля карточек: добавлены селекторы сортировки и убраны лишние тени в разделах заявлений, закупок и оборудования
This commit is contained in:
parent
3a2d05a64d
commit
decbadf47b
@ -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
@ -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)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user