igor_nadein 856e08db4a fix(documents): Исправлена кнопка открытия файла и удален document_type
- Добавлена закрывающая скобка } в onClick handler
- Добавлено содержимое кнопки с иконкой Eye
- Удален блок отображения document_type (поле не существует в модели)
2026-02-28 08:48:39 +03:00

327 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { useEffect, useMemo, useState } from "react";
import type { Document, DocumentStatus } from "@/types/api";
import { Search, FileText, Plus, Eye, X } from "lucide-react";
import { DocumentUploadForm } from "@/components/documents/DocumentUploadForm";
import { DocumentStatusBadge } from "@/components/documents/DocumentStatusBadge";
import { DocumentWorkflowButtons } from "@/components/documents/DocumentWorkflowButtons";
import { DocumentPreview } from "@/components/documents/DocumentPreview";
import { DocumentAcknowledgement } from "@/components/documents/DocumentAcknowledgement";
function formatDate(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
export default function DocumentsPage() {
const [documents, setDocuments] = useState<Document[]>([]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<DocumentStatus | "all">("all");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showUploadForm, setShowUploadForm] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
const loadDocuments = async () => {
try {
setLoading(true);
setError(null);
const params: any = {};
if (statusFilter !== "all") {
params.status = statusFilter;
}
const response = await apiClient.getDocuments(params);
setDocuments(response.results || []);
} catch (err) {
console.error("Ошибка загрузки документов:", err);
setError("Не удалось загрузить документы");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDocuments();
}, [statusFilter]);
const filteredDocuments = useMemo(() => {
const q = search.trim().toLowerCase();
const sorted = [...documents].sort((a, b) => {
const aTime = new Date(a.created_at).getTime() || 0;
const bTime = new Date(b.created_at).getTime() || 0;
return bTime - aTime;
});
if (!q) return sorted;
return sorted.filter((doc) => {
const title = doc.title.toLowerCase();
const description = (doc.description || "").toLowerCase();
const author = doc.created_by
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.toLowerCase()
: "";
return title.includes(q) || description.includes(q) || author.includes(q);
});
}, [documents, search]);
return (
<AppShell>
{loading ? (
<div className="rounded-2xl bg-white p-8 text-center shadow-sm ring-1 ring-gray-100">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка документов...</p>
</div>
) : error ? (
<div className="rounded-2xl bg-red-50 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<>
<section className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
{/* Header */}
<div className="mb-4 flex items-center justify-between gap-3">
<h1 className="text-xl font-semibold text-gray-900">Документы</h1>
<button
onClick={() => setShowUploadForm(true)}
className="inline-flex items-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>
</div>
{/* Filters */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row">
<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>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as DocumentStatus | "all")}
className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 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="all">Все статусы</option>
<option value="draft">Черновик</option>
<option value="in_review">На рассмотрении</option>
<option value="approved">Утверждено</option>
<option value="published">Опубликовано</option>
<option value="archived">В архиве</option>
<option value="rejected">Отклонено</option>
</select>
</div>
{/* Documents List */}
<div className="space-y-3">
{filteredDocuments.length === 0 ? (
<div className="rounded-xl bg-gray-50 p-8 text-center">
<FileText size={22} className="mx-auto mb-2 text-gray-400" />
<p className="text-sm text-gray-500">Документы не найдены</p>
</div>
) : (
filteredDocuments.map((doc) => {
const authorName = doc.created_by
? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim()
: "Неизвестно";
return (
<article
key={doc.id}
className="rounded-xl border border-gray-100 bg-white p-4 transition hover:bg-gray-50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-gray-900">{doc.title}</p>
<DocumentStatusBadge
status={doc.status}
statusCode={doc.status_code}
/>
</div>
</div>
<div className="flex shrink-0 gap-2">
{doc.file_url && (
<button
onClick={() =>
setPreviewFile({
url: doc.file_url!,
name: doc.file_name || doc.title,
})
}
className="inline-flex items-center gap-1 rounded-full bg-sky-50 px-2.5 py-1 text-xs text-sky-700 ring-1 ring-sky-100 hover:bg-sky-100"
>
<Eye size={12} />
Просмотр
</button>
)}
<button
onClick={() => setSelectedDocument(doc)}
className="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2.5 py-1 text-xs text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100"
>
Детали
</button>
</div>
</div>
<p className="mt-3 text-sm text-gray-700">
{doc.description || "Описание не заполнено"}
</p>
{/* Workflow Buttons */}
<div className="mt-3">
<DocumentWorkflowButtons
documentId={doc.id}
currentStatus={doc.status_code}
onStatusChange={loadDocuments}
/>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-500 sm:grid-cols-2">
<p>Автор: {authorName}</p>
<p>Создано: {formatDate(doc.created_at)}</p>
<p>Обновлено: {formatDate(doc.updated_at)}</p>
</div>
</article>
);
})
)}
</div>
</section>
{/* Upload Modal */}
{showUploadForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Загрузить документ</h2>
<button
onClick={() => setShowUploadForm(false)}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
<DocumentUploadForm
onSuccess={() => {
setShowUploadForm(false);
loadDocuments();
}}
onCancel={() => setShowUploadForm(false)}
/>
</div>
</div>
)}
{/* Document Details Modal */}
{selectedDocument && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{selectedDocument.title}
</h2>
<button
onClick={() => setSelectedDocument(null)}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<DocumentStatusBadge
status={selectedDocument.status}
statusCode={selectedDocument.status_code}
/>
</div>
<div>
<h3 className="mb-1 text-sm font-medium text-gray-700">Описание</h3>
<p className="text-sm text-gray-600">
{selectedDocument.description || "Описание отсутствует"}
</p>
</div>
{selectedDocument.file_url && (
<div>
<button
onClick={() =>
setPreviewFile({
url: selectedDocument.file_url!,
name: selectedDocument.file_name || selectedDocument.title,
})
}
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
>
<Eye size={16} />
Открыть файл
</button>
</div>
)}
{/* Workflow Actions */}
<div>
<h3 className="mb-2 text-sm font-medium text-gray-700">Действия</h3>
<DocumentWorkflowButtons
documentId={selectedDocument.id}
currentStatus={selectedDocument.status_code}
onStatusChange={() => {
loadDocuments();
setSelectedDocument(null);
}}
/>
</div>
{/* Acknowledgement */}
{selectedDocument.acknowledgement_required && (
<div>
<h3 className="mb-2 text-sm font-medium text-gray-700">
Подтверждение прочтения
</h3>
<DocumentAcknowledgement
document={selectedDocument}
onAcknowledge={() => {
loadDocuments();
// Refresh selected document
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
}}
/>
</div>
)}
</div>
</div>
</div>
)}
{/* File Preview Modal */}
{previewFile && (
<DocumentPreview
fileUrl={previewFile.url}
fileName={previewFile.name}
onClose={() => setPreviewFile(null)}
/>
)}
</>
)}
</AppShell>
);
}