fix and update

This commit is contained in:
lendry
2026-07-01 23:27:48 +03:00
parent 2b88e028c6
commit bcdfbc3861
36 changed files with 1504 additions and 320 deletions

View File

@@ -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>
);
}

View 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}
/>
);
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;