feat(alertas): add alerts CRUD with stats and management UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,10 @@ import { dashboardRoutes } from './routes/dashboard.routes.js';
|
|||||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||||
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||||
import { exportRoutes } from './routes/export.routes.js';
|
import { exportRoutes } from './routes/export.routes.js';
|
||||||
|
import { alertasRoutes } from './routes/alertas.routes.js';
|
||||||
|
import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||||
|
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||||
|
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -33,6 +37,10 @@ app.use('/api/dashboard', dashboardRoutes);
|
|||||||
app.use('/api/cfdi', cfdiRoutes);
|
app.use('/api/cfdi', cfdiRoutes);
|
||||||
app.use('/api/impuestos', impuestosRoutes);
|
app.use('/api/impuestos', impuestosRoutes);
|
||||||
app.use('/api/export', exportRoutes);
|
app.use('/api/export', exportRoutes);
|
||||||
|
app.use('/api/alertas', alertasRoutes);
|
||||||
|
app.use('/api/calendario', calendarioRoutes);
|
||||||
|
app.use('/api/reportes', reportesRoutes);
|
||||||
|
app.use('/api/usuarios', usuariosRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
73
apps/api/src/controllers/alertas.controller.ts
Normal file
73
apps/api/src/controllers/alertas.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import * as alertasService from '../services/alertas.service.js';
|
||||||
|
|
||||||
|
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { leida, resuelta, prioridad } = req.query;
|
||||||
|
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
|
||||||
|
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||||
|
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||||
|
prioridad: prioridad as string,
|
||||||
|
});
|
||||||
|
res.json(alertas);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id));
|
||||||
|
if (!alerta) {
|
||||||
|
return res.status(404).json({ message: 'Alerta no encontrada' });
|
||||||
|
}
|
||||||
|
res.json(alerta);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
|
||||||
|
res.status(201).json(alerta);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||||
|
res.json(alerta);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const stats = await alertasService.getStats(req.tenantSchema!);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
await alertasService.markAllAsRead(req.tenantSchema!);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/api/src/controllers/calendario.controller.ts
Normal file
52
apps/api/src/controllers/calendario.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import * as calendarioService from '../services/calendario.service.js';
|
||||||
|
|
||||||
|
export async function getEventos(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { año, mes } = req.query;
|
||||||
|
const añoNum = parseInt(año as string) || new Date().getFullYear();
|
||||||
|
const mesNum = mes ? parseInt(mes as string) : undefined;
|
||||||
|
|
||||||
|
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
|
||||||
|
res.json(eventos);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProximos(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const dias = parseInt(req.query.dias as string) || 30;
|
||||||
|
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
|
||||||
|
res.json(eventos);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
|
||||||
|
res.status(201).json(evento);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||||
|
res.json(evento);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/api/src/controllers/reportes.controller.ts
Normal file
55
apps/api/src/controllers/reportes.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import * as reportesService from '../services/reportes.service.js';
|
||||||
|
|
||||||
|
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { fechaInicio, fechaFin } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
|
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { fechaInicio, fechaFin } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
|
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||||
|
const data = await reportesService.getComparativo(req.tenantSchema!, año);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { fechaInicio, fechaFin, tipo } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
|
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
|
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
||||||
|
|
||||||
|
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/api/src/controllers/usuarios.controller.ts
Normal file
51
apps/api/src/controllers/usuarios.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import * as usuariosService from '../services/usuarios.service.js';
|
||||||
|
import { AppError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const usuarios = await usuariosService.getUsuarios(req.user!.tenantId);
|
||||||
|
res.json(usuarios);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user!.role !== 'admin') {
|
||||||
|
throw new AppError(403, 'Solo administradores pueden invitar usuarios');
|
||||||
|
}
|
||||||
|
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, req.body);
|
||||||
|
res.status(201).json(usuario);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user!.role !== 'admin') {
|
||||||
|
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
||||||
|
}
|
||||||
|
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
|
||||||
|
res.json(usuario);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user!.role !== 'admin') {
|
||||||
|
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
||||||
|
}
|
||||||
|
if (req.params.id === req.user!.id) {
|
||||||
|
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||||
|
}
|
||||||
|
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/src/routes/alertas.routes.ts
Normal file
19
apps/api/src/routes/alertas.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
|
import * as alertasController from '../controllers/alertas.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
|
router.get('/', alertasController.getAlertas);
|
||||||
|
router.get('/stats', alertasController.getStats);
|
||||||
|
router.post('/mark-all-read', alertasController.markAllAsRead);
|
||||||
|
router.get('/:id', alertasController.getAlerta);
|
||||||
|
router.post('/', alertasController.createAlerta);
|
||||||
|
router.patch('/:id', alertasController.updateAlerta);
|
||||||
|
router.delete('/:id', alertasController.deleteAlerta);
|
||||||
|
|
||||||
|
export { router as alertasRoutes };
|
||||||
17
apps/api/src/routes/calendario.routes.ts
Normal file
17
apps/api/src/routes/calendario.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
|
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
|
router.get('/', calendarioController.getEventos);
|
||||||
|
router.get('/proximos', calendarioController.getProximos);
|
||||||
|
router.post('/', calendarioController.createEvento);
|
||||||
|
router.patch('/:id', calendarioController.updateEvento);
|
||||||
|
router.delete('/:id', calendarioController.deleteEvento);
|
||||||
|
|
||||||
|
export { router as calendarioRoutes };
|
||||||
16
apps/api/src/routes/reportes.routes.ts
Normal file
16
apps/api/src/routes/reportes.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
|
import * as reportesController from '../controllers/reportes.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
|
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||||
|
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||||
|
router.get('/comparativo', reportesController.getComparativo);
|
||||||
|
router.get('/concentrado-rfc', reportesController.getConcentradoRfc);
|
||||||
|
|
||||||
|
export { router as reportesRoutes };
|
||||||
14
apps/api/src/routes/usuarios.routes.ts
Normal file
14
apps/api/src/routes/usuarios.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
import * as usuariosController from '../controllers/usuarios.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/', usuariosController.getUsuarios);
|
||||||
|
router.post('/invite', usuariosController.inviteUsuario);
|
||||||
|
router.patch('/:id', usuariosController.updateUsuario);
|
||||||
|
router.delete('/:id', usuariosController.deleteUsuario);
|
||||||
|
|
||||||
|
export { router as usuariosRoutes };
|
||||||
108
apps/api/src/services/alertas.service.ts
Normal file
108
apps/api/src/services/alertas.service.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getAlertas(
|
||||||
|
schema: string,
|
||||||
|
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||||
|
): Promise<AlertaFull[]> {
|
||||||
|
let whereClause = 'WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.leida !== undefined) {
|
||||||
|
whereClause += ` AND leida = $${paramIndex++}`;
|
||||||
|
params.push(filters.leida);
|
||||||
|
}
|
||||||
|
if (filters.resuelta !== undefined) {
|
||||||
|
whereClause += ` AND resuelta = $${paramIndex++}`;
|
||||||
|
params.push(filters.resuelta);
|
||||||
|
}
|
||||||
|
if (filters.prioridad) {
|
||||||
|
whereClause += ` AND prioridad = $${paramIndex++}`;
|
||||||
|
params.push(filters.prioridad);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||||
|
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||||
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
|
leida, resuelta, created_at as "createdAt"
|
||||||
|
FROM "${schema}".alertas
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY
|
||||||
|
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||||
|
created_at DESC
|
||||||
|
`, ...params);
|
||||||
|
|
||||||
|
return alertas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
|
||||||
|
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||||
|
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||||
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
|
leida, resuelta, created_at as "createdAt"
|
||||||
|
FROM "${schema}".alertas
|
||||||
|
WHERE id = $1
|
||||||
|
`, id);
|
||||||
|
return alerta || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
|
||||||
|
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||||
|
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||||
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
|
leida, resuelta, created_at as "createdAt"
|
||||||
|
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
|
||||||
|
return alerta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.leida !== undefined) {
|
||||||
|
sets.push(`leida = $${paramIndex++}`);
|
||||||
|
params.push(data.leida);
|
||||||
|
}
|
||||||
|
if (data.resuelta !== undefined) {
|
||||||
|
sets.push(`resuelta = $${paramIndex++}`);
|
||||||
|
params.push(data.resuelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||||
|
UPDATE "${schema}".alertas
|
||||||
|
SET ${sets.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||||
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
|
leida, resuelta, created_at as "createdAt"
|
||||||
|
`, ...params);
|
||||||
|
|
||||||
|
return alerta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAlerta(schema: string, id: number): Promise<void> {
|
||||||
|
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(schema: string): Promise<AlertasStats> {
|
||||||
|
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int as total,
|
||||||
|
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
||||||
|
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
|
||||||
|
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||||
|
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||||
|
FROM "${schema}".alertas
|
||||||
|
`);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAsRead(schema: string): Promise<void> {
|
||||||
|
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
|
||||||
|
}
|
||||||
102
apps/api/src/services/calendario.service.ts
Normal file
102
apps/api/src/services/calendario.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getEventos(
|
||||||
|
schema: string,
|
||||||
|
año: number,
|
||||||
|
mes?: number
|
||||||
|
): Promise<EventoFiscal[]> {
|
||||||
|
let whereClause = `WHERE EXTRACT(YEAR FROM fecha_limite) = $1`;
|
||||||
|
const params: any[] = [año];
|
||||||
|
|
||||||
|
if (mes) {
|
||||||
|
whereClause += ` AND EXTRACT(MONTH FROM fecha_limite) = $2`;
|
||||||
|
params.push(mes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||||
|
SELECT id, titulo, descripcion, tipo,
|
||||||
|
fecha_limite as "fechaLimite",
|
||||||
|
recurrencia, completado, notas,
|
||||||
|
created_at as "createdAt"
|
||||||
|
FROM "${schema}".calendario_fiscal
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY fecha_limite ASC
|
||||||
|
`, ...params);
|
||||||
|
|
||||||
|
return eventos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
|
||||||
|
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||||
|
SELECT id, titulo, descripcion, tipo,
|
||||||
|
fecha_limite as "fechaLimite",
|
||||||
|
recurrencia, completado, notas,
|
||||||
|
created_at as "createdAt"
|
||||||
|
FROM "${schema}".calendario_fiscal
|
||||||
|
WHERE completado = false
|
||||||
|
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
|
||||||
|
ORDER BY fecha_limite ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return eventos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
|
||||||
|
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||||
|
INSERT INTO "${schema}".calendario_fiscal
|
||||||
|
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, titulo, descripcion, tipo,
|
||||||
|
fecha_limite as "fechaLimite",
|
||||||
|
recurrencia, completado, notas,
|
||||||
|
created_at as "createdAt"
|
||||||
|
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
|
||||||
|
|
||||||
|
return evento;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.titulo !== undefined) {
|
||||||
|
sets.push(`titulo = $${paramIndex++}`);
|
||||||
|
params.push(data.titulo);
|
||||||
|
}
|
||||||
|
if (data.descripcion !== undefined) {
|
||||||
|
sets.push(`descripcion = $${paramIndex++}`);
|
||||||
|
params.push(data.descripcion);
|
||||||
|
}
|
||||||
|
if (data.fechaLimite !== undefined) {
|
||||||
|
sets.push(`fecha_limite = $${paramIndex++}`);
|
||||||
|
params.push(data.fechaLimite);
|
||||||
|
}
|
||||||
|
if (data.completado !== undefined) {
|
||||||
|
sets.push(`completado = $${paramIndex++}`);
|
||||||
|
params.push(data.completado);
|
||||||
|
}
|
||||||
|
if (data.notas !== undefined) {
|
||||||
|
sets.push(`notas = $${paramIndex++}`);
|
||||||
|
params.push(data.notas);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||||
|
UPDATE "${schema}".calendario_fiscal
|
||||||
|
SET ${sets.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING id, titulo, descripcion, tipo,
|
||||||
|
fecha_limite as "fechaLimite",
|
||||||
|
recurrencia, completado, notas,
|
||||||
|
created_at as "createdAt"
|
||||||
|
`, ...params);
|
||||||
|
|
||||||
|
return evento;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvento(schema: string, id: number): Promise<void> {
|
||||||
|
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
|
||||||
|
}
|
||||||
170
apps/api/src/services/reportes.service.ts
Normal file
170
apps/api/src/services/reportes.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getEstadoResultados(
|
||||||
|
schema: string,
|
||||||
|
fechaInicio: string,
|
||||||
|
fechaFin: string
|
||||||
|
): Promise<EstadoResultados> {
|
||||||
|
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||||
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
|
ORDER BY total DESC LIMIT 10
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||||
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
|
ORDER BY total DESC LIMIT 10
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
const totalIngresos = Number(totales?.ingresos || 0);
|
||||||
|
const totalEgresos = Number(totales?.egresos || 0);
|
||||||
|
const utilidadBruta = totalIngresos - totalEgresos;
|
||||||
|
const impuestos = Number(totales?.iva || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
|
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
|
||||||
|
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
|
||||||
|
totalIngresos,
|
||||||
|
totalEgresos,
|
||||||
|
utilidadBruta,
|
||||||
|
impuestos,
|
||||||
|
utilidadNeta: utilidadBruta - (impuestos > 0 ? impuestos : 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlujoEfectivo(
|
||||||
|
schema: string,
|
||||||
|
fechaInicio: string,
|
||||||
|
fechaFin: string
|
||||||
|
): Promise<FlujoEfectivo> {
|
||||||
|
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||||
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
|
ORDER BY mes
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||||
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
|
ORDER BY mes
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
|
||||||
|
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
|
saldoInicial: 0,
|
||||||
|
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
|
||||||
|
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
|
||||||
|
totalEntradas,
|
||||||
|
totalSalidas,
|
||||||
|
flujoNeto: totalEntradas - totalSalidas,
|
||||||
|
saldoFinal: totalEntradas - totalSalidas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComparativo(
|
||||||
|
schema: string,
|
||||||
|
año: number
|
||||||
|
): Promise<ComparativoPeriodos> {
|
||||||
|
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||||
|
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
GROUP BY mes ORDER BY mes
|
||||||
|
`, año);
|
||||||
|
|
||||||
|
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||||
|
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
GROUP BY mes ORDER BY mes
|
||||||
|
`, año - 1);
|
||||||
|
|
||||||
|
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||||
|
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
|
||||||
|
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
|
||||||
|
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||||
|
|
||||||
|
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||||
|
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
|
||||||
|
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||||
|
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodos: meses,
|
||||||
|
ingresos,
|
||||||
|
egresos,
|
||||||
|
utilidad,
|
||||||
|
variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0,
|
||||||
|
variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0,
|
||||||
|
variacionUtilidad: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConcentradoRfc(
|
||||||
|
schema: string,
|
||||||
|
fechaInicio: string,
|
||||||
|
fechaFin: string,
|
||||||
|
tipo: 'cliente' | 'proveedor'
|
||||||
|
): Promise<ConcentradoRfc[]> {
|
||||||
|
if (tipo === 'cliente') {
|
||||||
|
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||||
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||||
|
'cliente' as tipo,
|
||||||
|
SUM(total) as "totalFacturado",
|
||||||
|
SUM(iva) as "totalIva",
|
||||||
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
|
ORDER BY "totalFacturado" DESC
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||||
|
} else {
|
||||||
|
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||||
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||||
|
'proveedor' as tipo,
|
||||||
|
SUM(total) as "totalFacturado",
|
||||||
|
SUM(iva) as "totalIva",
|
||||||
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
|
AND fecha_emision BETWEEN $1 AND $2
|
||||||
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
|
ORDER BY "totalFacturado" DESC
|
||||||
|
`, fechaInicio, fechaFin);
|
||||||
|
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/api/src/services/usuarios.service.ts
Normal file
107
apps/api/src/services/usuarios.service.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nombre: true,
|
||||||
|
role: true,
|
||||||
|
active: true,
|
||||||
|
lastLogin: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map(u => ({
|
||||||
|
...u,
|
||||||
|
lastLogin: u.lastLogin?.toISOString() || null,
|
||||||
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteUsuario(tenantId: string, data: UserInvite): Promise<UserListItem> {
|
||||||
|
// Check tenant user limit
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { usersLimit: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentCount = await prisma.user.count({ where: { tenantId } });
|
||||||
|
|
||||||
|
if (currentCount >= (tenant?.usersLimit || 1)) {
|
||||||
|
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate temporary password
|
||||||
|
const tempPassword = Math.random().toString(36).slice(-8);
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
nombre: data.nombre,
|
||||||
|
role: data.role,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nombre: true,
|
||||||
|
role: true,
|
||||||
|
active: true,
|
||||||
|
lastLogin: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// In production, send email with tempPassword
|
||||||
|
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
lastLogin: user.lastLogin?.toISOString() || null,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUsuario(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
data: UserUpdate
|
||||||
|
): Promise<UserListItem> {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
data: {
|
||||||
|
...(data.nombre && { nombre: data.nombre }),
|
||||||
|
...(data.role && { role: data.role }),
|
||||||
|
...(data.active !== undefined && { active: data.active }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
nombre: true,
|
||||||
|
role: true,
|
||||||
|
active: true,
|
||||||
|
lastLogin: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
lastLogin: user.lastLogin?.toISOString() || null,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUsuario(tenantId: string, userId: string): Promise<void> {
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
208
apps/web/app/(dashboard)/alertas/page.tsx
Normal file
208
apps/web/app/(dashboard)/alertas/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas';
|
||||||
|
import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const prioridadStyles = {
|
||||||
|
alta: 'border-l-4 border-l-destructive bg-destructive/5',
|
||||||
|
media: 'border-l-4 border-l-warning bg-warning/5',
|
||||||
|
baja: 'border-l-4 border-l-muted bg-muted/5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const prioridadIcons = {
|
||||||
|
alta: AlertCircle,
|
||||||
|
media: AlertTriangle,
|
||||||
|
baja: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AlertasPage() {
|
||||||
|
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes');
|
||||||
|
const { data: alertas, isLoading } = useAlertas({
|
||||||
|
resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined,
|
||||||
|
});
|
||||||
|
const { data: stats } = useAlertasStats();
|
||||||
|
const updateAlerta = useUpdateAlerta();
|
||||||
|
const deleteAlerta = useDeleteAlerta();
|
||||||
|
const markAllAsRead = useMarkAllAsRead();
|
||||||
|
|
||||||
|
const handleMarkAsRead = (id: number) => {
|
||||||
|
updateAlerta.mutate({ id, data: { leida: true } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = (id: number) => {
|
||||||
|
updateAlerta.mutate({ id, data: { resuelta: true } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
if (confirm('¿Eliminar esta alerta?')) {
|
||||||
|
deleteAlerta.mutate(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
title="Alertas"
|
||||||
|
description="Gestiona tus alertas y notificaciones"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total</CardTitle>
|
||||||
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.total || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">No Leidas</CardTitle>
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">{stats?.noLeidas || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Alta Prioridad</CardTitle>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-warning">{stats?.alta || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Pendientes</CardTitle>
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{(stats?.alta || 0) + (stats?.media || 0) + (stats?.baja || 0)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filter === 'todas' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter('todas')}
|
||||||
|
>
|
||||||
|
Todas
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'pendientes' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter('pendientes')}
|
||||||
|
>
|
||||||
|
Pendientes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === 'resueltas' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter('resueltas')}
|
||||||
|
>
|
||||||
|
Resueltas
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markAllAsRead.mutate()}
|
||||||
|
disabled={markAllAsRead.isPending}
|
||||||
|
>
|
||||||
|
Marcar todas como leidas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alertas List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : alertas?.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
<CheckCircle className="h-12 w-12 mx-auto mb-4 text-success" />
|
||||||
|
<p>No hay alertas {filter === 'pendientes' ? 'pendientes' : ''}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
alertas?.map((alerta) => {
|
||||||
|
const Icon = prioridadIcons[alerta.prioridad];
|
||||||
|
return (
|
||||||
|
<Card key={alerta.id} className={cn(prioridadStyles[alerta.prioridad], alerta.leida && 'opacity-60')}>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Icon className={cn(
|
||||||
|
'h-5 w-5 mt-0.5',
|
||||||
|
alerta.prioridad === 'alta' && 'text-destructive',
|
||||||
|
alerta.prioridad === 'media' && 'text-warning',
|
||||||
|
alerta.prioridad === 'baja' && 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium">{alerta.titulo}</h4>
|
||||||
|
{!alerta.leida && (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-primary text-primary-foreground rounded-full">
|
||||||
|
Nueva
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{alerta.mensaje}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span>{new Date(alerta.createdAt).toLocaleDateString('es-MX')}</span>
|
||||||
|
{alerta.fechaVencimiento && (
|
||||||
|
<span>Vence: {new Date(alerta.fechaVencimiento).toLocaleDateString('es-MX')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{!alerta.leida && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleMarkAsRead(alerta.id)}
|
||||||
|
title="Marcar como leida"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!alerta.resuelta && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleResolve(alerta.id)}
|
||||||
|
title="Marcar como resuelta"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(alerta.id)}
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
apps/web/app/(dashboard)/calendario/page.tsx
Normal file
205
apps/web/app/(dashboard)/calendario/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useEventos, useUpdateEvento } from '@/lib/hooks/use-calendario';
|
||||||
|
import { Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText, CreditCard } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
|
|
||||||
|
const tipoIcons = {
|
||||||
|
declaracion: FileText,
|
||||||
|
pago: CreditCard,
|
||||||
|
obligacion: Clock,
|
||||||
|
custom: Calendar,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tipoColors = {
|
||||||
|
declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||||
|
obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||||
|
custom: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarioPage() {
|
||||||
|
const [año, setAño] = useState(new Date().getFullYear());
|
||||||
|
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||||
|
const { data: eventos, isLoading } = useEventos(año, mes);
|
||||||
|
const updateEvento = useUpdateEvento();
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
if (mes === 1) {
|
||||||
|
setMes(12);
|
||||||
|
setAño(año - 1);
|
||||||
|
} else {
|
||||||
|
setMes(mes - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
if (mes === 12) {
|
||||||
|
setMes(1);
|
||||||
|
setAño(año + 1);
|
||||||
|
} else {
|
||||||
|
setMes(mes + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleComplete = (id: number, completado: boolean) => {
|
||||||
|
updateEvento.mutate({ id, data: { completado: !completado } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate calendar days
|
||||||
|
const firstDay = new Date(año, mes - 1, 1).getDay();
|
||||||
|
const daysInMonth = new Date(año, mes, 0).getDate();
|
||||||
|
const days = Array.from({ length: 42 }, (_, i) => {
|
||||||
|
const day = i - firstDay + 1;
|
||||||
|
if (day < 1 || day > daysInMonth) return null;
|
||||||
|
return day;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEventosForDay = (day: number) => {
|
||||||
|
return eventos?.filter(e => {
|
||||||
|
const fecha = new Date(e.fechaLimite);
|
||||||
|
return fecha.getDate() === day;
|
||||||
|
}) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
title="Calendario Fiscal"
|
||||||
|
description="Obligaciones fiscales y eventos importantes"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{/* Calendar */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
{meses[mes - 1]} {año}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" size="icon" onClick={handlePrevMonth}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleNextMonth}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => (
|
||||||
|
<div key={d} className="text-center text-sm font-medium text-muted-foreground py-2">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const dayEventos = day ? getEventosForDay(day) : [];
|
||||||
|
const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[80px] p-1 border rounded-md',
|
||||||
|
day ? 'bg-background' : 'bg-muted/30',
|
||||||
|
isToday && 'ring-2 ring-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day && (
|
||||||
|
<>
|
||||||
|
<div className={cn('text-sm font-medium', isToday && 'text-primary')}>{day}</div>
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{dayEventos.slice(0, 2).map(e => {
|
||||||
|
const Icon = tipoIcons[e.tipo];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 py-0.5 rounded truncate flex items-center gap-1',
|
||||||
|
tipoColors[e.tipo],
|
||||||
|
e.completado && 'opacity-50 line-through'
|
||||||
|
)}
|
||||||
|
title={e.titulo}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{e.titulo}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{dayEventos.length > 2 && (
|
||||||
|
<div className="text-xs text-muted-foreground">+{dayEventos.length - 2} más</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Event List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Eventos del Mes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">Cargando...</div>
|
||||||
|
) : eventos?.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{eventos?.map(evento => {
|
||||||
|
const Icon = tipoIcons[evento.tipo];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={evento.id}
|
||||||
|
className={cn(
|
||||||
|
'p-3 rounded-lg border',
|
||||||
|
evento.completado && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className={cn('p-1.5 rounded', tipoColors[evento.tipo])}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className={cn('font-medium text-sm', evento.completado && 'line-through')}>
|
||||||
|
{evento.titulo}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{evento.descripcion}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{new Date(evento.fechaLimite).toLocaleDateString('es-MX', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleToggleComplete(evento.id, evento.completado)}
|
||||||
|
>
|
||||||
|
<Check className={cn('h-4 w-4', evento.completado && 'text-success')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
apps/web/app/(dashboard)/reportes/page.tsx
Normal file
283
apps/web/app/(dashboard)/reportes/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
|
||||||
|
import { BarChart } from '@/components/charts/bar-chart';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ReportesPage() {
|
||||||
|
const [año] = useState(new Date().getFullYear());
|
||||||
|
const fechaInicio = `${año}-01-01`;
|
||||||
|
const fechaFin = `${año}-12-31`;
|
||||||
|
|
||||||
|
const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin);
|
||||||
|
const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin);
|
||||||
|
const { data: comparativo, isLoading: loadingComp } = useComparativo(año);
|
||||||
|
const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
|
||||||
|
const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
title="Reportes"
|
||||||
|
description="Analisis financiero y reportes fiscales"
|
||||||
|
>
|
||||||
|
<Tabs defaultValue="estado-resultados" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="estado-resultados">Estado de Resultados</TabsTrigger>
|
||||||
|
<TabsTrigger value="flujo-efectivo">Flujo de Efectivo</TabsTrigger>
|
||||||
|
<TabsTrigger value="comparativo">Comparativo</TabsTrigger>
|
||||||
|
<TabsTrigger value="concentrado">Concentrado RFC</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="estado-resultados" className="space-y-4">
|
||||||
|
{loadingER ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : estadoResultados ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-success" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-success">
|
||||||
|
{formatCurrency(estadoResultados.totalIngresos)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
|
||||||
|
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">
|
||||||
|
{formatCurrency(estadoResultados.totalEgresos)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
|
{formatCurrency(estadoResultados.utilidadBruta)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
|
{formatCurrency(estadoResultados.utilidadNeta)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 10 Ingresos por Cliente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{estadoResultados.ingresos.map((item, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
|
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
|
||||||
|
<span className="font-medium">{formatCurrency(item.monto)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 10 Egresos por Proveedor</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{estadoResultados.egresos.map((item, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
|
<span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
|
||||||
|
<span className="font-medium">{formatCurrency(item.monto)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="flujo-efectivo" className="space-y-4">
|
||||||
|
{loadingFE ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : flujoEfectivo ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Entradas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-success">
|
||||||
|
{formatCurrency(flujoEfectivo.totalEntradas)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Salidas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-destructive">
|
||||||
|
{formatCurrency(flujoEfectivo.totalSalidas)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Flujo Neto</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${flujoEfectivo.flujoNeto >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
|
{formatCurrency(flujoEfectivo.flujoNeto)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Flujo de Efectivo Mensual</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChart
|
||||||
|
data={flujoEfectivo.entradas.map((e, i) => ({
|
||||||
|
mes: e.concepto,
|
||||||
|
ingresos: e.monto,
|
||||||
|
egresos: flujoEfectivo.salidas[i]?.monto || 0,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="comparativo" className="space-y-4">
|
||||||
|
{loadingComp ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : comparativo ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Var. Ingresos vs Ano Anterior</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${comparativo.variacionIngresos >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
|
{comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Var. Egresos vs Ano Anterior</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${comparativo.variacionEgresos <= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||||
|
{comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Ano Actual</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{año}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Comparativo Mensual {año}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChart
|
||||||
|
data={comparativo.periodos.map((mes, i) => ({
|
||||||
|
mes,
|
||||||
|
ingresos: comparativo.ingresos[i],
|
||||||
|
egresos: comparativo.egresos[i],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="concentrado" className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Clientes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{clientes?.slice(0, 10).map((c, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{c.nombre}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Proveedores
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{proveedores?.slice(0, 10).map((p, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{p.nombre}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
apps/web/app/(dashboard)/usuarios/page.tsx
Normal file
200
apps/web/app/(dashboard)/usuarios/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { Users, UserPlus, Trash2, Shield, Eye, Calculator } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
admin: { label: 'Administrador', icon: Shield, color: 'text-primary' },
|
||||||
|
contador: { label: 'Contador', icon: Calculator, color: 'text-success' },
|
||||||
|
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsuariosPage() {
|
||||||
|
const { user: currentUser } = useAuthStore();
|
||||||
|
const { data: usuarios, isLoading } = useUsuarios();
|
||||||
|
const inviteUsuario = useInviteUsuario();
|
||||||
|
const updateUsuario = useUpdateUsuario();
|
||||||
|
const deleteUsuario = useDeleteUsuario();
|
||||||
|
|
||||||
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
|
const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const });
|
||||||
|
|
||||||
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await inviteUsuario.mutateAsync(inviteForm);
|
||||||
|
setShowInvite(false);
|
||||||
|
setInviteForm({ email: '', nombre: '', role: 'visor' });
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.message || 'Error al invitar usuario');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = (id: string, active: boolean) => {
|
||||||
|
updateUsuario.mutate({ id, data: { active: !active } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('¿Eliminar este usuario?')) {
|
||||||
|
deleteUsuario.mutate(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === 'admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardShell
|
||||||
|
title="Usuarios"
|
||||||
|
description="Gestiona los usuarios de tu empresa"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
<span className="font-medium">{usuarios?.length || 0} usuarios</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button onClick={() => setShowInvite(true)}>
|
||||||
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
|
Invitar Usuario
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Form */}
|
||||||
|
{showInvite && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invitar Nuevo Usuario</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleInvite} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={inviteForm.email}
|
||||||
|
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nombre">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
id="nombre"
|
||||||
|
value={inviteForm.nombre}
|
||||||
|
onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Rol</Label>
|
||||||
|
<Select
|
||||||
|
value={inviteForm.role}
|
||||||
|
onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Administrador</SelectItem>
|
||||||
|
<SelectItem value="contador">Contador</SelectItem>
|
||||||
|
<SelectItem value="visor">Visor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={inviteUsuario.isPending}>
|
||||||
|
{inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowInvite(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users List */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{usuarios?.map(usuario => {
|
||||||
|
const roleInfo = roleLabels[usuario.role];
|
||||||
|
const RoleIcon = roleInfo.icon;
|
||||||
|
const isCurrentUser = usuario.id === currentUser?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={usuario.id} className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||||
|
'bg-primary/10 text-primary font-medium'
|
||||||
|
)}>
|
||||||
|
{usuario.nombre.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{usuario.nombre}</span>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tú</span>
|
||||||
|
)}
|
||||||
|
{!usuario.active && (
|
||||||
|
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{usuario.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={cn('flex items-center gap-1', roleInfo.color)}>
|
||||||
|
<RoleIcon className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{roleInfo.label}</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && !isCurrentUser && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleActive(usuario.id, usuario.active)}
|
||||||
|
>
|
||||||
|
{usuario.active ? 'Desactivar' : 'Activar'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(usuario.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</DashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/lib/api/alertas.ts
Normal file
33
apps/web/lib/api/alertas.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean }): Promise<AlertaFull[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.leida !== undefined) params.set('leida', String(filters.leida));
|
||||||
|
if (filters?.resuelta !== undefined) params.set('resuelta', String(filters.resuelta));
|
||||||
|
const response = await apiClient.get<AlertaFull[]>(`/alertas?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(): Promise<AlertasStats> {
|
||||||
|
const response = await apiClient.get<AlertasStats>('/alertas/stats');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAlerta(data: AlertaCreate): Promise<AlertaFull> {
|
||||||
|
const response = await apiClient.post<AlertaFull>('/alertas', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAlerta(id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||||
|
const response = await apiClient.patch<AlertaFull>(`/alertas/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAlerta(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/alertas/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAsRead(): Promise<void> {
|
||||||
|
await apiClient.post('/alertas/mark-all-read');
|
||||||
|
}
|
||||||
28
apps/web/lib/api/calendario.ts
Normal file
28
apps/web/lib/api/calendario.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getEventos(año: number, mes?: number): Promise<EventoFiscal[]> {
|
||||||
|
const params = new URLSearchParams({ año: año.toString() });
|
||||||
|
if (mes) params.set('mes', mes.toString());
|
||||||
|
const response = await apiClient.get<EventoFiscal[]>(`/calendario?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProximos(dias = 30): Promise<EventoFiscal[]> {
|
||||||
|
const response = await apiClient.get<EventoFiscal[]>(`/calendario/proximos?dias=${dias}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvento(data: EventoCreate): Promise<EventoFiscal> {
|
||||||
|
const response = await apiClient.post<EventoFiscal>('/calendario', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvento(id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||||
|
const response = await apiClient.patch<EventoFiscal>(`/calendario/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvento(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/calendario/${id}`);
|
||||||
|
}
|
||||||
36
apps/web/lib/api/reportes.ts
Normal file
36
apps/web/lib/api/reportes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string): Promise<EstadoResultados> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (fechaInicio) params.set('fechaInicio', fechaInicio);
|
||||||
|
if (fechaFin) params.set('fechaFin', fechaFin);
|
||||||
|
const response = await apiClient.get<EstadoResultados>(`/reportes/estado-resultados?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string): Promise<FlujoEfectivo> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (fechaInicio) params.set('fechaInicio', fechaInicio);
|
||||||
|
if (fechaFin) params.set('fechaFin', fechaFin);
|
||||||
|
const response = await apiClient.get<FlujoEfectivo>(`/reportes/flujo-efectivo?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComparativo(año?: number): Promise<ComparativoPeriodos> {
|
||||||
|
const params = año ? `?año=${año}` : '';
|
||||||
|
const response = await apiClient.get<ComparativoPeriodos>(`/reportes/comparativo${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConcentradoRfc(
|
||||||
|
tipo: 'cliente' | 'proveedor',
|
||||||
|
fechaInicio?: string,
|
||||||
|
fechaFin?: string
|
||||||
|
): Promise<ConcentradoRfc[]> {
|
||||||
|
const params = new URLSearchParams({ tipo });
|
||||||
|
if (fechaInicio) params.set('fechaInicio', fechaInicio);
|
||||||
|
if (fechaFin) params.set('fechaFin', fechaFin);
|
||||||
|
const response = await apiClient.get<ConcentradoRfc[]>(`/reportes/concentrado-rfc?${params}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
21
apps/web/lib/api/usuarios.ts
Normal file
21
apps/web/lib/api/usuarios.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getUsuarios(): Promise<UserListItem[]> {
|
||||||
|
const response = await apiClient.get<UserListItem[]>('/usuarios');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteUsuario(data: UserInvite): Promise<UserListItem> {
|
||||||
|
const response = await apiClient.post<UserListItem>('/usuarios/invite', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUsuario(id: string, data: UserUpdate): Promise<UserListItem> {
|
||||||
|
const response = await apiClient.patch<UserListItem>(`/usuarios/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUsuario(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/usuarios/${id}`);
|
||||||
|
}
|
||||||
61
apps/web/lib/hooks/use-alertas.ts
Normal file
61
apps/web/lib/hooks/use-alertas.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as alertasApi from '../api/alertas';
|
||||||
|
import type { AlertaCreate, AlertaUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export function useAlertas(filters?: { leida?: boolean; resuelta?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['alertas', filters],
|
||||||
|
queryFn: () => alertasApi.getAlertas(filters),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertasStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['alertas-stats'],
|
||||||
|
queryFn: alertasApi.getStats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAlerta() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: AlertaCreate) => alertasApi.createAlerta(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAlerta() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: AlertaUpdate }) => alertasApi.updateAlerta(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAlerta() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => alertasApi.deleteAlerta(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkAllAsRead() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: alertasApi.markAllAsRead,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['alertas-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
47
apps/web/lib/hooks/use-calendario.ts
Normal file
47
apps/web/lib/hooks/use-calendario.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as calendarioApi from '../api/calendario';
|
||||||
|
import type { EventoCreate, EventoUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export function useEventos(año: number, mes?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendario', año, mes],
|
||||||
|
queryFn: () => calendarioApi.getEventos(año, mes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProximosEventos(dias = 30) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendario-proximos', dias],
|
||||||
|
queryFn: () => calendarioApi.getProximos(dias),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEvento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: EventoCreate) => calendarioApi.createEvento(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendario'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEvento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: EventoUpdate }) => calendarioApi.updateEvento(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendario'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEvento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => calendarioApi.deleteEvento(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['calendario'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
30
apps/web/lib/hooks/use-reportes.ts
Normal file
30
apps/web/lib/hooks/use-reportes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import * as reportesApi from '../api/reportes';
|
||||||
|
|
||||||
|
export function useEstadoResultados(fechaInicio?: string, fechaFin?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['estado-resultados', fechaInicio, fechaFin],
|
||||||
|
queryFn: () => reportesApi.getEstadoResultados(fechaInicio, fechaFin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlujoEfectivo(fechaInicio?: string, fechaFin?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['flujo-efectivo', fechaInicio, fechaFin],
|
||||||
|
queryFn: () => reportesApi.getFlujoEfectivo(fechaInicio, fechaFin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComparativo(año?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['comparativo', año],
|
||||||
|
queryFn: () => reportesApi.getComparativo(año),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConcentradoRfc(tipo: 'cliente' | 'proveedor', fechaInicio?: string, fechaFin?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['concentrado-rfc', tipo, fechaInicio, fechaFin],
|
||||||
|
queryFn: () => reportesApi.getConcentradoRfc(tipo, fechaInicio, fechaFin),
|
||||||
|
});
|
||||||
|
}
|
||||||
40
apps/web/lib/hooks/use-usuarios.ts
Normal file
40
apps/web/lib/hooks/use-usuarios.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import * as usuariosApi from '../api/usuarios';
|
||||||
|
import type { UserInvite, UserUpdate } from '@horux/shared';
|
||||||
|
|
||||||
|
export function useUsuarios() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['usuarios'],
|
||||||
|
queryFn: usuariosApi.getUsuarios,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteUsuario() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: UserInvite) => usuariosApi.inviteUsuario(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUsuario() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuario(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteUsuario() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => usuariosApi.deleteUsuario(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
2755
docs/plans/2026-01-22-fase3-implementation.md
Normal file
2755
docs/plans/2026-01-22-fase3-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ export * from './types/user';
|
|||||||
export * from './types/cfdi';
|
export * from './types/cfdi';
|
||||||
export * from './types/dashboard';
|
export * from './types/dashboard';
|
||||||
export * from './types/impuestos';
|
export * from './types/impuestos';
|
||||||
|
export * from './types/alertas';
|
||||||
|
export * from './types/reportes';
|
||||||
|
export * from './types/calendario';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export * from './constants/plans';
|
export * from './constants/plans';
|
||||||
|
|||||||
35
packages/shared/src/types/alertas.ts
Normal file
35
packages/shared/src/types/alertas.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type TipoAlerta = 'vencimiento' | 'discrepancia' | 'iva_favor' | 'declaracion' | 'limite_cfdi' | 'custom';
|
||||||
|
export type PrioridadAlerta = 'alta' | 'media' | 'baja';
|
||||||
|
|
||||||
|
export interface AlertaCreate {
|
||||||
|
tipo: TipoAlerta;
|
||||||
|
titulo: string;
|
||||||
|
mensaje: string;
|
||||||
|
prioridad: PrioridadAlerta;
|
||||||
|
fechaVencimiento?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertaUpdate {
|
||||||
|
leida?: boolean;
|
||||||
|
resuelta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertaFull {
|
||||||
|
id: number;
|
||||||
|
tipo: TipoAlerta;
|
||||||
|
titulo: string;
|
||||||
|
mensaje: string;
|
||||||
|
prioridad: PrioridadAlerta;
|
||||||
|
fechaVencimiento: string | null;
|
||||||
|
leida: boolean;
|
||||||
|
resuelta: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertasStats {
|
||||||
|
total: number;
|
||||||
|
noLeidas: number;
|
||||||
|
alta: number;
|
||||||
|
media: number;
|
||||||
|
baja: number;
|
||||||
|
}
|
||||||
37
packages/shared/src/types/calendario.ts
Normal file
37
packages/shared/src/types/calendario.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type TipoEvento = 'declaracion' | 'pago' | 'obligacion' | 'custom';
|
||||||
|
export type Recurrencia = 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'unica';
|
||||||
|
|
||||||
|
export interface EventoFiscal {
|
||||||
|
id: number;
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
tipo: TipoEvento;
|
||||||
|
fechaLimite: string;
|
||||||
|
recurrencia: Recurrencia;
|
||||||
|
completado: boolean;
|
||||||
|
notas: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoCreate {
|
||||||
|
titulo: string;
|
||||||
|
descripcion: string;
|
||||||
|
tipo: TipoEvento;
|
||||||
|
fechaLimite: string;
|
||||||
|
recurrencia: Recurrencia;
|
||||||
|
notas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoUpdate {
|
||||||
|
titulo?: string;
|
||||||
|
descripcion?: string;
|
||||||
|
fechaLimite?: string;
|
||||||
|
completado?: boolean;
|
||||||
|
notas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarioMes {
|
||||||
|
año: number;
|
||||||
|
mes: number;
|
||||||
|
eventos: EventoFiscal[];
|
||||||
|
}
|
||||||
46
packages/shared/src/types/reportes.ts
Normal file
46
packages/shared/src/types/reportes.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export interface EstadoResultados {
|
||||||
|
periodo: { inicio: string; fin: string };
|
||||||
|
ingresos: { concepto: string; monto: number }[];
|
||||||
|
egresos: { concepto: string; monto: number }[];
|
||||||
|
totalIngresos: number;
|
||||||
|
totalEgresos: number;
|
||||||
|
utilidadBruta: number;
|
||||||
|
impuestos: number;
|
||||||
|
utilidadNeta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlujoEfectivo {
|
||||||
|
periodo: { inicio: string; fin: string };
|
||||||
|
saldoInicial: number;
|
||||||
|
entradas: { concepto: string; monto: number }[];
|
||||||
|
salidas: { concepto: string; monto: number }[];
|
||||||
|
totalEntradas: number;
|
||||||
|
totalSalidas: number;
|
||||||
|
flujoNeto: number;
|
||||||
|
saldoFinal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparativoPeriodos {
|
||||||
|
periodos: string[];
|
||||||
|
ingresos: number[];
|
||||||
|
egresos: number[];
|
||||||
|
utilidad: number[];
|
||||||
|
variacionIngresos: number;
|
||||||
|
variacionEgresos: number;
|
||||||
|
variacionUtilidad: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConcentradoRfc {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
tipo: 'cliente' | 'proveedor';
|
||||||
|
totalFacturado: number;
|
||||||
|
totalIva: number;
|
||||||
|
cantidadCfdis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReporteFilters {
|
||||||
|
fechaInicio: string;
|
||||||
|
fechaFin: string;
|
||||||
|
tipo?: 'mensual' | 'trimestral' | 'anual';
|
||||||
|
}
|
||||||
@@ -23,3 +23,35 @@ export interface UpdateUserRequest {
|
|||||||
role?: Role;
|
role?: Role;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserInvite {
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
role: 'admin' | 'contador' | 'visor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListItem {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nombre: string;
|
||||||
|
role: 'admin' | 'contador' | 'visor';
|
||||||
|
active: boolean;
|
||||||
|
lastLogin: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUpdate {
|
||||||
|
nombre?: string;
|
||||||
|
role?: 'admin' | 'contador' | 'visor';
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
action: string;
|
||||||
|
details: string;
|
||||||
|
ip: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user