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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Floresoftware Ideas')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

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 - -
+
+
+
+ + Taller moderno con QR, pagos y WhatsApp +
+

+ Repara móviles con una experiencia rápida, neon y confiable. +

+

+ 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. +

+
+ + +
+
+ {services.map((service) => ( +
+ {service} +
+ ))} +
+
-
+ +
+
+
+

Pedido demo

+

iPhone 11 · Pantalla

+
+ Nuevo +
+ +
+
+

Pago

+

Transfermóvil

+

REF-FI-24X9

+
+
+

WhatsApp

+

+5359177041

+

Mensaje en cola

+
+
+ +
+ +

QR

+

Escanea para crear un pedido desde el móvil.

+
+ +
+ {steps.map((step, index) => ( +
+ {index + 1} +
+

{step.title}

+

{step.text}

+
+
+ ))} +
+
+
+
+ + +
+
+
+ +

Bot con IA

+

Preparado para responder preguntas y convertir chats en solicitudes estructuradas.

+
+
+ +

QR de recepción

+

Un punto de entrada simple para mostrador, redes sociales, tarjetas y stickers.

+
+
+ +

Panel admin

+

Gestiona clientes, equipos, pagos, facturas y estados desde el backoffice.

+
+
+
+ + ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/repair-intake.tsx b/frontend/src/pages/repair-intake.tsx new file mode 100644 index 0000000..1bcfb93 --- /dev/null +++ b/frontend/src/pages/repair-intake.tsx @@ -0,0 +1,385 @@ +import { mdiCellphoneCog, mdiClipboardCheckOutline, mdiCreditCardOutline, mdiQrcodeScan, mdiWhatsapp } from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { getPageTitle } from '../config'; +import { useAppSelector } from '../stores/hooks'; + +type IntakeForm = { + fullName: string; + contactPhone: string; + deviceType: string; + deviceBrand: string; + deviceModel: string; + deviceColor: string; + accessoriesReceived: string; + deviceCondition: string; + problemDescription: string; + paymentProvider: 'transfermovil' | 'enzona'; + depositAmount: string; + acceptsMarketing: boolean; + source: 'public_web' | 'qr'; +}; + +type IntakeResult = { + order: { id: string; order_number: string; status: string; requested_at: string }; + device: { brand: string; model: string }; + payment: { provider_label: string; reference_code: string; status: string; amount?: string; currency: string }; + whatsapp: { to_number: string; status: string; message_body: string }; +}; + +type RecentOrder = { + id: string; + order_number: string; + status: string; + problem_description: string; + createdAt: string; + device?: { brand?: string; model?: string }; + payments_repair_order?: Array<{ + reference_code?: string; + status?: string; + payment_method?: { display_name?: string; provider?: string }; + }>; + whatsapp_notifications_repair_order?: Array<{ status?: string; to_number?: string }>; +}; + +const statusLabels: Record = { + new: 'Nuevo', + diagnosing: 'Diagnóstico', + awaiting_approval: 'Esperando aprobación', + approved: 'Aprobado', + in_repair: 'En reparación', + ready_for_pickup: 'Listo para recoger', + delivered: 'Entregado', + cancelled: 'Cancelado', +}; + +const paymentProviders = [ + { + value: 'transfermovil', + label: 'Transfermóvil', + helper: 'Ideal para clientes en Cuba. El pedido queda pendiente hasta confirmar el comprobante.', + }, + { + value: 'enzona', + label: 'Enzona', + helper: 'Usa la referencia generada y envía el comprobante al equipo del taller.', + }, +] as const; + +const fieldClass = 'w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-cyan-400 focus:ring-4 focus:ring-cyan-200/60 dark:border-dark-700 dark:bg-dark-900 dark:text-slate-100'; +const labelClass = 'mb-2 block text-sm font-semibold text-slate-700 dark:text-slate-200'; + +const buildInitialValues = (currentUser: any): IntakeForm => ({ + fullName: [currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(' ') || '', + contactPhone: currentUser?.phoneNumber || '', + deviceType: 'phone', + deviceBrand: '', + deviceModel: '', + deviceColor: '', + accessoriesReceived: '', + deviceCondition: '', + problemDescription: '', + paymentProvider: 'transfermovil', + depositAmount: '', + acceptsMarketing: true, + source: 'public_web', +}); + +const RepairIntakePage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [form, setForm] = useState(buildInitialValues(currentUser)); + const [recentOrders, setRecentOrders] = useState([]); + const [selectedOrderId, setSelectedOrderId] = useState(''); + const [result, setResult] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoadingOrders, setIsLoadingOrders] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + setForm((previous) => ({ ...previous, ...buildInitialValues(currentUser), problemDescription: previous.problemDescription })); + }, [currentUser]); + + const selectedOrder = useMemo( + () => recentOrders.find((order) => order.id === selectedOrderId) || recentOrders[0], + [recentOrders, selectedOrderId] + ); + + const fetchRecentOrders = async () => { + setIsLoadingOrders(true); + try { + const response = await axios.get('repair-intake'); + const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; + setRecentOrders(rows); + if (rows[0]?.id) setSelectedOrderId(rows[0].id); + } catch (fetchError: any) { + console.error('Failed to load repair intake orders:', fetchError); + setError(fetchError?.response?.data?.message || 'No se pudo cargar el historial de pedidos.'); + } finally { + setIsLoadingOrders(false); + } + }; + + useEffect(() => { + fetchRecentOrders(); + }, []); + + const setField = (field: keyof IntakeForm, value: string | boolean) => { + setForm((previous) => ({ ...previous, [field]: value })); + }; + + const submitIntake = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setResult(null); + setIsSubmitting(true); + + try { + const response = await axios.post('repair-intake', { + data: { + ...form, + depositAmount: form.depositAmount ? Number(form.depositAmount) : null, + }, + }); + setResult(response.data); + setForm((previous) => ({ + ...buildInitialValues(currentUser), + fullName: previous.fullName, + contactPhone: previous.contactPhone, + paymentProvider: previous.paymentProvider, + })); + await fetchRecentOrders(); + } catch (submitError: any) { + console.error('Failed to create repair intake:', submitError); + setError(submitError?.response?.data?.message || 'No se pudo crear el pedido. Revisa los datos e intenta de nuevo.'); + } finally { + setIsSubmitting(false); + } + }; + + const whatsappHref = result + ? `https://wa.me/5359177041?text=${encodeURIComponent(result.whatsapp.message_body)}` + : 'https://wa.me/5359177041'; + + return ( + <> + + {getPageTitle('Crear pedido de reparación')} + + + + + + +
+ +
+
+ Floresoftware Ideas + Transfermóvil + Enzona +
+

Crea una orden, genera referencia de pago y cola de WhatsApp.

+

+ 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. +

+
+ + {error && ( +
+ {error} +
+ )} + + {result && ( +
+
+
+

Pedido creado

+

{result.order.order_number}

+

+ Pago: {result.payment.provider_label} · Referencia {result.payment.reference_code} · WhatsApp {result.whatsapp.status} +

+
+ +
+
+ )} + +
+
+ + setField('fullName', event.target.value)} placeholder="Ej. Ana Flores" required /> +
+
+ + setField('contactPhone', event.target.value)} placeholder="Ej. +53 59177041" required /> +
+
+ + +
+
+ + +
+
+ + setField('deviceBrand', event.target.value)} placeholder="Samsung, Xiaomi, iPhone..." required /> +
+
+ + setField('deviceModel', event.target.value)} placeholder="A52, Redmi Note 12, 11 Pro..." required /> +
+
+ + setField('deviceColor', event.target.value)} placeholder="Negro, azul, dorado..." /> +
+
+ + setField('accessoriesReceived', event.target.value)} placeholder="Cargador, funda, SIM..." /> +
+
+ + setField('deviceCondition', event.target.value)} placeholder="Pantalla rota, mojado, no enciende, golpes..." /> +
+
+ +