refactor(frontend): удаление устаревших компонентов документов
- Удален DocumentStatusBadge (больше нет статусов) - Удален DocumentWorkflowButtons (нет FSM workflow) - Удален DocumentVersionHistory (дубль, есть другая реализация) - Удален AdvancedSearch (заменен на фильтры) - Удалена папка versions/ с компонентами истории версий
This commit is contained in:
parent
7d42fdac9d
commit
8c6753c188
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { AdvancedSearch, type SearchFilters, type SearchResult } from "./AdvancedSearch";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export { default as DocumentVersionHistory } from './DocumentVersionHistory';
|
||||
export { default as DocumentActivityTimeline } from './DocumentActivityTimeline';
|
||||
Loading…
x
Reference in New Issue
Block a user