- Redesigned category create/edit modal with elegant wizard-style UI - Redesigned batch categorization modal with visual cards and better preview - Added missing i18n translations (common.continue, creating, remove) - Added budgets.general and wizard translations for ES, PT-BR, EN - Fixed 3 demo user transactions that were missing categories
962 lines
42 KiB
JavaScript
962 lines
42 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { categoryService } from '../services/api';
|
|
import { useToast } from '../components/Toast';
|
|
import { ConfirmModal } from '../components/Modal';
|
|
import IconSelector from '../components/IconSelector';
|
|
|
|
const Categories = () => {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [categories, setCategories] = useState([]);
|
|
const [flatCategories, setFlatCategories] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [selectedItem, setSelectedItem] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [newKeyword, setNewKeyword] = useState('');
|
|
const [filter, setFilter] = useState({ type: '' });
|
|
const [expandedCategories, setExpandedCategories] = useState({});
|
|
|
|
// Estados para categorização em lote
|
|
const [showBatchModal, setShowBatchModal] = useState(false);
|
|
const [batchPreview, setBatchPreview] = useState(null);
|
|
const [loadingBatch, setLoadingBatch] = useState(false);
|
|
const [executingBatch, setExecutingBatch] = useState(false);
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
parent_id: '',
|
|
type: 'expense',
|
|
description: '',
|
|
color: '#3B82F6',
|
|
icon: 'bi-tag',
|
|
is_active: true,
|
|
keywords: [],
|
|
});
|
|
|
|
const categoryTypes = categoryService.types;
|
|
|
|
useEffect(() => {
|
|
loadCategories();
|
|
}, [filter]);
|
|
|
|
const loadCategories = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params = {};
|
|
if (filter.type) params.type = filter.type;
|
|
|
|
// Carregar hierárquicas
|
|
const response = await categoryService.getAll(params);
|
|
if (response.success) {
|
|
setCategories(response.data);
|
|
}
|
|
|
|
// Carregar flat para o select de parent
|
|
const flatResponse = await categoryService.getAll({ flat: true });
|
|
if (flatResponse.success) {
|
|
setFlatCategories(flatResponse.data.filter(c => !c.parent_id)); // Apenas categorias raiz
|
|
}
|
|
} catch (error) {
|
|
toast.error(t('categories.loadError'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const toggleExpand = (categoryId) => {
|
|
setExpandedCategories(prev => ({
|
|
...prev,
|
|
[categoryId]: !prev[categoryId],
|
|
}));
|
|
};
|
|
|
|
const handleOpenModal = (item = null, parentId = null) => {
|
|
if (item) {
|
|
setSelectedItem(item);
|
|
setFormData({
|
|
name: item.name || '',
|
|
parent_id: item.parent_id?.toString() || '',
|
|
type: item.type || 'expense',
|
|
description: item.description || '',
|
|
color: item.color || '#3B82F6',
|
|
icon: item.icon || 'bi-tag',
|
|
is_active: item.is_active ?? true,
|
|
keywords: item.keywords?.map(k => k.keyword) || [],
|
|
});
|
|
} else {
|
|
setSelectedItem(null);
|
|
setFormData({
|
|
name: '',
|
|
parent_id: parentId?.toString() || '',
|
|
type: 'expense',
|
|
description: '',
|
|
color: '#3B82F6',
|
|
icon: 'bi-tag',
|
|
is_active: true,
|
|
keywords: [],
|
|
});
|
|
}
|
|
setNewKeyword('');
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setShowModal(false);
|
|
setSelectedItem(null);
|
|
setNewKeyword('');
|
|
};
|
|
|
|
// Funções de categorização em lote
|
|
const handleOpenBatchModal = async () => {
|
|
setShowBatchModal(true);
|
|
setLoadingBatch(true);
|
|
try {
|
|
const response = await categoryService.categorizeBatchPreview(true, 50);
|
|
if (response.success) {
|
|
setBatchPreview(response.data);
|
|
}
|
|
} catch (error) {
|
|
toast.error(t('categories.batchPreviewError') || 'Erro ao carregar preview');
|
|
} finally {
|
|
setLoadingBatch(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseBatchModal = () => {
|
|
setShowBatchModal(false);
|
|
setBatchPreview(null);
|
|
};
|
|
|
|
const handleExecuteBatch = async () => {
|
|
setExecutingBatch(true);
|
|
try {
|
|
const response = await categoryService.categorizeBatch(true);
|
|
if (response.success) {
|
|
toast.success(
|
|
`${t('categories.batchSuccess') || 'Categorização concluída'}: ${response.data.categorized} ${t('categories.categorized') || 'categorizadas'}`
|
|
);
|
|
handleCloseBatchModal();
|
|
}
|
|
} catch (error) {
|
|
toast.error(t('categories.batchError') || 'Erro ao categorizar');
|
|
} finally {
|
|
setExecutingBatch(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (e) => {
|
|
const { name, value, type, checked } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: type === 'checkbox' ? checked : value,
|
|
}));
|
|
};
|
|
|
|
const handleAddKeyword = () => {
|
|
const keyword = newKeyword.trim();
|
|
if (keyword && !formData.keywords.includes(keyword)) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
keywords: [...prev.keywords, keyword],
|
|
}));
|
|
setNewKeyword('');
|
|
}
|
|
};
|
|
|
|
const handleRemoveKeyword = (keyword) => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
keywords: prev.keywords.filter(k => k !== keyword),
|
|
}));
|
|
};
|
|
|
|
const handleKeywordKeyPress = (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleAddKeyword();
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.name.trim()) {
|
|
toast.error(t('validation.required'));
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const data = {
|
|
...formData,
|
|
parent_id: formData.parent_id ? parseInt(formData.parent_id) : null,
|
|
};
|
|
|
|
let response;
|
|
if (selectedItem) {
|
|
response = await categoryService.update(selectedItem.id, data);
|
|
} else {
|
|
response = await categoryService.create(data);
|
|
}
|
|
|
|
if (response.success) {
|
|
toast.success(selectedItem ? t('categories.updateSuccess') : t('categories.createSuccess'));
|
|
handleCloseModal();
|
|
loadCategories();
|
|
}
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.message || t('categories.createError'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteClick = (item) => {
|
|
setSelectedItem(item);
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
if (!selectedItem) return;
|
|
|
|
setSaving(true);
|
|
try {
|
|
const response = await categoryService.delete(selectedItem.id);
|
|
if (response.success) {
|
|
toast.success(t('categories.deleteSuccess'));
|
|
setShowDeleteModal(false);
|
|
setSelectedItem(null);
|
|
loadCategories();
|
|
}
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.message || t('categories.deleteError'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const getTypeColor = (type) => {
|
|
switch (type) {
|
|
case 'income': return 'success';
|
|
case 'expense': return 'danger';
|
|
case 'both': return 'info';
|
|
default: return 'secondary';
|
|
}
|
|
};
|
|
|
|
const renderCategory = (category, level = 0, parentColor = null) => {
|
|
const hasChildren = category.children && category.children.length > 0;
|
|
const isExpanded = expandedCategories[category.id];
|
|
// Subcategorias herdam a cor da categoria pai - garantir que sempre use a cor do pai se disponível
|
|
const displayColor = level > 0 && parentColor ? parentColor : (category.color || '#94a3b8');
|
|
|
|
return (
|
|
<div key={category.id}>
|
|
<div
|
|
className={`d-flex align-items-center p-3 ${level > 0 ? 'ms-4' : ''}`}
|
|
style={{
|
|
borderBottom: '1px solid #334155',
|
|
backgroundColor: level > 0 ? '#1a2332' : 'transparent',
|
|
}}
|
|
>
|
|
{/* Expand Button */}
|
|
<div style={{ width: '30px' }}>
|
|
{hasChildren && (
|
|
<button
|
|
className="btn btn-link text-slate-400 p-0"
|
|
onClick={() => toggleExpand(category.id)}
|
|
>
|
|
<i className={`bi ${isExpanded ? 'bi-chevron-down' : 'bi-chevron-right'}`}></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Icon & Name */}
|
|
<div className="d-flex align-items-center flex-grow-1">
|
|
<div
|
|
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
style={{
|
|
width: '35px',
|
|
height: '35px',
|
|
backgroundColor: displayColor + '25',
|
|
}}
|
|
>
|
|
<i className={`bi ${category.icon}`} style={{ color: displayColor }}></i>
|
|
</div>
|
|
<div>
|
|
<div className="text-white fw-medium">{category.name}</div>
|
|
{category.keywords && category.keywords.length > 0 && (
|
|
<small className="text-slate-500">
|
|
{category.keywords.length} {t('categories.keywords')}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type Badge */}
|
|
<div className="me-3">
|
|
<span className={`badge bg-${getTypeColor(category.type)} bg-opacity-25 text-${getTypeColor(category.type)}`}>
|
|
{categoryTypes[category.type]}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="me-3">
|
|
{category.is_active ? (
|
|
<span className="badge bg-success">{t('common.active')}</span>
|
|
) : (
|
|
<span className="badge bg-secondary">{t('common.inactive')}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="d-flex gap-1">
|
|
<button
|
|
className="btn btn-link text-success p-1"
|
|
onClick={() => handleOpenModal(null, category.id)}
|
|
title={t('categories.createSubcategory')}
|
|
>
|
|
<i className="bi bi-plus-circle"></i>
|
|
</button>
|
|
<button
|
|
className="btn btn-link text-info p-1"
|
|
onClick={() => handleOpenModal(category)}
|
|
title={t('common.edit')}
|
|
>
|
|
<i className="bi bi-pencil"></i>
|
|
</button>
|
|
{!category.is_system && (
|
|
<button
|
|
className="btn btn-link text-danger p-1"
|
|
onClick={() => handleDeleteClick(category)}
|
|
title={t('common.delete')}
|
|
>
|
|
<i className="bi bi-trash"></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Children */}
|
|
{hasChildren && isExpanded && (
|
|
<div>
|
|
{category.children.map(child => renderCategory(child, level + 1, displayColor))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h2 className="text-white mb-1">
|
|
<i className="bi bi-tags me-2 text-info"></i>
|
|
{t('nav.categories')}
|
|
</h2>
|
|
<p className="text-slate-400 mb-0">
|
|
{t('categories.title')}
|
|
</p>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<button className="btn btn-outline-warning" onClick={handleOpenBatchModal}>
|
|
<i className="bi bi-lightning-charge me-2"></i>
|
|
{t('categories.batchCategorize') || 'Categorizar em Lote'}
|
|
</button>
|
|
<button className="btn btn-info" onClick={() => handleOpenModal()}>
|
|
<i className="bi bi-plus-lg me-2"></i>
|
|
{t('categories.newCategory')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="row mb-4">
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex align-items-center">
|
|
<div className="rounded-circle bg-danger bg-opacity-25 p-3 me-3">
|
|
<i className="bi bi-arrow-down-circle text-danger fs-4"></i>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-400 mb-0 small">{t('categories.types.expense')}</p>
|
|
<h3 className="mb-0 text-white">
|
|
{flatCategories.filter(c => c.type === 'expense' || c.type === 'both').length}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex align-items-center">
|
|
<div className="rounded-circle bg-success bg-opacity-25 p-3 me-3">
|
|
<i className="bi bi-arrow-up-circle text-success fs-4"></i>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-400 mb-0 small">{t('categories.types.income')}</p>
|
|
<h3 className="mb-0 text-white">
|
|
{flatCategories.filter(c => c.type === 'income' || c.type === 'both').length}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex align-items-center">
|
|
<div className="rounded-circle bg-info bg-opacity-25 p-3 me-3">
|
|
<i className="bi bi-tags text-info fs-4"></i>
|
|
</div>
|
|
<div>
|
|
<p className="text-slate-400 mb-0 small">{t('common.total')}</p>
|
|
<h3 className="mb-0 text-white">{flatCategories.length}</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="card border-0 mb-4" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="row g-3">
|
|
<div className="col-md-4">
|
|
<label className="form-label text-slate-400 small">{t('categories.filterByType')}</label>
|
|
<select
|
|
className="form-select bg-dark text-white border-secondary"
|
|
value={filter.type}
|
|
onChange={(e) => setFilter(prev => ({ ...prev, type: e.target.value }))}
|
|
>
|
|
<option value="">{t('common.all')}</option>
|
|
{Object.entries(categoryTypes).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Categories List */}
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
{loading ? (
|
|
<div className="card-body text-center py-5">
|
|
<div className="spinner-border text-primary" role="status">
|
|
<span className="visually-hidden">{t('common.loading')}</span>
|
|
</div>
|
|
</div>
|
|
) : categories.length === 0 ? (
|
|
<div className="card-body text-center py-5">
|
|
<i className="bi bi-tags display-1 text-slate-600"></i>
|
|
<p className="text-slate-400 mt-3">{t('categories.noCategories')}</p>
|
|
<button className="btn btn-info" onClick={() => handleOpenModal()}>
|
|
<i className="bi bi-plus-lg me-2"></i>
|
|
{t('categories.newCategory')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="card-body p-0">
|
|
{categories.map(category => renderCategory(category))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal de Criar/Editar - Design Elegante */}
|
|
{showModal && (
|
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
<div className="modal-dialog modal-dialog-centered modal-lg">
|
|
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
|
{/* Header elegante */}
|
|
<div className="modal-header border-0 pb-0">
|
|
<div>
|
|
<h5 className="modal-title text-white mb-1">
|
|
<i className={`bi ${selectedItem ? 'bi-pencil-square' : formData.parent_id ? 'bi-diagram-3' : 'bi-plus-circle-dotted'} me-2 text-info`}></i>
|
|
{selectedItem ? t('categories.editCategory') : formData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}
|
|
</h5>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{formData.parent_id
|
|
? `${t('categories.parentCategory')}: ${flatCategories.find(c => c.id == formData.parent_id)?.name || ''}`
|
|
: t('categories.title')
|
|
}
|
|
</p>
|
|
</div>
|
|
<button type="button" className="btn-close btn-close-white" onClick={handleCloseModal}></button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
|
|
|
{/* Preview Card */}
|
|
<div className="mb-4 p-3 rounded-3" style={{ background: '#0f172a' }}>
|
|
<div className="d-flex align-items-center">
|
|
<div
|
|
className="rounded-circle d-flex align-items-center justify-content-center me-3"
|
|
style={{
|
|
width: 50,
|
|
height: 50,
|
|
background: `${formData.color}25`,
|
|
border: `2px solid ${formData.color}`,
|
|
}}
|
|
>
|
|
<i className={`bi ${formData.icon}`} style={{ fontSize: '1.3rem', color: formData.color }}></i>
|
|
</div>
|
|
<div>
|
|
<h6 className="text-white mb-0">{formData.name || t('categories.newCategory')}</h6>
|
|
<small className="text-slate-400">{formData.description || t('common.description')}</small>
|
|
</div>
|
|
<div className="ms-auto">
|
|
<span className={`badge bg-${getTypeColor(formData.type)} bg-opacity-25 text-${getTypeColor(formData.type)}`}>
|
|
{categoryTypes[formData.type]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Nome e Tipo - Linha principal */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-md-8">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-type me-2 text-primary"></i>
|
|
{t('common.name')} *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="form-control bg-dark text-white border-0"
|
|
style={{ background: '#0f172a' }}
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
placeholder={t('categories.namePlaceholder') || 'Ex: Alimentación, Transporte...'}
|
|
required
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-arrow-left-right me-2 text-warning"></i>
|
|
{t('common.type')} *
|
|
</label>
|
|
<select
|
|
className="form-select bg-dark text-white border-0"
|
|
style={{ background: '#0f172a' }}
|
|
name="type"
|
|
value={formData.type}
|
|
onChange={handleChange}
|
|
required
|
|
>
|
|
{Object.entries(categoryTypes).map(([key, label]) => (
|
|
<option key={key} value={key}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Visual - Cor e Ícone */}
|
|
<div className="mb-4">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-palette me-2 text-success"></i>
|
|
{t('categories.visualSettings') || 'Aparência'}
|
|
</label>
|
|
<div className="row g-3">
|
|
<div className="col-4">
|
|
<div className="p-3 rounded text-center" style={{ background: '#0f172a' }}>
|
|
<label className="text-slate-400 small d-block mb-2">{t('common.color')}</label>
|
|
<input
|
|
type="color"
|
|
className="form-control form-control-color mx-auto border-0"
|
|
style={{ width: 50, height: 50, cursor: 'pointer', background: 'transparent' }}
|
|
name="color"
|
|
value={formData.color}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="col-8">
|
|
<div className="p-3 rounded h-100" style={{ background: '#0f172a' }}>
|
|
<label className="text-slate-400 small d-block mb-2">{t('common.icon')}</label>
|
|
<IconSelector
|
|
value={formData.icon}
|
|
onChange={(icon) => setFormData(prev => ({ ...prev, icon }))}
|
|
type="category"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Categoria Pai (se não for edição de uma categoria raiz) */}
|
|
{!selectedItem?.children?.length && (
|
|
<div className="mb-4">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-diagram-2 me-2 text-info"></i>
|
|
{t('categories.parentCategory')}
|
|
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
|
</label>
|
|
<div className="row g-2">
|
|
<div className="col-4 col-md-3">
|
|
<div
|
|
onClick={() => setFormData({...formData, parent_id: ''})}
|
|
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
|
style={{
|
|
background: !formData.parent_id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
|
cursor: 'pointer',
|
|
border: !formData.parent_id ? '2px solid #3b82f6' : '2px solid transparent',
|
|
minHeight: 60
|
|
}}
|
|
>
|
|
<i className="bi bi-app d-block mb-1 text-slate-400"></i>
|
|
<small className="text-white">{t('categories.noParent')}</small>
|
|
</div>
|
|
</div>
|
|
{flatCategories
|
|
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
|
.slice(0, 7)
|
|
.map(cat => (
|
|
<div key={cat.id} className="col-4 col-md-3">
|
|
<div
|
|
onClick={() => setFormData({...formData, parent_id: cat.id.toString()})}
|
|
className="p-2 rounded text-center h-100 d-flex flex-column justify-content-center"
|
|
style={{
|
|
background: formData.parent_id == cat.id ? 'rgba(59, 130, 246, 0.15)' : '#0f172a',
|
|
cursor: 'pointer',
|
|
border: formData.parent_id == cat.id ? '2px solid #3b82f6' : '2px solid transparent',
|
|
minHeight: 60
|
|
}}
|
|
>
|
|
<i className={`bi ${cat.icon || 'bi-tag'} d-block mb-1`} style={{ color: cat.color || '#6b7280' }}></i>
|
|
<small className="text-white text-truncate" title={cat.name}>{cat.name}</small>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{flatCategories.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both')).length > 7 && (
|
|
<select
|
|
className="form-select bg-dark text-white border-0 mt-2"
|
|
style={{ background: '#0f172a' }}
|
|
name="parent_id"
|
|
value={formData.parent_id}
|
|
onChange={handleChange}
|
|
>
|
|
<option value="">{t('categories.selectParent') || 'Mais categorias...'}</option>
|
|
{flatCategories
|
|
.filter(c => c.id !== selectedItem?.id && (c.type === formData.type || c.type === 'both'))
|
|
.map((cat) => (
|
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Descrição */}
|
|
<div className="mb-4">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-text-paragraph me-2 text-secondary"></i>
|
|
{t('common.description')}
|
|
<span className="badge bg-secondary ms-2" style={{ fontSize: '0.65rem' }}>{t('common.optional')}</span>
|
|
</label>
|
|
<textarea
|
|
className="form-control bg-dark text-white border-0"
|
|
style={{ background: '#0f172a' }}
|
|
name="description"
|
|
value={formData.description}
|
|
onChange={handleChange}
|
|
rows="2"
|
|
placeholder={t('categories.descPlaceholder') || 'Descreva esta categoria...'}
|
|
></textarea>
|
|
</div>
|
|
|
|
{/* Palavras-chave - Seção destacada */}
|
|
<div className="mb-3">
|
|
<label className="form-label text-white fw-medium mb-2">
|
|
<i className="bi bi-key me-2 text-warning"></i>
|
|
{t('categories.keywords')}
|
|
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: '0.65rem' }}>
|
|
{t('categories.autoCategorizationLabel') || 'Auto-categorização'}
|
|
</span>
|
|
</label>
|
|
<div className="p-3 rounded" style={{ background: '#0f172a' }}>
|
|
<div className="input-group mb-2">
|
|
<input
|
|
type="text"
|
|
className="form-control bg-dark text-white border-0"
|
|
style={{ background: '#1e293b' }}
|
|
value={newKeyword}
|
|
onChange={(e) => setNewKeyword(e.target.value)}
|
|
onKeyPress={handleKeywordKeyPress}
|
|
placeholder={t('categories.keywordPlaceholder') || 'Digite e pressione Enter...'}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-info px-3"
|
|
onClick={handleAddKeyword}
|
|
>
|
|
<i className="bi bi-plus-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div className="d-flex flex-wrap gap-2">
|
|
{formData.keywords.map((keyword, index) => (
|
|
<span
|
|
key={index}
|
|
className="badge d-flex align-items-center py-2 px-3"
|
|
style={{
|
|
backgroundColor: formData.color + '25',
|
|
color: formData.color,
|
|
fontSize: '0.85rem'
|
|
}}
|
|
>
|
|
{keyword}
|
|
<button
|
|
type="button"
|
|
className="btn-close ms-2"
|
|
style={{ fontSize: '8px', filter: 'brightness(1.5)' }}
|
|
onClick={() => handleRemoveKeyword(keyword)}
|
|
></button>
|
|
</span>
|
|
))}
|
|
{formData.keywords.length === 0 && (
|
|
<small className="text-slate-500">
|
|
<i className="bi bi-info-circle me-1"></i>
|
|
{t('categories.noKeywords') || 'Nenhuma palavra-chave. Transações serão categorizadas manualmente.'}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<small className="text-slate-500 mt-2 d-block">
|
|
<i className="bi bi-lightbulb me-1"></i>
|
|
{t('categories.keywordHelp') || 'Ex: "RESTAURANTE", "PIZZA" - Transações com essas palavras serão categorizadas automaticamente'}
|
|
</small>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="form-check form-switch">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
id="is_active"
|
|
name="is_active"
|
|
checked={formData.is_active}
|
|
onChange={handleChange}
|
|
role="switch"
|
|
/>
|
|
<label className="form-check-label text-white" htmlFor="is_active">
|
|
<i className={`bi ${formData.is_active ? 'bi-check-circle text-success' : 'bi-x-circle text-secondary'} me-2`}></i>
|
|
{formData.is_active ? t('common.active') : t('common.inactive')}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer elegante */}
|
|
<div className="modal-footer border-0">
|
|
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseModal}>
|
|
<i className="bi bi-x-lg me-2"></i>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="submit" className="btn btn-info px-4" disabled={saving || !formData.name.trim()}>
|
|
{saving ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
|
{t('common.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className={`bi ${selectedItem ? 'bi-check-lg' : 'bi-plus-lg'} me-2`}></i>
|
|
{selectedItem ? t('common.save') : t('common.create')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Confirmação de Exclusão */}
|
|
<ConfirmModal
|
|
show={showDeleteModal}
|
|
onHide={() => setShowDeleteModal(false)}
|
|
onConfirm={handleDeleteConfirm}
|
|
title={t('categories.deleteCategory')}
|
|
message={t('categories.deleteConfirm')}
|
|
confirmText={t('common.delete')}
|
|
loading={saving}
|
|
/>
|
|
|
|
{/* Modal de Categorização em Lote - Design Elegante */}
|
|
{showBatchModal && (
|
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}>
|
|
<div className="modal-dialog modal-lg modal-dialog-centered" style={{ maxWidth: '700px' }}>
|
|
<div className="modal-content border-0" style={{ background: '#1e293b', maxHeight: '90vh' }}>
|
|
{/* Header elegante */}
|
|
<div className="modal-header border-0 pb-0">
|
|
<div>
|
|
<h5 className="modal-title text-white mb-1">
|
|
<i className="bi bi-lightning-charge-fill me-2 text-warning"></i>
|
|
{t('categories.batchCategorize') || 'Categorização Automática'}
|
|
</h5>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{t('categories.batchDescription') || 'Categorize transações automaticamente usando palavras-chave'}
|
|
</p>
|
|
</div>
|
|
<button type="button" className="btn-close btn-close-white" onClick={handleCloseBatchModal}></button>
|
|
</div>
|
|
|
|
<div className="modal-body pt-3" style={{ maxHeight: '65vh', overflowY: 'auto' }}>
|
|
{loadingBatch ? (
|
|
<div className="text-center py-5">
|
|
<div className="spinner-border text-warning" role="status" style={{ width: '3rem', height: '3rem' }}>
|
|
<span className="visually-hidden">Loading...</span>
|
|
</div>
|
|
<p className="text-slate-400 mt-3 mb-0">{t('categories.analyzingTransactions') || 'Analisando transações...'}</p>
|
|
</div>
|
|
) : batchPreview ? (
|
|
<>
|
|
{/* Cards de Resumo */}
|
|
<div className="row g-3 mb-4">
|
|
<div className="col-6 col-md-3">
|
|
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
|
style={{ width: 40, height: 40, background: 'rgba(234, 179, 8, 0.2)' }}>
|
|
<i className="bi bi-question-circle text-warning"></i>
|
|
</div>
|
|
<h4 className="text-warning mb-0">{batchPreview.total_uncategorized}</h4>
|
|
<small className="text-slate-500">{t('categories.uncategorized') || 'Sem categoria'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
|
style={{ width: 40, height: 40, background: 'rgba(34, 197, 94, 0.2)' }}>
|
|
<i className="bi bi-check-circle text-success"></i>
|
|
</div>
|
|
<h4 className="text-success mb-0">{batchPreview.would_categorize}</h4>
|
|
<small className="text-slate-500">{t('categories.willCategorize') || 'Serão categorizadas'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
|
style={{ width: 40, height: 40, background: 'rgba(148, 163, 184, 0.2)' }}>
|
|
<i className="bi bi-dash-circle text-slate-400"></i>
|
|
</div>
|
|
<h4 className="text-slate-400 mb-0">{batchPreview.would_skip}</h4>
|
|
<small className="text-slate-500">{t('categories.willSkip') || 'Sem correspondência'}</small>
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="p-3 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<div className="rounded-circle d-inline-flex align-items-center justify-content-center mb-2"
|
|
style={{ width: 40, height: 40, background: 'rgba(59, 130, 246, 0.2)' }}>
|
|
<i className="bi bi-key text-info"></i>
|
|
</div>
|
|
<h4 className="text-info mb-0">{batchPreview.total_keywords}</h4>
|
|
<small className="text-slate-500">{t('categories.totalKeywords') || 'Palavras-chave'}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Table */}
|
|
{batchPreview.preview.length > 0 ? (
|
|
<>
|
|
<div className="d-flex align-items-center mb-3">
|
|
<i className="bi bi-eye me-2 text-info"></i>
|
|
<h6 className="text-white mb-0">
|
|
{t('categories.previewTitle') || 'Preview das categorizações'}
|
|
</h6>
|
|
<span className="badge bg-info bg-opacity-25 text-info ms-2">
|
|
{batchPreview.preview.length} {t('common.items') || 'itens'}
|
|
</span>
|
|
</div>
|
|
<div className="rounded-3 overflow-hidden" style={{ background: '#0f172a' }}>
|
|
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
|
<table className="table table-dark mb-0" style={{ background: 'transparent' }}>
|
|
<thead style={{ position: 'sticky', top: 0, background: '#0f172a', zIndex: 1 }}>
|
|
<tr>
|
|
<th className="border-0 text-slate-400 fw-normal small">{t('transactions.description') || 'Descrição'}</th>
|
|
<th className="border-0 text-slate-400 fw-normal small text-center" style={{ width: '120px' }}>{t('categories.matchedKeyword') || 'Keyword'}</th>
|
|
<th className="border-0 text-slate-400 fw-normal small" style={{ width: '140px' }}>{t('categories.category') || 'Categoria'}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{batchPreview.preview.map((item, index) => (
|
|
<tr key={index} style={{ borderColor: '#334155' }}>
|
|
<td className="text-white text-truncate border-secondary" style={{ maxWidth: '200px' }}>
|
|
{item.description}
|
|
</td>
|
|
<td className="text-center border-secondary">
|
|
<span className="badge bg-warning bg-opacity-25 text-warning">{item.matched_keyword}</span>
|
|
</td>
|
|
<td className="text-info border-secondary">{item.category_name}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<i className="bi bi-search display-4 text-slate-600 mb-3 d-block"></i>
|
|
<h6 className="text-white mb-2">{t('categories.noMatchesFoundTitle') || 'Nenhuma correspondência encontrada'}</h6>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{t('categories.noMatchesFound') || 'Adicione palavras-chave às categorias para permitir categorização automática'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="p-4 rounded-3 text-center" style={{ background: '#0f172a' }}>
|
|
<i className="bi bi-exclamation-triangle display-4 text-danger mb-3 d-block"></i>
|
|
<p className="text-slate-400 mb-0">{t('categories.previewError') || 'Erro ao carregar preview'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer elegante */}
|
|
<div className="modal-footer border-0">
|
|
<button type="button" className="btn btn-outline-secondary px-4" onClick={handleCloseBatchModal}>
|
|
<i className="bi bi-x-lg me-2"></i>
|
|
{t('common.cancel') || 'Cancelar'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-warning px-4"
|
|
onClick={handleExecuteBatch}
|
|
disabled={executingBatch || !batchPreview || batchPreview.would_categorize === 0}
|
|
>
|
|
{executingBatch ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
{t('common.processing') || 'Processando...'}
|
|
</>
|
|
) : (
|
|
<>
|
|
<i className="bi bi-lightning-charge-fill me-2"></i>
|
|
{t('categories.executeBatch') || 'Categorizar'} ({batchPreview?.would_categorize || 0})
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Categories;
|