Floresoftware Ideas
+Mobile Repair SaaS
+diff --git a/backend/src/index.js b/backend/src/index.js index 702ab6e..04ecfe9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,6 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); @@ -52,6 +51,7 @@ const ai_chat_sessionsRoutes = require('./routes/ai_chat_sessions'); const ai_chat_messagesRoutes = require('./routes/ai_chat_messages'); const qr_linksRoutes = require('./routes/qr_links'); +const repairIntakeRoutes = require('./routes/repair_intake'); const getBaseUrl = (url) => { @@ -143,6 +143,8 @@ app.use('/api/ai_chat_messages', passport.authenticate('jwt', {session: false}), app.use('/api/qr_links', passport.authenticate('jwt', {session: false}), qr_linksRoutes); +app.use('/api/repair-intake', passport.authenticate('jwt', { session: false }), repairIntakeRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/repair_intake.js b/backend/src/routes/repair_intake.js new file mode 100644 index 0000000..ecf1279 --- /dev/null +++ b/backend/src/routes/repair_intake.js @@ -0,0 +1,203 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const WORKSHOP_WHATSAPP = '+5359177041'; +const PAYMENT_PROVIDER_LABELS = { + transfermovil: 'Transfermóvil', + enzona: 'Enzona', +}; + +const sanitizeText = (value) => (typeof value === 'string' ? value.trim() : ''); + +const buildValidationErrors = (data) => { + const errors = []; + + if (!sanitizeText(data.fullName)) errors.push('El nombre del cliente es obligatorio.'); + if (!sanitizeText(data.contactPhone)) errors.push('El teléfono o WhatsApp es obligatorio.'); + if (!sanitizeText(data.deviceBrand)) errors.push('La marca del equipo es obligatoria.'); + if (!sanitizeText(data.deviceModel)) errors.push('El modelo del equipo es obligatorio.'); + if (!sanitizeText(data.problemDescription) || sanitizeText(data.problemDescription).length < 12) { + errors.push('Describe el problema con al menos 12 caracteres.'); + } + if (!['transfermovil', 'enzona'].includes(data.paymentProvider)) { + errors.push('Selecciona Transfermóvil o Enzona como pasarela de pago.'); + } + + return errors; +}; + +router.get('/', wrapAsync(async (req, res) => { + const rows = await db.repair_orders.findAll({ + where: { createdById: req.currentUser.id }, + include: [ + { model: db.customers, as: 'customer' }, + { model: db.devices, as: 'device' }, + { model: db.payments, as: 'payments_repair_order', include: [{ model: db.payment_methods, as: 'payment_method' }] }, + { model: db.whatsapp_notifications, as: 'whatsapp_notifications_repair_order' }, + ], + order: [['createdAt', 'DESC']], + limit: 8, + }); + + res.status(200).send({ rows }); +})); + +router.post('/', wrapAsync(async (req, res) => { + const data = req.body.data || {}; + const errors = buildValidationErrors(data); + + if (errors.length) { + return res.status(400).send({ message: errors.join(' ') }); + } + + const fullName = sanitizeText(data.fullName); + const [firstName, ...restName] = fullName.split(' '); + const lastName = restName.join(' '); + const contactPhone = sanitizeText(data.contactPhone); + const paymentProvider = data.paymentProvider; + const now = new Date(); + const orderNumber = `FI-${Date.now().toString(36).toUpperCase()}`; + const paymentReference = `REF-${orderNumber}`; + const providerLabel = PAYMENT_PROVIDER_LABELS[paymentProvider]; + + const transaction = await db.sequelize.transaction(); + + try { + const userUpdate = {}; + if (firstName && !req.currentUser.firstName) userUpdate.firstName = firstName; + if (lastName && !req.currentUser.lastName) userUpdate.lastName = lastName; + if (contactPhone && !req.currentUser.phoneNumber) userUpdate.phoneNumber = contactPhone; + + if (Object.keys(userUpdate).length) { + await db.users.update(userUpdate, { where: { id: req.currentUser.id }, transaction }); + } + + let customer = await db.customers.findOne({ + where: { userId: req.currentUser.id }, + transaction, + }); + + if (!customer) { + customer = await db.customers.create({ + customer_code: `CLI-${Date.now().toString(36).toUpperCase()}`, + preferred_contact_channel: 'whatsapp', + accepts_marketing: Boolean(data.acceptsMarketing), + userId: req.currentUser.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + } else { + await customer.update({ + preferred_contact_channel: 'whatsapp', + accepts_marketing: Boolean(data.acceptsMarketing), + updatedById: req.currentUser.id, + }, { transaction }); + } + + const device = await db.devices.create({ + device_type: data.deviceType || 'phone', + brand: sanitizeText(data.deviceBrand), + model: sanitizeText(data.deviceModel), + color: sanitizeText(data.deviceColor), + accessories_received: sanitizeText(data.accessoriesReceived), + device_condition: sanitizeText(data.deviceCondition), + customerId: customer.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + const repairOrder = await db.repair_orders.create({ + order_number: orderNumber, + source: data.source === 'qr' ? 'qr' : 'public_web', + status: 'new', + problem_description: sanitizeText(data.problemDescription), + internal_notes: `Cliente: ${fullName}\nContacto WhatsApp: ${contactPhone}\nPreferencia de pago: ${providerLabel}`, + requires_deposit: true, + deposit_amount: data.depositAmount || 0, + requested_at: now, + whatsapp_notified: true, + customerId: customer.id, + deviceId: device.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + const [paymentMethod] = await db.payment_methods.findOrCreate({ + where: { provider: paymentProvider }, + defaults: { + provider: paymentProvider, + display_name: providerLabel, + is_enabled: true, + instructions: `Realiza el pago por ${providerLabel} y conserva el comprobante. Usa la referencia ${paymentReference}.`, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, + transaction, + }); + + const payment = await db.payments.create({ + payment_type: data.depositAmount ? 'deposit' : 'full', + status: 'pending', + amount: data.depositAmount || null, + currency: 'CUP', + reference_code: paymentReference, + initiated_at: now, + notes: `Pago pendiente por ${providerLabel}. El admin debe confirmar cuando reciba el comprobante.`, + repair_orderId: repairOrder.id, + payment_methodId: paymentMethod.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + const whatsappMessage = `Nuevo pedido ${orderNumber} - ${fullName} (${contactPhone}). Equipo: ${device.brand} ${device.model}. Problema: ${repairOrder.problem_description}. Pago: ${providerLabel} / ${paymentReference}.`; + + const whatsappNotification = await db.whatsapp_notifications.create({ + event_type: 'order_created', + to_number: WORKSHOP_WHATSAPP, + message_body: whatsappMessage, + status: 'queued', + queued_at: now, + repair_orderId: repairOrder.id, + createdById: req.currentUser.id, + updatedById: req.currentUser.id, + }, { transaction }); + + await transaction.commit(); + + res.status(201).send({ + order: { + id: repairOrder.id, + order_number: repairOrder.order_number, + status: repairOrder.status, + requested_at: repairOrder.requested_at, + }, + device: { + brand: device.brand, + model: device.model, + }, + payment: { + id: payment.id, + provider: paymentProvider, + provider_label: providerLabel, + reference_code: payment.reference_code, + status: payment.status, + amount: payment.amount, + currency: payment.currency, + }, + whatsapp: { + id: whatsappNotification.id, + to_number: WORKSHOP_WHATSAPP, + status: whatsappNotification.status, + message_body: whatsappMessage, + }, + }); + } catch (error) { + await transaction.rollback(); + throw error; + } +})); + +module.exports = router; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 6548433..2ef67e9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 1f1440d..7d6cd34 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/repair-intake', + label: 'Nuevo pedido rápido', + icon: icon.mdiCellphoneCog, + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 61229b1..d023f77 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,147 @@ - -import React, { useEffect, useState } from 'react'; +import { mdiCellphoneCog, mdiLogin, mdiQrcodeScan, mdiRobotExcitedOutline, mdiShieldAccountOutline, mdiWhatsapp } from '@mdi/js'; import type { ReactElement } from 'react'; import Head from 'next/head'; -import Link from 'next/link'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const services = [ + 'Cambio de pantalla y batería', + 'Diagnóstico de humedad y carga', + 'Software, desbloqueos y optimización', + 'Facturas, pagos y seguimiento online', +]; + +const steps = [ + { title: 'Escanea o entra', text: 'El cliente llega desde QR, landing o bot y abre su pedido.' }, + { title: 'Describe el fallo', text: 'Se capturan equipo, síntomas, contacto y preferencia de pago.' }, + { title: 'Paga y recibe estado', text: 'Transfermóvil/Enzona quedan listos con referencia y cola de WhatsApp.' }, +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Floresoftware Ideas' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-For guides and documentation please check - your local README.md and the Flatlogic documentation
+Floresoftware Ideas
+Mobile Repair SaaS
+© 2026 {title}. All rights reserved
- - Privacy Policy - -+ Landing pública + portal de clientes + panel admin para convertir consultas en pedidos, generar referencias de Transfermóvil/Enzona y preparar avisos al WhatsApp del taller. +
+Pedido demo
+Pago
+Transfermóvil
+REF-FI-24X9
++5359177041
+Mensaje en cola
+QR
+Escanea para crear un pedido desde el móvil.
+{step.title}
+{step.text}
+Preparado para responder preguntas y convertir chats en solicitudes estructuradas.
+Un punto de entrada simple para mostrador, redes sociales, tarjetas y stickers.
+Gestiona clientes, equipos, pagos, facturas y estados desde el backoffice.
++ Este flujo captura los datos esenciales del cliente y del móvil, abre el pedido, deja el pago pendiente y prepara el mensaje para enviar al WhatsApp del taller. +
+Pedido creado
++ Pago: {result.payment.provider_label} · Referencia {result.payment.reference_code} · WhatsApp {result.whatsapp.status} +
+Historial
+Cargando pedidos...
} + + {!isLoadingOrders && recentOrders.length === 0 && ( +Aún no hay pedidos.
+Crea el primero y aparecerá aquí con su referencia de pago y estado.
+Detalle
+Problema
+{selectedOrder.problem_description}
+Pago
+{selectedOrder.payments_repair_order?.[0]?.payment_method?.display_name || 'Pendiente'}
+Referencia
+{selectedOrder.payments_repair_order?.[0]?.reference_code || 'Sin referencia'}
+{selectedOrder.whatsapp_notifications_repair_order?.[0]?.status || 'Sin cola'}
+Creado
+{new Date(selectedOrder.createdAt).toLocaleDateString()}
+Selecciona o crea un pedido para ver su detalle.
+ )} +