fix and update

This commit is contained in:
lendry
2026-07-01 23:27:48 +03:00
parent 2b88e028c6
commit bcdfbc3861
36 changed files with 1504 additions and 320 deletions

View File

@@ -1,20 +1,23 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Camera, Loader2, X } from 'lucide-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';
@@ -106,7 +109,9 @@ export function DocumentFormDialog({
token,
existing,
onSaved,
readOnly
readOnly,
showBackToView,
onBackToView
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -116,6 +121,8 @@ export function DocumentFormDialog({
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();
@@ -124,10 +131,12 @@ export function DocumentFormDialog({
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);
@@ -138,18 +147,23 @@ export function DocumentFormDialog({
fieldValuesRef.current = {};
draftDocumentIdRef.current = undefined;
photoStorageKeysRef.current = [];
attachmentMetaRef.current = {};
return;
}
if (!config || wasOpenRef.current) return;
wasOpenRef.current = true;
const initial = buildInitialValues(config, existing);
const photos = parseDocumentPhotos(parseMetadata(existing?.metadataJson));
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]);
@@ -200,33 +214,50 @@ export function DocumentFormDialog({
});
}
function buildMetadataPayload(source: Record<string, string>, photos: string[]) {
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));
return JSON.stringify(withDocumentPhotos(metadata, photos, meta));
}
function buildSaveBody(currentValues: Record<string, string>, photos: string[]) {
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)
metadataJson: buildMetadataPayload(currentValues, photos, meta)
};
}
async function patchDocument(documentId: string, currentValues: Record<string, string>, photos: string[]) {
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)) },
{ method: 'PATCH', body: JSON.stringify(buildSaveBody(currentValues, photos, meta)) },
token
);
}
async function ensureDocumentId(currentValues: Record<string, string>, photos: string[]) {
async function ensureDocumentId(
currentValues: Record<string, string>,
photos: string[],
meta: Record<string, DocumentAttachmentMeta>
) {
if (draftDocumentIdRef.current) {
return { id: draftDocumentIdRef.current, created: false };
}
@@ -240,7 +271,7 @@ export function DocumentFormDialog({
method: 'POST',
body: JSON.stringify({
type: documentType,
...buildSaveBody(currentValues, photos)
...buildSaveBody(currentValues, photos, meta)
})
},
token
@@ -250,36 +281,50 @@ export function DocumentFormDialog({
return { id: created.id, created: true };
}
async function uploadPhoto(file: File) {
async function uploadAttachment(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 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: file.type }) },
{
method: 'POST',
body: JSON.stringify({ contentType, fileName: file.name })
},
token
);
await uploadMediaObject(presigned, file, file.type);
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);
await patchDocument(targetDocumentId, currentValues, nextPhotos);
setAttachmentMeta(nextMeta);
await patchDocument(targetDocumentId, currentValues, nextPhotos, nextMeta);
if (created) {
await onSaved();
}
showToast(ото добавлено');
showToast(айл добавлен');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить фото');
const message = getApiErrorMessage(error, 'Не удалось загрузить файл');
if (message) showToast(message);
} finally {
setIsPhotoUploading(false);
@@ -290,14 +335,18 @@ export function DocumentFormDialog({
async function handlePhotoChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? []);
for (const file of files) {
await uploadPhoto(file);
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() {
@@ -307,15 +356,16 @@ export function DocumentFormDialog({
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);
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) }) },
{ method: 'POST', body: JSON.stringify({ type: documentType, ...buildSaveBody(currentValues, photos, meta) }) },
token
);
draftDocumentIdRef.current = created.id;
@@ -342,7 +392,14 @@ export function DocumentFormDialog({
<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>
<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>
@@ -355,6 +412,7 @@ export function DocumentFormDialog({
userId={userId}
token={token}
storageKeys={photoStorageKeys}
attachmentMeta={attachmentMeta}
readOnly={readOnly}
onRemove={readOnly ? undefined : removePhoto}
/>
@@ -368,10 +426,17 @@ export function DocumentFormDialog({
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 ? 'Добавить ещё фото' : 'Добавить фото'}
{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="image/*" multiple className="hidden" onChange={handlePhotoChange} />
<input
ref={photoInputRef}
type="file"
accept={DOCUMENT_FILE_ACCEPT}
multiple
className="hidden"
onChange={handlePhotoChange}
/>
</>
) : null}