fix and update
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarRange, RotateCcw } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarRange, ChevronDown, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function toDatetimeLocalValue(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
@@ -36,6 +38,21 @@ function pluralDays(value: number) {
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
function formatPeriodLabel(from: string, to: string) {
|
||||
const format = (value: string) => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(parsed);
|
||||
};
|
||||
return `${format(from)} — ${format(to)}`;
|
||||
}
|
||||
|
||||
export function AdminInsightsDateFilter({
|
||||
from,
|
||||
to,
|
||||
@@ -51,45 +68,62 @@ export function AdminInsightsDateFilter({
|
||||
onToChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const periodLabel = useMemo(() => formatPeriodLabel(from, to), [from, to]);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]">
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
Период просмотра
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||
С даты и времени
|
||||
</label>
|
||||
<Input
|
||||
id="insights-from"
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition hover:bg-[#f4f5f8]"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4 shrink-0 text-[#667085]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-[#667085]">Период просмотра</p>
|
||||
<p className="truncate text-sm text-[#1f2430]">{periodLabel}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
||||
По дату и время
|
||||
</label>
|
||||
<Input
|
||||
id="insights-to"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[#667085] transition', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-[#eceef4] px-3 pb-3 pt-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||
С даты и времени
|
||||
</label>
|
||||
<Input
|
||||
id="insights-from"
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
||||
По дату и время
|
||||
</label>
|
||||
<Input
|
||||
id="insights-to"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">
|
||||
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">
|
||||
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/frontend/components/documents/document-dialog.tsx
Normal file
80
apps/frontend/components/documents/document-dialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentViewDialog } from '@/components/documents/document-view-dialog';
|
||||
import { type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { UserDocument } from '@/lib/api';
|
||||
|
||||
export function DocumentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
userId,
|
||||
token,
|
||||
existing,
|
||||
onSaved,
|
||||
readOnly
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentTypeCode;
|
||||
userId: string;
|
||||
token: string | null;
|
||||
existing?: UserDocument | null;
|
||||
onSaved: () => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('edit');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(existing ? 'view' : 'edit');
|
||||
}, [existing?.id, open]);
|
||||
|
||||
function handleClose(nextOpen: boolean) {
|
||||
if (!nextOpen) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(true);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
if (mode === 'view' && existing) {
|
||||
return (
|
||||
<DocumentViewDialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
documentType={documentType}
|
||||
userId={userId}
|
||||
token={token}
|
||||
document={existing}
|
||||
readOnly={readOnly}
|
||||
onEdit={readOnly ? undefined : () => setMode('edit')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentFormDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && existing) {
|
||||
setMode('view');
|
||||
return;
|
||||
}
|
||||
handleClose(nextOpen);
|
||||
}}
|
||||
documentType={documentType}
|
||||
userId={userId}
|
||||
token={token}
|
||||
existing={existing}
|
||||
onSaved={onSaved}
|
||||
readOnly={readOnly}
|
||||
showBackToView={Boolean(existing) && !readOnly}
|
||||
onBackToView={existing ? () => setMode('view') : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Camera, Loader2, X } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2, Paperclip, X } from 'lucide-react';
|
||||
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { DOCUMENT_FILE_ACCEPT, resolveDocumentFileContentType } from '@/lib/document-attachments';
|
||||
import {
|
||||
buildDocumentNumber,
|
||||
DOCUMENT_TYPES,
|
||||
DocumentTypeDef,
|
||||
LICENSE_CATEGORIES,
|
||||
parseAttachmentMeta,
|
||||
parseDocumentPhotos,
|
||||
parseMetadata,
|
||||
withDocumentPhotos,
|
||||
type DocumentAttachmentMeta,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, uploadMediaObject, UserDocument } from '@/lib/api';
|
||||
@@ -106,7 +109,9 @@ export function DocumentFormDialog({
|
||||
token,
|
||||
existing,
|
||||
onSaved,
|
||||
readOnly
|
||||
readOnly,
|
||||
showBackToView,
|
||||
onBackToView
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -116,6 +121,8 @@ export function DocumentFormDialog({
|
||||
existing?: UserDocument | null;
|
||||
onSaved: () => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
showBackToView?: boolean;
|
||||
onBackToView?: () => void;
|
||||
}) {
|
||||
const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
|
||||
const { showToast } = useToast();
|
||||
@@ -124,10 +131,12 @@ export function DocumentFormDialog({
|
||||
const fieldValuesRef = useRef<Record<string, string>>({});
|
||||
const draftDocumentIdRef = useRef<string | undefined>(undefined);
|
||||
const photoStorageKeysRef = useRef<string[]>([]);
|
||||
const attachmentMetaRef = useRef<Record<string, DocumentAttachmentMeta>>({});
|
||||
const wasOpenRef = useRef(false);
|
||||
const [formInstanceKey, setFormInstanceKey] = useState(0);
|
||||
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
|
||||
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
|
||||
const [attachmentMeta, setAttachmentMeta] = useState<Record<string, DocumentAttachmentMeta>>({});
|
||||
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
|
||||
@@ -138,18 +147,23 @@ export function DocumentFormDialog({
|
||||
fieldValuesRef.current = {};
|
||||
draftDocumentIdRef.current = undefined;
|
||||
photoStorageKeysRef.current = [];
|
||||
attachmentMetaRef.current = {};
|
||||
return;
|
||||
}
|
||||
if (!config || wasOpenRef.current) return;
|
||||
|
||||
wasOpenRef.current = true;
|
||||
const initial = buildInitialValues(config, existing);
|
||||
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson));
|
||||
const metadata = parseMetadata(existing?.metadataJson);
|
||||
const photos = parseDocumentPhotos(metadata);
|
||||
const meta = parseAttachmentMeta(metadata);
|
||||
fieldValuesRef.current = { ...initial };
|
||||
photoStorageKeysRef.current = photos;
|
||||
attachmentMetaRef.current = meta;
|
||||
draftDocumentIdRef.current = existing?.id;
|
||||
setPickerValues(initial);
|
||||
setPhotoStorageKeys(photos);
|
||||
setAttachmentMeta(meta);
|
||||
setDraftDocumentId(existing?.id);
|
||||
setFormInstanceKey((current) => current + 1);
|
||||
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
|
||||
@@ -200,33 +214,50 @@ export function DocumentFormDialog({
|
||||
});
|
||||
}
|
||||
|
||||
function buildMetadataPayload(source: Record<string, string>, photos: string[]) {
|
||||
function buildMetadataPayload(
|
||||
source: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value;
|
||||
}
|
||||
return JSON.stringify(withDocumentPhotos(metadata, photos));
|
||||
return JSON.stringify(withDocumentPhotos(metadata, photos, meta));
|
||||
}
|
||||
|
||||
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) {
|
||||
function buildSaveBody(
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
return {
|
||||
number: buildDocumentNumber(config!, currentValues),
|
||||
issuedAt: currentValues.issuedAt ? new Date(currentValues.issuedAt).toISOString() : undefined,
|
||||
expiresAt: currentValues.expiresAt ? new Date(currentValues.expiresAt).toISOString() : undefined,
|
||||
metadataJson: buildMetadataPayload(currentValues, photos)
|
||||
metadataJson: buildMetadataPayload(currentValues, photos, meta)
|
||||
};
|
||||
}
|
||||
|
||||
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) {
|
||||
async function patchDocument(
|
||||
documentId: string,
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
if (!token || !config) return;
|
||||
await apiFetch(
|
||||
`/documents/users/${userId}/${documentId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) },
|
||||
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) {
|
||||
async function ensureDocumentId(
|
||||
currentValues: Record<string, string>,
|
||||
photos: string[],
|
||||
meta: Record<string, DocumentAttachmentMeta>
|
||||
) {
|
||||
if (draftDocumentIdRef.current) {
|
||||
return { id: draftDocumentIdRef.current, created: false };
|
||||
}
|
||||
@@ -240,7 +271,7 @@ export function DocumentFormDialog({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: documentType,
|
||||
...buildSaveBody(currentValues, photos)
|
||||
...buildSaveBody(currentValues, photos, meta)
|
||||
})
|
||||
},
|
||||
token
|
||||
@@ -250,36 +281,50 @@ export function DocumentFormDialog({
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
async function uploadPhoto(file: File) {
|
||||
async function uploadAttachment(file: File) {
|
||||
if (!token || readOnly) return;
|
||||
setIsPhotoUploading(true);
|
||||
try {
|
||||
await commitFocusedField();
|
||||
const currentValues = collectFormValues();
|
||||
const currentPhotos = photoStorageKeysRef.current;
|
||||
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos);
|
||||
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки фото');
|
||||
const currentMeta = attachmentMetaRef.current;
|
||||
const contentType = resolveDocumentFileContentType(file);
|
||||
const { id: targetDocumentId, created } = await ensureDocumentId(currentValues, currentPhotos, currentMeta);
|
||||
if (!targetDocumentId) throw new Error('Не удалось создать документ для загрузки файла');
|
||||
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/documents/${targetDocumentId}/photo/upload-url`,
|
||||
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType, fileName: file.name })
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
await uploadMediaObject(presigned, file, file.type);
|
||||
await uploadMediaObject(presigned, file, contentType);
|
||||
|
||||
const nextPhotos = [...currentPhotos, presigned.storageKey];
|
||||
const nextMeta = {
|
||||
...currentMeta,
|
||||
[presigned.storageKey]: {
|
||||
fileName: file.name,
|
||||
contentType
|
||||
}
|
||||
};
|
||||
photoStorageKeysRef.current = nextPhotos;
|
||||
attachmentMetaRef.current = nextMeta;
|
||||
setPhotoStorageKeys(nextPhotos);
|
||||
await patchDocument(targetDocumentId, currentValues, nextPhotos);
|
||||
setAttachmentMeta(nextMeta);
|
||||
await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta);
|
||||
|
||||
if (created) {
|
||||
await onSaved();
|
||||
}
|
||||
|
||||
showToast('Фото добавлено');
|
||||
showToast('Файл добавлен');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить файл');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsPhotoUploading(false);
|
||||
@@ -290,14 +335,18 @@ export function DocumentFormDialog({
|
||||
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
for (const file of files) {
|
||||
await uploadPhoto(file);
|
||||
await uploadAttachment(file);
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto(storageKey: string) {
|
||||
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
|
||||
const nextMeta = { ...attachmentMetaRef.current };
|
||||
delete nextMeta[storageKey];
|
||||
photoStorageKeysRef.current = nextPhotos;
|
||||
attachmentMetaRef.current = nextMeta;
|
||||
setPhotoStorageKeys(nextPhotos);
|
||||
setAttachmentMeta(nextMeta);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -307,15 +356,16 @@ export function DocumentFormDialog({
|
||||
await commitFocusedField();
|
||||
const currentValues = collectFormValues();
|
||||
const photos = photoStorageKeysRef.current;
|
||||
const meta = attachmentMetaRef.current;
|
||||
const documentId = draftDocumentIdRef.current ?? existing?.id;
|
||||
|
||||
if (documentId) {
|
||||
await patchDocument(documentId, currentValues, photos);
|
||||
await patchDocument(documentId, currentValues, photos, meta);
|
||||
showToast('Документ обновлён');
|
||||
} else {
|
||||
const created = await apiFetch<UserDocument>(
|
||||
`/documents/users/${userId}`,
|
||||
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) },
|
||||
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) },
|
||||
token
|
||||
);
|
||||
draftDocumentIdRef.current = created.id;
|
||||
@@ -342,7 +392,14 @@ export function DocumentFormDialog({
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[420px]">
|
||||
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{showBackToView && onBackToView ? (
|
||||
<button type="button" onClick={onBackToView} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Назад к просмотру">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<DialogTitle className="truncate text-xl font-semibold">{config.label}</DialogTitle>
|
||||
</div>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -355,6 +412,7 @@ export function DocumentFormDialog({
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={photoStorageKeys}
|
||||
attachmentMeta={attachmentMeta}
|
||||
readOnly={readOnly}
|
||||
onRemove={readOnly ? undefined : removePhoto}
|
||||
/>
|
||||
@@ -368,10 +426,17 @@ export function DocumentFormDialog({
|
||||
disabled={isPhotoUploading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-[20px] bg-[#f4f5f8] px-4 py-4 text-sm font-medium transition hover:bg-[#eceef4]"
|
||||
>
|
||||
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
|
||||
{photoStorageKeys.length > 0 ? 'Добавить ещё фото' : 'Добавить фото'}
|
||||
{isPhotoUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||
{photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'}
|
||||
</button>
|
||||
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} />
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept={DOCUMENT_FILE_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { Download, FileText, Loader2, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import {
|
||||
attachmentDisplayName,
|
||||
attachmentFileExtension,
|
||||
inferAttachmentContentType,
|
||||
isImageAttachment,
|
||||
isPdfAttachment,
|
||||
type DocumentAttachmentMeta
|
||||
} from '@/lib/document-attachments';
|
||||
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
||||
import { resolveBrowserMediaUrl } from '@/lib/resilient-media-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
||||
import { DocumentAttachmentViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
||||
|
||||
export type ResolvedDocumentAttachment = {
|
||||
storageKey: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
interface DocumentPhotoGalleryProps {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
storageKeys: string[];
|
||||
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
||||
readOnly?: boolean;
|
||||
onRemove?: (storageKey: string) => void;
|
||||
layout?: 'grid' | 'strip';
|
||||
@@ -22,20 +38,21 @@ export function DocumentPhotoGallery({
|
||||
userId,
|
||||
token,
|
||||
storageKeys,
|
||||
attachmentMeta = {},
|
||||
readOnly,
|
||||
onRemove,
|
||||
layout = 'grid',
|
||||
className
|
||||
}: DocumentPhotoGalleryProps) {
|
||||
const { isPinLocked } = useAuth();
|
||||
const [urls, setUrls] = React.useState<Record<string, string>>({});
|
||||
const [attachments, setAttachments] = React.useState<ResolvedDocumentAttachment[]>([]);
|
||||
const [loadingKeys, setLoadingKeys] = React.useState<Set<string>>(new Set());
|
||||
const [viewerOpen, setViewerOpen] = React.useState(false);
|
||||
const [viewerIndex, setViewerIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token || storageKeys.length === 0 || isPinLocked) {
|
||||
setUrls({});
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,34 +60,43 @@ export function DocumentPhotoGallery({
|
||||
setLoadingKeys(new Set(storageKeys));
|
||||
void Promise.all(
|
||||
storageKeys.map(async (storageKey) => {
|
||||
const meta = attachmentMeta[storageKey];
|
||||
const contentType = inferAttachmentContentType(storageKey, meta);
|
||||
const fileName = attachmentDisplayName(storageKey, meta);
|
||||
try {
|
||||
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
|
||||
return { storageKey, accessUrl: resolveBrowserMediaUrl(response.accessUrl) };
|
||||
const response = await fetchDocumentPhotoUrl(userId, storageKey, token, fileName);
|
||||
return {
|
||||
storageKey,
|
||||
accessUrl: resolveBrowserMediaUrl(response.accessUrl),
|
||||
contentType,
|
||||
fileName
|
||||
};
|
||||
} catch {
|
||||
return { storageKey, accessUrl: '' };
|
||||
return { storageKey, accessUrl: '', contentType, fileName };
|
||||
}
|
||||
})
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, string> = {};
|
||||
for (const item of results) {
|
||||
if (item.accessUrl) next[item.storageKey] = item.accessUrl;
|
||||
}
|
||||
setUrls(next);
|
||||
setAttachments(
|
||||
results
|
||||
.filter((item) => Boolean(item.accessUrl))
|
||||
.map((item) => ({
|
||||
storageKey: item.storageKey,
|
||||
url: item.accessUrl,
|
||||
contentType: item.contentType,
|
||||
fileName: item.fileName
|
||||
}))
|
||||
);
|
||||
setLoadingKeys(new Set());
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPinLocked, storageKeys.join('|'), token, userId]);
|
||||
|
||||
const resolvedImages = storageKeys
|
||||
.map((storageKey) => ({ storageKey, url: urls[storageKey] }))
|
||||
.filter((item) => Boolean(item.url));
|
||||
}, [attachmentMeta, isPinLocked, storageKeys.join('|'), token, userId]);
|
||||
|
||||
function openViewer(storageKey: string) {
|
||||
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
|
||||
const resolvedIndex = attachments.findIndex((item) => item.storageKey === storageKey);
|
||||
if (resolvedIndex >= 0) {
|
||||
setViewerIndex(resolvedIndex);
|
||||
setViewerOpen(true);
|
||||
@@ -84,67 +110,126 @@ export function DocumentPhotoGallery({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3',
|
||||
className
|
||||
)}
|
||||
className={cn(isStrip ? 'flex gap-2 overflow-x-auto pb-1' : 'grid grid-cols-2 gap-3', className)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{storageKeys.map((storageKey) => (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||
<DocumentPhotoThumbnail
|
||||
url={urls[storageKey]}
|
||||
loading={loadingKeys.has(storageKey)}
|
||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||
onClick={() => openViewer(storageKey)}
|
||||
/>
|
||||
{!readOnly && onRemove ? (
|
||||
{storageKeys.map((storageKey) => {
|
||||
const meta = attachmentMeta[storageKey];
|
||||
const contentType = inferAttachmentContentType(storageKey, meta);
|
||||
const fileName = attachmentDisplayName(storageKey, meta);
|
||||
const resolved = attachments.find((item) => item.storageKey === storageKey);
|
||||
const loading = loadingKeys.has(storageKey);
|
||||
|
||||
if (isImageAttachment(contentType)) {
|
||||
return (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-20 shrink-0')}>
|
||||
<DocumentPhotoThumbnail
|
||||
url={resolved?.url}
|
||||
loading={loading}
|
||||
className={isStrip ? 'aspect-square rounded-xl' : undefined}
|
||||
onClick={() => openViewer(storageKey)}
|
||||
/>
|
||||
{!readOnly && onRemove ? (
|
||||
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={storageKey} className={cn('relative', isStrip && 'w-36 shrink-0')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(storageKey);
|
||||
}}
|
||||
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||
aria-label="Удалить фото"
|
||||
onClick={() => (resolved ? openViewer(storageKey) : undefined)}
|
||||
className={cn(
|
||||
'flex h-full min-h-[96px] w-full flex-col items-start justify-between rounded-2xl border border-[#eceef4] bg-white p-3 text-left transition hover:border-[#20212b]',
|
||||
isStrip && 'min-h-[80px]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#f4f5f8] text-[#667085]">
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-5 w-5" />}
|
||||
</div>
|
||||
<span className="rounded-lg bg-[#20212b] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white">
|
||||
{attachmentFileExtension(contentType, fileName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#1f2430]">{fileName}</p>
|
||||
<p className="mt-0.5 text-xs text-[#667085]">{isPdfAttachment(contentType) ? 'Открыть PDF' : 'Скачать файл'}</p>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
{loadingKeys.has(storageKey) ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-white/40">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{resolved ? (
|
||||
<a
|
||||
href={`${resolved.url}${resolved.url.includes('?') ? '&' : '?'}download=1`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute bottom-2 right-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||
aria-label="Скачать файл"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
{!readOnly && onRemove ? (
|
||||
<RemoveAttachmentButton storageKey={storageKey} onRemove={onRemove} className="right-2 top-2" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DocumentFullscreenViewer
|
||||
<DocumentAttachmentViewer
|
||||
open={viewerOpen}
|
||||
onOpenChange={setViewerOpen}
|
||||
initialIndex={viewerIndex}
|
||||
images={resolvedImages}
|
||||
attachments={attachments}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAttachmentButton({
|
||||
storageKey,
|
||||
onRemove,
|
||||
className
|
||||
}: {
|
||||
storageKey: string;
|
||||
onRemove: (storageKey: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(storageKey);
|
||||
}}
|
||||
className={cn('absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80', className)}
|
||||
aria-label="Удалить файл"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentPhotosInlineProps {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
storageKeys: string[];
|
||||
attachmentMeta?: Record<string, DocumentAttachmentMeta>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Горизонтальная полоска фото документа для списков (страница «Документы», «Данные»). */
|
||||
export function DocumentPhotosInline({ userId, token, storageKeys, className }: DocumentPhotosInlineProps) {
|
||||
/** Горизонтальная полоска файлов документа для списков. */
|
||||
export function DocumentPhotosInline({ userId, token, storageKeys, attachmentMeta, className }: DocumentPhotosInlineProps) {
|
||||
if (!storageKeys.length) return null;
|
||||
return (
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={storageKeys}
|
||||
attachmentMeta={attachmentMeta}
|
||||
readOnly
|
||||
layout="strip"
|
||||
className={className}
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Download, X, ZoomIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { isImageAttachment, isPdfAttachment } from '@/lib/document-attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ResolvedDocumentAttachment } from './document-photo-gallery';
|
||||
|
||||
interface DocumentFullscreenViewerProps {
|
||||
images: { storageKey: string; url: string }[];
|
||||
interface DocumentAttachmentViewerProps {
|
||||
attachments: ResolvedDocumentAttachment[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
|
||||
export function DocumentAttachmentViewer({
|
||||
attachments,
|
||||
initialIndex = 0,
|
||||
open,
|
||||
onOpenChange
|
||||
}: DocumentAttachmentViewerProps) {
|
||||
const [index, setIndex] = React.useState(initialIndex);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
@@ -31,11 +38,11 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onOpenChange(false);
|
||||
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1));
|
||||
if (event.key === 'ArrowRight') setIndex((current) => Math.min(images.length - 1, current + 1));
|
||||
if (event.key === 'ArrowRight') setIndex((current) => Math.min(attachments.length - 1, current + 1));
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [images.length, onOpenChange, open]);
|
||||
}, [attachments.length, onOpenChange, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -46,33 +53,48 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open || images.length === 0 || !mounted) return null;
|
||||
if (!open || attachments.length === 0 || !mounted) return null;
|
||||
|
||||
const current = images[index];
|
||||
const current = attachments[index];
|
||||
const downloadUrl = `${current.url}${current.url.includes('?') ? '&' : '?'}download=1`;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-white">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">Фото документа</p>
|
||||
<p className="truncate text-sm font-medium">{current.fileName}</p>
|
||||
<p className="text-xs text-white/60">
|
||||
{index + 1} из {images.length}
|
||||
{index + 1} из {attachments.length}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Закрыть"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Скачать"
|
||||
asChild
|
||||
>
|
||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Закрыть"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
@@ -81,51 +103,73 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index <= 0 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Предыдущее фото"
|
||||
aria-label="Предыдущий файл"
|
||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.url}
|
||||
alt="Фото документа"
|
||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
||||
/>
|
||||
{isImageAttachment(current.contentType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.fileName}
|
||||
className="max-h-[calc(100vh-140px)] max-w-full object-contain"
|
||||
/>
|
||||
) : isPdfAttachment(current.contentType) ? (
|
||||
<iframe
|
||||
title={current.fileName}
|
||||
src={current.url}
|
||||
className="h-[calc(100vh-140px)] w-full max-w-5xl rounded-2xl bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-md rounded-[24px] bg-white/10 p-8 text-center text-white backdrop-blur-sm">
|
||||
<p className="text-lg font-medium">{current.fileName}</p>
|
||||
<p className="mt-2 text-sm text-white/70">Предпросмотр недоступен для этого типа файла</p>
|
||||
<Button className="mt-6 rounded-xl" asChild>
|
||||
<a href={downloadUrl} target="_blank" rel="noreferrer">
|
||||
Скачать файл
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index >= images.length - 1 && 'pointer-events-none opacity-30'
|
||||
index >= attachments.length - 1 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Следующее фото"
|
||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||
aria-label="Следующий файл"
|
||||
onClick={() => setIndex((value) => Math.min(attachments.length - 1, value + 1))}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
{attachments.length > 1 ? (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
||||
{images.map((image, imageIndex) => (
|
||||
{attachments.map((attachment, attachmentIndex) => (
|
||||
<button
|
||||
key={image.storageKey}
|
||||
key={attachment.storageKey}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2',
|
||||
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
'h-14 min-w-[56px] shrink-0 overflow-hidden rounded-lg border-2 px-2 text-xs text-white',
|
||||
attachmentIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
)}
|
||||
onClick={() => setIndex(imageIndex)}
|
||||
onClick={() => setIndex(attachmentIndex)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
||||
{isImageAttachment(attachment.contentType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={attachment.url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center">{attachment.fileName.slice(0, 8)}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -135,6 +179,27 @@ export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpe
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DocumentAttachmentViewer */
|
||||
export function DocumentFullscreenViewer({
|
||||
images,
|
||||
initialIndex = 0,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
images: { storageKey: string; url: string }[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const attachments: ResolvedDocumentAttachment[] = images.map((image) => ({
|
||||
storageKey: image.storageKey,
|
||||
url: image.url,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'Фото документа'
|
||||
}));
|
||||
return <DocumentAttachmentViewer attachments={attachments} initialIndex={initialIndex} open={open} onOpenChange={onOpenChange} />;
|
||||
}
|
||||
|
||||
interface DocumentPhotoThumbnailProps {
|
||||
url?: string;
|
||||
loading?: boolean;
|
||||
@@ -156,7 +221,7 @@ export function DocumentPhotoThumbnail({ url, loading, className, onClick }: Doc
|
||||
{!loading && url ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={url} alt="Фото документа" className="h-full w-full object-cover" />
|
||||
<img src={url} alt="Файл документа" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition group-hover:bg-black/20">
|
||||
<ZoomIn className="h-6 w-6 text-white opacity-0 transition group-hover:opacity-100" />
|
||||
</div>
|
||||
|
||||
270
apps/frontend/components/documents/document-view-card.tsx
Normal file
270
apps/frontend/components/documents/document-view-card.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import {
|
||||
buildDocumentNumber,
|
||||
DOCUMENT_TYPES,
|
||||
getDocumentType,
|
||||
LICENSE_CATEGORIES,
|
||||
parseMetadata,
|
||||
type DocumentTypeCode,
|
||||
type DocumentTypeDef
|
||||
} from '@/lib/document-catalog';
|
||||
import { UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatDateValue(value?: string) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return format(date, 'd MMMM yyyy', { locale: ru });
|
||||
}
|
||||
|
||||
function formatFieldValue(fieldKind: string, value: string) {
|
||||
if (!value) return '—';
|
||||
if (fieldKind === 'date') return formatDateValue(value);
|
||||
if (fieldKind === 'gender') return value === 'male' ? 'Мужской' : value === 'female' ? 'Женский' : value;
|
||||
if (fieldKind === 'checkbox') return value === 'true' ? 'Да' : '—';
|
||||
if (fieldKind === 'categories') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fullName(values: Record<string, string>) {
|
||||
const parts = [values.lastName, values.firstName, values.middleName].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '—';
|
||||
}
|
||||
|
||||
function latinFullName(values: Record<string, string>) {
|
||||
const parts = [values.lastNameLatin, values.firstNameLatin, values.middleNameLatin].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : '';
|
||||
}
|
||||
|
||||
function DocumentField({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
if (!value || value === '—') return null;
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-[10px] uppercase tracking-[0.12em] opacity-70">{label}</p>
|
||||
<p className="mt-0.5 text-sm font-medium leading-snug">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PassportRfCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#6b1d24]/20 bg-[linear-gradient(145deg,#7a2430_0%,#5c1820_55%,#3d1016_100%)] p-5 text-[#fff6eb] shadow-[0_24px_60px_rgba(92,24,32,0.35)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#f6d9a8]">Российская Федерация</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-[#f6d9a8]/40 bg-[#f6d9a8]/10 text-xs font-semibold text-[#f6d9a8]">
|
||||
РФ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-[18px] bg-black/15 p-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#f6d9a8]/80">Серия и номер</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || buildDocumentNumber(config, values)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
|
||||
<DocumentField label="Кем выдан" value={values.issuedBy ?? ''} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Код подразделения" value={values.departmentCode ?? ''} />
|
||||
</div>
|
||||
<DocumentField label="Адрес регистрации" value={values.registrationAddress ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForeignPassportCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const latin = latinFullName(values);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#5a1830]/20 bg-[linear-gradient(145deg,#6d2138_0%,#451222_100%)] p-5 text-[#fff4f0] shadow-[0_24px_60px_rgba(69,18,34,0.35)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#ffd3bf]">Passport</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/10 px-3 py-2 text-xs font-semibold uppercase tracking-widest text-[#ffd3bf]">P</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-[18px] bg-black/15 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] opacity-70">No.</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
{latin ? <DocumentField label="Name" value={latin} className="font-mono" /> : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Гражданство" value={values.citizenship ?? ''} />
|
||||
<DocumentField label="Пол" value={formatFieldValue('gender', values.gender ?? '')} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Место рождения" value={values.birthPlace ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverLicenseCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const categories = formatFieldValue('categories', values.categories ?? '');
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#7c5cff]/20 bg-[linear-gradient(145deg,#f3ecff_0%,#ddd0ff_45%,#c8b6ff_100%)] p-5 text-[#2f2450] shadow-[0_24px_60px_rgba(124,92,255,0.18)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6b4fd8]">Водительское удостоверение</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#6b4fd8] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">RU</div>
|
||||
</div>
|
||||
<div className="mt-5 rounded-[18px] bg-white/70 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#6b4fd8]">Номер</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tracking-wider">{document.number || values.number || '—'}</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="ФИО" value={fullName(values)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Дата рождения" value={formatFieldValue('date', values.birthDate ?? '')} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
<DocumentField label="Действует до" value={formatFieldValue('date', values.expiresAt ?? document.expiresAt?.slice(0, 10) ?? '')} />
|
||||
<DocumentField label="Категории" value={categories} />
|
||||
{values.specialMarks ? <DocumentField label="Особые отметки" value={values.specialMarks} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleRegistrationCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#3b82f6]/15 bg-[linear-gradient(145deg,#eff6ff_0%,#dbeafe_100%)] p-5 text-[#1e3a5f] shadow-[0_24px_60px_rgba(59,130,246,0.12)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#2563eb]">Свидетельство о регистрации</p>
|
||||
<p className="mt-1 text-lg font-semibold">{config.label}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#2563eb] px-3 py-2 text-xs font-bold uppercase tracking-widest text-white">СТС</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-[18px] bg-white/80 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Серия и номер</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold">{document.number || values.seriesNumber || '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-[18px] bg-white/80 p-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.14em] text-[#2563eb]">Госномер</p>
|
||||
<p className="mt-1 font-mono text-xl font-semibold uppercase">{values.plateNumber || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<DocumentField label="Марка, модель" value={values.makeModel ?? ''} />
|
||||
<DocumentField label="VIN" value={values.vin ?? ''} className="font-mono" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<DocumentField label="Год выпуска" value={values.year ?? ''} />
|
||||
<DocumentField label="Цвет" value={values.color ?? ''} />
|
||||
</div>
|
||||
<DocumentField label="Владелец" value={fullName(values)} />
|
||||
<DocumentField label="Дата выдачи" value={formatFieldValue('date', values.issuedAt ?? document.issuedAt?.slice(0, 10) ?? '')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericDocumentCard({ config, values, document }: { config: DocumentTypeDef; values: Record<string, string>; document: UserDocument }) {
|
||||
const Icon = config.icon;
|
||||
const visibleFields = config.fields
|
||||
.map((field) => ({
|
||||
label: field.label,
|
||||
value: formatFieldValue(field.kind, values[field.key] ?? (field.key === 'issuedAt' ? document.issuedAt?.slice(0, 10) ?? '' : field.key === 'expiresAt' ? document.expiresAt?.slice(0, 10) ?? '' : ''))
|
||||
}))
|
||||
.filter((field) => field.value && field.value !== '—');
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-[24px] border border-[#eceef4] bg-white p-5 shadow-[0_20px_50px_rgba(31,36,48,0.08)]')}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn('flex h-14 w-14 items-center justify-center rounded-2xl', config.accent)}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#667085]">Документ</p>
|
||||
<p className="mt-1 text-xl font-semibold text-[#1f2430]">{config.label}</p>
|
||||
<p className="mt-2 font-mono text-lg text-[#3390ec]">{document.number || buildDocumentNumber(config, values)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{visibleFields.map((field) => (
|
||||
<DocumentField key={field.label} label={field.label} value={field.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentViewCard({
|
||||
documentType,
|
||||
document
|
||||
}: {
|
||||
documentType: DocumentTypeCode;
|
||||
document: UserDocument;
|
||||
}) {
|
||||
const config = getDocumentType(documentType) ?? DOCUMENT_TYPES.find((item) => item.type === documentType);
|
||||
if (!config) return null;
|
||||
|
||||
const metadata = parseMetadata(document.metadataJson);
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of config.fields) {
|
||||
const raw = metadata[field.key];
|
||||
values[field.key] = typeof raw === 'string' ? raw : '';
|
||||
if (field.latinKey) {
|
||||
const latinRaw = metadata[field.latinKey];
|
||||
values[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
|
||||
}
|
||||
}
|
||||
if (document.issuedAt) values.issuedAt = document.issuedAt.slice(0, 10);
|
||||
if (document.expiresAt) values.expiresAt = document.expiresAt.slice(0, 10);
|
||||
|
||||
switch (documentType) {
|
||||
case 'PASSPORT_RF':
|
||||
return <PassportRfCard config={config} values={values} document={document} />;
|
||||
case 'FOREIGN_PASSPORT':
|
||||
return <ForeignPassportCard config={config} values={values} document={document} />;
|
||||
case 'DRIVER_LICENSE':
|
||||
return <DriverLicenseCard config={config} values={values} document={document} />;
|
||||
case 'VEHICLE_REGISTRATION':
|
||||
return <VehicleRegistrationCard config={config} values={values} document={document} />;
|
||||
default:
|
||||
return <GenericDocumentCard config={config} values={values} document={document} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function DriverLicenseCategoryBadges({ value }: { value: string }) {
|
||||
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
|
||||
if (!selected.size) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LICENSE_CATEGORIES.filter((category) => selected.has(category)).map((category) => (
|
||||
<span key={category} className="rounded-lg bg-[#20212b] px-2 py-1 text-xs font-medium text-white">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/frontend/components/documents/document-view-dialog.tsx
Normal file
75
apps/frontend/components/documents/document-view-dialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import { DocumentPhotoGallery } from '@/components/documents/document-photo-gallery';
|
||||
import { DocumentViewCard } from '@/components/documents/document-view-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { UserDocument } from '@/lib/api';
|
||||
|
||||
export function DocumentViewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
userId,
|
||||
token,
|
||||
document,
|
||||
readOnly,
|
||||
onEdit
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentTypeCode;
|
||||
userId: string;
|
||||
token: string | null;
|
||||
document: UserDocument;
|
||||
readOnly?: boolean;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const config = getDocumentType(documentType);
|
||||
const metadata = parseMetadata(document.metadataJson);
|
||||
const storageKeys = parseDocumentPhotos(metadata);
|
||||
const attachmentMeta = parseAttachmentMeta(metadata);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] p-0 sm:max-w-[480px]">
|
||||
<DialogHeader className="sticky top-0 z-10 border-b border-[#eceef4] bg-white px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-xl font-semibold">{config.label}</DialogTitle>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]" aria-label="Закрыть">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-5 pb-5 pt-4">
|
||||
<DocumentViewCard documentType={documentType} document={document} />
|
||||
|
||||
{storageKeys.length > 0 ? (
|
||||
<section>
|
||||
<p className="mb-3 text-sm font-medium text-[#1f2430]">Прикреплённые файлы</p>
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={storageKeys}
|
||||
attachmentMeta={attachmentMeta}
|
||||
readOnly
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!readOnly && onEdit ? (
|
||||
<Button type="button" className="w-full rounded-[18px] py-6" onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Редактировать
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, indexDocumentsByType, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { getDocumentType, indexDocumentsByType, parseAttachmentMeta, parseDocumentPhotos, parseMetadata, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -70,6 +70,7 @@ export function UserDocumentsDialog({
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||
return (
|
||||
<div key={document.id}>
|
||||
<button
|
||||
@@ -88,7 +89,7 @@ export function UserDocumentsDialog({
|
||||
</button>
|
||||
{photoKeys.length > 0 && token ? (
|
||||
<div className="mt-2 px-1">
|
||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={targetUserId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -99,7 +100,7 @@ export function UserDocumentsDialog({
|
||||
</Dialog>
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const SELECTED_FAMILY_STORAGE_KEY = 'id-selected-family-group-id';
|
||||
import { readStoredFamilyGroupId, writeStoredFamilyGroupId } from '@/lib/selected-family-storage';
|
||||
|
||||
type FamilyOverlayContextValue = {
|
||||
openMiniChat: () => void;
|
||||
@@ -14,6 +13,7 @@ type FamilyOverlayContextValue = {
|
||||
pendingMember: { userId: string; name: string; isBot?: boolean; botUsername?: string } | null;
|
||||
clearPending: () => void;
|
||||
selectedGroupId: string | null;
|
||||
isSelectionHydrated: boolean;
|
||||
setSelectedGroupId: (groupId: string | null) => void;
|
||||
};
|
||||
|
||||
@@ -24,23 +24,20 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
||||
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
|
||||
const [pendingMember, setPendingMember] = useState<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupIdState] = useState<string | null>(null);
|
||||
const [isSelectionHydrated, setIsSelectionHydrated] = useState(false);
|
||||
const pendingMemberRef = useRef<{ userId: string; name: string; isBot?: boolean; botUsername?: string } | null>(null);
|
||||
const selectionHydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionHydratedRef.current) return;
|
||||
selectionHydratedRef.current = true;
|
||||
const stored = window.localStorage.getItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
if (stored) setSelectedGroupIdState(stored);
|
||||
const stored = readStoredFamilyGroupId();
|
||||
if (stored) {
|
||||
setSelectedGroupIdState(stored);
|
||||
}
|
||||
setIsSelectionHydrated(true);
|
||||
}, []);
|
||||
|
||||
const setSelectedGroupId = useCallback((groupId: string | null) => {
|
||||
setSelectedGroupIdState(groupId);
|
||||
if (groupId) {
|
||||
window.localStorage.setItem(SELECTED_FAMILY_STORAGE_KEY, groupId);
|
||||
} else {
|
||||
window.localStorage.removeItem(SELECTED_FAMILY_STORAGE_KEY);
|
||||
}
|
||||
writeStoredFamilyGroupId(groupId);
|
||||
}, []);
|
||||
|
||||
const openMiniChat = useCallback(() => {
|
||||
@@ -86,11 +83,13 @@ export function FamilyOverlayProvider({ children }: { children: React.ReactNode
|
||||
pendingMember,
|
||||
clearPending,
|
||||
selectedGroupId,
|
||||
isSelectionHydrated,
|
||||
setSelectedGroupId
|
||||
}),
|
||||
[
|
||||
clearPending,
|
||||
closeMiniChat,
|
||||
isSelectionHydrated,
|
||||
miniChatOpen,
|
||||
openChatRoom,
|
||||
openChatWithMember,
|
||||
|
||||
@@ -456,6 +456,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
void refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionReady || isPinLocked) return;
|
||||
if (pathname.startsWith('/auth/')) return;
|
||||
const accessToken = token ?? window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
if (!accessToken) return;
|
||||
|
||||
void fetchAuthSession(accessToken)
|
||||
.then((session) => {
|
||||
applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPinRequiredError(error)) {
|
||||
activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? '');
|
||||
}
|
||||
});
|
||||
}, [activatePinLock, clearPinLock, isPinLocked, isSessionReady, pathname, token]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
|
||||
Reference in New Issue
Block a user