'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 (
);
}