Files
IdP/apps/frontend/app/documents/page.tsx
2026-07-01 23:27:48 +03:00

146 lines
6.3 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ChevronRight, Plus } from 'lucide-react';
import { DocumentDialog } from '@/components/documents/document-dialog';
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
import { useAuth } from '@/components/id/auth-provider';
import { IdShell } from '@/components/id/shell';
import { useToast } from '@/components/id/toast-provider';
import {
DOCUMENT_CATEGORIES,
DOCUMENT_TYPES,
getDocumentType,
parseAttachmentMeta,
parseDocumentPhotos,
parseMetadata,
QUICK_DOCUMENT_TYPES,
indexDocumentsByType,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useRequireAuth } from '@/hooks/use-require-auth';
export default function DocumentsPage() {
const router = useRouter();
const { user, token } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const [documents, setDocuments] = useState<UserDocument[]>([]);
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
const loadDocuments = useCallback(async () => {
if (!user || !token || isPinLocked) return;
try {
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
setDocuments(response.documents ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, user]);
useEffect(() => {
if (!isReady || !user || isPinLocked) return;
void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openCreate(type: DocumentTypeCode) {
setActiveType(type);
}
return (
<IdShell active="/documents" wide>
<h1 className="text-2xl font-medium tracking-tight sm:text-3xl">Документы</h1>
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы ID подставит данные там, где вы разрешите.</p>
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-lg font-medium">Здесь будут ваши документы</p>
<p className="mt-1 text-sm text-[#667085]">Добавьте паспорт, права или полис данные сохранятся в зашифрованном виде.</p>
</div>
<div className="flex h-16 w-16 items-center justify-center rounded-3xl bg-white text-3xl shadow-sm">🛂</div>
</div>
</div>
<div className="mt-5 grid grid-cols-3 gap-3">
{QUICK_DOCUMENT_TYPES.map((typeCode) => {
const config = getDocumentType(typeCode);
if (!config) return null;
const Icon = config.icon;
return (
<button
key={typeCode}
type="button"
onClick={() => openCreate(typeCode)}
className="flex min-h-[110px] flex-col items-center justify-center gap-2 rounded-[22px] bg-[#f4f5f8] p-3 text-center transition hover:bg-[#eceef4]"
>
<div className={cn('flex h-12 w-12 items-center justify-center rounded-2xl', config.accent)}>
<Icon className="h-5 w-5" />
</div>
<span className="text-[12px] font-medium leading-tight">Добавить {config.shortLabel.toLowerCase()}</span>
</button>
);
})}
</div>
{DOCUMENT_CATEGORIES.map((category) => (
<section key={category.id} className="mt-8">
<h2 className="mb-3 text-xl font-medium">{category.title}</h2>
<div className="overflow-hidden rounded-[24px] bg-[#f4f5f8]">
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
const Icon = item.icon;
const existing = documentsByType.get(item.type);
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
return (
<div key={item.type} className="border-b border-white/70 last:border-b-0">
<button
type="button"
onClick={() => openCreate(item.type)}
className="flex w-full items-center gap-4 px-4 py-4 text-left hover:bg-white/60"
>
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{item.label}</div>
{existing ? <div className="truncate text-sm text-[#667085]">{existing.number}</div> : null}
</div>
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
</button>
{existing && photoKeys.length > 0 && user && token ? (
<div className="px-4 pb-4">
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
</div>
) : null}
</div>
);
})}
</div>
</section>
))}
{activeType ? (
<DocumentDialog
open={Boolean(activeType)}
onOpenChange={(open) => {
if (!open) setActiveType(null);
}}
documentType={activeType}
userId={user?.id ?? ''}
token={token}
existing={documentsByType.get(activeType) ?? null}
onSaved={loadDocuments}
/>
) : null}
</IdShell>
);
}