refactor(frontend): удаление устаревших компонентов документов

- Удален DocumentStatusBadge (больше нет статусов)
- Удален DocumentWorkflowButtons (нет FSM workflow)
- Удален DocumentVersionHistory (дубль, есть другая реализация)
- Удален AdvancedSearch (заменен на фильтры)
- Удалена папка versions/ с компонентами истории версий
This commit is contained in:
lizerk 2026-03-06 12:23:11 +03:00
parent 7d42fdac9d
commit 8c6753c188
8 changed files with 0 additions and 1252 deletions

View File

@ -1,60 +0,0 @@
"use client";
import type { DocumentStatus } from "@/types/api";
interface DocumentStatusBadgeProps {
status: string; // human-readable: "Черновик", "На рассмотрении", etc.
statusCode: DocumentStatus; // machine-readable: "draft", "in_review", etc.
className?: string;
}
const statusConfig: Record<DocumentStatus, { label: string; color: string; bgColor: string; ringColor: string }> = {
draft: {
label: "Черновик",
color: "text-gray-700",
bgColor: "bg-gray-50",
ringColor: "ring-gray-200",
},
in_review: {
label: "На рассмотрении",
color: "text-cyan-700",
bgColor: "bg-cyan-50",
ringColor: "ring-cyan-200",
},
approved: {
label: "Утверждено",
color: "text-green-700",
bgColor: "bg-green-50",
ringColor: "ring-green-200",
},
published: {
label: "Опубликовано",
color: "text-blue-700",
bgColor: "bg-blue-50",
ringColor: "ring-blue-200",
},
archived: {
label: "В архиве",
color: "text-gray-600",
bgColor: "bg-gray-100",
ringColor: "ring-gray-300",
},
rejected: {
label: "Отклонено",
color: "text-red-700",
bgColor: "bg-red-50",
ringColor: "ring-red-200",
},
};
export function DocumentStatusBadge({ status, statusCode, className = "" }: DocumentStatusBadgeProps) {
const config = statusConfig[statusCode] || statusConfig.draft;
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 ${config.color} ${config.bgColor} ${config.ringColor} ${className}`}
>
{status || config.label}
</span>
);
}

View File

@ -1,196 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { History, RotateCcw, User as UserIcon, Clock } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
interface Version {
id: number;
revision_id: number;
date_created: string;
user: {
id: number;
full_name: string;
} | null;
comment: string;
data: any;
}
interface DocumentVersionHistoryProps {
documentId: number;
onRevert?: () => void;
}
export function DocumentVersionHistory({ documentId, onRevert }: DocumentVersionHistoryProps) {
const [versions, setVersions] = useState<Version[]>([]);
const [loading, setLoading] = useState(true);
const [reverting, setReverting] = useState(false);
useEffect(() => {
loadVersions();
}, [documentId]);
const loadVersions = async () => {
try {
setLoading(true);
const response = await apiClient.getDocumentVersions(documentId);
const data = Array.isArray(response) ? response : (response.results || []);
setVersions(data);
} catch (error) {
console.error("Ошибка загрузки версий:", error);
toast.error("Не удалось загрузить историю версий");
} finally {
setLoading(false);
}
};
const handleRevert = async (versionId: number) => {
const comment = prompt("Укажите причину отката (опционально):");
if (comment === null) return; // User cancelled
setReverting(true);
try {
await apiClient.revertDocumentToVersion(documentId, versionId);
toast.success("Документ откачен к выбранной версии");
if (onRevert) {
onRevert();
}
await loadVersions();
} catch (error) {
console.error("Ошибка отката:", error);
toast.error("Не удалось откатить документ");
} finally {
setReverting(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getChangedFields = (versionData: any): string[] => {
if (!versionData || !versionData.data) return [];
const fields = Object.keys(versionData.data);
return fields.filter(f => !['id', 'created_at', 'updated_at'].includes(f));
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
</div>
);
}
if (versions.length === 0) {
return (
<div className="py-8 text-center">
<History size={48} className="mx-auto mb-3 text-gray-300" />
<p className="text-sm text-gray-500">
История версий пока пуста
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<History size={18} className="text-gray-500" />
<h3 className="text-sm font-medium text-gray-900">
История изменений ({versions.length})
</h3>
</div>
<div className="space-y-2">
{versions.map((version, index) => {
const isLatest = index === 0;
const changedFields = getChangedFields(version);
return (
<div
key={version.id}
className={`rounded-lg border p-3 ${
isLatest
? "border-sky-300 bg-sky-50"
: "border-gray-200 bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
{/* Header */}
<div className="flex items-center gap-2">
{isLatest && (
<span className="rounded-full bg-sky-600 px-2 py-0.5 text-xs font-medium text-white">
Текущая
</span>
)}
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock size={12} />
{formatDate(version.date_created)}
</div>
</div>
{/* User */}
{version.user && (
<div className="flex items-center gap-1.5 text-sm">
<UserIcon size={14} className="text-gray-400" />
<span className="font-medium text-gray-700">
{version.user.full_name}
</span>
</div>
)}
{/* Comment */}
{version.comment && (
<p className="text-sm text-gray-600">{version.comment}</p>
)}
{/* Changed fields */}
{changedFields.length > 0 && (
<div className="flex flex-wrap gap-1">
{changedFields.slice(0, 5).map((field) => (
<span
key={field}
className="inline-block rounded bg-gray-200 px-2 py-0.5 text-xs text-gray-600"
>
{field}
</span>
))}
{changedFields.length > 5 && (
<span className="inline-block px-2 py-0.5 text-xs text-gray-500">
+{changedFields.length - 5} ещё
</span>
)}
</div>
)}
</div>
{/* Revert button */}
{!isLatest && (
<button
onClick={() => handleRevert(version.id)}
disabled={reverting}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
title="Откатить к этой версии"
>
<RotateCcw size={12} />
Откатить
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,168 +0,0 @@
"use client";
import { useState } from "react";
import {
Send,
CheckCircle,
XCircle,
Globe,
RotateCcw,
Archive,
ArchiveRestore
} from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import type { DocumentStatus } from "@/types/api";
interface DocumentWorkflowButtonsProps {
documentId: number;
currentStatus: DocumentStatus;
onStatusChange?: () => void;
}
interface WorkflowAction {
label: string;
icon: React.ReactNode;
action: () => Promise<any>;
confirmMessage?: string;
successMessage: string;
variant: "primary" | "success" | "danger" | "secondary";
}
export function DocumentWorkflowButtons({
documentId,
currentStatus,
onStatusChange,
}: DocumentWorkflowButtonsProps) {
const [isLoading, setIsLoading] = useState(false);
const handleAction = async (action: WorkflowAction) => {
if (action.confirmMessage && !window.confirm(action.confirmMessage)) {
return;
}
setIsLoading(true);
try {
await action.action();
toast.success(action.successMessage);
if (onStatusChange) {
onStatusChange();
}
} catch (err) {
console.error("Ошибка изменения статуса:", err);
toast.error("Не удалось изменить статус документа");
} finally {
setIsLoading(false);
}
};
const getAvailableActions = (): WorkflowAction[] => {
const actions: WorkflowAction[] = [];
switch (currentStatus) {
case "draft":
actions.push({
label: "Отправить на рассмотрение",
icon: <Send size={14} />,
action: () => apiClient.submitDocumentForReview(documentId),
successMessage: "Документ отправлен на рассмотрение",
variant: "primary",
});
break;
case "in_review":
actions.push(
{
label: "Утвердить",
icon: <CheckCircle size={14} />,
action: () => apiClient.approveDocument(documentId),
confirmMessage: "Вы уверены, что хотите утвердить этот документ?",
successMessage: "Документ утвержден",
variant: "success",
},
{
label: "Отклонить",
icon: <XCircle size={14} />,
action: () => apiClient.rejectDocument(documentId),
confirmMessage: "Вы уверены, что хотите отклонить этот документ?",
successMessage: "Документ отклонен",
variant: "danger",
},
{
label: "Вернуть в черновик",
icon: <RotateCcw size={14} />,
action: () => apiClient.returnDocumentToDraft(documentId),
successMessage: "Документ возвращен в черновик",
variant: "secondary",
}
);
break;
case "approved":
actions.push({
label: "Опубликовать",
icon: <Globe size={14} />,
action: () => apiClient.publishDocument(documentId),
confirmMessage: "Опубликовать этот документ для всех пользователей?",
successMessage: "Документ опубликован",
variant: "primary",
});
break;
case "published":
actions.push({
label: "Архивировать",
icon: <Archive size={14} />,
action: () => apiClient.archiveDocument(documentId),
confirmMessage: "Архивировать этот документ?",
successMessage: "Документ архивирован",
variant: "secondary",
});
break;
case "archived":
actions.push({
label: "Разархивировать",
icon: <ArchiveRestore size={14} />,
action: () => apiClient.unarchiveDocument(documentId),
successMessage: "Документ разархивирован",
variant: "primary",
});
break;
}
return actions;
};
const actions = getAvailableActions();
if (actions.length === 0) {
return null;
}
const variantClasses = {
primary: "bg-sky-600 hover:bg-sky-700 text-white",
success: "bg-green-600 hover:bg-green-700 text-white",
danger: "bg-red-600 hover:bg-red-700 text-white",
secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
};
return (
<div className="flex flex-wrap gap-2">
{actions.map((action, index) => (
<button
key={index}
type="button"
onClick={() => handleAction(action)}
disabled={isLoading}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
variantClasses[action.variant]
}`}
>
{action.icon}
{action.label}
</button>
))}
</div>
);
}

View File

@ -1,521 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Search, X, Filter, SlidersHorizontal, Save, Clock, ChevronDown } from "lucide-react";
import Fuse from "fuse.js";
export interface SearchFilters {
query: string;
documentTypes?: string[];
dateFrom?: string;
dateTo?: string;
statuses?: string[];
tags?: string[];
authors?: string[];
sortBy?: "relevance" | "date" | "title" | "author";
sortOrder?: "asc" | "desc";
}
export interface SearchResult {
id: number;
title: string;
description: string;
content?: string;
type?: string;
status?: string;
uploaded_at: string;
uploaded_by?: string;
tags?: string[];
score?: number;
highlights?: string[];
}
interface AdvancedSearchProps {
onSearch: (filters: SearchFilters) => void;
results?: SearchResult[];
isLoading?: boolean;
availableTypes?: Array<{ id: string; name: string }>;
availableStatuses?: Array<{ id: string; name: string }>;
availableTags?: Array<{ id: string; name: string }>;
availableAuthors?: Array<{ id: string; name: string }>;
}
const DEFAULT_FILTERS: SearchFilters = {
query: "",
documentTypes: [],
dateFrom: "",
dateTo: "",
statuses: [],
tags: [],
authors: [],
sortBy: "relevance",
sortOrder: "desc",
};
export function AdvancedSearch({
onSearch,
results = [],
isLoading = false,
availableTypes = [],
availableStatuses = [],
availableTags = [],
availableAuthors = [],
}: AdvancedSearchProps) {
const [filters, setFilters] = useState<SearchFilters>(DEFAULT_FILTERS);
const [showFilters, setShowFilters] = useState(false);
const [savedSearches, setSavedSearches] = useState<Array<{ name: string; filters: SearchFilters }>>(
[]
);
const [showSavedSearches, setShowSavedSearches] = useState(false);
const [saveSearchName, setSaveSearchName] = useState("");
// Загрузка сохраненных поисков из localStorage
useEffect(() => {
const saved = localStorage.getItem("savedSearches");
if (saved) {
try {
setSavedSearches(JSON.parse(saved));
} catch (e) {
console.error("Error loading saved searches:", e);
}
}
}, []);
// Обновление фильтра
const updateFilter = useCallback(
<K extends keyof SearchFilters>(key: K, value: SearchFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
},
[]
);
// Выполнение поиска
const handleSearch = useCallback(() => {
onSearch(filters);
}, [filters, onSearch]);
// Сброс фильтров
const resetFilters = useCallback(() => {
setFilters(DEFAULT_FILTERS);
onSearch(DEFAULT_FILTERS);
}, [onSearch]);
// Сохранение поиска
const saveSearch = useCallback(() => {
if (!saveSearchName.trim()) return;
const newSearch = {
name: saveSearchName.trim(),
filters: { ...filters },
};
const updated = [...savedSearches, newSearch];
setSavedSearches(updated);
localStorage.setItem("savedSearches", JSON.stringify(updated));
setSaveSearchName("");
setShowSavedSearches(false);
}, [saveSearchName, filters, savedSearches]);
// Загрузка сохраненного поиска
const loadSearch = useCallback(
(search: { name: string; filters: SearchFilters }) => {
setFilters(search.filters);
onSearch(search.filters);
setShowSavedSearches(false);
},
[onSearch]
);
// Удаление сохраненного поиска
const deleteSavedSearch = useCallback(
(index: number) => {
const updated = savedSearches.filter((_, i) => i !== index);
setSavedSearches(updated);
localStorage.setItem("savedSearches", JSON.stringify(updated));
},
[savedSearches]
);
// Fuzzy search для подсветки
const fuse = useMemo(() => {
if (!results.length || !filters.query) return null;
return new Fuse(results, {
keys: ["title", "description", "content"],
includeScore: true,
includeMatches: true,
threshold: 0.4,
});
}, [results, filters.query]);
// Подсветка совпадений
const highlightText = (text: string, query: string) => {
if (!query) return text;
const regex = new RegExp(`(${query})`, "gi");
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark key={index} className="bg-yellow-200 text-gray-900">
{part}
</mark>
) : (
<span key={index}>{part}</span>
)
);
};
// Активные фильтры (для отображения badges)
const activeFiltersCount = useMemo(() => {
let count = 0;
if (filters.documentTypes && filters.documentTypes.length > 0) count++;
if (filters.dateFrom) count++;
if (filters.dateTo) count++;
if (filters.statuses && filters.statuses.length > 0) count++;
if (filters.tags && filters.tags.length > 0) count++;
if (filters.authors && filters.authors.length > 0) count++;
return count;
}, [filters]);
return (
<div className="space-y-4">
{/* Поисковая строка */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search
size={20}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={filters.query}
onChange={(e) => updateFilter("query", e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Поиск документов..."
className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-10 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
{filters.query && (
<button
onClick={() => updateFilter("query", "")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
)}
</div>
<button
onClick={() => setShowFilters((prev) => !prev)}
className={`flex items-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition ${
showFilters
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
<SlidersHorizontal size={16} />
Фильтры
{activeFiltersCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-sky-500 text-xs text-white">
{activeFiltersCount}
</span>
)}
</button>
<button
onClick={handleSearch}
disabled={isLoading}
className="rounded-lg bg-sky-600 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Поиск..." : "Найти"}
</button>
<button
onClick={() => setShowSavedSearches((prev) => !prev)}
className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
title="Сохраненные поиски"
>
<Clock size={16} />
</button>
</div>
{/* Сохраненные поиски */}
{showSavedSearches && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">Сохраненные поиски</h3>
<button
onClick={() => setShowSavedSearches(false)}
className="text-gray-400 hover:text-gray-600"
>
<X size={16} />
</button>
</div>
{savedSearches.length > 0 ? (
<div className="space-y-2">
{savedSearches.map((search, index) => (
<div
key={index}
className="flex items-center justify-between rounded border border-gray-200 p-2 hover:bg-gray-50"
>
<button
onClick={() => loadSearch(search)}
className="flex-1 text-left text-sm font-medium text-gray-900"
>
{search.name}
</button>
<button
onClick={() => deleteSavedSearch(index)}
className="text-gray-400 hover:text-red-600"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Нет сохраненных поисков</p>
)}
{/* Сохранить текущий поиск */}
<div className="mt-3 flex gap-2 border-t border-gray-200 pt-3">
<input
type="text"
value={saveSearchName}
onChange={(e) => setSaveSearchName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveSearch()}
placeholder="Название поиска..."
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
<button
onClick={saveSearch}
disabled={!saveSearchName.trim()}
className="flex items-center gap-1 rounded bg-sky-600 px-3 py-1 text-sm text-white hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={14} />
Сохранить
</button>
</div>
</div>
)}
{/* Панель фильтров */}
{showFilters && (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">Расширенные фильтры</h3>
<button
onClick={resetFilters}
className="text-sm text-sky-600 hover:text-sky-700"
>
Сбросить все
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Тип документа */}
{availableTypes.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">
Тип документа
</label>
<select
multiple
value={filters.documentTypes || []}
onChange={(e) =>
updateFilter(
"documentTypes",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
)}
{/* Статус */}
{availableStatuses.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Статус</label>
<select
multiple
value={filters.statuses || []}
onChange={(e) =>
updateFilter(
"statuses",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableStatuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
</div>
)}
{/* Теги */}
{availableTags.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Теги</label>
<select
multiple
value={filters.tags || []}
onChange={(e) =>
updateFilter(
"tags",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</div>
)}
{/* Дата от */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Дата от</label>
<input
type="date"
value={filters.dateFrom || ""}
onChange={(e) => updateFilter("dateFrom", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
{/* Дата до */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Дата до</label>
<input
type="date"
value={filters.dateTo || ""}
onChange={(e) => updateFilter("dateTo", e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
{/* Автор */}
{availableAuthors.length > 0 && (
<div>
<label className="mb-1 block text-xs font-medium text-gray-700">Автор</label>
<select
multiple
value={filters.authors || []}
onChange={(e) =>
updateFilter(
"authors",
Array.from(e.target.selectedOptions, (option) => option.value)
)
}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
size={3}
>
{availableAuthors.map((author) => (
<option key={author.id} value={author.id}>
{author.name}
</option>
))}
</select>
</div>
)}
</div>
{/* Сортировка */}
<div className="mt-4 flex items-center gap-4 border-t border-gray-200 pt-4">
<label className="text-xs font-medium text-gray-700">Сортировка:</label>
<select
value={filters.sortBy || "relevance"}
onChange={(e) =>
updateFilter("sortBy", e.target.value as SearchFilters["sortBy"])
}
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="relevance">По релевантности</option>
<option value="date">По дате</option>
<option value="title">По названию</option>
<option value="author">По автору</option>
</select>
<select
value={filters.sortOrder || "desc"}
onChange={(e) =>
updateFilter("sortOrder", e.target.value as SearchFilters["sortOrder"])
}
className="rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
<option value="asc">По возрастанию</option>
<option value="desc">По убыванию</option>
</select>
</div>
</div>
)}
{/* Результаты поиска */}
{results.length > 0 && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Найдено результатов: <span className="font-medium text-gray-900">{results.length}</span>
</p>
{results.map((result) => (
<div
key={result.id}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-sky-300 hover:shadow-sm"
>
<h4 className="mb-1 text-base font-medium text-gray-900">
{highlightText(result.title, filters.query)}
</h4>
{result.description && (
<p className="mb-2 text-sm text-gray-600">
{highlightText(result.description, filters.query)}
</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-500">
{result.type && (
<span className="rounded bg-gray-100 px-2 py-0.5">{result.type}</span>
)}
{result.status && (
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-700">
{result.status}
</span>
)}
{result.uploaded_by && <span>Автор: {result.uploaded_by}</span>}
{result.uploaded_at && (
<span>{new Date(result.uploaded_at).toLocaleDateString("ru-RU")}</span>
)}
</div>
</div>
))}
</div>
)}
{/* Пустое состояние */}
{!isLoading && results.length === 0 && filters.query && (
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
<Search size={48} className="mx-auto mb-3 text-gray-300" />
<p className="text-sm text-gray-600">Ничего не найдено</p>
<p className="mt-1 text-xs text-gray-500">Попробуйте изменить параметры поиска</p>
</div>
)}
</div>
);
}

View File

@ -1 +0,0 @@
export { AdvancedSearch, type SearchFilters, type SearchResult } from "./AdvancedSearch";

View File

@ -1,114 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { DocumentActivity } from '@/types/api';
import { apiClient } from '@/lib/api';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
interface DocumentActivityTimelineProps {
documentId: number;
}
export default function DocumentActivityTimeline({ documentId }: DocumentActivityTimelineProps) {
const [activities, setActivities] = useState<DocumentActivity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadActivities();
}, [documentId]);
const loadActivities = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.getDocumentActivity(documentId);
setActivities(response.results || response);
} catch (err: any) {
setError(err.message || 'Ошибка загрузки активности');
console.error('Failed to load activity:', err);
} finally {
setLoading(false);
}
};
const getActionIcon = (action: string) => {
const actionLower = action.toLowerCase();
if (actionLower.includes('created') || actionLower.includes('создан')) return 'bi-plus-circle-fill text-success';
if (actionLower.includes('updated') || actionLower.includes('обновлен')) return 'bi-pencil-fill text-primary';
if (actionLower.includes('deleted') || actionLower.includes('удалён')) return 'bi-trash-fill text-danger';
if (actionLower.includes('comment') || actionLower.includes('комментарий')) return 'bi-chat-fill text-info';
if (actionLower.includes('version') || actionLower.includes('версия')) return 'bi-clock-history text-warning';
return 'bi-circle-fill text-secondary';
};
if (loading) {
return (
<div className="text-center py-3">
<div className="spinner-border" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
{error}
</div>
);
}
if (activities.length === 0) {
return (
<div className="text-muted text-center py-3">
История активности пуста
</div>
);
}
return (
<div className="document-activity-timeline">
<h5 className="mb-3">Активность ({activities.length})</h5>
<div className="timeline">
{activities.map((activity, index) => {
const timeAgo = formatDistanceToNow(new Date(activity.timestamp), {
addSuffix: true,
locale: ru,
});
return (
<div key={activity.id} className="d-flex gap-3 mb-3">
{/* Иконка */}
<div className="flex-shrink-0">
<i className={`bi ${getActionIcon(activity.action)} fs-5`}></i>
</div>
{/* Содержимое */}
<div className="flex-grow-1">
<div className="card">
<div className="card-body py-2 px-3">
<div className="d-flex justify-content-between align-items-start">
<div>
<strong>{activity.user}</strong>
<span className="mx-2">·</span>
<span className="text-muted">{activity.action}</span>
</div>
<small className="text-muted">{timeAgo}</small>
</div>
{activity.description && (
<div className="mt-1 small">{activity.description}</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,190 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { DocumentVersion } from '@/types/api';
import { apiClient } from '@/lib/api';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
interface DocumentVersionHistoryProps {
documentId: number;
onRevert?: () => void;
}
export default function DocumentVersionHistory({
documentId,
onRevert,
}: DocumentVersionHistoryProps) {
const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reverting, setReverting] = useState<number | null>(null);
const [expandedVersion, setExpandedVersion] = useState<number | null>(null);
useEffect(() => {
loadVersions();
}, [documentId]);
const loadVersions = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.getDocumentVersions(documentId);
setVersions(response.results || response);
} catch (err: any) {
setError(err.message || 'Ошибка загрузки версий');
console.error('Failed to load versions:', err);
} finally {
setLoading(false);
}
};
const handleRevert = async (versionId: number) => {
if (!confirm('Вернуться к этой версии документа? Текущее состояние будет сохранено как новая версия.')) {
return;
}
try {
setReverting(versionId);
await apiClient.revertDocumentToVersion(documentId, versionId);
await loadVersions();
if (onRevert) onRevert();
} catch (err: any) {
alert(err.message || 'Ошибка при откате к версии');
} finally {
setReverting(null);
}
};
const toggleExpanded = (versionId: number) => {
setExpandedVersion(expandedVersion === versionId ? null : versionId);
};
if (loading) {
return (
<div className="text-center py-3">
<div className="spinner-border" role="status">
<span className="visually-hidden">Загрузка...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
{error}
</div>
);
}
if (versions.length === 0) {
return (
<div className="text-muted text-center py-3">
История версий пуста
</div>
);
}
return (
<div className="document-version-history">
<h5 className="mb-3">История версий ({versions.length})</h5>
<div className="timeline">
{versions.map((version, index) => {
const isLatest = index === 0;
const timeAgo = formatDistanceToNow(new Date(version.created_at), {
addSuffix: true,
locale: ru,
});
const hasChanges = version.changes && Object.keys(version.changes).length > 0;
const isExpanded = expandedVersion === version.id;
return (
<div key={version.id} className="timeline-item mb-3">
<div className="card">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<div className="flex-grow-1">
<h6 className="mb-1">
Версия {version.version}
{isLatest && (
<span className="badge bg-success ms-2 small">Текущая</span>
)}
</h6>
<div className="text-muted small">
<i className="bi bi-person"></i> {version.user}
<span className="mx-2">·</span>
<i className="bi bi-clock"></i> {timeAgo}
</div>
{version.comment && (
<div className="mt-2 small">{version.comment}</div>
)}
</div>
{!isLatest && (
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => handleRevert(version.revision_id)}
disabled={reverting !== null}
>
{reverting === version.revision_id ? (
<>
<span className="spinner-border spinner-border-sm me-1"></span>
Откат...
</>
) : (
<>
<i className="bi bi-arrow-counterclockwise"></i> Откатить
</>
)}
</button>
)}
</div>
{/* Изменения */}
{hasChanges && (
<div className="mt-2">
<button
type="button"
className="btn btn-sm btn-link text-decoration-none p-0"
onClick={() => toggleExpanded(version.id)}
>
<i className={`bi bi-chevron-${isExpanded ? 'up' : 'down'}`}></i>
{isExpanded ? 'Скрыть' : 'Показать'} изменения
</button>
{isExpanded && (
<div className="mt-2 p-2 bg-light rounded">
<table className="table table-sm table-borderless mb-0">
<tbody>
{Object.entries(version.changes).map(([field, value]) => (
<tr key={field}>
<td className="text-muted small" style={{ width: '30%' }}>
{field}:
</td>
<td className="small">
<code className="text-dark">
{typeof value === 'object'
? JSON.stringify(value, null, 2)
: String(value)}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -1,2 +0,0 @@
export { default as DocumentVersionHistory } from './DocumentVersionHistory';
export { default as DocumentActivityTimeline } from './DocumentActivityTimeline';