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/).
|
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
|
## [1.31.1] - 2025-12-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -363,16 +363,15 @@ public function duplicate(Request $request, Transaction $transaction): JsonRespo
|
|||||||
return response()->json(['message' => 'Transação não encontrada'], 404);
|
return response()->json(['message' => 'Transação não encontrada'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$newTransaction = $transaction->replicate([
|
$newTransaction = $transaction->replicate();
|
||||||
'amount',
|
|
||||||
'effective_date',
|
|
||||||
'status',
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
// Resetear campos que no deben duplicarse
|
||||||
$newTransaction->status = 'pending';
|
$newTransaction->status = 'pending';
|
||||||
$newTransaction->amount = null;
|
$newTransaction->amount = null;
|
||||||
$newTransaction->effective_date = null;
|
$newTransaction->effective_date = null;
|
||||||
$newTransaction->planned_date = now()->toDateString();
|
$newTransaction->planned_date = now()->toDateString();
|
||||||
|
$newTransaction->import_hash = null; // IMPORTANTE: debe ser null para evitar duplicidad
|
||||||
|
|
||||||
$newTransaction->save();
|
$newTransaction->save();
|
||||||
|
|
||||||
return response()->json(
|
return response()->json(
|
||||||
|
|||||||
@ -24,20 +24,30 @@ export default function CategorySelector({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [expandedCategories, setExpandedCategories] = useState({});
|
const [expandedCategories, setExpandedCategories] = useState({});
|
||||||
const [searchTerm, setSearchTerm] = 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 containerRef = useRef(null);
|
||||||
const buttonRef = useRef(null);
|
const buttonRef = useRef(null);
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
const searchInputRef = 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 = () => {
|
const updateDropdownPosition = () => {
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
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({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY,
|
top: showAbove ? rect.top - Math.min(dropdownHeight, spaceAbove) : rect.bottom,
|
||||||
left: rect.left + window.scrollX,
|
left: rect.left,
|
||||||
width: rect.width
|
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,
|
left: dropdownPosition.left,
|
||||||
width: dropdownPosition.width,
|
width: dropdownPosition.width,
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
maxHeight: '400px',
|
maxHeight: dropdownPosition.maxHeight || 350,
|
||||||
minHeight: '200px',
|
minHeight: '150px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '0.5rem 0',
|
padding: '0.5rem 0',
|
||||||
boxShadow: '0 6px 20px rgba(0,0,0,0.4)'
|
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 { businessSettingService } from '../../services/api';
|
||||||
import useFormatters from '../../hooks/useFormatters';
|
import useFormatters from '../../hooks/useFormatters';
|
||||||
import BusinessSettingModal from './BusinessSettingModal';
|
import BusinessSettingModal from './BusinessSettingModal';
|
||||||
|
import ConfirmModal from '../ConfirmModal';
|
||||||
|
|
||||||
const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
|
const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -11,6 +12,7 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
|
|||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingSetting, setEditingSetting] = useState(null);
|
const [editingSetting, setEditingSetting] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
|
const [confirmModal, setConfirmModal] = useState({ show: false, setting: null });
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingSetting(null);
|
setEditingSetting(null);
|
||||||
@ -23,7 +25,12 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (setting) => {
|
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);
|
setDeleting(setting.id);
|
||||||
try {
|
try {
|
||||||
@ -204,6 +211,15 @@ const BusinessSettingsTab = ({ settings, onCreated, onUpdated, onDeleted }) => {
|
|||||||
onClose={() => setShowModal(false)}
|
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 { campaignService } from '../../services/api';
|
||||||
import useFormatters from '../../hooks/useFormatters';
|
import useFormatters from '../../hooks/useFormatters';
|
||||||
import CampaignModal from './CampaignModal';
|
import CampaignModal from './CampaignModal';
|
||||||
|
import ConfirmModal from '../ConfirmModal';
|
||||||
|
|
||||||
const CampaignsTab = ({ sheets }) => {
|
const CampaignsTab = ({ sheets }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -15,6 +16,7 @@ const CampaignsTab = ({ sheets }) => {
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingCampaign, setEditingCampaign] = useState(null);
|
const [editingCampaign, setEditingCampaign] = useState(null);
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [confirmModal, setConfirmModal] = useState({ show: false, campaign: null });
|
||||||
|
|
||||||
// Carregar dados
|
// Carregar dados
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@ -59,9 +61,12 @@ const CampaignsTab = ({ sheets }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (campaign) => {
|
const handleDelete = async (campaign) => {
|
||||||
if (!window.confirm(t('campaigns.deleteConfirm', { name: campaign.name }))) {
|
setConfirmModal({ show: true, campaign });
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
const executeDelete = async () => {
|
||||||
|
const campaign = confirmModal.campaign;
|
||||||
|
setConfirmModal({ show: false, campaign: null });
|
||||||
try {
|
try {
|
||||||
await campaignService.delete(campaign.id);
|
await campaignService.delete(campaign.id);
|
||||||
setCampaigns(prev => prev.filter(c => c.id !== campaign.id));
|
setCampaigns(prev => prev.filter(c => c.id !== campaign.id));
|
||||||
@ -320,6 +325,15 @@ const CampaignsTab = ({ sheets }) => {
|
|||||||
presets={presets}
|
presets={presets}
|
||||||
sheets={sheets}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { productSheetService } from '../../services/api';
|
import { productSheetService } from '../../services/api';
|
||||||
import useFormatters from '../../hooks/useFormatters';
|
import useFormatters from '../../hooks/useFormatters';
|
||||||
import ProductSheetModal from './ProductSheetModal';
|
import ProductSheetModal from './ProductSheetModal';
|
||||||
|
import ConfirmModal from '../ConfirmModal';
|
||||||
|
|
||||||
const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
|
const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -12,6 +13,7 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
|
|||||||
const [editingSheet, setEditingSheet] = useState(null);
|
const [editingSheet, setEditingSheet] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
const [filter, setFilter] = useState({ category: '', active: 'all' });
|
const [filter, setFilter] = useState({ category: '', active: 'all' });
|
||||||
|
const [confirmModal, setConfirmModal] = useState({ show: false, sheet: null });
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingSheet(null);
|
setEditingSheet(null);
|
||||||
@ -33,7 +35,12 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (sheet) => {
|
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);
|
setDeleting(sheet.id);
|
||||||
try {
|
try {
|
||||||
@ -320,6 +327,15 @@ const ProductSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
|
|||||||
onClose={() => setShowModal(false)}
|
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 { serviceSheetService } from '../../services/api';
|
||||||
import useFormatters from '../../hooks/useFormatters';
|
import useFormatters from '../../hooks/useFormatters';
|
||||||
import ServiceSheetModal from './ServiceSheetModal';
|
import ServiceSheetModal from './ServiceSheetModal';
|
||||||
|
import ConfirmModal from '../ConfirmModal';
|
||||||
|
|
||||||
const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
|
const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -12,6 +13,7 @@ const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
|
|||||||
const [editingSheet, setEditingSheet] = useState(null);
|
const [editingSheet, setEditingSheet] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(null);
|
const [deleting, setDeleting] = useState(null);
|
||||||
const [filter, setFilter] = useState({ category: '', active: 'all' });
|
const [filter, setFilter] = useState({ category: '', active: 'all' });
|
||||||
|
const [confirmModal, setConfirmModal] = useState({ show: false, sheet: null });
|
||||||
|
|
||||||
// Filtrar configurações que permitem serviços
|
// Filtrar configurações que permitem serviços
|
||||||
const serviceSettings = settings.filter(s => s.business_type === 'services' || s.business_type === 'both');
|
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) => {
|
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);
|
setDeleting(sheet.id);
|
||||||
try {
|
try {
|
||||||
@ -310,6 +317,15 @@ const ServiceSheetsTab = ({ sheets, settings, onCreated, onUpdated, onDeleted })
|
|||||||
onClose={() => setShowModal(false)}
|
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 (
|
return (
|
||||||
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
<div className="card-header border-0 d-flex justify-content-between align-items-center py-3"
|
<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)' }}>
|
style={{ background: 'transparent', borderBottom: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
<h6 className="text-white mb-0 d-flex align-items-center">
|
<h6 className="text-white mb-0 d-flex align-items-center">
|
||||||
<i className="bi bi-exclamation-triangle me-2 text-danger"></i>
|
<i className="bi bi-exclamation-triangle me-2 text-danger"></i>
|
||||||
{t('dashboard.overdueTransactions')}
|
{t('dashboard.overdueTransactions')}
|
||||||
|
{data?.summary?.total_items > 0 && (
|
||||||
|
<span className="badge bg-danger ms-2">{data.summary.total_items}</span>
|
||||||
|
)}
|
||||||
</h6>
|
</h6>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-outline-secondary border-0"
|
className="btn btn-sm btn-outline-secondary border-0"
|
||||||
@ -100,7 +103,7 @@ const OverdueWidget = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-body py-2" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
<div className="card-body py-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="spinner-border spinner-border-sm text-danger" role="status">
|
<div className="spinner-border spinner-border-sm text-danger" role="status">
|
||||||
@ -108,23 +111,21 @@ const OverdueWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : !data?.items?.length ? (
|
) : !data?.items?.length ? (
|
||||||
<div className="text-center text-slate-400 py-4">
|
<div className="text-center text-slate-400 py-3">
|
||||||
<i className="bi bi-check-circle fs-1 mb-2 d-block text-success"></i>
|
<i className="bi bi-check-circle fs-2 mb-2 d-block text-success"></i>
|
||||||
<p className="small mb-0">{t('dashboard.noOverdueTransactions')}</p>
|
<p className="small mb-0">{t('dashboard.noOverdueTransactions')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="row g-3">
|
||||||
{data.by_range.map((range) => (
|
{data.by_range.map((range) => (
|
||||||
<div key={range.key} className="mb-2">
|
<div key={range.key} className="col-md-6 col-lg-3">
|
||||||
{/* Header da faixa (clicável) */}
|
{/* Header da faixa */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: `${getRangeColor(range.key)}15`,
|
background: `${getRangeColor(range.key)}20`,
|
||||||
borderLeft: `3px solid ${getRangeColor(range.key)}`,
|
borderBottom: `2px solid ${getRangeColor(range.key)}`,
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => toggleRange(range.key)}
|
|
||||||
>
|
>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
<i
|
<i
|
||||||
@ -134,34 +135,39 @@ const OverdueWidget = () => {
|
|||||||
<span className="text-white small fw-semibold">
|
<span className="text-white small fw-semibold">
|
||||||
{t(`dashboard.overdueRange.${range.key}`)}
|
{t(`dashboard.overdueRange.${range.key}`)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className="badge rounded-pill"
|
className="badge rounded-pill"
|
||||||
style={{
|
style={{
|
||||||
background: getRangeColor(range.key),
|
background: getRangeColor(range.key),
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
padding: '2px 6px',
|
padding: '2px 8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{range.count}
|
{range.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items da faixa (expandível) */}
|
{/* Items da faixa */}
|
||||||
{expandedRange === range.key && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{range.items.map((item) => (
|
|
||||||
<div
|
<div
|
||||||
key={`${item.type}-${item.id}`}
|
className="rounded-bottom p-2"
|
||||||
className="d-flex align-items-center gap-2 py-2 px-2 rounded mb-1 ms-2"
|
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(30, 41, 59, 0.5)',
|
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"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(15, 23, 42, 0.5)',
|
||||||
borderLeft: `3px solid ${getTypeColor(item)}`,
|
borderLeft: `3px solid ${getTypeColor(item)}`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
@ -171,98 +177,41 @@ const OverdueWidget = () => {
|
|||||||
<div
|
<div
|
||||||
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
className="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
width: '28px',
|
width: '24px',
|
||||||
height: '28px',
|
height: '24px',
|
||||||
background: `${getTypeColor(item)}20`,
|
background: `${getTypeColor(item)}20`,
|
||||||
color: getTypeColor(item),
|
color: getTypeColor(item),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className={`bi ${getTypeIcon(item)}`} style={{ fontSize: '12px' }}></i>
|
<i className={`bi ${getTypeIcon(item)}`} style={{ fontSize: '10px' }}></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-grow-1 min-width-0">
|
<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}
|
{item.description}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-1 flex-wrap">
|
||||||
<small className="text-slate-500" style={{ fontSize: '10px' }}>
|
|
||||||
{item.planned_date_formatted}
|
|
||||||
</small>
|
|
||||||
<span
|
<span
|
||||||
className="badge bg-danger"
|
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>
|
||||||
{item.type === 'recurring' && (
|
<span className={`fw-bold ${item.transaction_type === 'credit' ? 'text-success' : 'text-danger'}`} style={{ fontSize: '10px' }}>
|
||||||
<span
|
{currency(item.amount, item.currency || 'EUR')}
|
||||||
className="badge bg-warning text-dark"
|
|
||||||
style={{ fontSize: '8px', padding: '1px 4px' }}
|
|
||||||
>
|
|
||||||
#{item.occurrence_number}
|
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -49,8 +49,8 @@ const UpcomingWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
<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-3"
|
<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)' }}>
|
style={{ background: 'transparent', borderBottom: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||||
<h6 className="text-white mb-0 d-flex align-items-center">
|
<h6 className="text-white mb-0 d-flex align-items-center">
|
||||||
<i className="bi bi-clock-history me-2 text-primary"></i>
|
<i className="bi bi-clock-history me-2 text-primary"></i>
|
||||||
@ -65,7 +65,7 @@ const UpcomingWidget = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
|||||||
@ -302,11 +302,15 @@ const Dashboard = () => {
|
|||||||
<CalendarWidget />
|
<CalendarWidget />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-4">
|
<div className="col-lg-4">
|
||||||
<div className="d-flex flex-column gap-4 h-100">
|
|
||||||
<UpcomingWidget />
|
<UpcomingWidget />
|
||||||
<OverdueWidget />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Transacciones Vencidas - Fila independiente */}
|
||||||
|
<div className="row g-4 mb-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<OverdueWidget />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
{/* Main Content Grid */}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import Toast from '../components/Toast';
|
|||||||
import CreateRecurrenceModal from '../components/CreateRecurrenceModal';
|
import CreateRecurrenceModal from '../components/CreateRecurrenceModal';
|
||||||
import IconSelector from '../components/IconSelector';
|
import IconSelector from '../components/IconSelector';
|
||||||
import CategorySelector from '../components/CategorySelector';
|
import CategorySelector from '../components/CategorySelector';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
|
||||||
export default function Transactions() {
|
export default function Transactions() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@ -157,6 +158,14 @@ export default function Transactions() {
|
|||||||
const [savingQuickItem, setSavingQuickItem] = useState(false);
|
const [savingQuickItem, setSavingQuickItem] = useState(false);
|
||||||
const [quickCreateSource, setQuickCreateSource] = useState('form'); // 'form' ou 'batch'
|
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
|
// Estado para modal de categorização rápida individual
|
||||||
const [showQuickCategorizeModal, setShowQuickCategorizeModal] = useState(false);
|
const [showQuickCategorizeModal, setShowQuickCategorizeModal] = useState(false);
|
||||||
const [quickCategorizeData, setQuickCategorizeData] = useState({
|
const [quickCategorizeData, setQuickCategorizeData] = useState({
|
||||||
@ -426,7 +435,12 @@ export default function Transactions() {
|
|||||||
|
|
||||||
// Cancelar transação
|
// Cancelar transação
|
||||||
const handleCancel = async (transaction) => {
|
const handleCancel = async (transaction) => {
|
||||||
if (!window.confirm(t('transactions.confirmCancel'))) return;
|
setConfirmModal({
|
||||||
|
show: true,
|
||||||
|
message: t('transactions.confirmCancel'),
|
||||||
|
variant: 'warning',
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmModal(prev => ({ ...prev, show: false }));
|
||||||
try {
|
try {
|
||||||
await transactionService.cancel(transaction.id);
|
await transactionService.cancel(transaction.id);
|
||||||
showToast(t('transactions.cancelled'), 'success');
|
showToast(t('transactions.cancelled'), 'success');
|
||||||
@ -434,6 +448,8 @@ export default function Transactions() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reverter para pendente
|
// Reverter para pendente
|
||||||
@ -460,7 +476,12 @@ export default function Transactions() {
|
|||||||
|
|
||||||
// Excluir transação
|
// Excluir transação
|
||||||
const handleDelete = async (transaction) => {
|
const handleDelete = async (transaction) => {
|
||||||
if (!window.confirm(t('transactions.confirmDelete'))) return;
|
setConfirmModal({
|
||||||
|
show: true,
|
||||||
|
message: t('transactions.confirmDelete'),
|
||||||
|
variant: 'danger',
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmModal(prev => ({ ...prev, show: false }));
|
||||||
try {
|
try {
|
||||||
await transactionService.delete(transaction.id);
|
await transactionService.delete(transaction.id);
|
||||||
showToast(t('transactions.deleted'), 'success');
|
showToast(t('transactions.deleted'), 'success');
|
||||||
@ -468,6 +489,8 @@ export default function Transactions() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@ -731,7 +754,12 @@ export default function Transactions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnsplit = async (transaction) => {
|
const handleUnsplit = async (transaction) => {
|
||||||
if (!window.confirm(t('transactions.unsplitConfirm'))) return;
|
setConfirmModal({
|
||||||
|
show: true,
|
||||||
|
message: t('transactions.unsplitConfirm'),
|
||||||
|
variant: 'warning',
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmModal(prev => ({ ...prev, show: false }));
|
||||||
try {
|
try {
|
||||||
await transactionService.unsplit(transaction.id);
|
await transactionService.unsplit(transaction.id);
|
||||||
showToast(t('transactions.unsplitSuccess'), 'success');
|
showToast(t('transactions.unsplitSuccess'), 'success');
|
||||||
@ -739,6 +767,8 @@ export default function Transactions() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@ -752,7 +782,12 @@ export default function Transactions() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlinkTransfer = async (transfer) => {
|
const handleUnlinkTransfer = async (transfer) => {
|
||||||
if (!window.confirm(t('transactions.unlinkTransferConfirm'))) return;
|
setConfirmModal({
|
||||||
|
show: true,
|
||||||
|
message: t('transactions.unlinkTransferConfirm'),
|
||||||
|
variant: 'warning',
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmModal(prev => ({ ...prev, show: false }));
|
||||||
try {
|
try {
|
||||||
await transactionService.unlinkTransfer(transfer.debit_transaction_id || transfer.id);
|
await transactionService.unlinkTransfer(transfer.debit_transaction_id || transfer.id);
|
||||||
showToast(t('transactions.unlinkTransferSuccess'), 'success');
|
showToast(t('transactions.unlinkTransferSuccess'), 'success');
|
||||||
@ -760,6 +795,8 @@ export default function Transactions() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
showToast(err.response?.data?.message || t('common.error'), 'danger');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Converter transação em transferência
|
// Converter transação em transferência
|
||||||
@ -2055,6 +2092,15 @@ export default function Transactions() {
|
|||||||
onClose={() => setToast(prev => ({ ...prev, show: false }))}
|
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 de Transferência */}
|
||||||
<Modal
|
<Modal
|
||||||
show={showTransferModal}
|
show={showTransferModal}
|
||||||
@ -2322,23 +2368,12 @@ export default function Transactions() {
|
|||||||
{t('categories.category')}
|
{t('categories.category')}
|
||||||
</label>
|
</label>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<select
|
<CategorySelector
|
||||||
className="form-select"
|
categories={categories}
|
||||||
value={batchFormData.category_id}
|
value={batchFormData.category_id}
|
||||||
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
|
onChange={(e) => setBatchFormData({ ...batchFormData, category_id: e.target.value })}
|
||||||
style={{ background: '#0f172a', color: '#e2e8f0', border: '1px solid #334155' }}
|
placeholder={t('transactions.selectCategory')}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user