'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 ; } 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 }: { open: boolean; onOpenChange: (open: boolean) => void; documentType: DocumentTypeCode; userId: string; token: string | null; existing?: UserDocument | null; onSaved: () => void | Promise; readOnly?: boolean; }) { 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 wasOpenRef = useRef(false); const [formInstanceKey, setFormInstanceKey] = useState(0); const [pickerValues, setPickerValues] = useState>({}); const [photoStorageKeys, setPhotoStorageKeys] = 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 = []; 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((resolve) => { requestAnimationFrame(() => resolve()); }); } function buildMetadataPayload(source: Record, photos: string[]) { 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)); } function buildSaveBody(currentValues: Record, 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, 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, photos: string[]) { 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) }) }, 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( `/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) { 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( `/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 (
{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}