V1.0-190725y2

This commit is contained in:
Flatlogic Bot 2025-07-19 16:34:24 +00:00
parent d48528d613
commit b9289338b9
12 changed files with 436 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@ -26,6 +26,8 @@
"multer": "^1.4.4",
"mysql2": "2.2.5",
"nodemailer": "6.9.9",
"googleapis": "^100.0.0",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-jwt": "^4.0.1",

View File

@ -28,6 +28,8 @@ const config = {
swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080',
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
calendarId: process.env.GOOGLE_CALENDAR_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
microsoft: {

View File

@ -20,6 +20,8 @@ const organizationForAuthRoutes = require('./routes/organizationLogin');
const openaiRoutes = require('./routes/openai');
const contactFormRoutes = require('./routes/contactForm');
const publicDashboardRoutes = require('./routes/publicDashboard');
const anonymousEventsRoutes = require('./routes/anonymousEvents');
const usersRoutes = require('./routes/users');
@ -164,7 +166,9 @@ app.use(
openaiRoutes,
);
app.use('/api/public-dashboard', publicDashboardRoutes);
app.use('/api/contact-form', contactFormRoutes);
app.use('/api/anonymous/events', anonymousEventsRoutes);
app.use(
'/api/search',

View File

@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const { models } = require('../db/models');
/**
* Anonymous event reporting endpoint
*/
router.post('/', async (req, res) => {
try {
const org = req.query.org;
const { eventType, description, eventDate, branchId } = req.body;
if (!org) {
return res.status(400).json({ error: 'org (organization id) is required' });
}
if (!eventType || !description || !eventDate || !branchId) {
return res.status(400).json({ error: 'eventType, description, eventDate and branchId are required' });
}
const newEvent = await models.events.create({
event_type: eventType,
description,
event_date: eventDate,
branchId,
organizationsId: org,
reported_byId: null,
});
res.status(201).json(newEvent);
} catch (error) {
console.error('Error saving anonymous event', error);
res.status(500).json({ error: 'Error saving anonymous event' });
}
});
/**
* Get branches for anonymous reporting (filtered by organization)
*/
router.get('/branches', async (req, res) => {
try {
const org = req.query.org;
if (!org) {
return res.status(400).json({ error: 'org (organization id) is required' });
}
const branches = await models.branches.findAll({
where: { organizationsId: org },
});
res.json(branches);
} catch (error) {
console.error('Error fetching branches for anonymous events', error);
res.status(500).json({ error: 'Error fetching branches' });
}
});
module.exports = router;

View File

@ -0,0 +1,38 @@
const express = require('express');
const router = express.Router();
const publicDashboardService = require('../services/publicDashboard');
// GET KPIs: total open incidents, overdue actions, inspections completed this month
router.get('/kpis', async (req, res) => {
try {
const kpis = await publicDashboardService.getKPIs();
res.json(kpis);
} catch (error) {
console.error('Error fetching public KPIs:', error);
res.status(500).json({ error: 'Error fetching public KPIs' });
}
});
// GET chart data: incidents by branch
router.get('/charts/incidents-by-branch', async (req, res) => {
try {
const data = await publicDashboardService.getIncidentsByBranch();
res.json(data);
} catch (error) {
console.error('Error fetching incidents by branch:', error);
res.status(500).json({ error: 'Error fetching incidents by branch' });
}
});
// GET chart data: actions status breakdown
router.get('/charts/actions-status', async (req, res) => {
try {
const data = await publicDashboardService.getActionsStatus();
res.json(data);
} catch (error) {
console.error('Error fetching actions status:', error);
res.status(500).json({ error: 'Error fetching actions status' });
}
});
module.exports = router;

View File

@ -6,17 +6,49 @@ const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const googleWorkspace = require('./googleWorkspace');
module.exports = class ActionsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await ActionsDBApi.create(data, {
const action = await ActionsDBApi.create(data, {
currentUser,
transaction,
});
await transaction.commit();
// Notify via Gmail
let assignedUser = null;
if (action.assigned_toId) {
assignedUser = await db.users.findByPk(action.assigned_toId);
await googleWorkspace.sendEmail(
assignedUser.email,
`Acción asignada: ${action.description}`,
`<p>Hola ${assignedUser.firstName || ''},</p>
<p>Se te ha asignado la siguiente acción:</p>
<ul>
<li><strong>Descripción:</strong> ${action.description}</li>
<li><strong>Fecha de vencimiento:</strong> ${action.due_date}</li>
</ul>
<p>Revisa la plataforma para más detalles.</p>`
);
}
// Create Calendar event
if (action.due_date) {
await googleWorkspace.createCalendarEvent({
summary: action.description,
description: action.description,
start: action.due_date,
end: action.due_date,
attendees: assignedUser ? [assignedUser.email] : undefined,
});
}
return action;
} catch (error) {
await transaction.rollback();
throw error;

View File

@ -0,0 +1,58 @@
const { google } = require('googleapis');
const config = require('../config');
let authClient;
function getAuth() {
if (!authClient) {
authClient = new google.auth.GoogleAuth({
credentials: JSON.parse(process.env.GOOGLE_CREDENTIALS || '{}'),
scopes: [
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/gmail.send',
],
});
}
return authClient;
}
async function sendEmail(to, subject, html) {
const auth = await getAuth();
const gmail = google.gmail({ version: 'v1', auth });
const rawMessage = [
`To: ${to}`,
'Content-Type: text/html; charset=utf-8',
'MIME-Version: 1.0',
`Subject: ${subject}`,
'',
html,
].join('\n');
const encoded = Buffer.from(rawMessage)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw: encoded },
});
}
async function createCalendarEvent({ summary, description, start, end, attendees }) {
const auth = await getAuth();
const calendar = google.calendar({ version: 'v3', auth });
await calendar.events.insert({
calendarId: config.google.calendarId,
requestBody: {
summary,
description,
start: { dateTime: start },
end: { dateTime: end },
attendees: attendees ? attendees.map(email => ({ email })) : undefined,
},
});
}
module.exports = {
sendEmail,
createCalendarEvent,
};

View File

@ -0,0 +1,70 @@
const { models, sequelize } = require('../db/models');
const { Op } = require('sequelize');
/**
* Service for Public Dashboard KPIs and Charts
*/
class PublicDashboardService {
/**
* Get KPIs: total open incidents, overdue actions, inspections completed this month
*/
static async getKPIs() {
const totalOpenIncidents = await models.events.count({
where: { status: 'Abierto' },
});
const today = new Date();
const totalOverdueActions = await models.actions.count({
where: {
dueDate: { [Op.lt]: today },
status: { [Op.ne]: 'Completada' },
},
});
// First day of current month
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const inspectionsThisMonth = await models.inspections.count({
where: {
completionDate: { [Op.gte]: startOfMonth, [Op.lte]: today },
},
});
return {
totalOpenIncidents,
totalOverdueActions,
inspectionsThisMonth,
};
}
/**
* Get incidents count grouped by branch
*/
static async getIncidentsByBranch() {
const results = await sequelize.query(
`SELECT b.name AS branch, COUNT(e.id) AS count
FROM events e
JOIN branches b ON e.branchId = b.id
WHERE e."deletedAt" IS NULL
GROUP BY b.name
ORDER BY b.name;`,
{ type: sequelize.QueryTypes.SELECT }
);
return results;
}
/**
* Get actions count grouped by status
*/
static async getActionsStatus() {
const results = await sequelize.query(
`SELECT status, COUNT(id) AS count
FROM actions
WHERE "deletedAt" IS NULL
GROUP BY status;`,
{ type: sequelize.QueryTypes.SELECT }
);
return results;
}
}
module.exports = PublicDashboardService;

View File

@ -0,0 +1 @@
{}

View File

@ -43,6 +43,11 @@ const nextConfig = {
source: '/contact',
destination: '/web_pages/contact',
},
{
source: '/reportar',
destination: '/web_pages/reportar',
},
];
},
};

View File

@ -0,0 +1,169 @@
import React, { useState, useEffect, FormEvent } from 'react';
import { useRouter } from 'next/router';
import axios from 'axios';
import LayoutGuest from '../../layouts/Guest';
import WebSiteHeader from '../../components/WebPageComponents/Header';
import WebSiteFooter from '../../components/WebPageComponents/Footer';
export default function ReportarPage() {
const router = useRouter();
const { org } = router.query;
const [branches, setBranches] = useState<Array<{ id: number; name: string }>>([]);
const [formData, setFormData] = useState({
event_type: 'Incidente',
description: '',
event_date: new Date().toISOString().slice(0, 16),
risk_level: 'Bajo',
branch_id: '',
photos: [''],
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (org) {
axios
.get(`/api/branches?org=${org}`)
.then((res) => setBranches(res.data))
.catch(() => console.error('Error cargando sucursales'));
}
}, [org]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handlePhotoChange = (index: number, value: string) => {
setFormData((prev) => {
const photos = [...prev.photos];
photos[index] = value;
return { ...prev, photos };
});
};
const addPhotoField = () => {
setFormData((prev) => ({ ...prev, photos: [...prev.photos, ''] }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!org) return;
setSubmitting(true);
try {
await axios.post(`/api/anonymous/events?org=${org}`, formData);
alert('Reporte enviado correctamente. ¡Gracias!');
router.push('/');
} catch (err) {
console.error(err);
alert('Error al enviar el reporte. Intenta nuevamente.');
} finally {
setSubmitting(false);
}
};
return (
<LayoutGuest>
<WebSiteHeader />
<main className="max-w-xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Reporte Anónimo</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
Evento
<select
name="event_type"
value={formData.event_type}
onChange={handleChange}
className="mt-1 block w-full"
>
<option value="Incidente">Incidente</option>
<option value="Cuasi-incidente">Cuasi-incidente</option>
<option value="Observación de Seguridad">Observación de Seguridad</option>
</select>
</label>
<label className="block">
Descripción
<textarea
name="description"
value={formData.description}
onChange={handleChange}
className="mt-1 block w-full"
required
/>
</label>
<label className="block">
Fecha y hora
<input
type="datetime-local"
name="event_date"
value={formData.event_date}
onChange={handleChange}
className="mt-1 block w-full"
required
/>
</label>
<label className="block">
Nivel de riesgo
<select
name="risk_level"
value={formData.risk_level}
onChange={handleChange}
className="mt-1 block w-full"
>
<option value="Bajo">Bajo</option>
<option value="Medio">Medio</option>
<option value="Alto">Alto</option>
</select>
</label>
<label className="block">
Sucursal
<select
name="branch_id"
value={formData.branch_id}
onChange={handleChange}
className="mt-1 block w-full"
required
>
<option value="">Selecciona una sucursal</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
<div>
<label className="block mb-1">Fotos (URLs)</label>
{formData.photos.map((url, idx) => (
<input
key={idx}
type="text"
value={url}
onChange={(e) => handlePhotoChange(idx, e.target.value)}
className="mt-1 block w-full mb-2"
placeholder="https://..."
/>
))}
<button type="button" onClick={addPhotoField} className="text-sm text-blue-600">
+ Agregar otra foto
</button>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-blue-600 text-white py-2 rounded"
>
{submitting ? 'Enviando...' : 'Enviar reporte'}
</button>
</form>
</main>
<WebSiteFooter />
</LayoutGuest>
);
}