EPIC-P2-001: Frontend Actions Implementation - Replace 31 console.log placeholders with navigate() calls - 16 pages updated with proper routing EPIC-P2-002: Settings Subpages Creation - Add CompanySettingsPage.tsx - Add ProfileSettingsPage.tsx - Add SecuritySettingsPage.tsx - Add SystemSettingsPage.tsx - Update router with new routes EPIC-P2-003: Bug Fix ValuationReportsPage - Fix recursive getToday() function EPIC-P2-006: CRM Pipeline Kanban - Add PipelineKanbanPage.tsx - Add KanbanColumn.tsx component - Add KanbanCard.tsx component - Add CRM routes to router Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
BookOpen,
|
|
Plus,
|
|
MoreVertical,
|
|
Eye,
|
|
Edit,
|
|
Trash2,
|
|
RefreshCw,
|
|
Search,
|
|
FolderTree,
|
|
CheckCircle,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
import { Button } from '@components/atoms/Button';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
|
import { ConfirmModal } from '@components/organisms/Modal';
|
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
|
import { useAccounts, useAccountTypes } from '@features/financial/hooks';
|
|
import type { Account, AccountTypeEnum } from '@features/financial/types';
|
|
import { formatNumber } from '@utils/formatters';
|
|
|
|
const accountTypeLabels: Record<AccountTypeEnum, string> = {
|
|
asset: 'Activo',
|
|
liability: 'Pasivo',
|
|
equity: 'Capital',
|
|
income: 'Ingreso',
|
|
expense: 'Gasto',
|
|
};
|
|
|
|
const accountTypeColors: Record<AccountTypeEnum, string> = {
|
|
asset: 'bg-blue-100 text-blue-700',
|
|
liability: 'bg-red-100 text-red-700',
|
|
equity: 'bg-purple-100 text-purple-700',
|
|
income: 'bg-green-100 text-green-700',
|
|
expense: 'bg-amber-100 text-amber-700',
|
|
};
|
|
|
|
// Helper function to format currency with 2 decimals
|
|
const formatCurrency = (value: number): string => {
|
|
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
};
|
|
|
|
export function AccountsPage() {
|
|
const navigate = useNavigate();
|
|
const [selectedType, setSelectedType] = useState<AccountTypeEnum | ''>('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
|
const [accountToDelete, setAccountToDelete] = useState<Account | null>(null);
|
|
|
|
const { accountTypes } = useAccountTypes();
|
|
|
|
const {
|
|
accounts,
|
|
total,
|
|
page,
|
|
totalPages,
|
|
isLoading,
|
|
error,
|
|
setPage,
|
|
refresh,
|
|
deleteAccount,
|
|
} = useAccounts({
|
|
search: searchTerm || undefined,
|
|
isDeprecated: showDeprecated ? undefined : false,
|
|
limit: 20,
|
|
});
|
|
|
|
// Filter accounts by type if selected
|
|
const filteredAccounts = selectedType
|
|
? accounts.filter(a => {
|
|
const accountType = accountTypes.find(t => t.id === a.accountTypeId);
|
|
return accountType?.accountType === selectedType;
|
|
})
|
|
: accounts;
|
|
|
|
const getActionsMenu = (account: Account): DropdownItem[] => {
|
|
return [
|
|
{
|
|
key: 'view',
|
|
label: 'Ver detalle',
|
|
icon: <Eye className="h-4 w-4" />,
|
|
onClick: () => navigate(`/financial/accounts/${account.id}`),
|
|
},
|
|
{
|
|
key: 'edit',
|
|
label: 'Editar',
|
|
icon: <Edit className="h-4 w-4" />,
|
|
onClick: () => navigate(`/financial/accounts/${account.id}/edit`),
|
|
},
|
|
{
|
|
key: 'children',
|
|
label: 'Ver subcuentas',
|
|
icon: <FolderTree className="h-4 w-4" />,
|
|
onClick: () => navigate(`/financial/accounts?parentId=${account.id}`),
|
|
},
|
|
{
|
|
key: 'delete',
|
|
label: 'Eliminar',
|
|
icon: <Trash2 className="h-4 w-4" />,
|
|
danger: true,
|
|
onClick: () => setAccountToDelete(account),
|
|
},
|
|
];
|
|
};
|
|
|
|
const columns: Column<Account>[] = [
|
|
{
|
|
key: 'code',
|
|
header: 'Cuenta',
|
|
render: (account) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
|
<BookOpen className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900">{account.code}</div>
|
|
<div className="text-sm text-gray-500">{account.name}</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'accountType',
|
|
header: 'Tipo',
|
|
render: (account) => {
|
|
const accountType = accountTypes.find(t => t.id === account.accountTypeId);
|
|
const typeKey = accountType?.accountType as AccountTypeEnum;
|
|
return (
|
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${accountTypeColors[typeKey] || 'bg-gray-100 text-gray-700'}`}>
|
|
{accountTypeLabels[typeKey] || accountType?.name || 'Desconocido'}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'parent',
|
|
header: 'Cuenta Padre',
|
|
render: (account) => (
|
|
<span className="text-sm text-gray-600">
|
|
{account.parentName || '-'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'balance',
|
|
header: 'Saldo',
|
|
sortable: true,
|
|
render: (account) => (
|
|
<div className="text-right">
|
|
<span className="font-medium text-gray-900">
|
|
${formatCurrency(account.balance || 0)}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'reconcilable',
|
|
header: 'Conciliable',
|
|
render: (account) => (
|
|
account.isReconcilable ? (
|
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="h-5 w-5 text-gray-300" />
|
|
)
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Estado',
|
|
render: (account) => (
|
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${account.isDeprecated ? 'bg-gray-100 text-gray-500' : 'bg-green-100 text-green-700'}`}>
|
|
{account.isDeprecated ? 'Obsoleta' : 'Activa'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (account) => (
|
|
<Dropdown
|
|
trigger={
|
|
<button className="rounded p-1 hover:bg-gray-100">
|
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
}
|
|
items={getActionsMenu(account)}
|
|
align="right"
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
const handleDelete = async () => {
|
|
if (accountToDelete) {
|
|
await deleteAccount(accountToDelete.id);
|
|
setAccountToDelete(null);
|
|
}
|
|
};
|
|
|
|
// Calculate summary stats
|
|
const assetAccounts = accounts.filter(a => {
|
|
const type = accountTypes.find(t => t.id === a.accountTypeId);
|
|
return type?.accountType === 'asset';
|
|
}).length;
|
|
|
|
const liabilityAccounts = accounts.filter(a => {
|
|
const type = accountTypes.find(t => t.id === a.accountTypeId);
|
|
return type?.accountType === 'liability';
|
|
}).length;
|
|
|
|
const incomeAccounts = accounts.filter(a => {
|
|
const type = accountTypes.find(t => t.id === a.accountTypeId);
|
|
return type?.accountType === 'income';
|
|
}).length;
|
|
|
|
const expenseAccounts = accounts.filter(a => {
|
|
const type = accountTypes.find(t => t.id === a.accountTypeId);
|
|
return type?.accountType === 'expense';
|
|
}).length;
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<ErrorEmptyState onRetry={refresh} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
<Breadcrumbs items={[
|
|
{ label: 'Contabilidad', href: '/financial' },
|
|
{ label: 'Plan de Cuentas' },
|
|
]} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Plan de Cuentas</h1>
|
|
<p className="text-sm text-gray-500">
|
|
Gestiona el catalogo de cuentas contables
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</Button>
|
|
<Button>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Nueva cuenta
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('asset')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
|
<BookOpen className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Activos</div>
|
|
<div className="text-xl font-bold text-blue-600">{assetAccounts}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('liability')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
|
<BookOpen className="h-5 w-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Pasivos</div>
|
|
<div className="text-xl font-bold text-red-600">{liabilityAccounts}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('income')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
|
<BookOpen className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Ingresos</div>
|
|
<div className="text-xl font-bold text-green-600">{incomeAccounts}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedType('expense')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
|
<BookOpen className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Gastos</div>
|
|
<div className="text-xl font-bold text-amber-600">{expenseAccounts}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Lista de Cuentas</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar cuentas..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={selectedType}
|
|
onChange={(e) => setSelectedType(e.target.value as AccountTypeEnum | '')}
|
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="">Todos los tipos</option>
|
|
{Object.entries(accountTypeLabels).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={showDeprecated}
|
|
onChange={(e) => setShowDeprecated(e.target.checked)}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-600">Mostrar obsoletas</span>
|
|
</label>
|
|
|
|
{(selectedType || searchTerm || showDeprecated) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedType('');
|
|
setSearchTerm('');
|
|
setShowDeprecated(false);
|
|
}}
|
|
>
|
|
Limpiar filtros
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{filteredAccounts.length === 0 && !isLoading ? (
|
|
<NoDataEmptyState
|
|
entityName="cuentas contables"
|
|
/>
|
|
) : (
|
|
<DataTable
|
|
data={filteredAccounts}
|
|
columns={columns}
|
|
isLoading={isLoading}
|
|
pagination={{
|
|
page,
|
|
totalPages,
|
|
total,
|
|
limit: 20,
|
|
onPageChange: setPage,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete Account Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!accountToDelete}
|
|
onClose={() => setAccountToDelete(null)}
|
|
onConfirm={handleDelete}
|
|
title="Eliminar cuenta"
|
|
message={`¿Eliminar la cuenta ${accountToDelete?.code} - ${accountToDelete?.name}? Esta accion no se puede deshacer.`}
|
|
variant="danger"
|
|
confirmText="Eliminar"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AccountsPage;
|