'use client'; import { useEffect, useMemo, useRef, useState } from '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'; import { cn } from '@/lib/utils'; interface PresignedUploadResponse { uploadUrl: string; storageKey: string; expiresAt: string; } function FieldLabel({ children }: { children: React.ReactNode }) { return ; } function GenderToggle({ value, onChange }: { value: string; onChange: (value: string) => void }) { return (
{[ { id: 'male', label: 'Мужской' }, { id: 'female', label: 'Женский' } ].map((option) => ( ))}
); } function CategoryPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) { const selected = new Set(value.split(',').map((item) => item.trim()).filter(Boolean)); return (
{LICENSE_CATEGORIES.map((category) => { const active = selected.has(category); return ( ); })}
); } function buildInitialValues(config: DocumentTypeDef, existing?: UserDocument | null) { const metadata = parseMetadata(existing?.metadataJson); const initial: Record = {}; 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, showBackToView, onBackToView }: { open: boolean; onOpenChange: (open: boolean) => void; documentType: DocumentTypeCode; userId: string; token: string | null; existing?: UserDocument | null; onSaved: () => void | Promise; readOnly?: boolean; showBackToView?: boolean; onBackToView?: () => void; }) { const config = useMemo(() => DOCUMENT_TYPES.find((item) => item.type === documentType), [documentType]); const { showToast } = useToast(); const photoInputRef = useRef(null); const formRef = useRef(null); const fieldValuesRef = useRef>({}); const draftDocumentIdRef = useRef(undefined); const photoStorageKeysRef = useRef([]); const attachmentMetaRef = useRef>({}); const wasOpenRef = useRef(false); const [formInstanceKey, setFormInstanceKey] = useState(0); const [pickerValues, setPickerValues] = useState>({}); const [photoStorageKeys, setPhotoStorageKeys] = useState([]); const [attachmentMeta, setAttachmentMeta] = useState>({}); const [draftDocumentId, setDraftDocumentId] = useState(); const [isSaving, setIsSaving] = useState(false); const [isPhotoUploading, setIsPhotoUploading] = useState(false); useEffect(() => { if (!open) { wasOpenRef.current = false; fieldValuesRef.current = {}; draftDocumentIdRef.current = undefined; photoStorageKeysRef.current = []; attachmentMetaRef.current = {}; return; } if (!config || wasOpenRef.current) return; wasOpenRef.current = true; const initial = buildInitialValues(config, existing); 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]); 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((resolve) => { requestAnimationFrame(() => resolve()); }); } function buildMetadataPayload( source: Record, photos: string[], meta: Record ) { const metadata: Record = {}; for (const [key, value] of Object.entries(source)) { if (key !== 'issuedAt' && key !== 'expiresAt') metadata[key] = value; } return JSON.stringify(withDocumentPhotos(metadata, photos, meta)); } function buildSaveBody( currentValues: Record, photos: string[], meta: Record ) { 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, meta) }; } async function patchDocument( documentId: string, currentValues: Record, photos: string[], meta: Record ) { if (!token || !config) return; await apiFetch( `/documents/users/${userId}/${documentId}`, { method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) }, token ); } async function ensureDocumentId( currentValues: Record, photos: string[], meta: Record ) { if (draftDocumentIdRef.current) { return { id: draftDocumentIdRef.current, created: false }; } if (!token || !config) { return { id: undefined, created: false }; } const created = await apiFetch( `/documents/users/${userId}`, { method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) }, token ); draftDocumentIdRef.current = created.id; setDraftDocumentId(created.id); return { id: created.id, created: true }; } async function uploadAttachment(file: File) { if (!token || readOnly) return; setIsPhotoUploading(true); try { await commitFocusedField(); const currentValues = collectFormValues(); const currentPhotos = photoStorageKeysRef.current; 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( `/media/documents/${targetDocumentId}/photo/upload-url`, { method: 'POST', body: JSON.stringify({ contentType, fileName: file.name }) }, token ); 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); setAttachmentMeta(nextMeta); await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta); 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) { const files = Array.from(event.target.files ?? []); for (const file of files) { 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() { if (!token || !config || readOnly) return; setIsSaving(true); try { 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, meta); showToast('Документ обновлён'); } else { const created = await apiFetch( `/documents/users/${userId}`, { method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) }, 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 (
{showBackToView && onBackToView ? ( ) : null} {config.label}
{photoStorageKeys.length > 0 ? ( ) : null} {!readOnly ? ( <> ) : null}
{ event.preventDefault(); void handleSave(); }} > {config.fields.map((field) => { if (readOnly) { const value = initialValues[field.key]; if (!value) return null; return (
{field.label}

{value}

); } if (field.kind === 'checkbox') { return ( ); } if (field.kind === 'gender') { return (
{field.label} setPickerField(field.key, value)} />
); } if (field.kind === 'categories') { return (
{field.label} setPickerField(field.key, value)} />
); } if (field.kind === 'textarea') { return (
{field.label}