first commit
This commit is contained in:
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal file
502
apps/frontend/components/documents/document-form-dialog.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Camera, Loader2, 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 {
|
||||
buildDocumentNumber,
|
||||
DOCUMENT_TYPES,
|
||||
DocumentTypeDef,
|
||||
LICENSE_CATEGORIES,
|
||||
parseDocumentPhotos,
|
||||
parseMetadata,
|
||||
withDocumentPhotos,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
|
||||
}
|
||||
|
||||
function GenderToggle({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-2xl bg-[#f4f5f8] p-1">
|
||||
{[
|
||||
{ id: 'male', label: 'Мужской' },
|
||||
{ id: 'female', label: 'Женский' }
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={cn(
|
||||
'flex-1 rounded-xl px-3 py-2 text-sm transition',
|
||||
value === option.id ? 'bg-white font-medium shadow-sm' : 'text-[#667085]'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean));
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{LICENSE_CATEGORIES.map((category) => {
|
||||
const active = selected.has(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = new Set(selected);
|
||||
if (active) next.delete(category);
|
||||
else next.add(category);
|
||||
onChange(Array.from(next).join(','));
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-xl border px-2 py-2 text-sm transition',
|
||||
active ? 'border-[#20212b] bg-[#20212b] text-white' : 'border-[#eceef4] bg-white text-[#667085]'
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildInitialValues(config: DocumentTypeDef, existing?: UserDocument | null) {
|
||||
const metadata = parseMetadata(existing?.metadataJson);
|
||||
const initial: Record<string, string> = {};
|
||||
for (const field of config.fields) {
|
||||
const raw = metadata[field.key];
|
||||
initial[field.key] = typeof raw === 'string' ? raw : '';
|
||||
if (field.latinKey) {
|
||||
const latinRaw = metadata[field.latinKey];
|
||||
initial[field.latinKey] = typeof latinRaw === 'string' ? latinRaw : '';
|
||||
}
|
||||
}
|
||||
if (existing?.issuedAt) initial.issuedAt = existing.issuedAt.slice(0, 10);
|
||||
if (existing?.expiresAt) initial.expiresAt = existing.expiresAt.slice(0, 10);
|
||||
return initial;
|
||||
}
|
||||
|
||||
export function DocumentFormDialog({
|
||||
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 config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]);
|
||||
const { showToast } = useToast();
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fieldValuesRef = useRef<Record<string, string>>({});
|
||||
const draftDocumentIdRef = useRef<string | undefined>(undefined);
|
||||
const photoStorageKeysRef = useRef<string[]>([]);
|
||||
const wasOpenRef = useRef(false);
|
||||
const [formInstanceKey, setFormInstanceKey] = useState(0);
|
||||
const [pickerValues, setPickerValues] = useState<Record<string, string>>({});
|
||||
const [photoStorageKeys, setPhotoStorageKeys] = useState<string[]>([]);
|
||||
const [draftDocumentId, setDraftDocumentId] = useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPhotoUploading, setIsPhotoUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
wasOpenRef.current = false;
|
||||
fieldValuesRef.current = {};
|
||||
draftDocumentIdRef.current = undefined;
|
||||
photoStorageKeysRef.current = [];
|
||||
return;
|
||||
}
|
||||
if (!config || wasOpenRef.current) return;
|
||||
|
||||
wasOpenRef.current = true;
|
||||
const initial = buildInitialValues(config, existing);
|
||||
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson));
|
||||
fieldValuesRef.current = { ...initial };
|
||||
photoStorageKeysRef.current = photos;
|
||||
draftDocumentIdRef.current = existing?.id;
|
||||
setPickerValues(initial);
|
||||
setPhotoStorageKeys(photos);
|
||||
setDraftDocumentId(existing?.id);
|
||||
setFormInstanceKey((current) => current + 1);
|
||||
}, [open, config, existing?.id, existing?.metadataJson, existing?.issuedAt, existing?.expiresAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !existing?.id || draftDocumentIdRef.current) return;
|
||||
draftDocumentIdRef.current = existing.id;
|
||||
setDraftDocumentId(existing.id);
|
||||
}, [open, existing?.id]);
|
||||
|
||||
function syncField(key: string, value: string) {
|
||||
fieldValuesRef.current[key] = value;
|
||||
}
|
||||
|
||||
function setPickerField(key: string, value: string) {
|
||||
syncField(key, value);
|
||||
setPickerValues((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function collectFormValues() {
|
||||
const values = { ...fieldValuesRef.current, ...pickerValues };
|
||||
|
||||
if (formRef.current) {
|
||||
for (const element of formRef.current.elements) {
|
||||
if (element instanceof HTMLInputElement && element.name) {
|
||||
if (element.type === 'checkbox') {
|
||||
values[element.name] = element.checked ? 'true' : '';
|
||||
} else if (element.type !== 'radio') {
|
||||
values[element.name] = element.value;
|
||||
}
|
||||
} else if (element instanceof HTMLTextAreaElement && element.name) {
|
||||
values[element.name] = element.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fieldValuesRef.current = values;
|
||||
return values;
|
||||
}
|
||||
|
||||
async function commitFocusedField() {
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function buildMetadataPayload(source: Record<string, string>, photos: string[]) {
|
||||
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));
|
||||
}
|
||||
|
||||
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) {
|
||||
if (!token || !config) return;
|
||||
await apiFetch(
|
||||
`/documents/users/${userId}/${documentId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos)) },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) {
|
||||
if (draftDocumentIdRef.current) {
|
||||
return { id: draftDocumentIdRef.current, created: false };
|
||||
}
|
||||
if (!token || !config) {
|
||||
return { id: undefined, created: false };
|
||||
}
|
||||
|
||||
const created = await apiFetch<UserDocument>(
|
||||
`/documents/users/${userId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: documentType,
|
||||
...buildSaveBody(currentValues, photos)
|
||||
})
|
||||
},
|
||||
token
|
||||
);
|
||||
draftDocumentIdRef.current = created.id;
|
||||
setDraftDocumentId(created.id);
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
async function uploadPhoto(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 presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/documents/${targetDocumentId}/photo/upload-url`,
|
||||
{ method: 'POST', body: JSON.stringify({ contentType: file.type }) },
|
||||
token
|
||||
);
|
||||
|
||||
const uploadResponse = await fetch(presigned.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file
|
||||
});
|
||||
if (!uploadResponse.ok) throw new Error('Не удалось загрузить фото');
|
||||
|
||||
const nextPhotos = [...currentPhotos, presigned.storageKey];
|
||||
photoStorageKeysRef.current = nextPhotos;
|
||||
setPhotoStorageKeys(nextPhotos);
|
||||
await patchDocument(targetDocumentId, currentValues, nextPhotos);
|
||||
|
||||
if (created) {
|
||||
await onSaved();
|
||||
}
|
||||
|
||||
showToast('Фото добавлено');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsPhotoUploading(false);
|
||||
if (photoInputRef.current) photoInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
for (const file of files) {
|
||||
await uploadPhoto(file);
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto(storageKey: string) {
|
||||
const nextPhotos = photoStorageKeysRef.current.filter((item) => item !== storageKey);
|
||||
photoStorageKeysRef.current = nextPhotos;
|
||||
setPhotoStorageKeys(nextPhotos);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!token || !config || readOnly) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await commitFocusedField();
|
||||
const currentValues = collectFormValues();
|
||||
const photos = photoStorageKeysRef.current;
|
||||
const documentId = draftDocumentIdRef.current ?? existing?.id;
|
||||
|
||||
if (documentId) {
|
||||
await patchDocument(documentId, currentValues, photos);
|
||||
showToast('Документ обновлён');
|
||||
} else {
|
||||
const created = await apiFetch<UserDocument>(
|
||||
`/documents/users/${userId}`,
|
||||
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos) }) },
|
||||
token
|
||||
);
|
||||
draftDocumentIdRef.current = created.id;
|
||||
setDraftDocumentId(created.id);
|
||||
showToast('Документ сохранён');
|
||||
}
|
||||
|
||||
await onSaved();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось сохранить документ');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const initialValues = fieldValuesRef.current;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-[#f4f5f8]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-5 pb-5">
|
||||
{photoStorageKeys.length > 0 ? (
|
||||
<DocumentPhotoGallery
|
||||
userId={userId}
|
||||
token={token}
|
||||
storageKeys={photoStorageKeys}
|
||||
readOnly={readOnly}
|
||||
onRemove={readOnly ? undefined : removePhoto}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
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 ? 'Добавить ещё фото' : 'Добавить фото'}
|
||||
</button>
|
||||
<input ref={photoInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoChange} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
key={formInstanceKey}
|
||||
ref={formRef}
|
||||
className="space-y-3 rounded-[24px] bg-[#f4f5f8] p-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
}}
|
||||
>
|
||||
{config.fields.map((field) => {
|
||||
if (readOnly) {
|
||||
const value = initialValues[field.key];
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
<p className="mt-1 text-sm text-[#1f2430]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'checkbox') {
|
||||
return (
|
||||
<label key={field.key} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={field.key}
|
||||
defaultChecked={initialValues[field.key] === 'true'}
|
||||
onChange={(event) => syncField(field.key, event.target.checked ? 'true' : '')}
|
||||
/>
|
||||
{field.label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'gender') {
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
|
||||
<GenderToggle value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'categories') {
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
<input type="hidden" name={field.key} value={pickerValues[field.key] ?? ''} readOnly />
|
||||
<CategoryPicker value={pickerValues[field.key] ?? ''} onChange={(value) => setPickerField(field.key, value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'textarea') {
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
<textarea
|
||||
name={field.key}
|
||||
defaultValue={initialValues[field.key] ?? ''}
|
||||
onChange={(event) => syncField(field.key, event.target.value)}
|
||||
onBlur={(event) => syncField(field.key, event.target.value)}
|
||||
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
className="min-h-[96px] w-full rounded-2xl border border-[#eceef4] bg-white px-4 py-3 text-sm outline-none focus:border-[#20212b]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<FieldLabel>{field.label}</FieldLabel>
|
||||
<Input
|
||||
type={field.kind === 'date' ? 'date' : 'text'}
|
||||
name={field.key}
|
||||
defaultValue={initialValues[field.key] ?? ''}
|
||||
onChange={(event) => syncField(field.key, event.target.value)}
|
||||
onBlur={(event) => syncField(field.key, event.target.value)}
|
||||
onCompositionEnd={(event) => syncField(field.key, event.currentTarget.value)}
|
||||
placeholder={field.placeholder ?? field.label}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{field.latinKey ? (
|
||||
<div className="space-y-2">
|
||||
<FieldLabel>{field.latinLabel ?? field.latinKey}</FieldLabel>
|
||||
<Input
|
||||
name={field.latinKey}
|
||||
defaultValue={initialValues[field.latinKey] ?? ''}
|
||||
onChange={(event) => syncField(field.latinKey!, event.target.value)}
|
||||
onBlur={(event) => syncField(field.latinKey!, event.target.value)}
|
||||
onCompositionEnd={(event) => syncField(field.latinKey!, event.currentTarget.value)}
|
||||
placeholder={field.latinLabel ?? field.latinKey}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</form>
|
||||
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full rounded-[18px] py-6"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Сохраняем...' : 'Сохранить'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal file
105
apps/frontend/components/documents/document-photo-gallery.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { fetchDocumentPhotoUrl } from '@/lib/api';
|
||||
import { DocumentFullscreenViewer, DocumentPhotoThumbnail } from './document-photo-viewer';
|
||||
|
||||
interface DocumentPhotoGalleryProps {
|
||||
userId: string;
|
||||
token: string | null;
|
||||
storageKeys: string[];
|
||||
readOnly?: boolean;
|
||||
onRemove?: (storageKey: string) => void;
|
||||
}
|
||||
|
||||
export function DocumentPhotoGallery({ userId, token, storageKeys, readOnly, onRemove }: DocumentPhotoGalleryProps) {
|
||||
const { isPinLocked } = useAuth();
|
||||
const [urls, setUrls] = React.useState<Record<string, string>>({});
|
||||
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({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingKeys(new Set(storageKeys));
|
||||
void Promise.all(
|
||||
storageKeys.map(async (storageKey) => {
|
||||
try {
|
||||
const response = await fetchDocumentPhotoUrl(userId, storageKey, token);
|
||||
return { storageKey, accessUrl: response.accessUrl };
|
||||
} catch {
|
||||
return { storageKey, accessUrl: '' };
|
||||
}
|
||||
})
|
||||
).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);
|
||||
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));
|
||||
|
||||
if (storageKeys.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{storageKeys.map((storageKey) => (
|
||||
<div key={storageKey} className="relative">
|
||||
<DocumentPhotoThumbnail
|
||||
url={urls[storageKey]}
|
||||
loading={loadingKeys.has(storageKey)}
|
||||
onClick={() => {
|
||||
const resolvedIndex = resolvedImages.findIndex((item) => item.storageKey === storageKey);
|
||||
if (resolvedIndex >= 0) {
|
||||
setViewerIndex(resolvedIndex);
|
||||
setViewerOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!readOnly && onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(storageKey)}
|
||||
className="absolute right-2 top-2 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80"
|
||||
aria-label="Удалить фото"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DocumentFullscreenViewer
|
||||
open={viewerOpen}
|
||||
onOpenChange={setViewerOpen}
|
||||
initialIndex={viewerIndex}
|
||||
images={resolvedImages}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal file
109
apps/frontend/components/documents/document-photo-viewer.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DocumentFullscreenViewerProps {
|
||||
images: { storageKey: string; url: string }[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DocumentFullscreenViewer({ images, initialIndex = 0, open, onOpenChange }: DocumentFullscreenViewerProps) {
|
||||
const [index, setIndex] = React.useState(initialIndex);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setIndex(initialIndex);
|
||||
}, [initialIndex, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
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));
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [images.length, onOpenChange, open]);
|
||||
|
||||
if (!open || images.length === 0) return null;
|
||||
|
||||
const current = images[index];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex flex-col bg-black/95">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-white">
|
||||
<span className="text-sm text-white/80">
|
||||
{index + 1} / {images.length}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenChange(false)} className="rounded-full p-2 hover:bg-white/10" aria-label="Закрыть">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-4 pb-6">
|
||||
{images.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
||||
className="absolute left-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
||||
aria-label="Предыдущее фото"
|
||||
>
|
||||
<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-120px)] max-w-full object-contain" />
|
||||
|
||||
{images.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === images.length - 1}
|
||||
onClick={() => setIndex((value) => Math.min(images.length - 1, value + 1))}
|
||||
className="absolute right-4 z-10 rounded-full bg-white/10 p-2 text-white disabled:opacity-30"
|
||||
aria-label="Следующее фото"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentPhotoThumbnailProps {
|
||||
url?: string;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function DocumentPhotoThumbnail({ url, loading, className, onClick }: DocumentPhotoThumbnailProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group relative aspect-[4/3] overflow-hidden rounded-2xl border border-[#eceef4] bg-[#f4f5f8] transition hover:border-[#20212b]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{loading ? <div className="h-full w-full animate-pulse bg-[#eceef4]" /> : null}
|
||||
{!loading && url ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
{!loading && !url ? <div className="flex h-full items-center justify-center text-xs text-[#667085]">Нет превью</div> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal file
112
apps/frontend/components/documents/user-documents-dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { getDocumentType, indexDocumentsByType, type DocumentTypeCode } from '@/lib/document-catalog';
|
||||
import { apiFetch, UserDocument } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function UserDocumentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetUserId,
|
||||
targetUserName,
|
||||
token
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetUserId: string;
|
||||
targetUserName: string;
|
||||
token: string | null;
|
||||
}) {
|
||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeType, setActiveType] = useState<DocumentTypeCode | null>(null);
|
||||
const [selectedDocument, setSelectedDocument] = useState<UserDocument | null>(null);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!token || !targetUserId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${targetUserId}`, {}, token);
|
||||
setDocuments(response.documents ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetUserId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadDocuments();
|
||||
}, [loadDocuments, open]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
setSelectedDocument(documentsByType.get(type) ?? null);
|
||||
setActiveType(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Документы пользователя</DialogTitle>
|
||||
<p className="text-sm text-[#667085]">{targetUserName}</p>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? <p className="py-8 text-center text-sm text-[#667085]">Загрузка документов...</p> : null}
|
||||
|
||||
{!loading && documents.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-[#667085]">У пользователя нет сохранённых документов</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{documents.map((document) => {
|
||||
const config = getDocumentType(document.type);
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-left transition hover:bg-[#eceef4]"
|
||||
>
|
||||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-xl', config.accent)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{config.label}</div>
|
||||
<div className="truncate text-sm text-[#667085]">{document.number}</div>
|
||||
</div>
|
||||
<FileText className="h-4 w-4 text-[#a8adbc]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setActiveType(null);
|
||||
setSelectedDocument(null);
|
||||
}
|
||||
}}
|
||||
documentType={activeType}
|
||||
userId={targetUserId}
|
||||
token={token}
|
||||
existing={selectedDocument}
|
||||
onSaved={loadDocuments}
|
||||
readOnly
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user