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')} + />