v1.31.2: ConfirmModal, CategorySelector en batch modal, OverdueWidget horizontal, fix duplicate transaction
This commit is contained in:
parent
1c864463d6
commit
10d2f81649
26
CHANGELOG.md
26
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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)'
|
||||
|
||||
72
frontend/src/components/ConfirmModal.jsx
Normal file
72
frontend/src/components/ConfirmModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user