Files
IdP/apps/frontend/components/documents/document-form-dialog.tsx
2026-07-01 23:27:48 +03:00

563 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 <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,
showBackToView,
onBackToView
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentTypeCode;
userId: string;
token: string | null;
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();
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 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);
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<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
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, meta));
}
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, meta)
};
}
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, meta)) },
token
);
}
async function ensureDocumentId(
currentValues: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
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, 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<PresignedUploadResponse>(
`/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<HTMLInputElement>) {
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<UserDocument>(
`/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 (
<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">
<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>
</div>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{photoStorageKeys.length > 0 ? (
<DocumentPhotoGallery
userId={userId}
token={token}
storageKeys={photoStorageKeys}
attachmentMeta={attachmentMeta}
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" /> : <Paperclip className="h-4 w-4" />}
{photoStorageKeys.length > 0 ? 'Добавить ещё файл' : 'Добавить фото или документ'}
</button>
<input
ref={photoInputRef}
type="file"
accept={DOCUMENT_FILE_ACCEPT}
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>
);
}