/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/ReturnForm.js
282 строки
15 KB
agent
feat: возврат из расхода — расход locked readonly без поиска
14 июн 2026, 14:49
14 июн 2026, 14:49
2e5e408
Код
Авторство
О чём код?
import React, { useState, useMemo, useEffect } from 'react'; import { api } from '../services/api'; import { refreshIdempotencyKey } from '../services/restApi'; export default function ReturnForm({ item, onSubmit, onCancel, lookups, isMobile, defaultExpenseId }) { const [expenseId, setExpenseId] = useState(item?.expense || ''); const [amount, setAmount] = useState(item?.amount ? String(item.amount) : ''); const [date, setDate] = useState(item?.date || new Date().toISOString().split('T')[0]); const [accountId, setAccountId] = useState(item?.accountId || ''); const [note, setNote] = useState(item?.note || ''); const [isSaving, setIsSaving] = useState(false); // lockedExpense — расход зафиксирован при создании из формы расхода (defaultExpenseId) // В этом режиме пользователь не может выбрать другой расход const [lockedExpense, setLockedExpense] = useState(null); const isExpenseLocked = !!lockedExpense; // Предзаполнение из defaultExpenseId (создание возврата из формы расхода) useEffect(() => { if (!defaultExpenseId || item?.id) return; // Ищем в уже загруженном списке const expenseInList = recentExpenses.find(e => e.id === defaultExpenseId); if (expenseInList) { setExpenseId(expenseInList.id); setLockedExpense(expenseInList); setAmount(String(expenseInList.amount || '')); setAccountId(expenseInList.accountId || ''); return; } // Загружаем напрямую по API api.expenses.getById(defaultExpenseId) .then(exp => { if (exp) { setExpenseId(exp.id); setLockedExpense(exp); setAmount(String(exp.amount || '')); setAccountId(exp.accountId || ''); } }) .catch(() => {}); }, [defaultExpenseId, item?.id]); // Список расходов пользователя для выбора const [expenseSearch, setExpenseSearch] = useState(''); const [recentExpensesLocal, setRecentExpensesLocal] = useState([]); useEffect(() => { if (lookups.recentExpenses?.length) return; api.expenses.getPage(1, 50, { date__gte: '2020-01-01' }) .then(r => setRecentExpensesLocal((r.results || []).filter(e => !e.hidden))) .catch(() => {}); }, [lookups.recentExpenses]); const recentExpenses = useMemo(() => { return (lookups.recentExpenses?.length ? lookups.recentExpenses : recentExpensesLocal).filter(e => !e.hidden); }, [lookups.recentExpenses, recentExpensesLocal]); const selectedExpense = useMemo(() => { return recentExpenses.find(e => e.id === expenseId); }, [recentExpenses, expenseId]); // Фильтр расходов по поиску const filteredExpenses = useMemo(() => { if (!expenseSearch.trim()) return recentExpenses.slice(0, 20); const q = expenseSearch.toLowerCase(); return recentExpenses.filter(e => (e.note || '').toLowerCase().includes(q) || (e.categoryName || '').toLowerCase().includes(q) || (e.date || '').includes(q) ).slice(0, 20); }, [recentExpenses, expenseSearch]); // Автозаполнение при выборе расхода const handleSelectExpense = (exp) => { setExpenseId(exp.id); setExpenseSearch(''); if (!amount) setAmount(String(exp.amount || '')); if (!accountId) setAccountId(exp.accountId || ''); }; const handleSave = async () => { if (isSaving) return; if (!expenseId) { alert('Выберите исходный расход'); return; } if (!amount || Number(amount) <= 0) { alert('Введите сумму возврата'); return; } if (!accountId) { alert('Выберите счёт зачисления'); return; } setIsSaving(true); try { const payload = { expense: expenseId, amount: Number(amount), date, account: accountId, note, }; if (item?.id) { await api.returns.update(item.id, payload); } else { await api.returns.create(payload); } refreshIdempotencyKey(); onSubmit(); } catch (err) { alert(err.message || 'Ошибка сохранения'); } finally { setIsSaving(false); } }; const visibleAccounts = (lookups.accounts || []).filter(a => !a.hidden); if (isMobile) { return ( <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#f5f5f0', zIndex: 10000, display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', background: '#1976d2', color: '#fff', flexShrink: 0 }}> <button type="button" style={{ background: 'none', border: 'none', color: '#fff', fontSize: 15, cursor: 'pointer' }} onClick={onCancel}>Отмена</button> <span style={{ flex: 1, textAlign: 'center', fontWeight: 600, fontSize: 16 }}>{item?.id ? 'Изменение возврата' : 'Новый возврат'}</span> <button type="button" style={{ background: 'none', border: 'none', color: '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer', opacity: isSaving ? 0.5 : 1 }} onClick={handleSave} disabled={isSaving}>{isSaving ? '...' : 'Сохранить'}</button> </div> <div style={{ flex: 1, overflow: 'auto' }}> {/* Выбор расхода */} <div style={{ padding: '12px 16px', background: '#fff', borderBottom: '1px solid #e8e8e8' }}> <div style={{ fontSize: 14, color: '#666', marginBottom: 6 }}>Исходный расход *</div> {isExpenseLocked && lockedExpense ? ( <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#e8f5e9', borderRadius: 4 }}> <span style={{ fontSize: 13, color: '#2e7d32' }}>🔒</span> <div> <div style={{ fontSize: 16, color: '#333' }}>{lockedExpense.categoryName || lockedExpense.category?.name} — {Number(lockedExpense.amount).toLocaleString('ru-RU')}</div> <div style={{ fontSize: 12, color: '#666' }}>{lockedExpense.date} {lockedExpense.note}</div> </div> </div> ) : selectedExpense ? ( <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div> <div style={{ fontSize: 16, color: '#333' }}>{selectedExpense.categoryName} — {Number(selectedExpense.amount).toLocaleString('ru-RU')}</div> <div style={{ fontSize: 12, color: '#888' }}>{selectedExpense.date} {selectedExpense.note}</div> </div> <button type="button" style={{ background: 'none', border: 'none', color: '#999', fontSize: 18, cursor: 'pointer' }} onClick={() => { setExpenseId(''); }}>×</button> </div> ) : ( <> <input type="text" placeholder="Поиск по категории, дате, примечанию..." value={expenseSearch} onChange={e => setExpenseSearch(e.target.value)} style={{ width: '100%', fontSize: 15, padding: '8px 12px', border: '1px solid #ccc', borderRadius: 4, marginBottom: 8 }} autoFocus /> <div style={{ maxHeight: 200, overflow: 'auto' }}> {filteredExpenses.map(exp => ( <div key={exp.id} onClick={() => handleSelectExpense(exp)} style={{ padding: '8px 12px', cursor: 'pointer', borderBottom: '1px solid #f0f0f0' }}> <div> <span style={{ fontSize: 14, color: '#333' }}>{exp.categoryName}</span> <span style={{ fontSize: 14, color: '#666', marginLeft: 8 }}>{Number(exp.amount).toLocaleString('ru-RU')}</span> <span style={{ fontSize: 12, color: '#999', marginLeft: 8 }}>{exp.date}</span> </div> {exp.note && <div style={{ fontSize: 11, color: '#bbb', marginTop: 2 }}>{exp.note}</div>} </div> ))} </div> </> )} </div> {/* Сумма возврата */} <div className="mobile-form-row"> <span className="mobile-form-label">Сумма возврата *</span> <input type="number" step="0.01" className="mobile-form-amount-input" placeholder="0.00" value={amount} onChange={e => setAmount(e.target.value)} /> </div> {/* Дата */} <div className="mobile-form-row"> <span className="mobile-form-label">Дата возврата</span> <input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ fontSize: 16, border: 'none', outline: 'none' }} /> </div> {/* Счёт */} <div className="mobile-form-row"> <span className="mobile-form-label">Счёт зачисления *</span> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <span style={{ fontSize: 16, color: accountId ? '#333' : '#999' }}> {lookups.accounts?.find(a => a.id === accountId)?.name || 'Выберите...'} </span> <select value={accountId} onChange={e => setAccountId(e.target.value)} style={{ position: 'absolute', right: 16, opacity: 0, width: 100, height: 40, cursor: 'pointer' }}> <option value="">Выберите...</option> {visibleAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)} </select> <span style={{ color: '#999', fontSize: 18 }}>›</span> </div> </div> {/* Примечание */} <div style={{ padding: '14px 16px', background: '#fff', borderBottom: '1px solid #e8e8e8' }}> <div style={{ fontSize: 16, color: '#666', marginBottom: 4 }}>Примечание</div> <input type="text" placeholder="Введите текст" value={note} onChange={e => setNote(e.target.value)} style={{ border: 'none', fontSize: 16, width: '100%', outline: 'none', color: '#333' }} /> </div> </div> </div> ); } // Desktop return ( <div className="modal-overlay" onClick={onCancel} style={{ zIndex: 2000 }}> <div className="modal-window" onClick={e => e.stopPropagation()} style={{ width: 560, maxWidth: '95vw' }}> <div className="modal-title-bar"> <h2>{item?.id ? 'Изменение возврата' : 'Новый возврат'}</h2> <button className="modal-close" onClick={onCancel}>×</button> </div> <div style={{ padding: 20 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> {/* Выбор расхода */} <div className="form-group"> <label>Исходный расход *</label> {isExpenseLocked && lockedExpense ? ( <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#e8f5e9', borderRadius: 4 }}> <span style={{ fontSize: 13, color: '#2e7d32' }}>🔒</span> <div> <strong>{lockedExpense.categoryName || lockedExpense.category?.name}</strong> — {Number(lockedExpense.amount).toLocaleString('ru-RU')} <span style={{ color: '#888', marginLeft: 8 }}>{lockedExpense.date}</span> {lockedExpense.note && <span style={{ color: '#999', marginLeft: 8 }}>{lockedExpense.note}</span>} </div> </div> ) : selectedExpense ? ( <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', background: '#e3f2fd', borderRadius: 4 }}> <div> <strong>{selectedExpense.categoryName}</strong> — {Number(selectedExpense.amount).toLocaleString('ru-RU')} <span style={{ color: '#888', marginLeft: 8 }}>{selectedExpense.date}</span> </div> <button type="button" className="btn" style={{ padding: '2px 8px' }} onClick={() => setExpenseId('')}>×</button> </div> ) : ( <> <input type="text" placeholder="Поиск по категории, дате..." value={expenseSearch} onChange={e => setExpenseSearch(e.target.value)} style={{ marginBottom: 8 }} /> <div style={{ maxHeight: 150, overflow: 'auto', border: '1px solid #eee', borderRadius: 4 }}> {filteredExpenses.map(exp => ( <div key={exp.id} onClick={() => handleSelectExpense(exp)} style={{ padding: '6px 10px', cursor: 'pointer', borderBottom: '1px solid #f0f0f0' }}> {exp.categoryName} — {Number(exp.amount).toLocaleString('ru-RU')} <span style={{ color: '#999' }}>({exp.date})</span> {exp.note && <div style={{ fontSize: 11, color: '#bbb', marginTop: 2 }}>{exp.note}</div>} </div> ))} </div> </> )} </div> <div className="form-group"> <label>Сумма возврата *</label> <input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" /> </div> <div className="form-group"> <label>Дата возврата</label> <input type="date" value={date} onChange={e => setDate(e.target.value)} /> </div> <div className="form-group"> <label>Счёт зачисления *</label> <select value={accountId} onChange={e => setAccountId(e.target.value)}> <option value="">Выберите...</option> {visibleAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)} </select> </div> <div className="form-group"> <label>Примечание</label> <input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder="Введите текст" /> </div> </div> </div> <div className="modal-footer"> <button type="button" className="btn btn-secondary" onClick={onCancel}>Отмена</button> <button type="button" className="btn btn-primary" onClick={handleSave} disabled={isSaving}>{isSaving ? 'Сохранение...' : 'Сохранить'}</button> </div> </div> </div> ); }