feat: novas categorias e melhorias UI transações

- Adicionadas categorias para Marco Leite:
  - Gastos Trabajo (com subcategorias: Máquina de Vending, Café/Snacks, etc.)
  - Tabaco/Vaper (com subcategorias: Cigarros, Vaper/Pod, Líquidos, etc.)
  - Beleza (com subcategorias: Cabeleireiro, Barbearia, Skincare, etc.)
  - Subscrições (com subcategorias: Streaming, Software, Cloud, etc.)
  - Bono Transporte (subcategoria de Transporte)
  - Descarga de Passivo (subcategoria de Finanças)

- Lista de transações filtradas agora exibe menu completo de ações
  (igual à listagem por semana): editar, duplicar, dividir, etc.
This commit is contained in:
marco 2025-12-19 21:15:36 +01:00
parent d1ab280997
commit 99a68f4520
8 changed files with 395 additions and 34 deletions

View File

@ -228,7 +228,7 @@ public function update(Request $request, $id)
$user = User::findOrFail($id);
// Don't allow changing main admin's admin status
if ($user->email === 'marco@cnxifly.com' && $request->has('is_admin') && !$request->is_admin) {
if ($user->email === 'marcoitaloesp@icloud.com' && $request->has('is_admin') && !$request->is_admin) {
return response()->json([
'success' => false,
'message' => 'No se puede remover permisos del administrador principal',
@ -288,7 +288,7 @@ public function destroy($id)
$user = User::findOrFail($id);
// Don't allow deleting admin
if ($user->email === 'marco@cnxifly.com') {
if ($user->email === 'marcoitaloesp@icloud.com') {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar el usuario administrador',

View File

@ -8,19 +8,15 @@
class AdminOnly
{
/**
* Admin email - only this user can access restricted features
*/
private const ADMIN_EMAIL = 'marco@cnxifly.com';
/**
* Handle an incoming request.
* Only users with is_admin = true can access admin routes.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user || $user->email !== self::ADMIN_EMAIL) {
if (!$user || !$user->is_admin) {
return response()->json([
'success' => false,
'message' => 'Access denied. This feature is not available.',

View File

@ -14,7 +14,7 @@ const Layout = ({ children }) => {
const { date } = useFormatters();
// Admin email - only this user can see business module
const ADMIN_EMAIL = 'marco@cnxifly.com';
const ADMIN_EMAIL = 'marcoitaloesp@icloud.com';
const isAdmin = user?.email === ADMIN_EMAIL;
// Mobile: sidebar oculta por padrão | Desktop: expandida

View File

@ -568,6 +568,7 @@
"description": "Description",
"originalDescription": "Original Bank Description",
"notes": "Notes",
"notesPlaceholder": "Add notes about this transaction (optional)",
"reference": "Reference",
"referencePlaceholder": "Document number, invoice, etc.",
"date": "Date",

View File

@ -576,6 +576,7 @@
"description": "Descripción",
"originalDescription": "Descripción Original del Banco",
"notes": "Observaciones",
"notesPlaceholder": "Añade observaciones sobre esta transacción (opcional)",
"reference": "Referencia",
"referencePlaceholder": "Nº de documento, factura, etc.",
"date": "Fecha",

View File

@ -578,6 +578,7 @@
"description": "Descrição",
"originalDescription": "Descrição Original do Banco",
"notes": "Observações",
"notesPlaceholder": "Adicione observações sobre esta transação (opcional)",
"reference": "Referência",
"referencePlaceholder": "Nº do documento, fatura, etc.",
"date": "Data",

View File

@ -179,8 +179,18 @@ export default function Transactions() {
category_id: '',
cost_center_id: '',
add_keyword: true,
notes: '',
});
const [savingQuickCategorize, setSavingQuickCategorize] = useState(false);
// Estados para criar categoria/subcategoria inline no modal de categorização
const [showInlineCategoryForm, setShowInlineCategoryForm] = useState(false);
const [inlineCategoryData, setInlineCategoryData] = useState({
name: '',
parent_id: '',
type: 'expense',
icon: 'bi-tag',
});
const [savingInlineCategory, setSavingInlineCategory] = useState(false);
// Calcular se há filtros ativos (excluindo date_field que é sempre preenchido)
const hasActiveFilters = Object.entries(filters).some(([key, v]) => key !== 'date_field' && v !== '');
@ -615,10 +625,53 @@ export default function Transactions() {
category_id: transaction.category_id || '',
cost_center_id: transaction.cost_center_id || '',
add_keyword: true,
notes: transaction.notes || '',
});
setShowInlineCategoryForm(false);
setInlineCategoryData({
name: '',
parent_id: '',
type: transaction.type === 'credit' ? 'income' : 'expense',
icon: 'bi-tag',
});
setShowQuickCategorizeModal(true);
};
// Criar categoria/subcategoria inline no modal de categorização
const handleInlineCategorySubmit = async (e) => {
e.preventDefault();
if (!inlineCategoryData.name.trim()) return;
try {
setSavingInlineCategory(true);
const result = await categoryService.create({
...inlineCategoryData,
parent_id: inlineCategoryData.parent_id ? parseInt(inlineCategoryData.parent_id) : null,
});
const newCategory = result.data || result;
// Recarregar categorias
const categoriesRes = await categoryService.getAll({ flat: true });
setCategories(Array.isArray(categoriesRes) ? categoriesRes : (categoriesRes.data || []));
// Selecionar a nova categoria no modal
setQuickCategorizeData(prev => ({ ...prev, category_id: newCategory.id }));
showToast(t('categories.createSuccess'), 'success');
setShowInlineCategoryForm(false);
setInlineCategoryData({
name: '',
parent_id: '',
type: quickCategorizeData.transaction?.type === 'credit' ? 'income' : 'expense',
icon: 'bi-tag',
});
} catch (err) {
showToast(err.response?.data?.message || t('categories.createError'), 'danger');
} finally {
setSavingInlineCategory(false);
}
};
const handleQuickCategorizeSubmit = async (e) => {
e.preventDefault();
try {
@ -627,6 +680,7 @@ export default function Transactions() {
const updateData = {
category_id: quickCategorizeData.category_id || null,
cost_center_id: quickCategorizeData.cost_center_id || null,
notes: quickCategorizeData.notes || null,
};
// Se add_keyword está ativo e há descrição original, criar keyword
@ -638,8 +692,12 @@ export default function Transactions() {
cost_center_id: updateData.cost_center_id,
add_keyword: true,
});
// Atualizar notas separadamente se houver
if (updateData.notes) {
await transactionService.update(quickCategorizeData.transaction.id, { notes: updateData.notes });
}
} else {
// Apenas atualizar a transação
// Atualizar a transação com todos os dados
await transactionService.update(quickCategorizeData.transaction.id, updateData);
}
@ -1526,12 +1584,122 @@ export default function Transactions() {
{transaction.type === 'credit' ? '+' : '-'}
{formatCurrency(transaction.amount || transaction.planned_amount, selectedCurrency)}
</div>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => openQuickCategorizeModal(transaction)}
>
<i className="bi bi-tags"></i>
</button>
<div className="dropdown" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end shadow-sm">
{/* Ações de Status */}
{transaction.status === 'pending' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => openCompleteModal(transaction)}>
<i className="bi bi-check-circle text-success me-2"></i>
{t('transactions.markComplete')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleQuickComplete(transaction)}>
<i className="bi bi-lightning-fill text-warning me-2"></i>
{t('transactions.quickComplete')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{transaction.status === 'completed' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => handleRevert(transaction)}>
<i className="bi bi-arrow-counterclockwise text-warning me-2"></i>
{t('transactions.revert')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{/* Ações Principais */}
<li>
<button className="dropdown-item" onClick={() => openQuickCategorizeModal(transaction)}>
<i className="bi bi-tags text-success me-2"></i>
{t('transactions.quickCategorize')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => openEditModal(transaction)}>
<i className="bi bi-pencil text-primary me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleDuplicate(transaction)}>
<i className="bi bi-copy text-info me-2"></i>
{t('transactions.duplicate')}
</button>
</li>
{/* Dividir transação */}
{!transaction.is_transfer && !transaction.split_parent_id && (
<li>
<button className="dropdown-item" onClick={() => openSplitModal(transaction)}>
<i className="bi bi-diagram-3 text-secondary me-2"></i>
{t('transactions.split')}
</button>
</li>
)}
{/* Converter em transferência */}
{!transaction.is_transfer && !transaction.is_split_child && (
<li>
<button className="dropdown-item" onClick={() => openConvertTransferModal(transaction)}>
<i className="bi bi-arrow-left-right text-purple me-2"></i>
{t('transactions.convertToTransfer')}
</button>
</li>
)}
{/* Conciliar com passivo */}
{transaction.type === 'debit' && !transaction.is_reconciled && !transaction.is_transfer && (
<li>
<button className="dropdown-item" onClick={() => openReconcileLiabilityModal(transaction)}>
<i className="bi bi-link-45deg text-purple me-2"></i>
{t('transactions.reconcileWithLiability')}
</button>
</li>
)}
{/* Criar Recorrência */}
{!transaction.is_transfer && !transaction.recurring_instance_id && (
<li>
<button className="dropdown-item" onClick={() => openRecurrenceModal(transaction)}>
<i className="bi bi-calendar-check text-info me-2"></i>
{t('recurring.makeRecurring')}
</button>
</li>
)}
{/* Cancelar */}
{transaction.status === 'pending' && (
<li>
<button className="dropdown-item" onClick={() => handleCancel(transaction)}>
<i className="bi bi-x-circle text-secondary me-2"></i>
{t('transactions.cancel')}
</button>
</li>
)}
<li><hr className="dropdown-divider" /></li>
<li>
<button className="dropdown-item text-danger" onClick={() => handleDelete(transaction)}>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
@ -1631,13 +1799,122 @@ export default function Transactions() {
</span>
</td>
<td className="text-center col-actions">
<button
className="txn-actions-btn"
onClick={() => openQuickCategorizeModal(transaction)}
title={t('transactions.quickCategorize')}
>
<i className="bi bi-tags"></i>
</button>
<div className="dropdown">
<button className="txn-actions-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-end shadow-sm">
{/* Ações de Status */}
{transaction.status === 'pending' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => openCompleteModal(transaction)}>
<i className="bi bi-check-circle text-success me-2"></i>
{t('transactions.markComplete')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleQuickComplete(transaction)}>
<i className="bi bi-lightning-fill text-warning me-2"></i>
{t('transactions.quickComplete')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{transaction.status === 'completed' && (
<>
<li className="dropdown-header small text-muted">{t('transactions.status.label')}</li>
<li>
<button className="dropdown-item" onClick={() => handleRevert(transaction)}>
<i className="bi bi-arrow-counterclockwise text-warning me-2"></i>
{t('transactions.revert')}
</button>
</li>
<li><hr className="dropdown-divider" /></li>
</>
)}
{/* Ações Principais */}
<li>
<button className="dropdown-item" onClick={() => openQuickCategorizeModal(transaction)}>
<i className="bi bi-tags text-success me-2"></i>
{t('transactions.quickCategorize')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => openEditModal(transaction)}>
<i className="bi bi-pencil text-primary me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button className="dropdown-item" onClick={() => handleDuplicate(transaction)}>
<i className="bi bi-copy text-info me-2"></i>
{t('transactions.duplicate')}
</button>
</li>
{/* Dividir transação */}
{!transaction.is_transfer && !transaction.split_parent_id && (
<li>
<button className="dropdown-item" onClick={() => openSplitModal(transaction)}>
<i className="bi bi-diagram-3 text-secondary me-2"></i>
{t('transactions.split')}
</button>
</li>
)}
{/* Converter em transferência */}
{!transaction.is_transfer && !transaction.is_split_child && (
<li>
<button className="dropdown-item" onClick={() => openConvertTransferModal(transaction)}>
<i className="bi bi-arrow-left-right text-purple me-2"></i>
{t('transactions.convertToTransfer')}
</button>
</li>
)}
{/* Conciliar com passivo */}
{transaction.type === 'debit' && !transaction.is_reconciled && !transaction.is_transfer && (
<li>
<button className="dropdown-item" onClick={() => openReconcileLiabilityModal(transaction)}>
<i className="bi bi-link-45deg text-purple me-2"></i>
{t('transactions.reconcileWithLiability')}
</button>
</li>
)}
{/* Criar Recorrência */}
{!transaction.is_transfer && !transaction.recurring_instance_id && (
<li>
<button className="dropdown-item" onClick={() => openRecurrenceModal(transaction)}>
<i className="bi bi-calendar-check text-info me-2"></i>
{t('recurring.makeRecurring')}
</button>
</li>
)}
{/* Cancelar */}
{transaction.status === 'pending' && (
<li>
<button className="dropdown-item" onClick={() => handleCancel(transaction)}>
<i className="bi bi-x-circle text-secondary me-2"></i>
{t('transactions.cancel')}
</button>
</li>
)}
<li><hr className="dropdown-divider" /></li>
<li>
<button className="dropdown-item text-danger" onClick={() => handleDelete(transaction)}>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</td>
</tr>
))}
@ -3810,7 +4087,10 @@ export default function Transactions() {
{/* Modal de Categorização Rápida Individual */}
<Modal
show={showQuickCategorizeModal}
onClose={() => setShowQuickCategorizeModal(false)}
onClose={() => {
setShowQuickCategorizeModal(false);
setShowInlineCategoryForm(false);
}}
title={t('transactions.quickCategorize')}
size="md"
>
@ -3836,14 +4116,81 @@ export default function Transactions() {
</div>
)}
{/* Seção Categoria com botão de criar */}
<div className="mb-3">
<label className="form-label">{t('transactions.category')}</label>
<CategorySelector
categories={categories}
value={quickCategorizeData.category_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, category_id: e.target.value }))}
placeholder={t('transactions.selectCategory')}
/>
<div className="d-flex justify-content-between align-items-center mb-1">
<label className="form-label mb-0">{t('transactions.category')}</label>
<button
type="button"
className="btn btn-link btn-sm p-0 text-decoration-none"
onClick={() => setShowInlineCategoryForm(!showInlineCategoryForm)}
>
<i className={`bi ${showInlineCategoryForm ? 'bi-x-circle' : 'bi-plus-circle'} me-1`}></i>
{showInlineCategoryForm ? t('common.cancel') : t('categories.newCategory')}
</button>
</div>
{showInlineCategoryForm ? (
<div className="card border-primary mb-2" style={{ backgroundColor: '#0f172a' }}>
<div className="card-body p-3">
<div className="mb-2">
<input
type="text"
className="form-control form-control-sm"
value={inlineCategoryData.name}
onChange={(e) => setInlineCategoryData(prev => ({ ...prev, name: e.target.value }))}
placeholder={t('categories.categoryName')}
autoFocus
/>
</div>
<div className="row g-2 mb-2">
<div className="col-6">
<select
className="form-select form-select-sm"
value={inlineCategoryData.parent_id}
onChange={(e) => setInlineCategoryData(prev => ({ ...prev, parent_id: e.target.value }))}
>
<option value="">{t('categories.noParent')}</option>
{parentCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div className="col-6">
<select
className="form-select form-select-sm"
value={inlineCategoryData.type}
onChange={(e) => setInlineCategoryData(prev => ({ ...prev, type: e.target.value }))}
>
<option value="expense">{t('categories.types.expense')}</option>
<option value="income">{t('categories.types.income')}</option>
<option value="both">{t('categories.types.both')}</option>
</select>
</div>
</div>
<small className="text-muted d-block mb-2">{t('transactions.quickCategory.parentHelp')}</small>
<button
type="button"
className="btn btn-primary btn-sm w-100"
onClick={handleInlineCategorySubmit}
disabled={savingInlineCategory || !inlineCategoryData.name.trim()}
>
{savingInlineCategory ? (
<><span className="spinner-border spinner-border-sm me-1"></span>{t('common.saving')}</>
) : (
<><i className="bi bi-plus-lg me-1"></i>{inlineCategoryData.parent_id ? t('categories.createSubcategory') : t('categories.newCategory')}</>
)}
</button>
</div>
</div>
) : (
<CategorySelector
categories={categories}
value={quickCategorizeData.category_id}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, category_id: e.target.value }))}
placeholder={t('transactions.selectCategory')}
/>
)}
</div>
<div className="mb-3">
@ -3860,6 +4207,18 @@ export default function Transactions() {
</select>
</div>
{/* Campo de Observações */}
<div className="mb-3">
<label className="form-label">{t('transactions.notes')}</label>
<textarea
className="form-control"
rows="2"
value={quickCategorizeData.notes}
onChange={(e) => setQuickCategorizeData(prev => ({ ...prev, notes: e.target.value }))}
placeholder={t('transactions.notesPlaceholder')}
/>
</div>
{quickCategorizeData.transaction?.original_description && (
<div className="form-check mb-3">
<input
@ -3877,7 +4236,10 @@ export default function Transactions() {
)}
<div className="d-flex justify-content-end gap-2">
<button type="button" className="btn btn-secondary" onClick={() => setShowQuickCategorizeModal(false)}>
<button type="button" className="btn btn-secondary" onClick={() => {
setShowQuickCategorizeModal(false);
setShowInlineCategoryForm(false);
}}>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-success" disabled={savingQuickCategorize}>

View File

@ -515,7 +515,7 @@ function Users() {
setSelectedUser(user);
setShowDeleteModal(true);
}}
disabled={user.email === 'marco@cnxifly.com'}
disabled={user.email === 'marcoitaloesp@icloud.com'}
className="btn btn-sm btn-outline-danger"
title="Eliminar usuario"
>
@ -1117,7 +1117,7 @@ function Users() {
</div>
{/* Admin Toggle - Only show if not the main admin */}
{editingUser.email !== 'marco@cnxifly.com' && (
{editingUser.email !== 'marcoitaloesp@icloud.com' && (
<div className="form-check form-switch mb-3">
<input
className="form-check-input"