diff --git a/CHANGELOG.md b/CHANGELOG.md
index 760a2ac..397164b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/VERSION b/VERSION
index 6bae540..3492b09 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.31.1
+1.31.2
diff --git a/backend/app/Http/Controllers/Api/TransactionController.php b/backend/app/Http/Controllers/Api/TransactionController.php
index 0c3cc91..fbb7bcc 100644
--- a/backend/app/Http/Controllers/Api/TransactionController.php
+++ b/backend/app/Http/Controllers/Api/TransactionController.php
@@ -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(
diff --git a/frontend/src/components/CategorySelector.jsx b/frontend/src/components/CategorySelector.jsx
index 32e3b96..f87737c 100644
--- a/frontend/src/components/CategorySelector.jsx
+++ b/frontend/src/components/CategorySelector.jsx
@@ -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)'
diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx
new file mode 100644
index 0000000..554ec86
--- /dev/null
+++ b/frontend/src/components/ConfirmModal.jsx
@@ -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 (
+
+
+
+
+
+ {title &&
{title}
}
+
{message}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/business/BusinessSettingsTab.jsx b/frontend/src/components/business/BusinessSettingsTab.jsx
index 882d970..58ed0bc 100644
--- a/frontend/src/components/business/BusinessSettingsTab.jsx
+++ b/frontend/src/components/business/BusinessSettingsTab.jsx
@@ -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 */}
+ setConfirmModal({ show: false, setting: null })}
+ />
>
);
};
diff --git a/frontend/src/components/business/CampaignsTab.jsx b/frontend/src/components/business/CampaignsTab.jsx
index 083f169..fa5793e 100644
--- a/frontend/src/components/business/CampaignsTab.jsx
+++ b/frontend/src/components/business/CampaignsTab.jsx
@@ -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 */}
+ setConfirmModal({ show: false, campaign: null })}
+ />
);
};
diff --git a/frontend/src/components/business/ProductSheetsTab.jsx b/frontend/src/components/business/ProductSheetsTab.jsx
index 8a42c2d..7d95bf8 100644
--- a/frontend/src/components/business/ProductSheetsTab.jsx
+++ b/frontend/src/components/business/ProductSheetsTab.jsx
@@ -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 */}
+ setConfirmModal({ show: false, sheet: null })}
+ />
>
);
};
diff --git a/frontend/src/components/business/ServiceSheetsTab.jsx b/frontend/src/components/business/ServiceSheetsTab.jsx
index e58a64a..4cad0c2 100644
--- a/frontend/src/components/business/ServiceSheetsTab.jsx
+++ b/frontend/src/components/business/ServiceSheetsTab.jsx
@@ -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 */}
+ setConfirmModal({ show: false, sheet: null })}
+ />
>
);
};
diff --git a/frontend/src/components/dashboard/OverdueWidget.jsx b/frontend/src/components/dashboard/OverdueWidget.jsx
index 851a1b8..a956f3e 100644
--- a/frontend/src/components/dashboard/OverdueWidget.jsx
+++ b/frontend/src/components/dashboard/OverdueWidget.jsx
@@ -84,12 +84,15 @@ const OverdueWidget = () => {
};
return (
-
-
+
{t('dashboard.overdueTransactions')}
+ {data?.summary?.total_items > 0 && (
+ {data.summary.total_items}
+ )}
-
+
{loading ? (
@@ -108,23 +111,21 @@ const OverdueWidget = () => {
) : !data?.items?.length ? (
-
-
+
+
{t('dashboard.noOverdueTransactions')}
) : (
- <>
+
{data.by_range.map((range) => (
-
- {/* Header da faixa (clicável) */}
+
+ {/* Header da faixa */}
toggleRange(range.key)}
>
{
{t(`dashboard.overdueRange.${range.key}`)}
-
- {range.count}
-
-
-
-
- {range.count} {range.count === 1 ? t('common.item') : t('common.items')}
-
-
+
+ {range.count}
+
- {/* Items da faixa (expandível) */}
- {expandedRange === range.key && (
-
- {range.items.map((item) => (
+ {/* Items da faixa */}
+
+ {range.items.length === 0 ? (
+
+ -
+
+ ) : (
+ range.items.map((item) => (
{
-
+
{/* Info */}
-
+
{item.description}
-
-
- {item.planned_date_formatted}
-
+
- {item.days_overdue} {t('dashboard.daysLate')}
+ {item.days_overdue}d
+
+
+ {currency(item.amount, item.currency || 'EUR')}
- {item.type === 'recurring' && (
-
- #{item.occurrence_number}
-
- )}
- {item.type === 'liability' && (
-
-
- #{item.installment_number}
-
- )}
-
- {/* Valor */}
-
- {item.transaction_type === 'credit' ? '+' : '-'}
- {currency(item.amount, item.currency || item.account?.currency || 'EUR')}
-
- ))}
-
- )}
+ ))
+ )}
+
))}
- >
+
)}
-
- {/* Footer com resumo */}
- {data?.summary && !loading && data.items?.length > 0 && (
-
-
-
-
- {t('dashboard.totalOverdue')}
-
-
- {data.summary.total_items}
-
-
-
-
- {t('dashboard.avgDays')}
-
-
- ~{Math.round(data.summary.max_days_overdue / 2)} {t('common.days')}
-
-
-
-
- {t('dashboard.maxDelay')}
-
-
- {data.summary.max_days_overdue} {t('common.days')}
-
-
-
-
- )}
);
};
diff --git a/frontend/src/components/dashboard/UpcomingWidget.jsx b/frontend/src/components/dashboard/UpcomingWidget.jsx
index 3cfd7ac..0d0ba70 100644
--- a/frontend/src/components/dashboard/UpcomingWidget.jsx
+++ b/frontend/src/components/dashboard/UpcomingWidget.jsx
@@ -49,8 +49,8 @@ const UpcomingWidget = () => {
};
return (
-
-
+
@@ -65,7 +65,7 @@ const UpcomingWidget = () => {
-
+
{loading ? (
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index ac655fb..8f61ee7 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -302,10 +302,14 @@ const Dashboard = () => {
+
+
+ {/* Transacciones Vencidas - Fila independiente */}
+
diff --git a/frontend/src/pages/TransactionsByWeek.jsx b/frontend/src/pages/TransactionsByWeek.jsx
index 7a769db..4d137b2 100644
--- a/frontend/src/pages/TransactionsByWeek.jsx
+++ b/frontend/src/pages/TransactionsByWeek.jsx
@@ -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 */}
+
setConfirmModal(prev => ({ ...prev, show: false }))}
+ />
+
{/* Modal de Transferência */}
-
+ placeholder={t('transactions.selectCategory')}
+ />