/
Pitonx
/
MyFinance
Обзор
Документация
Войти
/
Pitonx
/
MyFinance
Код
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/DepositOperationsModal.js
405 строк
19 KB
agent
Deposit interest: remove Income FK dependency, use account directly
05 июн 2026, 15:40
05 июн 2026, 15:40
c65a533
Код
Авторство
О чём код?
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { ICONS } from '../config/entities'; import { api } from '../services/api'; import { refreshIdempotencyKey } from '../services/restApi'; export default function DepositOperationsModal({ deposit: depositProp, onClose, onUpdate, lookups }) { const [deposit, setDeposit] = useState(depositProp); const [position, setPosition] = useState({ x: 0, y: 0 }); const fetchedRef = useRef(false); // Загружаем детали вклада (с transactions/accruals) при открытии — guard от двойного запроса useEffect(() => { if (fetchedRef.current) return; fetchedRef.current = true; api.deposits.getById(depositProp.id).then(data => { setDeposit(data); onUpdate(data); }).catch(() => setDeposit(depositProp)); }, [depositProp.id]); const [isDragging, setIsDragging] = useState(false); const dragStart = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); const modalRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [operationTab, setOperationTab] = useState('deposit'); // deposit | withdraw | interest // Форма пополнения/снятия const [form, setForm] = useState({ date: new Date().toISOString().split('T')[0], amount: '', accountId: '', note: '', }); // Форма начисления % const [interestForm, setInterestForm] = useState({ date: new Date().toISOString().split('T')[0], periodStart: new Date(new Date().setDate(1)).toISOString().split('T')[0], periodEnd: new Date().toISOString().split('T')[0], accountId: '', baseAmount: '', note: '', }); // Валюта вклада const depositCurrencyId = useMemo(() => { const acc = lookups.accounts?.find(a => a.id === deposit.targetAccountId || a.id === deposit.target_account); return deposit.currencyId || acc?.currencyId; }, [lookups.accounts, deposit]); const currencySymbol = useMemo(() => { const curr = lookups.currencies?.find(c => c.id === depositCurrencyId); return curr?.symbol || '₽'; }, [lookups.currencies, depositCurrencyId]); const sameCurrencyAccounts = useMemo(() => { const visible = (lookups.accounts || []).filter(a => !a.hidden); if (!depositCurrencyId) return visible; return visible.filter(a => a.currencyId === depositCurrencyId); }, [lookups.accounts, depositCurrencyId]); // Drag handlers const handleMouseDown = useCallback((e) => { if (e.button !== 0) return; if (e.target.closest('.modal-close')) return; setIsDragging(true); dragStart.current = { mouseX: e.clientX, mouseY: e.clientY, posX: position.x, posY: position.y }; e.preventDefault(); }, [position]); const handleMouseMove = useCallback((e) => { if (!isDragging) return; const dx = e.clientX - dragStart.current.mouseX; const dy = e.clientY - dragStart.current.mouseY; setPosition({ x: dragStart.current.posX + dx, y: dragStart.current.posY + dy }); }, [isDragging]); const handleMouseUp = useCallback(() => setIsDragging(false), []); useEffect(() => { if (isDragging) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; } }, [isDragging, handleMouseMove, handleMouseUp]); const resetForm = () => { setForm({ date: new Date().toISOString().split('T')[0], amount: '', accountId: '', note: '' }); }; const resetInterestForm = () => { setInterestForm({ date: new Date().toISOString().split('T')[0], periodStart: new Date(new Date().setDate(1)).toISOString().split('T')[0], periodEnd: new Date().toISOString().split('T')[0], accountId: '', baseAmount: '', note: '', }); }; // Пополнение const handleDeposit = async () => { if (isSubmitting) return; const amount = parseFloat(form.amount); if (!amount || amount <= 0) return; if (!form.accountId) { alert('Выберите счёт списания'); return; } setIsSubmitting(true); try { const res = await api.deposits.action(deposit.id, 'deposit', { date: form.date, amount, account_id: form.accountId, note: form.note || `Пополнение вклада "${deposit.name}"`, }); refreshIdempotencyKey(); // Обновляем вклад (перезагружаем) const updated = await api.deposits.getById(deposit.id); onUpdate(updated); resetForm(); } catch (e) { alert('Ошибка: ' + (e.message || e)); } finally { setIsSubmitting(false); } }; // Снятие const handleWithdraw = async () => { if (isSubmitting) return; const amount = parseFloat(form.amount); if (!amount || amount <= 0) return; if (!form.accountId) { alert('Выберите счёт зачисления'); return; } setIsSubmitting(true); try { await api.deposits.action(deposit.id, 'withdraw', { date: form.date, amount, account_id: form.accountId, note: form.note || `Снятие с вклада "${deposit.name}"`, }); refreshIdempotencyKey(); const updated = await api.deposits.getById(deposit.id); onUpdate(updated); resetForm(); } catch (e) { alert('Ошибка: ' + (e.message || e)); } finally { setIsSubmitting(false); } }; // Начисление % const handleAccrueInterest = async () => { if (isSubmitting) return; const amount = parseFloat(interestForm.baseAmount); if (!amount || amount <= 0) { alert('Введите сумму начисления'); return; } setIsSubmitting(true); try { const payload = { date: interestForm.date, amount, base_amount: interestForm.baseAmount || undefined, note: interestForm.note || `Начисление процентов по вкладу "${deposit.name}" (${deposit.rate}%, ${interestForm.periodStart} - ${interestForm.periodEnd})`, }; // Без капитализации — требуем выбор счёта зачисления if (!deposit.capitalization || deposit.capitalization === 'none') { if (!interestForm.accountId) { alert('Выберите счёт зачисления процентов'); setIsSubmitting(false); return; } payload.account_id = interestForm.accountId; } await api.deposits.action(deposit.id, 'accrue_interest', payload); refreshIdempotencyKey(); const updated = await api.deposits.getById(deposit.id); onUpdate(updated); resetInterestForm(); } catch (e) { alert('Ошибка: ' + (e.message || e)); } finally { setIsSubmitting(false); } }; // Удаление транзакции const handleDeleteTransaction = async (txId) => { if (isSubmitting) return; if (!window.confirm('Удалить операцию?')) return; setIsSubmitting(true); try { await api.depositTransactions.delete(txId); refreshIdempotencyKey(); const updated = await api.deposits.getById(deposit.id); onUpdate(updated); } catch (e) { alert('Ошибка: ' + (e.message || e)); } finally { setIsSubmitting(false); } }; const visibleAccounts = (lookups.accounts || []).filter(a => !a.hidden && !a.is_deposit); return ( <div className="modal-overlay" onClick={onClose} style={{ zIndex: 2000 }}> <div className="modal-window" ref={modalRef} onClick={e => e.stopPropagation()} style={{ transform: `translate(${position.x}px, ${position.y}px)`, width: 800, maxWidth: '95vw' }} > <div className="modal-title-bar" onMouseDown={handleMouseDown} style={{ cursor: isDragging ? 'grabbing' : 'grab', userSelect: 'none' }} title="Перетащите за заголовок" > <span className="modal-icon">{ICONS.deposits}</span> <h2>Операции по вкладу — {deposit.name}</h2> <button className="modal-close" onClick={onClose}>×</button> </div> <div style={{ padding: 15 }}> {/* Сводка */} <div style={{ marginBottom: 12, fontSize: 13, display: 'flex', gap: 16, flexWrap: 'wrap' }}> <span>Сумма: <strong>{Number(deposit.amount).toLocaleString('ru-RU')}</strong> {currencySymbol}</span> <span>Ставка: <strong>{deposit.rate}%</strong></span> <span>Срок: <strong>{deposit.period} мес.</strong></span> <span>Капитализация: <strong>{deposit.capitalization === 'monthly' ? 'Ежемесячная' : deposit.capitalization === 'yearly' ? 'Ежегодная' : 'Без'}</strong></span> </div> {/* Табы */} <div style={{ display: 'flex', borderBottom: '2px solid #ddd', marginBottom: 15 }}> {[ { id: 'deposit', label: 'Пополнение' }, { id: 'withdraw', label: 'Снятие' }, { id: 'interest', label: 'Начисление %' }, ].map(tab => ( <button key={tab.id} onClick={() => setOperationTab(tab.id)} style={{ padding: '8px 20px', border: 'none', borderBottom: operationTab === tab.id ? '2px solid #1976d2' : '2px solid transparent', background: 'none', color: operationTab === tab.id ? '#1976d2' : '#666', fontWeight: operationTab === tab.id ? 600 : 400, cursor: 'pointer', marginBottom: -2, }} > {tab.label} </button> ))} </div> {/* Форма пополнения / снятия */} {(operationTab === 'deposit' || operationTab === 'withdraw') && ( <div style={{ display: 'flex', gap: 8, marginBottom: 15, alignItems: 'flex-end', flexWrap: 'wrap' }}> <div className="form-group" style={{ flex: 1, minWidth: 120, marginBottom: 0 }}> <label>Дата</label> <input type="date" value={form.date} onChange={e => setForm(f => ({...f, date: e.target.value}))} /> </div> <div className="form-group" style={{ flex: 1, minWidth: 110, marginBottom: 0 }}> <label>Сумма</label> <input type="number" step="0.01" value={form.amount} onChange={e => setForm(f => ({...f, amount: e.target.value}))} /> </div> <div className="form-group" style={{ flex: 2, minWidth: 160, marginBottom: 0 }}> <label>{operationTab === 'deposit' ? 'Счёт списания' : 'Счёт зачисления'}</label> <select value={form.accountId} onChange={e => setForm(f => ({...f, accountId: e.target.value}))}> <option value="">Выберите...</option> {sameCurrencyAccounts.map(acc => { const curr = lookups.currencies?.find(c => c.id === acc.currencyId); return <option key={acc.id} value={acc.id}>{acc.name} ({curr?.code || ''})</option>; })} </select> </div> <div className="form-group" style={{ flex: 2, minWidth: 180, marginBottom: 0 }}> <label>Примечание</label> <input type="text" value={form.note} onChange={e => setForm(f => ({...f, note: e.target.value}))} placeholder={operationTab === 'deposit' ? `Пополнение вклада "${deposit.name}"` : `Снятие с вклада "${deposit.name}"`} /> </div> <div style={{ display: 'flex', alignItems: 'flex-end' }}> <button type="button" className={operationTab === 'deposit' ? 'btn btn-success' : 'btn btn-primary'} onClick={operationTab === 'deposit' ? handleDeposit : handleWithdraw} disabled={isSubmitting} > {ICONS.create} </button> </div> </div> )} {/* Форма начисления % */} {operationTab === 'interest' && ( <div style={{ display: 'flex', gap: 8, marginBottom: 15, alignItems: 'flex-end', flexWrap: 'wrap' }}> <div className="form-group" style={{ flex: 1, minWidth: 120, marginBottom: 0 }}> <label>Дата</label> <input type="date" value={interestForm.date} onChange={e => setInterestForm(f => ({...f, date: e.target.value}))} /> </div> <div className="form-group" style={{ flex: 1, minWidth: 120, marginBottom: 0 }}> <label>Начало периода</label> <input type="date" value={interestForm.periodStart} onChange={e => setInterestForm(f => ({...f, periodStart: e.target.value}))} /> </div> <div className="form-group" style={{ flex: 1, minWidth: 120, marginBottom: 0 }}> <label>Конец периода</label> <input type="date" value={interestForm.periodEnd} onChange={e => setInterestForm(f => ({...f, periodEnd: e.target.value}))} /> </div> <div className="form-group" style={{ flex: 1, minWidth: 110, marginBottom: 0 }}> <label>Сумма</label> <input type="number" step="0.01" value={interestForm.baseAmount} onChange={e => setInterestForm(f => ({...f, baseAmount: e.target.value}))} placeholder={Number(deposit.amount).toLocaleString('ru-RU')} /> </div> {/* Счёт зачисления — только без капитализации */} {(!deposit.capitalization || deposit.capitalization === 'none') && ( <div className="form-group" style={{ flex: 2, minWidth: 160, marginBottom: 0 }}> <label>Счёт зачисления</label> <select value={interestForm.accountId} onChange={e => setInterestForm(f => ({...f, accountId: e.target.value}))}> <option value="">Выберите...</option> {(lookups.accounts || []).filter(a => !a.hidden && !a.is_deposit).map(acc => { const curr = lookups.currencies?.find(c => c.id === acc.currencyId); return <option key={acc.id} value={acc.id}>{acc.name} ({curr?.code || ''})</option>; })} </select> </div> )} <div className="form-group" style={{ flex: 2, minWidth: 180, marginBottom: 0 }}> <label>Примечание</label> <input type="text" value={interestForm.note} onChange={e => setInterestForm(f => ({...f, note: e.target.value}))} placeholder={`Начисление процентов по вкладу "${deposit.name}"`} /> </div> <div style={{ display: 'flex', alignItems: 'flex-end' }}> <button type="button" className="btn btn-success" onClick={handleAccrueInterest} disabled={isSubmitting}>{ICONS.create}</button> </div> </div> )} {/* История операций */} <div style={{ border: '1px solid #ddd', borderRadius: 4 }}> <div style={{ display: 'flex', background: '#f0f0f0', fontWeight: 600, fontSize: 12, padding: '6px 10px', borderBottom: '1px solid #ddd' }}> <span style={{ width: '12%' }}>Дата</span> <span style={{ width: '12%' }}>Тип</span> <span style={{ width: '14%', textAlign: 'right' }}>Сумма</span> <span style={{ width: '36%' }}>Примечание</span> <span style={{ width: '14%' }}>Создано</span> <span style={{ width: '12%', textAlign: 'right' }}></span> </div> {(deposit.transactions || []).map((tx) => { const txType = tx.type === 'deposit' ? 'Пополнение' : tx.type === 'withdraw' ? 'Снятие' : tx.type; const txColor = tx.type === 'deposit' ? '#388e3c' : tx.type === 'withdraw' ? '#d32f2f' : '#666'; const accountName = tx.account_name || lookups.getAccountName?.(tx.account_id) || '—'; return ( <div key={tx.id} style={{ display: 'flex', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid #eee', fontSize: 13 }}> <span style={{ width: '12%' }}>{tx.date}</span> <span style={{ width: '12%', color: txColor }}>{txType}</span> <span style={{ width: '14%', textAlign: 'right', fontWeight: 600 }}>{Number(tx.amount).toLocaleString('ru-RU')} {currencySymbol}</span> <span style={{ width: '24%', color: '#ff9800' }}>{accountName}</span> <span style={{ width: '26%', color: '#777' }}>{tx.note || '—'}</span> <span style={{ width: '12%', color: '#999', fontSize: 11 }}>{tx.created ? new Date(tx.created).toLocaleDateString('ru-RU') : '—'}</span> <span style={{ width: '12%', textAlign: 'right' }}> <button type="button" className="btn btn-danger" style={{ padding: '2px 8px', fontSize: 12 }} onClick={() => handleDeleteTransaction(tx.id)} disabled={isSubmitting}>{ICONS.delete}</button> </span> </div> ); })} {(deposit.transactions || []).length === 0 && ( <div style={{ color: '#999', fontStyle: 'italic', padding: '12px', textAlign: 'center' }}>Операций пока нет</div> )} </div> {/* Начисления */} {(deposit.accruals || []).length > 0 && ( <div style={{ marginTop: 15, border: '1px solid #ddd', borderRadius: 4 }}> <div style={{ display: 'flex', background: '#f0f0f0', fontWeight: 600, fontSize: 12, padding: '6px 10px', borderBottom: '1px solid #ddd' }}> <span style={{ width: '12%' }}>Дата</span> <span style={{ width: '25%' }}>Период</span> <span style={{ width: '15%', textAlign: 'right' }}>Сумма</span> <span style={{ width: '20%', color: '#ff9800' }}>Счёт</span> <span style={{ width: '28%' }}>Примечание</span> </div> {(deposit.accruals || []).map((a) => ( <div key={a.id} style={{ display: 'flex', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid #eee', fontSize: 13 }}> <span style={{ width: '12%' }}>{a.date}</span> <span style={{ width: '25%' }}>{a.period_start || a.periodStart} — {a.period_end || a.periodEnd}</span> <span style={{ width: '15%', textAlign: 'right', fontWeight: 600 }}>{Number(a.amount).toLocaleString('ru-RU')} {currencySymbol}</span> <span style={{ width: '20%', color: '#ff9800' }}>{a.account_name || '—'}</span> <span style={{ width: '28%', color: '#777' }}>{a.note || '—'}</span> </div> ))} </div> )} </div> <div className="modal-footer"> <button type="button" className="btn" onClick={onClose}>{ICONS.cancel}</button> </div> </div> </div> ); }