V1.0-190725y2
This commit is contained in:
parent
d48528d613
commit
b9289338b9
File diff suppressed because one or more lines are too long
@ -26,6 +26,8 @@
|
|||||||
"multer": "^1.4.4",
|
"multer": "^1.4.4",
|
||||||
"mysql2": "2.2.5",
|
"mysql2": "2.2.5",
|
||||||
"nodemailer": "6.9.9",
|
"nodemailer": "6.9.9",
|
||||||
|
"googleapis": "^100.0.0",
|
||||||
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@ -28,6 +28,8 @@ const config = {
|
|||||||
swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080',
|
swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080',
|
||||||
google: {
|
google: {
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||||
|
calendarId: process.env.GOOGLE_CALENDAR_ID || '',
|
||||||
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||||
},
|
},
|
||||||
microsoft: {
|
microsoft: {
|
||||||
|
|||||||
@ -20,6 +20,8 @@ const organizationForAuthRoutes = require('./routes/organizationLogin');
|
|||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
const contactFormRoutes = require('./routes/contactForm');
|
const contactFormRoutes = require('./routes/contactForm');
|
||||||
|
const publicDashboardRoutes = require('./routes/publicDashboard');
|
||||||
|
const anonymousEventsRoutes = require('./routes/anonymousEvents');
|
||||||
|
|
||||||
const usersRoutes = require('./routes/users');
|
const usersRoutes = require('./routes/users');
|
||||||
|
|
||||||
@ -164,7 +166,9 @@ app.use(
|
|||||||
openaiRoutes,
|
openaiRoutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.use('/api/public-dashboard', publicDashboardRoutes);
|
||||||
app.use('/api/contact-form', contactFormRoutes);
|
app.use('/api/contact-form', contactFormRoutes);
|
||||||
|
app.use('/api/anonymous/events', anonymousEventsRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/search',
|
'/api/search',
|
||||||
|
|||||||
52
backend/src/routes/anonymousEvents.js
Normal file
52
backend/src/routes/anonymousEvents.js
Normal 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;
|
||||||
38
backend/src/routes/publicDashboard.js
Normal file
38
backend/src/routes/publicDashboard.js
Normal 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;
|
||||||
@ -6,17 +6,49 @@ const csv = require('csv-parser');
|
|||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
const googleWorkspace = require('./googleWorkspace');
|
||||||
|
|
||||||
|
|
||||||
module.exports = class ActionsService {
|
module.exports = class ActionsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
await ActionsDBApi.create(data, {
|
const action = await ActionsDBApi.create(data, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
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) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
58
backend/src/services/googleWorkspace.js
Normal file
58
backend/src/services/googleWorkspace.js
Normal 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,
|
||||||
|
};
|
||||||
70
backend/src/services/publicDashboard.js
Normal file
70
backend/src/services/publicDashboard.js
Normal 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;
|
||||||
1
frontend/json/runtimeError.json
Normal file
1
frontend/json/runtimeError.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@ -43,6 +43,11 @@ const nextConfig = {
|
|||||||
source: '/contact',
|
source: '/contact',
|
||||||
destination: '/web_pages/contact',
|
destination: '/web_pages/contact',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
source: '/reportar',
|
||||||
|
destination: '/web_pages/reportar',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
169
frontend/src/pages/web_pages/reportar.tsx
Normal file
169
frontend/src/pages/web_pages/reportar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user