Files
IdP/apps/frontend/components/documents/document-form-dialog.tsx
2026-06-24 20:15:19 +03:00

498 lines
18 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 { 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, 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
}: {
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
);
await uploadMediaObject(presigned, file, file.type);
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>
);
}