563 lines
20 KiB
TypeScript
563 lines
20 KiB
TypeScript
'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>
|
||
);
|
||
}
|