/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/EntityForm.js
2 467 строк
114 KB
agent
v15.7.4 — убрано поле Сумма вклада из формы deposits, default amount=0
10 июл 2026, 19:41
10 июл 2026, 19:41
32e5f5d
Код
Авторство
О чём код?
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { FORM_FIELDS, ICONS, ENTITY_LABELS } from '../config/entities'; import { useLookupData, addLookupItem } from '../hooks/useLookupData'; import { api } from '../services/api'; import { refreshIdempotencyKey } from '../services/restApi'; import { compressImages } from '../utils/compressImage'; import Calculator from './Calculator'; import LoanPayments from './LoanPayments'; import MobileLoanPayments from './MobileLoanPayments'; import DepositOperations from './DepositOperations'; import DepositOperationsMobile from './DepositOperationsMobile'; import CategoryManager from './CategoryManager'; import TagSelector from './TagSelector'; /** Загружает фото чека после сохранения расхода */ async function uploadReceiptPhotos(expenseId, receiptFiles, deletedPhotoIds, fileNotes = []) { const results = { uploaded: [], deleted: [], errors: [] }; if (!expenseId) return results; // Удаляем отмеченные фото if (deletedPhotoIds?.length) { for (const photoId of deletedPhotoIds) { try { await api.expenses.deleteReceipt(expenseId, photoId); results.deleted.push(photoId); } catch (e) { results.errors.push(`Удаление ${photoId}: ${e.message}`); } } } // Загружаем новые файлы с описаниями if (receiptFiles?.length) { try { const notes = receiptFiles.map((_, i) => fileNotes[i] || ''); const res = await api.expenses.uploadReceipt(expenseId, receiptFiles, notes); if (res.attachments) results.uploaded = res.attachments; else if (res.photos) results.uploaded = res.photos; } catch (e) { results.errors.push(`Загрузка: ${e.message}`); } } refreshIdempotencyKey(); return results; } function IconSelector({ options, value, onSelect, label }) { const [open, setOpen] = useState(false); const ref = useRef(null); const selectedOpt = options.find(o => o.value === value); useEffect(() => { const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) { setOpen(false); } }; if (open) document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); return ( <div className="form-group" ref={ref}> <label>{label}</label> <div style={{ position: 'relative' }}> <button type="button" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px', border: '1px solid #ccc', borderRadius: 4, background: '#fff', cursor: 'pointer', minWidth: 120, }} > {selectedOpt?.value ? ( <img src={`/assets/account-icons/${selectedOpt.value}.png`} alt="" style={{ width: 28, height: 28, objectFit: 'contain' }} /> ) : ( <span style={{ color: '#999' }}>—</span> )} <span style={{ fontSize: 12, color: '#666' }}>▼</span> </button> {open && ( <div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: '#fff', border: '1px solid #ccc', borderRadius: 4, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', padding: 8, display: 'grid', gridTemplateColumns: 'repeat(4, 56px)', gap: 6, }} > {options.map(opt => ( <button key={opt.value} type="button" onClick={() => { onSelect(opt.value); setOpen(false); }} title={opt.label} style={{ width: 56, height: 56, padding: 4, border: (value || '') === opt.value ? '2px solid #ff9800' : '2px solid transparent', borderRadius: 8, background: (value || '') === opt.value ? '#fff3e0' : '#f5f5f5', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > {opt.value ? ( <img src={`/assets/account-icons/${opt.value}.png`} alt="" style={{ width: 32, height: 32, objectFit: 'contain' }} /> ) : ( <span style={{ fontSize: 11, color: '#999' }}>—</span> )} </button> ))} </div> )} </div> </div> ); } function FormField({ field, value, onChange, lookups, formData, entity, localTags, handleCreateTag, isReadOnly }) { const { accounts, currencies, expenseCategories, expenseSubcategories, incomeCategories, incomeSubcategories, creditors, debtors, } = lookups; const [showCalc, setShowCalc] = useState(false); const [calcPos, setCalcPos] = useState(null); const [showCategoryManager, setShowCategoryManager] = useState(false); const calcIconRef = useRef(null); // Закрытие калькулятора по клику вне его и не на иконку useEffect(() => { if (!showCalc) return; const handleClick = (e) => { if (e.target.closest('.calc-popup') || e.target.closest('.calc-trigger')) return; setShowCalc(false); }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [showCalc]); const openCalc = () => { if (calcIconRef.current) { const rect = calcIconRef.current.getBoundingClientRect(); setCalcPos({ top: rect.bottom + 4, left: rect.left }); } setShowCalc(true); }; const isDisabled = useMemo(() => { if (isReadOnly) return true; if (!field.disabledWhen) return false; return formData[field.disabledWhen.field] === field.disabledWhen.value; }, [isReadOnly, field.disabledWhen, formData]); const isHidden = useMemo(() => { if (!field.hiddenWhen) return false; return formData[field.hiddenWhen.field] === field.hiddenWhen.value; }, [field.hiddenWhen, formData]); if (isHidden) return null; const getSelectOptions = () => { switch (field.source) { case 'accounts': return accounts.filter(a => !a.hidden); case 'currencies': return currencies; case 'expenseCategories': return expenseCategories; case 'expenseSubcategories': return expenseSubcategories.filter(s => String(s.categoryId ?? s.category_id ?? s.category ?? '') === String(formData.categoryId ?? '')); case 'incomeCategories': return incomeCategories; case 'incomeSubcategories': return incomeSubcategories.filter(s => String(s.categoryId ?? s.category_id ?? s.category ?? '') === String(formData.categoryId ?? '')); case 'creditors': return creditors; case 'debtors': return debtors; default: return field.options || []; } }; const inputProps = { value: value || '', onChange: e => onChange(field.name, e.target.value), required: field.required, disabled: isDisabled, }; const isCategoryField = field.name === 'categoryId' && (entity === 'expenses' || entity === 'incomes'); const isSubcategoryField = field.name === 'subcategoryId' && (entity === 'expenses' || entity === 'incomes'); if (isCategoryField || isSubcategoryField) { const catType = entity === 'expenses' ? 'expense' : 'income'; const options = getSelectOptions(); const labelEmpty = isSubcategoryField ? '-' : 'Выберите...'; return ( <div className="form-group" style={{ position: 'relative' }}> <label>{field.label}</label> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <select {...inputProps} style={{ flex: 1 }}> <option value="">{isSubcategoryField ? '-' : (field.required ? labelEmpty : 'Все')}</option> {options.map(opt => ( <option key={opt.id || opt.value || opt.id} value={opt.id || opt.value || opt.id}> {opt.name || opt.label || opt.id || ''} </option> ))} </select> <button type="button" className="btn" style={{ padding: '0 6px', height: 24, minWidth: 24 }} title={isCategoryField ? 'Добавить категорию' : 'Добавить подкатегорию'} onClick={() => { if (isCategoryField) { const name = window.prompt('Новая категория:'); if (name) onChange('_quickAddCategory', { type: catType, name }); } else { if (!formData.categoryId) { alert('Сначала выберите категорию'); return; } const name = window.prompt('Новая подкатегория:'); if (name) onChange('_quickAddSubcategory', { type: catType, categoryId: formData.categoryId, name }); } }} > {ICONS.create} </button> <button type="button" className="btn" style={{ padding: '0 6px', height: 24, minWidth: 24 }} title="Открыть справочник" onClick={() => setShowCategoryManager(true)} > Справ. </button> </div> {showCategoryManager && ( <CategoryManager type={catType} onClose={() => setShowCategoryManager(false)} /> )} </div> ); } if (field.type === 'textarea') { return ( <div className={`form-group ${field.fullWidth ? 'full-width' : ''}`}> <label>{field.label}</label> <textarea {...inputProps} rows={3} /> </div> ); } if (field.name === 'debtorId' || field.name === 'creditorId') { const options = getSelectOptions(); const isDebtor = field.name === 'debtorId'; return ( <div className="form-group"> <label>{field.label}</label> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <select {...inputProps} style={{ flex: 1 }}> <option value="">{field.required ? 'Выберите...' : '-'}</option> {options.map(opt => ( <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> <button type="button" className="btn" style={{ padding: '0 6px', height: 24, minWidth: 24 }} title={isDebtor ? 'Добавить должника' : 'Добавить кредитора'} onClick={async () => { const name = window.prompt(`Введите имя ${isDebtor ? 'должника' : 'кредитора'}:`); if (!name || !name.trim()) return; try { const apiName = isDebtor ? 'debtors' : 'creditors'; const created = await api[apiName].create({ name: name.trim(), note: '' }); addLookupItem(isDebtor ? 'debtors' : 'creditors', created); onChange(field.name, created.id); } catch (e) { alert(`Ошибка создания ${isDebtor ? 'должника' : 'кредитора'}: ` + (e.message || e)); } }} > {ICONS.create} </button> </div> </div> ); } if (field.type === 'checkbox') { return ( <div className="form-group"> <label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', userSelect: 'none' }}> <input type="checkbox" checked={!!value} onChange={(e) => onChange(field.name, e.target.checked)} style={{ width: 'auto', margin: 0 }} /> <span>{field.label}</span> </label> </div> ); } if (field.type === 'color') { return ( <div className="form-group"> <label>{field.label}</label> <input type="color" {...inputProps} style={{ width: 60, height: 32, padding: 2 }} /> </div> ); } if (field.type === 'tags') { return ( <TagSelector options={localTags} value={value || []} onChange={(newIds) => onChange(field.name, newIds)} onCreateTag={handleCreateTag} label={field.label} /> ); } if (field.type === 'select') { const options = getSelectOptions(); const isAccount = field.source === 'accounts'; const isToAccount = field.name === 'toAccountId'; const isFeeType = field.name === 'feeType'; const filteredOptions = isToAccount && formData.fromAccountId ? options.filter(opt => opt.id !== formData.fromAccountId) : options; return ( <div className="form-group"> <label>{field.label}</label> <select {...inputProps}> {!isFeeType && <option value="">{field.required ? 'Выберите...' : 'Все'}</option>} {filteredOptions.map(opt => { const label = isAccount ? (() => { const curr = currencies.find(c => c.id === opt.currencyId); return curr ? `${opt.name} (${curr.code})` : opt.name; })() : (opt.name || opt.label || opt.id || ''); return ( <option key={opt.id || opt.value || opt.id} value={opt.id || opt.value || opt.id}> {label} </option> ); })} </select> </div> ); } if (field.type === 'icon') { return ( <IconSelector options={field.options || []} value={value} onSelect={v => onChange(field.name, v)} label={field.label} /> ); } if (field.type === 'calculator') { return ( <div className="form-group"> <label>{field.label}</label> <div style={{ display: 'flex', alignItems: 'center' }}> <input type="number" step={field.step} {...inputProps} style={{ flex: 1 }} /> <span className="calc-trigger" ref={calcIconRef} onClick={openCalc} title="Калькулятор" style={{ cursor: 'pointer', fontSize: 20 }}>{ICONS.calculator}</span> </div> {showCalc && calcPos && createPortal( <div style={{ position: 'fixed', top: calcPos.top, left: calcPos.left, zIndex: 9999 }}> <Calculator onResult={val => { onChange(field.name, val); setShowCalc(false); }} onClose={() => setShowCalc(false)} /> </div>, document.body )} </div> ); } if (field.type === 'display') { return ( <div className="form-group"> <label>{field.label}</label> <div style={{ padding: '6px 10px', background: '#f5f5f5', border: '1px solid #ddd', borderRadius: 4, fontSize: 14, color: '#333', minHeight: 32, display: 'flex', alignItems: 'center', }}> {value || '—'} </div> </div> ); } return ( <div className="form-group"> <label>{field.label}</label> <input type={field.type} step={field.step} {...inputProps} onInput={field.type === 'date' ? (e => onChange(field.name, e.target.value)) : undefined} /> </div> ); } // ── Компоненты для фото чека ──────────────────────────────────── /** Лайтбокс для просмотра фото с поворотом */ function PhotoViewer({ url, onClose }) { const [rotation, setRotation] = useState(0); if (!url) return null; return ( <div onClick={() => onClose()} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.85)', zIndex: 9999, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'zoom-out', }}> <img src={url} alt="" onClick={e => e.stopPropagation()} style={{ maxWidth: '90vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 4, transform: `rotate(${rotation}deg)`, transition: 'transform 0.2s ease', }} /> <div style={{ marginTop: 16, display: 'flex', gap: 20 }} onClick={e => e.stopPropagation()}> <button onClick={() => setRotation(r => r - 90)} title="Повернуть влево" style={{ background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)', color: '#fff', fontSize: 22, padding: '8px 16px', borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, }} > ↺ Влево </button> <button onClick={() => setRotation(0)} title="Сбросить" style={{ background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)', color: '#fff', fontSize: 16, padding: '8px 16px', borderRadius: 8, cursor: 'pointer', }} > ⟲ Сброс </button> <button onClick={() => setRotation(r => r + 90)} title="Повернуть вправо" style={{ background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)', color: '#fff', fontSize: 22, padding: '8px 16px', borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, }} > Вправо ↻ </button> </div> </div> ); } /** Мобильный UI для вложений: кнопки Камера / Галерея / PDF + превью + описание */ function ReceiptAttachmentMobile({ attachments, files, fileNotes = [], onAddFiles, onRemoveFile, onRemoveAttachment, onUpdateFileNote, onUpdateAttachmentNote }) { const [viewerUrl, setViewerUrl] = useState(null); const [editingAttId, setEditingAttId] = useState(null); const [compressing, setCompressing] = useState(false); const cameraInputRef = useRef(null); const galleryInputRef = useRef(null); const pdfInputRef = useRef(null); const totalCount = (attachments?.length || 0) + (files?.length || 0); const handleImageFiles = async (files) => { if (!files?.length) return; setCompressing(true); try { const compressed = await compressImages(Array.from(files)); onAddFiles(compressed); } catch (e) { console.error('[ReceiptAttachmentMobile] compression error:', e); onAddFiles(Array.from(files)); } finally { setCompressing(false); } }; return ( <div style={{ padding: '12px 16px', borderBottom: '1px solid #e0e0e0', background: '#fff' }}> <div style={{ fontSize: 14, color: '#555', marginBottom: 8, fontWeight: 500 }}> 📎 Вложения {totalCount > 0 && <span style={{ color: '#1976d2' }}>({totalCount})</span>} </div> {/* Скрытые input'ы */} <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" multiple style={{ display: 'none' }} onChange={e => { if (e.target.files?.length) handleImageFiles(e.target.files); e.target.value = ''; }} /> <input ref={galleryInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={e => { if (e.target.files?.length) handleImageFiles(e.target.files); e.target.value = ''; }} /> <input ref={pdfInputRef} type="file" accept=".pdf" multiple style={{ display: 'none' }} onChange={e => { if (e.target.files?.length) onAddFiles(Array.from(e.target.files)); e.target.value = ''; }} /> {/* Кнопки */} {compressing && ( <div style={{ textAlign: 'center', padding: '6px', fontSize: 12, color: '#1976d2', marginBottom: 8 }}> 🔄 Сжатие изображений... </div> )} <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div onClick={() => cameraInputRef.current?.click()} style={{ flex: 1, padding: '10px', background: '#f5f5f0', borderRadius: 6, textAlign: 'center', fontSize: 13, cursor: 'pointer', color: '#333', opacity: compressing ? 0.5 : 1 }} > 📷 Камера </div> <div onClick={() => galleryInputRef.current?.click()} style={{ flex: 1, padding: '10px', background: '#f5f5f0', borderRadius: 6, textAlign: 'center', fontSize: 13, cursor: 'pointer', color: '#333', opacity: compressing ? 0.5 : 1 }} > 🖼️ Галерея </div> <div onClick={() => pdfInputRef.current?.click()} style={{ flex: 1, padding: '10px', background: '#f5f5f0', borderRadius: 6, textAlign: 'center', fontSize: 13, cursor: 'pointer', color: '#333', opacity: compressing ? 0.5 : 1 }} > 📄 PDF </div> </div> {/* Превью существующих вложений: note вместо имени, ✏️ для редактирования */} {attachments?.map((att, idx) => ( <div key={att.id || `att-${idx}`} style={{ marginBottom: 6, padding: '6px 8px', background: '#fafafa', borderRadius: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> {att.file_type === 'pdf' ? ( <div onClick={() => window.open(att.url || `/media/${att.filePath}`, '_blank')} style={{ width: 48, height: 48, background: '#ffebee', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 20 }}>📄</div> ) : ( <img src={att.url || (att.filePath ? `/media/${att.filePath}` : '')} alt="" loading="lazy" onClick={() => setViewerUrl(att.url || (att.filePath ? `/media/${att.filePath}` : ''))} style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4, cursor: 'zoom-in' }} /> )} <span style={{ flex: 1, fontSize: 12, color: '#666', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> {att.note || att.original_name} </span> <span style={{ color: '#1976d2', fontSize: 16, cursor: 'pointer', padding: '4px 8px' }} onClick={() => setEditingAttId(editingAttId === att.id ? null : att.id)}>✏️</span> <span style={{ color: '#d32f2f', fontSize: 16, cursor: 'pointer', padding: '4px 8px' }} onClick={() => onRemoveAttachment(att.id)}>🗑</span> </div> {editingAttId === att.id && ( <div style={{ marginTop: 4, marginLeft: 56 }}> <input type="text" placeholder="Описание..." defaultValue={att.note || ''} onBlur={e => { onUpdateAttachmentNote?.(att.id, e.target.value); setEditingAttId(null); }} autoFocus style={{ width: '100%', fontSize: 12, padding: '4px 6px', border: '1px solid #ddd', borderRadius: 4, color: '#555' }} /> </div> )} </div> ))} {/* Превью новых файлов с полем описания */} {files?.map((f, idx) => ( <div key={`new-${idx}`} style={{ marginBottom: 6, padding: '6px 8px', background: '#f0f7ff', borderRadius: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ width: 48, height: 48, background: '#e3f2fd', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}> {f.name?.toLowerCase().endsWith('.pdf') ? '📄' : '🖼️'} </div> <span style={{ flex: 1, fontSize: 12, color: '#666', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span> <span style={{ color: '#d32f2f', fontSize: 16, cursor: 'pointer', padding: '4px 8px' }} onClick={() => onRemoveFile(idx)}>🗑</span> </div> <div style={{ marginTop: 4, marginLeft: 56 }}> <input type="text" placeholder="Описание..." value={fileNotes[idx] || ''} onChange={e => onUpdateFileNote?.(idx, e.target.value)} style={{ width: '100%', fontSize: 12, padding: '4px 6px', border: '1px solid #ddd', borderRadius: 4, color: '#555' }} /> </div> </div> ))} {/* Лайтбокс просмотра (только для изображений) */} <PhotoViewer url={viewerUrl} onClose={() => setViewerUrl(null)} /> </div> ); } /** Десктопный UI для вложений: input file + превью + описание */ function ReceiptAttachmentDesktop({ attachments, files, fileNotes = [], onAddFiles, onRemoveFile, onRemoveAttachment, onUpdateFileNote, onUpdateAttachmentNote }) { const [viewerUrl, setViewerUrl] = useState(null); const [editingAttId, setEditingAttId] = useState(null); const [compressing, setCompressing] = useState(false); const imgInputRef = useRef(null); const pdfInputRef = useRef(null); const totalCount = (attachments?.length || 0) + (files?.length || 0); const handleImageFiles = async (files) => { if (!files?.length) return; setCompressing(true); try { const compressed = await compressImages(Array.from(files)); onAddFiles(compressed); } catch (e) { console.error('[ReceiptAttachmentDesktop] compression error:', e); onAddFiles(Array.from(files)); } finally { setCompressing(false); } }; return ( <div style={{ marginTop: 12, padding: '10px 14px', background: '#fafafa', borderRadius: 6, border: '1px solid #e8e4de' }}> <div style={{ fontSize: 14, fontWeight: 500, color: '#333', marginBottom: 8 }}> 📎 Вложения {totalCount > 0 && <span style={{ color: '#1976d2' }}>({totalCount})</span>} {compressing && <span style={{ fontSize: 12, color: '#1976d2', marginLeft: 8 }}>🔄 Сжатие...</span>} </div> <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <input ref={imgInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={e => { if (e.target.files?.length) handleImageFiles(e.target.files); e.target.value = ''; }} /> <input ref={pdfInputRef} type="file" accept=".pdf" multiple style={{ display: 'none' }} onChange={e => { if (e.target.files?.length) onAddFiles(Array.from(e.target.files)); e.target.value = ''; }} /> <button type="button" onClick={() => imgInputRef.current?.click()} disabled={compressing} style={{ flex: 1, padding: '8px 12px', background: '#f5f5f0', borderRadius: 6, fontSize: 13, cursor: compressing ? 'wait' : 'pointer', color: '#333', border: 'none', opacity: compressing ? 0.6 : 1 }} > + Изображения </button> <button type="button" onClick={() => pdfInputRef.current?.click()} disabled={compressing} style={{ flex: 1, padding: '8px 12px', background: '#f5f5f0', borderRadius: 6, fontSize: 13, cursor: compressing ? 'wait' : 'pointer', color: '#333', border: 'none', opacity: compressing ? 0.6 : 1 }} > + PDF </button> </div> {/* Превью существующих: note вместо имени, ✏️ для редактирования */} <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> {attachments?.map(att => ( <div key={att.id} style={{ position: 'relative', width: 80 }}> <div style={{ position: 'relative', width: 80, height: 80 }}> {att.file_type === 'pdf' ? ( <div onClick={() => window.open(att.url || `/media/${att.filePath}`, '_blank')} style={{ width: '100%', height: '100%', background: '#ffebee', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 11, color: '#555', padding: 4, textAlign: 'center', overflow: 'hidden' }}> <span>📄 {att.note || att.original_name}</span> </div> ) : ( <img src={att.url || (att.filePath ? `/media/${att.filePath}` : '')} alt="" loading="lazy" onClick={() => setViewerUrl(att.url || (att.filePath ? `/media/${att.filePath}` : ''))} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4, cursor: 'zoom-in' }} /> )} <span style={{ position: 'absolute', top: -4, right: -4, color: '#d32f2f', fontSize: 14, cursor: 'pointer', background: '#fff', borderRadius: '50%', width: 20, height: 20, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.2)' }} onClick={() => onRemoveAttachment(att.id)}>×</span> <span style={{ position: 'absolute', top: -4, left: -4, color: '#1976d2', fontSize: 12, cursor: 'pointer', background: '#fff', borderRadius: '50%', width: 18, height: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.2)' }} onClick={() => setEditingAttId(editingAttId === att.id ? null : att.id)}>✏️</span> </div> {editingAttId === att.id && ( <input type="text" placeholder="Описание..." defaultValue={att.note || ''} onBlur={e => { onUpdateAttachmentNote?.(att.id, e.target.value); setEditingAttId(null); }} autoFocus style={{ marginTop: 4, width: 80, fontSize: 10, padding: '2px 4px', border: '1px solid #ddd', borderRadius: 4, color: '#555' }} /> )} </div> ))} </div> {/* Превью новых файлов с полем описания */} {files?.length > 0 && ( <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}> {files?.map((f, idx) => ( <div key={`new-${idx}`} style={{ width: 80 }}> <div style={{ position: 'relative', width: 80, height: 80, background: '#e3f2fd', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: '#555', padding: 4, textAlign: 'center', overflow: 'hidden' }}> <span>{f.name?.toLowerCase().endsWith('.pdf') ? '📄 ' : '🖼️ '}{f.name}</span> <span style={{ position: 'absolute', top: -4, right: -4, color: '#d32f2f', fontSize: 14, cursor: 'pointer', background: '#fff', borderRadius: '50%', width: 20, height: 20, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.2)' }} onClick={() => onRemoveFile(idx)}>×</span> </div> <input type="text" placeholder="Описание..." value={fileNotes[idx] || ''} onChange={e => onUpdateFileNote?.(idx, e.target.value)} style={{ marginTop: 4, width: 80, fontSize: 10, padding: '2px 4px', border: '1px solid #ddd', borderRadius: 4, color: '#555' }} /> </div> ))} </div> )} {/* Лайтбокс просмотра (только для изображений) */} <PhotoViewer url={viewerUrl} onClose={() => setViewerUrl(null)} /> </div> ); } export default function EntityForm({ entity, item, defaults, operationalDate, onSubmit, onCancel, onSaveOnly, onCreateReturn }) { const [formData, setFormData] = useState({}); const [showMobileCalc, setShowMobileCalc] = useState(false); const [showLoanPayments, setShowLoanPayments] = useState(false); const [showDepositOps, setShowDepositOps] = useState(false); const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 768px)').matches); const [formError, setFormError] = useState(''); const [savedItemId, setSavedItemId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [elapsed, setElapsed] = useState(0); const isAddAnotherRef = useRef(false); const submittingRef = useRef(false); const lookups = useLookupData(); // Таймер ожидания ответа от сервера useEffect(() => { if (!isSaving) { setElapsed(0); return; } const timer = setInterval(() => setElapsed(s => s + 1), 1000); return () => clearInterval(timer); }, [isSaving]); // Теги для расходов (desktop + mobile) const [localTags, setLocalTags] = useState([]); const handleCreateTag = useCallback(async (newTagName) => { try { const newTag = await api.tags.create({ name: newTagName.trim(), order: 0 }); setLocalTags(prev => [...prev, newTag]); // НЕ добавляем тег в formData здесь — TagSelector сделает это // через addTag() → onChange() после получения ответа, // избегая race condition с useEffect(() => setLocalOptions(options)) return newTag; } catch (err) { console.error('Error creating tag:', err); return null; } }, []); useEffect(() => { if (lookups.tags) setLocalTags(lookups.tags); }, [lookups.tags]); useEffect(() => { const mq = window.matchMedia('(max-width: 768px)'); const handler = (e) => setIsMobile(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); useEffect(() => { refreshIdempotencyKey(); }, [entity, item]); const isDocumentsEntity = ['expenses', 'incomes', 'transfer', 'loansGiven', 'deposits', 'loansTaken'].includes(entity); const isMobileForm = isMobile && isDocumentsEntity; // Переводы, созданные из операций вклада — только просмотр const isReadOnly = entity === 'transfer' && !!(item?.deposit || formData?.deposit); // Валидация обязательных полей: блокируем крутилку пока не заполнены const isFormValid = useMemo(() => { if (!isDocumentsEntity) return true; // справочники — своя валидация const required = []; if (entity === 'expenses' || entity === 'incomes') { required.push(formData.accountId, formData.categoryId, formData.amount); } else if (entity === 'transfer') { required.push(formData.fromAccountId, formData.toAccountId, formData.amount); } else if (entity === 'loansGiven' || entity === 'loansTaken') { required.push(formData.name, formData.amount, formData.accountId); } else if (entity === 'deposits') { required.push(formData.name, formData.amount, formData.rate, formData.period); } return required.every(v => v && String(v).trim() !== '' && String(v) !== '0'); }, [entity, formData.accountId, formData.categoryId, formData.amount, formData.fromAccountId, formData.toAccountId, formData.name, formData.rate, formData.period, isDocumentsEntity]); useEffect(() => { // Не перезаписываем formData при "Сохранить и добавить ещё" — // иначе useEffect выиграет race condition и восстановит сумму из нового item if (isAddAnotherRef.current) return; if (item) { const data = { ...item }; if (item.tags && !data.tagIds) { data.tagIds = item.tags.map(t => t.id); } // Split-payment: если есть дочерние записи — заполняем split-поля. // SplitSerializer возвращает account_id (read-only CharField), не account. const split = item.splits?.find?.(s => s?.id && s?.account_id); if (split) { data.splitEnabled = true; data.splitAccountId = split.account_id || ''; data.splitAmount = String(split.amount || ''); } else { data.splitEnabled = false; data.splitAccountId = ''; data.splitAmount = ''; } // Фото чека data.receiptAttachments = item.receiptAttachments || []; data.receiptFiles = []; data.fileNotes = []; data.deletedReceiptPhotos = []; if (entity === 'deposits') { console.log('[EntityForm] deposit item loaded:', { id: item.id, amount: item.amount, current_amount: item.current_amount, currentAmount: item.currentAmount, total_interest_earned: item.total_interest_earned, totalInterestEarned: item.totalInterestEarned, base_amount: item.base_amount, baseAmount: item.baseAmount, raw: item, }); } setFormData(data); setSavedItemId(item.id); } else { const d = {}; const fields = FORM_FIELDS[entity] || []; fields.forEach(f => { if (f.name === 'date') d[f.name] = operationalDate || new Date().toISOString().split('T')[0]; else if (f.name === 'rateType') d[f.name] = 'annual'; else if (f.name === 'status') d[f.name] = 'active'; else if (f.name === 'feeType') d[f.name] = 'none'; else if (f.name === 'feeAccount') d[f.name] = 'to'; else if (f.name === 'exchangeRate') d[f.name] = ''; else if (f.name === 'convertedAmount') d[f.name] = ''; else if (f.name === 'deductFee') d[f.name] = false; else if (f.name === 'currencyId') d[f.name] = '1'; else if (f.name === 'active' || f.name === 'hidden') d[f.name] = false; }); // Для вкладов сумма по умолчанию 0 (поле скрыто) if (entity === 'deposits') d.amount = 0; // Применяем defaults из пропсов (например, при "Сохранить и добавить ещё") if (defaults) { Object.keys(defaults).forEach(k => { d[k] = defaults[k]; }); } // tagIds всегда массив (TagSelector ожидает array) d.tagIds = Array.isArray(d.tagIds) ? d.tagIds : []; // Split-payment: инициализация d.splitEnabled = false; d.splitAccountId = ''; d.splitAmount = ''; setFormData(d); setSavedItemId(null); } }, [entity, item, defaults]); // Автообновление статуса займа при изменении платежей / суммы useEffect(() => { if (entity !== 'loansGiven' && entity !== 'loansTaken') return; const payments = formData.payments || []; const paid = payments.reduce((sum, p) => sum + Number(p.amount), 0); const remaining = Number(formData.amount) - paid; if (remaining <= 0 && formData.status !== 'closed') { const today = new Date().toISOString().split('T')[0]; setFormData(prev => ({ ...prev, status: 'closed', closedDate: prev.closedDate || today })); } else if (remaining > 0 && formData.status === 'closed') { setFormData(prev => ({ ...prev, status: 'active', closedDate: '' })); } }, [formData.payments, formData.amount, formData.status, entity]); // Определяем, нужна ли конвертация валют (для переводов) const isConversionNeeded = useMemo(() => { if (entity !== 'transfer') return false; const fromAcc = lookups.accounts?.find(a => a.id === formData.fromAccountId); const toAcc = lookups.accounts?.find(a => a.id === formData.toAccountId); if (!fromAcc || !toAcc) return false; return fromAcc.currencyId !== toAcc.currencyId; }, [entity, formData.fromAccountId, formData.toAccountId, lookups.accounts]); // Сброс exchangeRate/convertedAmount когда конвертация не нужна useEffect(() => { if (entity !== 'transfer') return; if (!isConversionNeeded && (formData.exchangeRate || formData.convertedAmount)) { setFormData(prev => ({ ...prev, exchangeRate: '', convertedAmount: '' })); } }, [entity, isConversionNeeded]); // Авто-расчёт convertedAmount при изменении amount или exchangeRate useEffect(() => { if (entity !== 'transfer' || !isConversionNeeded) return; const amount = parseFloat(formData.amount) || 0; const rate = parseFloat(formData.exchangeRate) || 0; if (amount > 0 && rate > 0) { const converted = (amount / rate).toFixed(2); setFormData(prev => { if (prev.convertedAmount === converted) return prev; return { ...prev, convertedAmount: converted }; }); } }, [entity, formData.amount, formData.exchangeRate, isConversionNeeded]); const handleChange = useCallback((name, value) => { if (name === '_quickAddCategory' && value) { const apiKey = value.type === 'expense' ? 'expenseCategories' : 'incomeCategories'; api[apiKey].create({ name: value.name, note: '' }).then((newCat) => { setFormData(prev => ({ ...prev, categoryId: newCat.id, subcategoryId: '' })); }); return; } if (name === '_quickAddSubcategory' && value) { const apiKey = value.type === 'expense' ? 'expenseSubcategories' : 'incomeSubcategories'; api[apiKey].create({ name: value.name, categoryId: value.categoryId, note: '' }).then((newSub) => { setFormData(prev => ({ ...prev, subcategoryId: newSub.id })); }); return; } if (name === 'active') { setFormData(prev => ({ ...prev, [name]: value === 'true' || value === true })); } else if (name === 'categoryId') { setFormData(prev => ({ ...prev, [name]: value, subcategoryId: '' })); } else if (name === 'accountId' || name === 'fromAccountId' || name === 'toAccountId') { setFormData(prev => { const next = { ...prev, [name]: value }; if (value && lookups.accounts) { const account = lookups.accounts.find(a => a.id === value); if (account && account.currencyId) { next.currencyId = account.currencyId; } } return next; }); } else { setFormData(prev => ({ ...prev, [name]: value })); } }, [lookups.accounts]); const handleSubmit = async (e) => { e.preventDefault(); if (isReadOnly) { onCancel(); return; } if (submittingRef.current || isSaving) return; submittingRef.current = true; setIsSaving(true); setFormError(''); try { // Валидация курса конвертации для переводов с разными валютами if (entity === 'transfer' && isConversionNeeded) { const rate = parseFloat(formData.exchangeRate); if (!rate || rate <= 0) { setFormError('Укажите положительный курс конвертации (единиц валюты списания = 1 ед. валюты зачисления)'); return; } } const cleanFormData = { ...formData }; delete cleanFormData.receiptFiles; delete cleanFormData.receiptAttachments; delete cleanFormData.deletedReceiptPhotos; // Передаём receipt-данные в EntityList.handleSaveOnly для единообразной обработки cleanFormData._receiptFiles = formData.receiptFiles || []; cleanFormData._fileNotes = formData.fileNotes || []; cleanFormData._deletedReceiptPhotos = formData.deletedReceiptPhotos || []; if (onSaveOnly) { refreshIdempotencyKey(); await onSaveOnly(cleanFormData); onCancel(); } else { // Fallback: если onSaveOnly нет — передаём через onSubmit (без файлов) onSubmit(cleanFormData); } } finally { submittingRef.current = false; setIsSaving(false); } }; const handleAddAnother = async () => { // "Сохранить и добавить ещё" для desktop if (!onSaveOnly) return; if (submittingRef.current || isSaving) return; submittingRef.current = true; setIsSaving(true); setFormError(''); try { const missing = []; fields.forEach(f => { if (f.required && !formData[f.name]) missing.push(f.label); }); if (entity !== 'deposits' && (!formData.amount || Number(formData.amount) <= 0)) missing.push('сумма'); if (missing.length > 0) { setFormError(`Заполните обязательные поля: ${missing.join(', ')}`); return; } // Всегда создаём новый (без id) isAddAnotherRef.current = true; const dataToSave = { ...formData }; delete dataToSave.id; dataToSave._receiptFiles = formData.receiptFiles || []; dataToSave._fileNotes = formData.fileNotes || []; dataToSave._deletedReceiptPhotos = formData.deletedReceiptPhotos || []; refreshIdempotencyKey(); try { await onSaveOnly(dataToSave, { addAnother: true }); } finally { isAddAnotherRef.current = false; } // Очищаем сумму, подкатегорию, примечание, теги, id; сохраняем дату, категорию, счёт setFormData(prev => { const next = { ...prev, amount: '', subcategoryId: '', note: '', tagIds: [], receiptAttachments: [], receiptFiles: [], fileNotes: [], deletedReceiptPhotos: [] }; delete next.id; // гарантированно создаём новую запись return next; }); setSavedItemId(null); } finally { setIsSaving(false); } }; const fields = FORM_FIELDS[entity] || []; // ===== Мобильный layout для справочников (кроме категорий) ===== const isReferencesEntity = ['currencies', 'accounts', 'creditors', 'debtors', 'tags'].includes(entity); if (isMobile && isReferencesEntity) { const entityLabel = ENTITY_LABELS[entity] || 'Справочник'; const refFields = FORM_FIELDS[entity] || []; const handleRefSubmit = async (e) => { e.preventDefault(); if (isSaving) return; setFormError(''); const missing = []; refFields.forEach(f => { if (f.required && !formData[f.name]) missing.push(f.label); }); if (missing.length > 0) { setFormError(`Заполните: ${missing.join(', ')}`); return; } setIsSaving(true); try { await onSubmit(formData); } finally { setIsSaving(false); } }; return ( <form onSubmit={handleRefSubmit}> <div className="mobile-form-body"> {/* Шапка */} <div className="mobile-form-header"> <button type="button" className="mobile-form-header-btn" onClick={onCancel}>Отменить</button> <span className="mobile-form-title">{item ? 'Изменение' : 'Добавление'} {entityLabel.toLowerCase()}</span> <span style={{ width: 60 }} /> </div> {/* Ошибка валидации */} {formError && ( <div style={{ padding: '10px 12px', background: '#ffcdd2', color: '#b71c1c', fontSize: 12, borderBottom: '1px solid #ef9a9a' }}> {formError} </div> )} {/* Поля формы */} {refFields.map(field => { const value = formData[field.name]; if (field.type === 'checkbox') { return ( <label key={field.name} className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">{field.label}</span> <input type="checkbox" checked={!!value} onChange={e => handleChange(field.name, e.target.checked)} style={{ width: 22, height: 22, marginLeft: 'auto', marginRight: 8, cursor: 'pointer' }} /> </label> ); } if (field.type === 'textarea') { return ( <div key={field.name} style={{ padding: '8px 12px', borderBottom: '1px solid #e8e8e8' }}> <label style={{ display: 'block', fontSize: 13, color: '#666', marginBottom: 4 }}>{field.label}</label> <textarea value={value || ''} onChange={e => handleChange(field.name, e.target.value)} placeholder="Введите текст" rows={3} style={{ width: '100%', border: '1px solid #ddd', borderRadius: 4, padding: '6px 8px', fontSize: 15, resize: 'vertical' }} /> </div> ); } if (field.type === 'icon') { const iconOptions = field.options || []; return ( <div key={field.name} style={{ padding: '8px 12px', borderBottom: '1px solid #e8e8e8' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <span className="mobile-form-label">{field.label}</span> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> {value ? ( <img src={`/assets/account-icons/${value}.png`} alt="" style={{ width: 24, height: 24, objectFit: 'contain' }} /> ) : null} <select value={value || ''} onChange={e => handleChange(field.name, e.target.value)} style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontSize: 14, background: '#fff' }} > {iconOptions.map(opt => ( <option key={opt.value} value={opt.value}>{opt.label}</option> ))} </select> </div> </div> </div> ); } if (field.type === 'select') { const options = field.source ? (() => { switch (field.source) { case 'accounts': return lookups.accounts?.filter(a => !a.hidden) || []; case 'currencies': return lookups.currencies || []; default: return field.options || []; } })() : (field.options || []); return ( <label key={field.name} className="mobile-form-row" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">{field.label}</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {(() => { const opt = options.find(o => String(o.id || o.value) === String(value || '')); return opt?.name || opt?.label || 'Выберите...'; })()} </span> <span className="mobile-form-arrow">›</span> <select className="mobile-form-native-select" value={value || ''} onChange={e => handleChange(field.name, e.target.value)} > <option value="">{field.required ? 'Выберите...' : '-'}</option> {options.map(opt => ( <option key={opt.id || opt.value} value={opt.id || opt.value}> {opt.name || opt.label || opt.id || ''} </option> ))} </select> </label> ); } return ( <label key={field.name} className="mobile-form-row" style={{ display: 'flex' }}> <span className="mobile-form-label">{field.label}</span> <input type={field.type === 'calculator' ? 'number' : field.type} step={field.step} value={value || ''} onChange={e => handleChange(field.name, e.target.value)} placeholder={field.label} style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} /> </label> ); })} {/* Кнопки */} <div style={{ display: 'flex', gap: 8, padding: '0 4px', marginTop: 8 }}> <button type="button" className="mobile-form-save-btn" style={{ flex: 1, background: '#9e9e9e' }} onClick={onCancel}> {isReadOnly ? 'Закрыть' : 'Отменить'} </button> {!isReadOnly && ( <button type="button" className="mobile-form-save-btn" style={{ flex: 1 }} onClick={handleRefSubmit} disabled={isSaving}> Сохранить </button> )} </div> </div> {!isFormValid && ( <div style={{ position: 'absolute', inset: 0, background: 'rgba(245,245,245,0.85)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 50, pointerEvents: 'all' }}> <div style={{ width: 40, height: 40, border: '3px solid #ccc', borderTopColor: '#ff9800', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <span style={{ marginTop: 10, fontSize: 13, color: '#888' }}>Заполните обязательные поля</span> </div> )} {isSaving && isFormValid && ( <div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 50, pointerEvents: 'all' }}> <div style={{ width: 36, height: 36, border: '3px solid #ff9800', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <span style={{ marginTop: 8, fontSize: 13, color: '#666' }}>Ожидание… {elapsed} с</span> </div> )} </form> ); } // ===== Мобильный карточный layout для документов ===== if (isMobileForm) { const entityLabel = ENTITY_LABELS[entity] || 'Операция'; const accountOptions = lookups.accounts?.filter(a => !a.hidden).map(a => { const curr = lookups.currencies?.find(c => c.id === a.currencyId); return { id: a.id, name: `${a.name} (${curr?.code || ''})` }; }) || []; const handleMobileSubmit = (mode) => async (e) => { e.preventDefault(); if (submittingRef.current || isSaving) return; submittingRef.current = true; setIsSaving(true); setFormError(''); try { // Проверка обязательных полей const missing = []; if (entity === 'expenses' || entity === 'incomes') { if (!formData.accountId) missing.push('счёт'); if (!formData.categoryId) missing.push('категория'); } if (entity === 'expenses' && formData.splitEnabled) { if (!formData.splitAccountId) missing.push('второй счёт'); if (!formData.splitAmount || Number(formData.splitAmount) <= 0) missing.push('сумму со второго счёта'); } if (entity === 'transfer') { if (!formData.fromAccountId) missing.push('счёт списания'); if (!formData.toAccountId) missing.push('счёт зачисления'); if (isConversionNeeded && (!parseFloat(formData.exchangeRate) || parseFloat(formData.exchangeRate) <= 0)) { missing.push('курс конвертации (положительное число)'); } } if (entity === 'loansGiven') { if (!formData.debtorId) missing.push('должник'); if (!formData.accountId) missing.push('счёт'); } if (entity === 'loansTaken') { if (!formData.creditorId) missing.push('кредитор'); if (!formData.accountId) missing.push('счёт'); } if (entity !== 'deposits' && (!formData.amount || Number(formData.amount) <= 0)) missing.push('сумма'); if (missing.length > 0) { setFormError(`Заполните обязательные поля: ${missing.join(', ')}`); return; } // mode: 'close' | 'save' | 'addAnother' const shouldClose = mode === 'close'; // "Сохранить и добавить ещё" — сохраняем через onSaveOnly (не закрывает модалку), // затем очищаем сумму/примечание/теги, оставляя дату и категории/счета if (mode === 'addAnother' && onSaveOnly) { isAddAnotherRef.current = true; const dataToSave = { ...formData }; delete dataToSave.id; // всегда создаём новый расход delete dataToSave.splitEnabled; // служебное поле // Передаём receipt-данные в EntityList.handleSaveOnly для единообразной обработки dataToSave._receiptFiles = formData.receiptFiles || []; dataToSave._deletedReceiptPhotos = formData.deletedReceiptPhotos || []; try { refreshIdempotencyKey(); await onSaveOnly(dataToSave, { addAnother: true }); } finally { isAddAnotherRef.current = false; } setFormData(prev => { const next = { ...prev, amount: '', subcategoryId: '', note: '', tagIds: [], splitEnabled: false, splitAccountId: '', splitAmount: '', receiptAttachments: [], receiptFiles: [], fileNotes: [], deletedReceiptPhotos: [] }; delete next.id; return next; }); setSavedItemId(null); return; } if (!shouldClose && onSaveOnly) { const dataToSave = savedItemId ? { ...formData, id: savedItemId } : { ...formData }; delete dataToSave.splitEnabled; // служебное поле // Передаём receipt-данные в EntityList.handleSaveOnly для единообразной обработки dataToSave._receiptFiles = formData.receiptFiles || []; dataToSave._deletedReceiptPhotos = formData.deletedReceiptPhotos || []; refreshIdempotencyKey(); const saved = await onSaveOnly(dataToSave); if (saved?.id && !savedItemId) setSavedItemId(saved.id); return; } // Сохраняем и закрываем const cleanFormData = { ...formData }; delete cleanFormData.splitEnabled; // служебное поле, не нужно на бэкенде delete cleanFormData.receiptFiles; delete cleanFormData.receiptAttachments; delete cleanFormData.deletedReceiptPhotos; // Передаём receipt-данные в EntityList.handleSaveOnly для единообразной обработки cleanFormData._receiptFiles = formData.receiptFiles || []; cleanFormData._deletedReceiptPhotos = formData.deletedReceiptPhotos || []; if (onSaveOnly) { refreshIdempotencyKey(); await onSaveOnly(cleanFormData); onCancel(); } else { refreshIdempotencyKey(); onSubmit(cleanFormData); } } finally { submittingRef.current = false; setIsSaving(false); } }; return ( <> <form onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); handleMobileSubmit()(e); }}> <div className="mobile-form-body"> {/* Шапка */} <div className="mobile-form-header"> <button type="button" className="mobile-form-header-btn" onClick={onCancel}>Отменить</button> <span className="mobile-form-title">{item ? 'Изменение' : 'Добавление'} {entityLabel.toLowerCase()}</span> <span style={{ width: 60 }} /> </div> {/* Ошибка валидации */} {formError && ( <div style={{ padding: '10px 12px', background: '#ffcdd2', color: '#b71c1c', fontSize: 12, borderBottom: '1px solid #ef9a9a' }}> {formError} </div> )} {/* Дата */} <MobileDateRow label="Дата" value={formData.date} onChange={v => handleChange('date', v)} disabled={isReadOnly} /> {/* expenses/incomes: Счёт */} {(entity === 'expenses' || entity === 'incomes') && ( <MobileAccountRow label={entity === 'incomes' ? 'Занести на счёт' : 'Списать со счёта'} value={formData.accountId} options={accountOptions} onChange={v => handleChange('accountId', v)} lookups={lookups} /> )} {/* expenses: Split-payment — оплата с двух счетов */} {entity === 'expenses' && ( <> <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Оплата с двух счетов</span> <input type="checkbox" checked={formData.splitEnabled || false} onChange={e => handleChange('splitEnabled', e.target.checked)} style={{ width: 22, height: 22, marginLeft: 'auto', marginRight: 8, cursor: 'pointer' }} /> </label> {formData.splitEnabled && ( <> <MobileAccountRow label="Второй счёт" value={formData.splitAccountId || ''} options={accountOptions.filter(o => o.value !== formData.accountId)} onChange={v => handleChange('splitAccountId', v)} lookups={lookups} /> <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Сумма со второго счёта</span> <input type="number" step="0.01" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="0.00" value={formData.splitAmount || ''} onChange={e => handleChange('splitAmount', e.target.value)} /> </label> </> )} </> )} {/* transfer: Баннер только просмотр (из вклада) */} {entity === 'transfer' && isReadOnly && ( <div style={{ padding: '10px 16px', background: '#fff3e0', borderBottom: '1px solid #ffe0b2', display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ fontSize: 14, color: '#e65100' }}>🔒 Просмотр</span> <span style={{ fontSize: 13, color: '#f57c00' }}>Этот перевод создан из операции вклада — редактирование недоступно</span> </div> )} {/* transfer: Счёт списания */} {entity === 'transfer' && ( <MobileAccountRow label="Счёт списания" value={formData.fromAccountId} options={accountOptions} onChange={v => handleChange('fromAccountId', v)} lookups={lookups} disabled={isReadOnly} /> )} {/* transfer: Счёт зачисления — исключаем счёт списания */} {entity === 'transfer' && ( <MobileAccountRow label="Счёт зачисления" value={formData.toAccountId} options={accountOptions} onChange={v => handleChange('toAccountId', v)} lookups={lookups} excludeId={formData.fromAccountId} disabled={isReadOnly} /> )} {/* transfer: Курс конвертации — только при разных валютах */} {entity === 'transfer' && isConversionNeeded && ( <div style={{ padding: '8px 16px', borderBottom: '1px solid #e0e0e0', opacity: isReadOnly ? 0.6 : 1 }}> <label style={{ fontSize: 12, color: '#555', display: 'block', marginBottom: 4 }}>Курс конвертации</label> <input type="text" inputMode="decimal" value={formData.exchangeRate || ''} onChange={e => handleChange('exchangeRate', e.target.value)} placeholder="Например: 84" style={{ width: '100%', padding: '8px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 15 }} disabled={isReadOnly} /> <span style={{ fontSize: 11, color: '#888' }}>Единиц списания = 1 ед. зачисления</span> </div> )} {/* transfer: Будет зачислено — только при разных валютах */} {entity === 'transfer' && isConversionNeeded && ( <div style={{ padding: '8px 16px', borderBottom: '1px solid #e0e0e0', background: '#f5f5f5' }}> <label style={{ fontSize: 12, color: '#555', display: 'block', marginBottom: 4 }}>Будет зачислено</label> <div style={{ fontSize: 16, fontWeight: 600, color: '#2e7d32', padding: '4px 0' }}> {formData.convertedAmount ? Number(formData.convertedAmount).toLocaleString('ru-RU', { minimumFractionDigits: 2 }) : '—'} </div> </div> )} {/* loansGiven: Должник */} {entity === 'loansGiven' && ( <MobileSelectRow label="Должник" value={formData.debtorId} options={lookups.debtors?.map(d => ({ id: d.id, name: d.name })) || []} onChange={v => handleChange('debtorId', v)} placeholder="Выберите..." onAdd={async () => { const name = window.prompt('Введите имя должника:'); if (!name || !name.trim()) return; try { const created = await api.debtors.create({ name: name.trim(), note: '' }); addLookupItem('debtors', created); handleChange('debtorId', created.id); } catch (e) { alert('Ошибка создания должника: ' + (e.message || e)); } }} /> )} {/* loansTaken: Кредитор */} {entity === 'loansTaken' && ( <MobileSelectRow label="Кредитор" value={formData.creditorId} options={lookups.creditors?.map(c => ({ id: c.id, name: c.name })) || []} onChange={v => handleChange('creditorId', v)} placeholder="Выберите..." onAdd={async () => { const name = window.prompt('Введите имя кредитора:'); if (!name || !name.trim()) return; try { const created = await api.creditors.create({ name: name.trim(), note: '' }); addLookupItem('creditors', created); handleChange('creditorId', created.id); } catch (e) { alert('Ошибка создания кредитора: ' + (e.message || e)); } }} /> )} {/* loansGiven/loansTaken: Название */} {(entity === 'loansGiven' || entity === 'loansTaken') && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Название</span> <input type="text" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="Необязательно" value={formData.name || ''} onChange={e => handleChange('name', e.target.value)} /> </label> )} {/* loansGiven/loansTaken: Счёт */} {(entity === 'loansGiven' || entity === 'loansTaken') && ( <MobileAccountRow label="Счёт" value={formData.accountId} options={accountOptions} onChange={v => handleChange('accountId', v)} lookups={lookups} /> )} {/* loansGiven/loansTaken: Процентная ставка */} {(entity === 'loansGiven' || entity === 'loansTaken') && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Процентная ставка</span> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, justifyContent: 'flex-end' }}> {formData.rateType !== 'one-time' && ( <input type="number" step="0.01" placeholder="0.00" value={formData.rate || ''} onChange={e => handleChange('rate', e.target.value)} style={{ width: 70, textAlign: 'right', border: '1px solid #ddd', borderRadius: 4, padding: '2px 4px', fontSize: 15, background: '#fff' }} /> )} <button type="button" onClick={() => { const next = formData.rateType === 'one-time' ? 'annual' : 'one-time'; handleChange('rateType', next); if (next === 'one-time') handleChange('rate', 0); }} style={{ padding: '4px 10px', border: formData.rateType === 'one-time' ? '1px solid #ff9800' : '1px solid #ccc', borderRadius: 4, background: formData.rateType === 'one-time' ? '#ff9800' : '#fff', color: formData.rateType === 'one-time' ? '#fff' : '#666', fontSize: 13, cursor: 'pointer', }} > {formData.rateType === 'one-time' ? 'Единовременно' : 'В год'} </button> </div> </label> )} {/* loansGiven/loansTaken: Период - скрывается при Единовременно */} {(entity === 'loansGiven' || entity === 'loansTaken') && formData.rateType !== 'one-time' && ( <label className="mobile-form-row" style={{ display: 'flex' }}> <span className="mobile-form-label">Период кредитования, мес</span> <input type="number" step="1" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="0" value={formData.period || ''} onChange={e => handleChange('period', e.target.value)} /> </label> )} {/* loansGiven/loansTaken: Возврат долга */} {(entity === 'loansGiven' || entity === 'loansTaken') && ( <div className="mobile-form-row" style={{ display: 'flex', cursor: (savedItemId || item?.id) ? 'pointer' : 'default', opacity: (savedItemId || item?.id) ? 1 : 0.5 }} onClick={() => { if (savedItemId || item?.id) setShowLoanPayments(true); }} > <span className="mobile-form-label">Возврат долга</span> <span style={{ marginLeft: 'auto', color: '#999' }}>›</span> </div> )} {/* deposits: Название */} {entity === 'deposits' && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Название</span> <input type="text" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="Необязательно" value={formData.name || ''} onChange={e => handleChange('name', e.target.value)} /> </label> )} {/* deposits: Итоговая сумма (только просмотр) */} {entity === 'deposits' && (item?.id || savedItemId) && ( <> <div className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Итого</span> <span className="mobile-form-value" style={{ fontWeight: 600, fontSize: 16, color: '#ff9800' }}> {Number(formData.current_amount || formData.currentAmount || formData.amount || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2 })} {lookups.currencies?.find(c => c.id === formData.currencyId)?.symbol || ''} </span> </div> <div className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Без процентов</span> <span className="mobile-form-value" style={{ fontWeight: 500, color: '#333' }}> {Number(formData.base_amount || formData.baseAmount || (formData.current_amount || formData.currentAmount || formData.amount || 0) - (formData.total_interest_earned || formData.totalInterestEarned || 0)).toLocaleString('ru-RU', { minimumFractionDigits: 2 })} {lookups.currencies?.find(c => c.id === formData.currencyId)?.symbol || ''} </span> </div> <div className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Начислено %</span> <span className="mobile-form-value" style={{ fontWeight: 500, color: '#388e3c' }}> {Number(formData.total_interest_earned || formData.totalInterestEarned || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2 })} {lookups.currencies?.find(c => c.id === formData.currencyId)?.symbol || ''} </span> </div> </> )} {/* deposits: Процентная ставка */} {entity === 'deposits' && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Процентная ставка</span> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, justifyContent: 'flex-end' }}> <input type="number" step="0.01" placeholder="0.00" value={formData.rate || ''} onChange={e => handleChange('rate', e.target.value)} style={{ width: 70, textAlign: 'right', border: '1px solid #ddd', borderRadius: 4, padding: '2px 4px', fontSize: 15, background: '#fff' }} /> <span style={{ fontSize: 13, color: '#666' }}>% годовых</span> </div> </label> )} {/* deposits: Период */} {entity === 'deposits' && ( <label className="mobile-form-row" style={{ display: 'flex' }}> <span className="mobile-form-label">Срок (мес.)</span> <input type="number" step="1" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="0" value={formData.period || ''} onChange={e => handleChange('period', e.target.value)} /> </label> )} {/* deposits: Капитализация */} {entity === 'deposits' && ( <label className="mobile-form-row" style={{ position: 'relative', display: 'flex' }}> <span className="mobile-form-label">Капитализация</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {formData.capitalization === 'monthly' ? 'Ежемесячная' : formData.capitalization === 'yearly' ? 'Ежегодная' : 'Без'} </span> <span className="mobile-form-arrow">›</span> <select className="mobile-form-native-select" value={formData.capitalization || 'monthly'} onChange={e => handleChange('capitalization', e.target.value)} > <option value="monthly">Ежемесячная</option> <option value="yearly">Ежегодная</option> <option value="none">Без</option> </select> </label> )} {/* deposits: Операции с вкладом */} {entity === 'deposits' && ( <div className="mobile-form-row" style={{ display: 'flex', cursor: (savedItemId || item?.id) ? 'pointer' : 'default', opacity: (savedItemId || item?.id) ? 1 : 0.5 }} onClick={() => { if (savedItemId || item?.id) setShowDepositOps(true); }} > <span className="mobile-form-label">Открыть операции</span> <span style={{ marginLeft: 'auto', color: '#999' }}>›</span> </div> )} {/* deposits: Статус тоггл */} {entity === 'deposits' && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Отметить как закрытый</span> <input type="checkbox" checked={formData.status === 'closed'} onChange={e => { const isClosed = e.target.checked; handleChange('status', isClosed ? 'closed' : 'active'); if (isClosed && !formData.closedDate) { handleChange('closedDate', new Date().toISOString().split('T')[0]); } else if (!isClosed) { handleChange('closedDate', ''); } }} style={{ width: 22, height: 22, marginLeft: 'auto', marginRight: 8, cursor: 'pointer' }} /> </label> )} {/* deposits: Дата закрытия */} {entity === 'deposits' && formData.status === 'closed' && ( <MobileDateRow label="Дата закрытия" value={formData.closedDate} onChange={v => handleChange('closedDate', v)} /> )} {/* loansGiven/loansTaken: Статус тоггл */} {(entity === 'loansGiven' || entity === 'loansTaken') && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Отметить как полностью погашенный</span> <input type="checkbox" checked={formData.status === 'closed'} onChange={e => { const isClosed = e.target.checked; handleChange('status', isClosed ? 'closed' : 'active'); if (isClosed && !formData.closedDate) { handleChange('closedDate', new Date().toISOString().split('T')[0]); } else if (!isClosed) { handleChange('closedDate', ''); } }} style={{ width: 22, height: 22, marginLeft: 'auto', marginRight: 8, cursor: 'pointer' }} /> </label> )} {/* loansGiven/loansTaken: Дата гашения */} {(entity === 'loansGiven' || entity === 'loansTaken') && formData.status === 'closed' && ( <MobileDateRow label="Дата гашения" value={formData.closedDate} onChange={v => handleChange('closedDate', v)} /> )} {/* expenses/incomes: Категория */} {(entity === 'expenses' || entity === 'incomes') && ( <MobileCategoryRow entity={entity} formData={formData} handleChange={handleChange} lookups={lookups} api={api} addLookupItem={addLookupItem} /> )} {/* expenses: Количество */} {entity === 'expenses' && ( <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Количество</span> <input type="number" step="0.001" className="mobile-form-value" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} placeholder="1.000" value={formData.quantity || ''} onChange={e => handleChange('quantity', e.target.value)} /> </label> )} {/* Сумма (не для вкладов — у вкладов сумма только через операции) */} {entity !== 'deposits' && ( <div className="mobile-form-amount-row" style={{ opacity: isReadOnly ? 0.6 : 1 }}> <input type="number" step="0.01" className="mobile-form-amount-input" placeholder="0.00" value={formData.amount || ''} onChange={e => handleChange('amount', e.target.value)} disabled={isReadOnly} /> {!isReadOnly && ( <span className="mobile-form-calc-icon" onClick={(e) => { e.stopPropagation(); e.preventDefault(); setShowMobileCalc(true); }} title="Калькулятор" style={{ fontSize: 22, lineHeight: '36px' }} > {ICONS.calculator} </span> )} </div> )} {/* expenses/incomes: Теги (mobile) */} {entity === 'expenses' && ( <div style={{ padding: '8px 16px', borderBottom: '1px solid #e0e0e0' }}> <label style={{ fontSize: 12, color: '#555', display: 'block', marginBottom: 4 }}>Теги</label> <TagSelector options={localTags} value={formData.tagIds || []} onChange={(newIds) => handleChange('tagIds', newIds)} onCreateTag={handleCreateTag} label="" /> </div> )} {/* Примечание */} <div className="mobile-form-note" style={{ opacity: isReadOnly ? 0.6 : 1 }}> <label>Примечание</label> <textarea placeholder="Введите текст" value={formData.note || ''} onChange={e => handleChange('note', e.target.value)} rows={3} disabled={isReadOnly} /> </div> {/* expenses + loans: Вложения (mobile) — после примечания */} {(entity === 'expenses' || entity === 'loansGiven' || entity === 'loansTaken') && ( <ReceiptAttachmentMobile attachments={formData.receiptAttachments || []} files={formData.receiptFiles || []} fileNotes={formData.fileNotes || []} onAddFiles={(files) => { handleChange('receiptFiles', [...(formData.receiptFiles || []), ...files]); handleChange('fileNotes', [...(formData.fileNotes || []), ...files.map(() => '')]); }} onRemoveFile={(idx) => { const nextFiles = [...(formData.receiptFiles || [])]; const nextNotes = [...(formData.fileNotes || [])]; nextFiles.splice(idx, 1); nextNotes.splice(idx, 1); handleChange('receiptFiles', nextFiles); handleChange('fileNotes', nextNotes); }} onRemoveAttachment={(attId) => { const next = (formData.receiptAttachments || []).filter(a => a.id !== attId); handleChange('receiptAttachments', next); handleChange('deletedReceiptPhotos', [...(formData.deletedReceiptPhotos || []), attId]); }} onUpdateFileNote={(idx, note) => { const next = [...(formData.fileNotes || [])]; next[idx] = note; handleChange('fileNotes', next); }} onUpdateAttachmentNote={async (attId, note) => { const itemId = formData.id; if (!itemId) return; try { const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; const updated = await api[apiName].updateReceiptNote(itemId, attId, note); const next = (formData.receiptAttachments || []).map(a => a.id === attId ? { ...a, note: updated.note } : a); handleChange('receiptAttachments', next); } catch (e) { console.error('Failed to update note:', e); } }} /> )} {/* transfer: Комиссия */} {entity === 'transfer' && ( <> <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', opacity: isReadOnly ? 0.6 : 1 }}> <span className="mobile-form-label">Комиссия</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {formData.feeType === 'none' ? 'Без комиссии' : formData.feeType === 'percentage' ? 'Процент (%)' : 'Фикс. сумма'} </span> {!isReadOnly && <span className="mobile-form-arrow">›</span>} <select className="mobile-form-native-select" value={formData.feeType || 'none'} onChange={e => handleChange('feeType', e.target.value)} disabled={isReadOnly} style={{ opacity: isReadOnly ? 0 : 1 }} > <option value="none">Без комиссии</option> <option value="percentage">Процент (%)</option> <option value="fixed">Фиксированная сумма</option> </select> </label> {formData.feeType !== 'none' && ( <> <label className="mobile-form-row" style={{ display: 'flex', opacity: isReadOnly ? 0.6 : 1 }}> <span className="mobile-form-label">Сумма комиссии</span> <input type="number" step="0.01" value={formData.feeValue || ''} onChange={e => handleChange('feeValue', e.target.value)} placeholder="0.00" style={{ flex: 1, textAlign: 'right', border: 'none', background: 'transparent', fontSize: 15 }} disabled={isReadOnly} /> </label> <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', opacity: isReadOnly ? 0.6 : 1 }}> <span className="mobile-form-label">Счёт комиссии</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {formData.feeAccount === 'from' ? 'Счет списания' : 'Счет зачисления'} </span> {!isReadOnly && <span className="mobile-form-arrow">›</span>} <select className="mobile-form-native-select" value={formData.feeAccount || 'to'} onChange={e => handleChange('feeAccount', e.target.value)} disabled={isReadOnly} style={{ opacity: isReadOnly ? 0 : 1 }} > <option value="to">Счет зачисления</option> <option value="from">Счет списания</option> </select> </label> {(() => { const feeAmount = formData.feeType === 'percentage' ? (Number(formData.amount || 0) * Number(formData.feeValue || 0) / 100).toFixed(2) : formData.feeType === 'fixed' ? Number(formData.feeValue || 0).toFixed(2) : '0.00'; const actualAmount = formData.deductFee ? (Number(formData.amount || 0) - Number(feeAmount)).toFixed(2) : Number(formData.amount || 0).toFixed(2); const fromAcc = lookups.accounts?.find(a => a.id === formData.fromAccountId); const currencySymbol = fromAcc ? lookups.currencies?.find(c => c.id === fromAcc.currencyId)?.symbol || '' : ''; return ( <div style={{ padding: '8px 12px', margin: '4px 12px', background: '#e3f2fd', borderRadius: 6, fontSize: 13, color: '#1565c0' }}> <div>Будет создана автоматически операция расхода на сумму {feeAmount} {currencySymbol}</div> {formData.deductFee && Number(feeAmount) > 0 && ( <div style={{ marginTop: 4, color: '#2e7d32' }}> Фактическая сумма перевода: {actualAmount} {currencySymbol} </div> )} </div> ); })()} <label className="mobile-form-row" style={{ display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Списать комиссию с суммы перевода</span> <input type="checkbox" checked={!!formData.deductFee} onChange={e => handleChange('deductFee', e.target.checked)} style={{ width: 22, height: 22, marginLeft: 'auto', marginRight: 8, cursor: 'pointer' }} /> </label> </> )} </> )} {/* expenses: Возвраты — кнопка + список */} {entity === 'expenses' && (item?.id || savedItemId) && ( <div style={{ padding: '12px 16px', background: '#fff', borderTop: '1px solid #e8e8e8' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>↩️ Возвраты</span> <button type="button" style={{ background: '#e8f5e9', border: '1px solid #c8e6c9', color: '#2e7d32', padding: '4px 10px', borderRadius: 4, fontSize: 13, cursor: 'pointer' }} onClick={() => onCreateReturn?.(item?.id || savedItemId)}> + Создать </button> </div> {formData.returns?.length > 0 ? ( formData.returns.map(r => ( <div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid #f5f5f5' }}> <span style={{ fontSize: 12, color: '#888' }}>{r.date} — {r.accountName || r.account_name}</span> <span style={{ fontSize: 13, color: '#4caf50', fontWeight: 500 }}>+{Number(r.amount || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2 })}</span> </div> )) ) : ( <div style={{ fontSize: 12, color: '#bbb' }}>Нет возвратов</div> )} </div> )} {/* Оранжевая кнопка */} {isReadOnly ? ( <button type="button" className="mobile-form-save-btn" style={{ background: '#9e9e9e' }} onClick={onCancel}> Закрыть </button> ) : entity === 'expenses' ? ( <div style={{ display: 'flex', gap: 8, padding: '0 4px' }}> <button type="button" className="mobile-form-save-btn" style={{ flex: 1, background: '#ff9800' }} onClick={handleMobileSubmit('addAnother')} disabled={isSaving}> Сохранить и добавить еще </button> <button type="button" className="mobile-form-save-btn" style={{ flex: 1 }} onClick={handleMobileSubmit('close')} disabled={isSaving}> Сохранить и закрыть </button> </div> ) : ( <button type="button" className="mobile-form-save-btn" onClick={handleMobileSubmit('close')} disabled={isSaving}> Сохранить и закрыть </button> )} </div> {/* Fullscreen калькулятор */} {showMobileCalc && createPortal( <Calculator mode="fullscreen" title={`${item ? 'Изменение' : 'Добавление'} ${entityLabel.toLowerCase()}`} onResult={val => handleChange('amount', val)} onClose={() => setShowMobileCalc(false)} />, document.body )} {/* Fullscreen возврат долга */} {showLoanPayments && createPortal( <MobileLoanPayments entity={entity} loan={formData} onUpdate={(upd) => setFormData(upd || { ...formData })} onClose={() => setShowLoanPayments(false)} lookups={lookups} />, document.body )} {isSaving && ( <div style={{ position: 'fixed', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}> <div style={{ width: 36, height: 36, border: '3px solid #ff9800', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <span style={{ marginTop: 8, fontSize: 13, color: '#666' }}>Ожидание… {elapsed} с</span> </div> )} </form> {showDepositOps && ( <DepositOperationsMobile deposit={formData} onUpdate={(upd) => setFormData(upd || { ...formData })} onClose={() => setShowDepositOps(false)} lookups={lookups} /> )} </> ); } // ===== Вспомогательные компоненты мобильной формы ===== function MobileAccountRow({ label, value, options, onChange, lookups, excludeId, disabled }) { const filtered = excludeId ? options.filter(opt => opt.id !== excludeId) : options; const account = lookups.accounts?.find(a => a.id === value); const accountCurrency = account ? lookups.currencies?.find(c => c.id === account.currencyId) : null; const displayName = account ? `${account.name} (${accountCurrency?.code || ''})` : 'Выберите...'; return ( <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', opacity: disabled ? 0.6 : 1 }}> <span className="mobile-form-label">{label}</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {displayName} </span> {!disabled && <span className="mobile-form-arrow">›</span>} <select className="mobile-form-native-select" value={value || ''} onChange={e => onChange(e.target.value)} disabled={disabled} style={{ opacity: disabled ? 0 : 1 }} > <option value="">Выберите...</option> {filtered.map(opt => ( <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> </label> ); } function MobileSelectRow({ label, value, options, onChange, placeholder, onAdd, disabled }) { const displayName = options.find(o => o.id === value)?.name || placeholder; return ( <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', alignItems: 'center', opacity: disabled ? 0.6 : 1 }}> <span className="mobile-form-label">{label}</span> <span className="mobile-form-value" style={{ marginRight: 4, flex: 1, textAlign: 'right' }}> {displayName} </span> {!disabled && <span className="mobile-form-arrow" style={{ marginRight: onAdd ? 8 : 0 }}>›</span>} {onAdd && !disabled && ( <button type="button" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onAdd(); }} style={{ width: 28, height: 28, borderRadius: '50%', border: '1px solid #2196F3', background: '#2196F3', color: '#fff', fontSize: 18, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', zIndex: 10, marginRight: 4, padding: 0, }} title="Добавить" > + </button> )} <select className="mobile-form-native-select" value={value || ''} onChange={e => onChange(e.target.value)} disabled={disabled} style={{ opacity: disabled ? 0 : 1 }} > <option value="">{placeholder}</option> {options.map(opt => ( <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> </label> ); } function MobileCategoryRow({ entity, formData, handleChange, lookups, api, addLookupItem }) { const isExpense = entity === 'expenses'; const catKey = isExpense ? 'expenseCategories' : 'incomeCategories'; const subKey = isExpense ? 'expenseSubcategories' : 'incomeSubcategories'; const categoryName = lookups[catKey]?.find(c => c.id === formData.categoryId)?.name || 'Выберите...'; const subcategoryName = lookups[subKey]?.find(s => s.id === formData.subcategoryId)?.name || 'Не выбрана'; const categoryOptions = lookups[catKey]?.map(c => ({ id: c.id, name: c.name })) || []; const subcategoryOptions = lookups[subKey] ?.filter(s => String(s.categoryId ?? s.category_id ?? s.category ?? '') === String(formData.categoryId ?? '')) .map(s => ({ id: s.id, name: s.name })) || []; return ( <> <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Категория</span> <span className="mobile-form-value" style={{ marginRight: 4, flex: 1, textAlign: 'right' }}>{categoryName}</span> <span className="mobile-form-arrow" style={{ marginRight: 8 }}>›</span> <button type="button" onClick={(e) => { e.stopPropagation(); e.preventDefault(); const name = window.prompt('Введите название категории:'); if (!name || !name.trim()) return; api[catKey].create({ name: name.trim(), note: '' }).then((created) => { addLookupItem(catKey, created); handleChange('categoryId', created.id); }).catch((e) => alert('Ошибка создания категории: ' + (e.message || e))); }} style={{ width: 28, height: 28, borderRadius: '50%', border: '1px solid #2196F3', background: '#2196F3', color: '#fff', fontSize: 18, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', zIndex: 10, marginRight: 4, padding: 0, }} title="Добавить категорию" >+</button> <select className="mobile-form-native-select" value={formData.categoryId || ''} onChange={e => handleChange('categoryId', e.target.value)} > <option value="">Выберите...</option> {categoryOptions.map(opt => ( <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> </label> <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', alignItems: 'center' }}> <span className="mobile-form-label">Подкатегория</span> <span className="mobile-form-value" style={{ marginRight: 4, flex: 1, textAlign: 'right' }}>{subcategoryName}</span> <span className="mobile-form-arrow" style={{ marginRight: 8 }}>›</span> <button type="button" onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (!formData.categoryId) { alert('Сначала выберите категорию'); return; } const name = window.prompt('Введите название подкатегории:'); if (!name || !name.trim()) return; api[subKey].create({ name: name.trim(), categoryId: formData.categoryId, note: '' }).then((created) => { addLookupItem(subKey, created); handleChange('subcategoryId', created.id); }).catch((e) => alert('Ошибка создания подкатегории: ' + (e.message || e))); }} style={{ width: 28, height: 28, borderRadius: '50%', border: '1px solid #2196F3', background: '#2196F3', color: '#fff', fontSize: 18, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', zIndex: 10, marginRight: 4, padding: 0, }} title="Добавить подкатегорию" >+</button> <select className="mobile-form-native-select" value={formData.subcategoryId || ''} onChange={e => handleChange('subcategoryId', e.target.value)} > <option value="">Не выбрана</option> {subcategoryOptions.map(opt => ( <option key={opt.id} value={opt.id}>{opt.name}</option> ))} </select> </label> </> ); } function MobileDateRow({ label, value, onChange, disabled }) { const [displayDate, setDisplayDate] = useState(value || new Date().toISOString().split('T')[0]); const inputRef = useRef(null); // Синхронизация при изменении value извне (например, при смене item) useEffect(() => { const newDate = value || new Date().toISOString().split('T')[0]; setDisplayDate(newDate); if (inputRef.current) { inputRef.current.value = newDate; } }, [value]); // Нативные события - обход React synthetic events для мобильных useEffect(() => { const el = inputRef.current; if (!el) return; const handler = (e) => { const val = e.target.value; if (val) { setDisplayDate(val); onChange(val); } }; el.addEventListener('input', handler); el.addEventListener('change', handler); return () => { el.removeEventListener('input', handler); el.removeEventListener('change', handler); }; }, [onChange]); return ( <label className="mobile-form-row" style={{ position: 'relative', display: 'flex', opacity: disabled ? 0.6 : 1 }}> <span className="mobile-form-label">{label}</span> <span className="mobile-form-value" style={{ marginRight: 4 }}> {displayDate} </span> {!disabled && <span className="mobile-form-arrow">›</span>} <input ref={inputRef} type="date" className="mobile-form-native-input" defaultValue={displayDate} disabled={disabled} style={{ opacity: disabled ? 0 : 1 }} /> </label> ); } // ===== Десктопный layout (grid) ===== return ( <form onSubmit={handleSubmit}> <div className="modal-body"> {/* transfer: Баннер только просмотр (из вклада) — desktop */} {entity === 'transfer' && isReadOnly && ( <div style={{ marginBottom: 12, padding: '10px 14px', background: '#fff3e0', borderRadius: 6, border: '1px solid #ffe0b2', display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ fontSize: 14, color: '#e65100' }}>🔒 Просмотр</span> <span style={{ fontSize: 13, color: '#f57c00' }}>Этот перевод создан из операции вклада — редактирование недоступно</span> </div> )} <div className="form-grid"> {fields.map(field => ( <FormField key={field.name} field={field} value={formData[field.name]} onChange={handleChange} lookups={lookups} formData={formData} entity={entity} localTags={localTags} handleCreateTag={handleCreateTag} isReadOnly={isReadOnly} /> ))} </div> {/* expenses: Split-payment — оплата с двух счетов (desktop) */} {entity === 'expenses' && ( <div style={{ marginTop: 12, padding: '10px 14px', background: '#faf8f5', borderRadius: 6, border: '1px solid #e8e4de' }}> <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', marginBottom: formData.splitEnabled ? 12 : 0 }}> <input type="checkbox" checked={formData.splitEnabled || false} onChange={e => handleChange('splitEnabled', e.target.checked)} style={{ marginRight: 8 }} /> <span style={{ fontSize: 14 }}>Оплата с двух счетов</span> </label> {formData.splitEnabled && ( <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div className="form-group"> <label>Второй счёт</label> <select className="form-control" value={formData.splitAccountId || ''} onChange={e => handleChange('splitAccountId', e.target.value)} > <option value="">Выберите счёт</option> {(lookups.accounts || []).filter(a => !a.hidden && a.id !== formData.accountId).map(a => ( <option key={a.id} value={a.id}>{a.name}</option> ))} </select> </div> <div className="form-group"> <label>Сумма со второго счёта</label> <input type="number" step="0.01" className="form-control" placeholder="0.00" value={formData.splitAmount || ''} onChange={e => handleChange('splitAmount', e.target.value)} /> </div> </div> )} </div> )} {/* expenses + loans: Вложения (desktop) */} {(entity === 'expenses' || entity === 'loansGiven' || entity === 'loansTaken') && ( <ReceiptAttachmentDesktop attachments={formData.receiptAttachments || []} files={formData.receiptFiles || []} fileNotes={formData.fileNotes || []} onAddFiles={(files) => { handleChange('receiptFiles', [...(formData.receiptFiles || []), ...files]); handleChange('fileNotes', [...(formData.fileNotes || []), ...files.map(() => '')]); }} onRemoveFile={(idx) => { const nextFiles = [...(formData.receiptFiles || [])]; const nextNotes = [...(formData.fileNotes || [])]; nextFiles.splice(idx, 1); nextNotes.splice(idx, 1); handleChange('receiptFiles', nextFiles); handleChange('fileNotes', nextNotes); }} onRemoveAttachment={(attId) => { const next = (formData.receiptAttachments || []).filter(a => a.id !== attId); handleChange('receiptAttachments', next); handleChange('deletedReceiptPhotos', [...(formData.deletedReceiptPhotos || []), attId]); }} onUpdateFileNote={(idx, note) => { const next = [...(formData.fileNotes || [])]; next[idx] = note; handleChange('fileNotes', next); }} onUpdateAttachmentNote={async (attId, note) => { const itemId = formData.id; if (!itemId) return; try { const apiName = entity === 'loansGiven' ? 'loansGiven' : entity === 'loansTaken' ? 'loansTaken' : 'expenses'; const updated = await api[apiName].updateReceiptNote(itemId, attId, note); const next = (formData.receiptAttachments || []).map(a => a.id === attId ? { ...a, note: updated.note } : a); handleChange('receiptAttachments', next); } catch (e) { console.error('Failed to update note:', e); } }} /> )} {/* transfer: информация о комиссии */} {entity === 'transfer' && formData.feeType !== 'none' && (() => { const feeAmount = formData.feeType === 'percentage' ? (Number(formData.amount || 0) * Number(formData.feeValue || 0) / 100).toFixed(2) : formData.feeType === 'fixed' ? Number(formData.feeValue || 0).toFixed(2) : '0.00'; const actualAmount = formData.deductFee ? (Number(formData.amount || 0) - Number(feeAmount)).toFixed(2) : Number(formData.amount || 0).toFixed(2); const fromAcc = lookups.accounts?.find(a => a.id === formData.fromAccountId); const currencySymbol = fromAcc ? lookups.currencies?.find(c => c.id === fromAcc.currencyId)?.symbol || '' : ''; return Number(feeAmount) > 0 ? ( <div style={{ marginTop: 12, padding: 10, background: '#e3f2fd', borderRadius: 6, fontSize: 13, color: '#1565c0' }}> <div>Будет создана автоматически операция расхода на сумму {feeAmount} {currencySymbol}</div> {formData.deductFee && ( <div style={{ marginTop: 4, color: '#2e7d32' }}> Фактическая сумма перевода: {actualAmount} {currencySymbol} </div> )} </div> ) : null; })()} {/* expenses: Возвраты — кнопка + список (desktop) */} {entity === 'expenses' && (item?.id || savedItemId) && ( <div style={{ marginTop: 12, padding: '10px 14px', background: '#fafafa', borderRadius: 6, border: '1px solid #e8e8e8' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <span style={{ fontSize: 14, fontWeight: 600, color: '#333' }}>↩️ Возвраты</span> <button type="button" className="btn btn-sm" style={{ background: '#e8f5e9', border: '1px solid #c8e6c9', color: '#2e7d32' }} onClick={() => onCreateReturn?.(item?.id || savedItemId)}> + Создать возврат </button> </div> {formData.returns?.length > 0 ? ( <div>{formData.returns.map(r => ( <div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid #f5f5f5' }}> <span style={{ fontSize: 13, color: '#666' }}>{r.date} — {r.accountName || r.account_name}</span> <span style={{ fontSize: 13, color: '#4caf50', fontWeight: 500 }}>+{Number(r.amount || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2 })}</span> </div> ))}</div> ) : ( <div style={{ fontSize: 12, color: '#bbb' }}>Нет возвратов</div> )} </div> )} {/* Возврат частями — только для займов (desktop) */} {(entity === 'loansGiven' || entity === 'loansTaken') && (item?.id || savedItemId) && ( <LoanPayments entity={entity} item={formData} onUpdate={(upd) => setFormData(upd || { ...formData })} lookups={lookups} /> )} {/* Операции с вкладом — только для вкладов (desktop) */} {entity === 'deposits' && (item?.id || savedItemId) && ( <DepositOperations deposit={formData} onUpdate={(upd) => setFormData(upd || { ...formData })} lookups={lookups} isMobile={isMobile} /> )} </div> <div className="modal-footer"> <button type="button" className="btn btn-secondary" onClick={onCancel}> {isReadOnly ? 'Закрыть' : 'Отмена'} </button> {!isReadOnly && entity === 'expenses' && ( <button type="button" className="btn btn-primary" style={{ background: '#ff9800', borderColor: '#f57c00' }} onClick={handleAddAnother} disabled={isSaving}> Сохранить и добавить еще </button> )} {!isReadOnly && ( <button type="submit" className="btn btn-primary" disabled={isSaving}> Сохранить </button> )} </div> {isSaving && ( <div style={{ position: 'fixed', inset: 0, background: 'rgba(255,255,255,0.7)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: 50 }}> <div style={{ width: 36, height: 36, border: '3px solid #ff9800', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 1s linear infinite' }} /> <span style={{ marginTop: 8, fontSize: 13, color: '#666' }}>Ожидание… {elapsed} с</span> </div> )} </form> ); }