v1.31.2: ConfirmModal, CategorySelector en batch modal, OverdueWidget horizontal, fix duplicate transaction

This commit is contained in:
marcoitaloesp-ai 2025-12-14 16:09:01 +00:00 committed by GitHub
parent 1c864463d6
commit 10d2f81649
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 336 additions and 179 deletions

View File

@ -5,6 +5,32 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.31.2] - 2025-12-14
### Added
- **ConfirmModal** - Componente de confirmação customizado
- Substitui todos os `window.confirm` nativos do navegador
- Suporta variantes: danger (vermelho), warning (amarelo), primary (azul)
- Ícones dinâmicos baseados na variante
- Design dark theme consistente com a aplicação
- Integrado em TransactionsByWeek (delete, cancel, unsplit, unlink)
- Integrado em BusinessSettingsTab, CampaignsTab, ServiceSheetsTab, ProductSheetsTab
### Changed
- **CategorySelector em Modal Categorizar em Lote** - Adicionado dropdown hierárquico no modal de categorização em lote
- **OverdueWidget Redesenhado** - Layout horizontal em fila própria no Dashboard
- 4 colunas para níveis de urgência (Crítico, Alto, Médio, Baixo)
- Scroll interno por coluna
- Total de itens vencidos no header
- Responsivo: 2 colunas tablet, 4 desktop
- **UpcomingWidget Ajustado** - Altura fixa com scroll interno
### Fixed
- **Duplicate Transaction Error** - Corrigido erro 500 ao duplicar transação (import_hash unique constraint)
- **CategorySelector Selection** - Corrigido onChange para permitir seleção de subcategorias
- **Dropdown Positioning** - Melhorado cálculo de posição para evitar dropdown fora da tela
## [1.31.1] - 2025-12-14
### Added

View File

@ -1 +1 @@
1.31.1
1.31.2

View File

@ -363,16 +363,15 @@ public function duplicate(Request $request, Transaction $transaction): JsonRespo
return response()->json(['message' => 'Transação não encontrada'], 404);
}
$newTransaction = $transaction->replicate([
'amount',
'effective_date',
'status',
]);
$newTransaction = $transaction->replicate();
// Resetear campos que no deben duplicarse
$newTransaction->status = 'pending';
$newTransaction->amount = null;
$newTransaction->effective_date = null;
$newTransaction->planned_date = now()->toDateString();
$newTransaction->import_hash = null; // IMPORTANTE: debe ser null para evitar duplicidad
$newTransaction->save();
return response()->json(

View File

@ -24,20 +24,30 @@ export default function CategorySelector({
const [isOpen, setIsOpen] = useState(false);
const [expandedCategories, setExpandedCategories] = useState({});
const [searchTerm, setSearchTerm] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0, showAbove: false });
const containerRef = useRef(null);
const buttonRef = useRef(null);
const dropdownRef = useRef(null);
const searchInputRef = useRef(null);
// Calcular posición del dropdown
// Calcular posición del dropdown (arriba o abajo según espacio disponible)
const updateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const dropdownHeight = 350; // altura aproximada del dropdown
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom - 10; // 10px margen
const spaceAbove = rect.top - 10;
// Decidir si mostrar arriba o abajo
const showAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width
top: showAbove ? rect.top - Math.min(dropdownHeight, spaceAbove) : rect.bottom,
left: rect.left,
width: rect.width,
showAbove: showAbove,
maxHeight: showAbove ? Math.min(dropdownHeight, spaceAbove) : Math.min(dropdownHeight, spaceBelow)
});
}
};
@ -193,8 +203,8 @@ export default function CategorySelector({
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex: 99999,
maxHeight: '400px',
minHeight: '200px',
maxHeight: dropdownPosition.maxHeight || 350,
minHeight: '150px',
overflowY: 'auto',
padding: '0.5rem 0',
boxShadow: '0 6px 20px rgba(0,0,0,0.4)'

View File

@ -0,0 +1,72 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
/**
* ConfirmModal - Modal de confirmación personalizado
*
* Props:
* - show: boolean - Si el modal está visible
* - onConfirm: function - Callback cuando se confirma
* - onCancel: function - Callback cuando se cancela
* - title: string - Título del modal (opcional)
* - message: string - Mensaje de confirmación
* - confirmText: string - Texto del botón confirmar (opcional)
* - cancelText: string - Texto del botón cancelar (opcional)
* - variant: string - 'danger' | 'warning' | 'primary' (opcional, default: 'danger')
*/
export default function ConfirmModal({
show,
onConfirm,
onCancel,
title,
message,
confirmText,
cancelText,
variant = 'danger'
}) {
const { t } = useTranslation();
if (!show) return null;
const variantClass = {
danger: 'btn-danger',
warning: 'btn-warning',
primary: 'btn-primary'
}[variant] || 'btn-danger';
const iconClass = {
danger: 'bi-exclamation-triangle-fill text-danger',
warning: 'bi-question-circle-fill text-warning',
primary: 'bi-info-circle-fill text-primary'
}[variant] || 'bi-exclamation-triangle-fill text-danger';
return (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100000 }}>
<div className="modal-dialog modal-dialog-centered modal-sm">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-body text-center py-4">
<i className={`bi ${iconClass} mb-3`} style={{ fontSize: '3rem' }}></i>
{title && <h5 className="text-white mb-3">{title}</h5>}
<p className="text-slate-300 mb-4">{message}</p>
<div className="d-flex gap-2 justify-content-center">
<button
type="button"
className="btn btn-outline-secondary"
onClick={onCancel}
>
{cancelText || t('common.cancel')}
</button>
<button
type="button"
className={`btn ${variantClass}`}
onClick={onConfirm}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { businessSettingService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import BusinessSettingModal from './BusinessSettingModal';
import ConfirmModal from '../ConfirmModal';
const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
const { t } = useTranslation();
@ -11,6 +12,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
const [showModal, setShowModal] = useState(false);
const [editingSetting, setEditingSetting] = useState(null);
const [deleting, setDeleting] = useState(null);
const [confirmModal, setConfirmModal] = useState({ show: false, setting: null });
const handleCreate = () => {
setEditingSetting(null);
@ -23,7 +25,12 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
};
const handleDelete = async (setting) => {
if (!window.confirm(t('business.settings.confirmDelete'))) return;
setConfirmModal({ show: true, setting });
};
const executeDelete = async () => {
const setting = confirmModal.setting;
setConfirmModal({ show: false, setting: null });
setDeleting(setting.id);
try {
@ -204,6 +211,15 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
onClose={() => setShowModal(false)}
/>
)}
{/* Confirm Modal */}
<ConfirmModal
show={confirmModal.show}
message={t('business.settings.confirmDelete')}
variant="danger"
onConfirm={executeDelete}
onCancel={() => setConfirmModal({ show: false, setting: null })}
/>
</>
);
};

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { campaignService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import CampaignModal from './CampaignModal';
import ConfirmModal from '../ConfirmModal';
const CampaignsTab = ({ sheets }) => {
const { t } = useTranslation();
@ -15,6 +16,7 @@ const CampaignsTab = ({ sheets }) => {
const [modalOpen, setModalOpen] = useState(false);
const [editingCampaign, setEditingCampaign] = useState(null);
const [statusFilter, setStatusFilter] = useState('all');
const [confirmModal, setConfirmModal] = useState({ show: false, campaign: null });
// Carregar dados
const loadData = useCallback(async () => {
@ -59,9 +61,12 @@ const CampaignsTab = ({ sheets }) => {
};
const handleDelete = async (campaign) => {
if (!window.confirm(t('campaigns.deleteConfirm', { name: campaign.name }))) {
return;
}
setConfirmModal({ show: true, campaign });
};
const executeDelete = async () => {
const campaign = confirmModal.campaign;
setConfirmModal({ show: false, campaign: null });
try {
await campaignService.delete(campaign.id);
setCampaigns(prev => prev.filter(c => c.id !== campaign.id));
@ -320,6 +325,15 @@ const CampaignsTab = ({ sheets }) => {
presets={presets}
sheets={sheets}
/>
{/* Confirm Modal */}
<ConfirmModal
show={confirmModal.show}
message={confirmModal.campaign ? t('campaigns.deleteConfirm', { name: confirmModal.campaign.name }) : ''}
variant="danger"
onConfirm={executeDelete}
onCancel={() => setConfirmModal({ show: false, campaign: null })}
/>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { productSheetService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import ProductSheetModal from './ProductSheetModal';
import ConfirmModal from '../ConfirmModal';
const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
const { t } = useTranslation();
@ -12,6 +13,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
const [editingSheet, setEditingSheet] = useState(null);
const [deleting, setDeleting] = useState(null);
const [filter, setFilter] = useState({ category: '', active: 'all' });
const [confirmModal, setConfirmModal] = useState({ show: false, sheet: null });
const handleCreate = () => {
setEditingSheet(null);
@ -33,7 +35,12 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
};
const handleDelete = async (sheet) => {
if (!window.confirm(t('business.products.confirmDelete'))) return;
setConfirmModal({ show: true, sheet });
};
const executeDelete = async () => {
const sheet = confirmModal.sheet;
setConfirmModal({ show: false, sheet: null });
setDeleting(sheet.id);
try {
@ -320,6 +327,15 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
onClose={() => setShowModal(false)}
/>
)}
{/* Confirm Modal */}
<ConfirmModal
show={confirmModal.show}
message={t('business.products.confirmDelete')}
variant="danger"
onConfirm={executeDelete}
onCancel={() => setConfirmModal({ show: false, sheet: null })}
/>
</>
);
};

View File

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { serviceSheetService } from '../../services/api';
import useFormatters from '../../hooks/useFormatters';
import ServiceSheetModal from './ServiceSheetModal';
import ConfirmModal from '../ConfirmModal';
const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
const { t } = useTranslation();
@ -12,6 +13,7 @@ const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
const [editingSheet, setEditingSheet] = useState(null);
const [deleting, setDeleting] = useState(null);
const [filter, setFilter] = useState({ category: '', active: 'all' });
const [confirmModal, setConfirmModal] = useState({ show: false, sheet: null });
// Filtrar configurações que permitem serviços
const serviceSettings = settings.filter(s => s.business_type === 'services' || s.business_type === 'both');
@ -40,7 +42,12 @@ const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
};
const handleDelete = async (sheet) => {
if (!window.confirm(t('business.services.confirmDelete'))) return;
setConfirmModal({ show: true, sheet });
};
const executeDelete = async () => {
const sheet = confirmModal.sheet;
setConfirmModal({ show: false, sheet: null });
setDeleting(sheet.id);
try {
@ -310,6 +317,15 @@ const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
onClose={() => setShowModal(false)}
/>
)}
{/* Confirm Modal */}
<ConfirmModal
show={confirmModal.show}
message={t('business.services.confirmDelete')}
variant="danger"
onConfirm={executeDelete}
onCancel={() => setConfirmModal({ show: false, sheet: null })}
/>
</>
);
};

View File

@ -84,12 +84,15 @@ const OverdueWidget = () => {
};
return (
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-3"
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-2"
style={{ background: 'transparent', borderBottom: '1px solid rgba(239, 68, 68, 0.3)' }}>
<h6 className="text-white mb-0 d-flex align-items-center">
<i className="bi bi-exclamation-triangle me-2 text-danger"></i>
{t('dashboard.overdueTransactions')}
{data?.summary?.total_items > 0 && (
<span className="badge bg-danger ms-2">{data.summary.total_items}</span>
)}
</h6>
<button
className="btn btn-sm btn-outline-secondary border-0"
@ -100,7 +103,7 @@ const OverdueWidget = () => {
</button>
</div>
<div className="card-body py-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<div className="card-body py-3">
{loading ? (
<div className="text-center py-4">
<div className="spinner-border spinner-border-sm text-danger" role="status">
@ -108,23 +111,21 @@ const OverdueWidget = () => {
</div>
</div>
) : !data?.items?.length ? (
<div className="text-center text-slate-400 py-4">
<i className="bi bi-check-circle fs-1 mb-2 d-block text-success"></i>
<div className="text-center text-slate-400 py-3">
<i className="bi bi-check-circle fs-2 mb-2 d-block text-success"></i>
<p className="small mb-0">{t('dashboard.noOverdueTransactions')}</p>
</div>
) : (
<>
<div className="row g-3">
{data.by_range.map((range) => (
<div key={range.key} className="mb-2">
{/* Header da faixa (clicável) */}
<div key={range.key} className="col-md-6 col-lg-3">
{/* Header da faixa */}
<div
className="d-flex align-items-center justify-content-between p-2 rounded cursor-pointer"
className="d-flex align-items-center justify-content-between p-2 rounded-top"
style={{
background: `${getRangeColor(range.key)}15`,
borderLeft: `3px solid ${getRangeColor(range.key)}`,
cursor: 'pointer',
background: `${getRangeColor(range.key)}20`,
borderBottom: `2px solid ${getRangeColor(range.key)}`,
}}
onClick={() => toggleRange(range.key)}
>
<div className="d-flex align-items-center gap-2">
<i
@ -134,34 +135,39 @@ const OverdueWidget = () => {
<span className="text-white small fw-semibold">
{t(`dashboard.overdueRange.${range.key}`)}
</span>
<span
className="badge rounded-pill"
style={{
background: getRangeColor(range.key),
fontSize: '10px',
padding: '2px 6px',
}}
>
{range.count}
</span>
</div>
<div className="d-flex align-items-center gap-2">
<span className="badge bg-danger" style={{ fontSize: '10px' }}>
{range.count} {range.count === 1 ? t('common.item') : t('common.items')}
</span>
<i className={`bi bi-chevron-${expandedRange === range.key ? 'up' : 'down'} text-slate-500`}></i>
</div>
<span
className="badge rounded-pill"
style={{
background: getRangeColor(range.key),
fontSize: '10px',
padding: '2px 8px',
}}
>
{range.count}
</span>
</div>
{/* Items da faixa (expandível) */}
{expandedRange === range.key && (
<div className="mt-1">
{range.items.map((item) => (
{/* Items da faixa */}
<div
className="rounded-bottom p-2"
style={{
background: 'rgba(30, 41, 59, 0.5)',
maxHeight: '200px',
overflowY: 'auto',
}}
>
{range.items.length === 0 ? (
<div className="text-center text-slate-500 py-2">
<small>-</small>
</div>
) : (
range.items.map((item) => (
<div
key={`${item.type}-${item.id}`}
className="d-flex align-items-center gap-2 py-2 px-2 rounded mb-1 ms-2"
className="d-flex align-items-center gap-2 py-2 px-2 rounded mb-1"
style={{
background: 'rgba(30, 41, 59, 0.5)',
background: 'rgba(15, 23, 42, 0.5)',
borderLeft: `3px solid ${getTypeColor(item)}`,
cursor: 'pointer',
}}
@ -171,98 +177,41 @@ const OverdueWidget = () => {
<div
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
style={{
width: '28px',
height: '28px',
width: '24px',
height: '24px',
background: `${getTypeColor(item)}20`,
color: getTypeColor(item),
}}
>
<i className={`bi ${getTypeIcon(item)}`} style={{ fontSize: '12px' }}></i>
<i className={`bi ${getTypeIcon(item)}`} style={{ fontSize: '10px' }}></i>
</div>
{/* Info */}
<div className="flex-grow-1 min-width-0">
<div className="text-white small text-truncate" title={item.description}>
<div className="text-white small text-truncate" style={{ fontSize: '11px' }} title={item.description}>
{item.description}
</div>
<div className="d-flex align-items-center gap-2">
<small className="text-slate-500" style={{ fontSize: '10px' }}>
{item.planned_date_formatted}
</small>
<div className="d-flex align-items-center gap-1 flex-wrap">
<span
className="badge bg-danger"
style={{ fontSize: '9px', padding: '1px 4px' }}
style={{ fontSize: '8px', padding: '1px 3px' }}
>
{item.days_overdue} {t('dashboard.daysLate')}
{item.days_overdue}d
</span>
<span className={`fw-bold ${item.transaction_type === 'credit' ? 'text-success' : 'text-danger'}`} style={{ fontSize: '10px' }}>
{currency(item.amount, item.currency || 'EUR')}
</span>
{item.type === 'recurring' && (
<span
className="badge bg-warning text-dark"
style={{ fontSize: '8px', padding: '1px 4px' }}
>
#{item.occurrence_number}
</span>
)}
{item.type === 'liability' && (
<span
className="badge text-white"
style={{ fontSize: '8px', padding: '1px 4px', background: '#8b5cf6' }}
>
<i className="bi bi-credit-card-2-back me-1" style={{ fontSize: '8px' }}></i>
#{item.installment_number}
</span>
)}
</div>
</div>
{/* Valor */}
<div className={`fw-bold small text-end ${
item.transaction_type === 'credit' ? 'text-success' : 'text-danger'
}`}>
{item.transaction_type === 'credit' ? '+' : '-'}
{currency(item.amount, item.currency || item.account?.currency || 'EUR')}
</div>
</div>
))}
</div>
)}
))
)}
</div>
</div>
))}
</>
</div>
)}
</div>
{/* Footer com resumo */}
{data?.summary && !loading && data.items?.length > 0 && (
<div className="card-footer border-0 py-2" style={{ background: 'rgba(239, 68, 68, 0.1)' }}>
<div className="row g-2 text-center">
<div className="col-4">
<small className="text-slate-500 d-block" style={{ fontSize: '10px' }}>
{t('dashboard.totalOverdue')}
</small>
<span className="text-danger fw-bold small">
{data.summary.total_items}
</span>
</div>
<div className="col-4">
<small className="text-slate-500 d-block" style={{ fontSize: '10px' }}>
{t('dashboard.avgDays')}
</small>
<span className="text-danger fw-bold small">
~{Math.round(data.summary.max_days_overdue / 2)} {t('common.days')}
</span>
</div>
<div className="col-4">
<small className="text-slate-500 d-block" style={{ fontSize: '10px' }}>
{t('dashboard.maxDelay')}
</small>
<span className="text-danger fw-bold small">
{data.summary.max_days_overdue} {t('common.days')}
</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -49,8 +49,8 @@ const UpcomingWidget = () => {
};
return (
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-3"
<div className="card border-0 d-flex flex-column" style={{ background: '#0f172a', height: '320px' }}>
<div className="card-header border-0 d-flex justify-content-between align-items-center py-2 flex-shrink-0"
style={{ background: 'transparent', borderBottom: '1px solid rgba(59, 130, 246, 0.2)' }}>
<h6 className="text-white mb-0 d-flex align-items-center">
<i className="bi bi-clock-history me-2 text-primary"></i>
@ -65,7 +65,7 @@ const UpcomingWidget = () => {
</button>
</div>
<div className="card-body py-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<div className="card-body py-2 flex-grow-1" style={{ overflowY: 'auto', minHeight: 0 }}>
{loading ? (
<div className="text-center py-4">
<div className="spinner-border spinner-border-sm text-primary" role="status">

View File

@ -302,10 +302,14 @@ const Dashboard = () => {
<CalendarWidget />
</div>
<div className="col-lg-4">
<div className="d-flex flex-column gap-4 h-100">
<UpcomingWidget />
<OverdueWidget />
</div>
<UpcomingWidget />
</div>
</div>
{/* Transacciones Vencidas - Fila independiente */}
<div className="row g-4 mb-4">
<div className="col-12">
<OverdueWidget />
</div>
</div>

View File

@ -8,6 +8,7 @@ import Toast from '../components/Toast';
import CreateRecurrenceModal from '../components/CreateRecurrenceModal';
import IconSelector from '../components/IconSelector';
import CategorySelector from '../components/CategorySelector';
import ConfirmModal from '../components/ConfirmModal';
export default function Transactions() {
const { t, i18n } = useTranslation();
@ -157,6 +158,14 @@ export default function Transactions() {
const [savingQuickItem, setSavingQuickItem] = useState(false);
const [quickCreateSource, setQuickCreateSource] = useState('form'); // 'form' ou 'batch'
// Estado para modal de confirmação
const [confirmModal, setConfirmModal] = useState({
show: false,
message: '',
onConfirm: null,
variant: 'danger',
});
// Estado para modal de categorização rápida individual
const [showQuickCategorizeModal, setShowQuickCategorizeModal] = useState(false);
const [quickCategorizeData, setQuickCategorizeData] = useState({
@ -426,14 +435,21 @@ export default function Transactions() {
// Cancelar transação
const handleCancel = async (transaction) => {
if (!window.confirm(t('transactions.confirmCancel'))) return;
try {
await transactionService.cancel(transaction.id);
showToast(t('transactions.cancelled'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
setConfirmModal({
show: true,
message: t('transactions.confirmCancel'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.cancel(transaction.id);
showToast(t('transactions.cancelled'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// Reverter para pendente
@ -460,14 +476,21 @@ export default function Transactions() {
// Excluir transação
const handleDelete = async (transaction) => {
if (!window.confirm(t('transactions.confirmDelete'))) return;
try {
await transactionService.delete(transaction.id);
showToast(t('transactions.deleted'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
setConfirmModal({
show: true,
message: t('transactions.confirmDelete'),
variant: 'danger',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.delete(transaction.id);
showToast(t('transactions.deleted'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// ========================================
@ -731,14 +754,21 @@ export default function Transactions() {
};
const handleUnsplit = async (transaction) => {
if (!window.confirm(t('transactions.unsplitConfirm'))) return;
try {
await transactionService.unsplit(transaction.id);
showToast(t('transactions.unsplitSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
setConfirmModal({
show: true,
message: t('transactions.unsplitConfirm'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.unsplit(transaction.id);
showToast(t('transactions.unsplitSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// ========================================
@ -752,14 +782,21 @@ export default function Transactions() {
};
const handleUnlinkTransfer = async (transfer) => {
if (!window.confirm(t('transactions.unlinkTransferConfirm'))) return;
try {
await transactionService.unlinkTransfer(transfer.debit_transaction_id || transfer.id);
showToast(t('transactions.unlinkTransferSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
setConfirmModal({
show: true,
message: t('transactions.unlinkTransferConfirm'),
variant: 'warning',
onConfirm: async () => {
setConfirmModal(prev => ({ ...prev, show: false }));
try {
await transactionService.unlinkTransfer(transfer.debit_transaction_id || transfer.id);
showToast(t('transactions.unlinkTransferSuccess'), 'success');
loadWeeklyData();
} catch (err) {
showToast(err.response?.data?.message || t('common.error'), 'danger');
}
}
});
};
// Converter transação em transferência
@ -2055,6 +2092,15 @@ export default function Transactions() {
onClose={() => setToast(prev => ({ ...prev, show: false }))}
/>
{/* Modal de Confirmação */}
<ConfirmModal
show={confirmModal.show}
message={confirmModal.message}
variant={confirmModal.variant}
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal(prev => ({ ...prev, show: false }))}
/>
{/* Modal de Transferência */}
<Modal
show={showTransferModal}
@ -2322,23 +2368,12 @@ export default function Transactions() {
{t('categories.category')}
</label>
<div className="input-group">
<select
className="form-select"
<CategorySelector
categories={categories}
value={batchFormData.category_id}
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
>
<option value="">{t('transactions.selectCategory')}</option>
{categories.filter(c => c.parent_id === null).map(parent => (
<optgroup key={parent.id} label={parent.name}>
{categories.filter(c => c.parent_id === parent.id).map(sub => (
<option key={sub.id} value={sub.id}>
{sub.name}
</option>
))}
</optgroup>
))}
</select>
placeholder={t('transactions.selectCategory')}
/>
<button
type="button"
className="btn btn-outline-primary"