Compare commits

...

8 Commits

Author SHA1 Message Date
Flatlogic Bot
f78da17780 Auto commit: 2026-05-29T19:52:59.334Z 2026-05-29 19:52:59 +00:00
Flatlogic Bot
a166cd6619 Auto commit: 2026-05-29T19:49:46.819Z 2026-05-29 19:49:46 +00:00
Flatlogic Bot
50d4397e09 Auto commit: 2026-05-29T19:46:50.256Z 2026-05-29 19:46:50 +00:00
Flatlogic Bot
4c38294fd8 Auto commit: 2026-05-29T19:40:55.663Z 2026-05-29 19:40:55 +00:00
Flatlogic Bot
29cc743de4 Auto commit: 2026-05-29T19:31:15.291Z 2026-05-29 19:31:15 +00:00
Flatlogic Bot
c01569b8e3 Auto commit: 2026-05-29T19:28:01.594Z 2026-05-29 19:28:01 +00:00
Flatlogic Bot
166ce78504 Auto commit: 2026-05-29T19:12:08.594Z 2026-05-29 19:12:08 +00:00
Flatlogic Bot
f3c47a0aab Auto commit: 2026-05-29T19:02:23.571Z 2026-05-29 19:02:23 +00:00
15 changed files with 755 additions and 164 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -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 }),

View File

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -1,6 +1,7 @@
import React from 'react'
import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import Logo from './Logo'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
@ -37,12 +38,10 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Floresoftware Ideas</b>
</div>
<Link href="/" className="flex flex-1 items-center gap-3 overflow-hidden px-3">
<Logo className="h-10 w-16 shrink-0 rounded-xl object-cover object-center shadow-lg shadow-cyan-400/20" />
<span className="truncate text-sm font-black leading-tight">Floresoftware Ideas</span>
</Link>
<button
className="hidden lg:inline-block xl:hidden p-3"
onClick={handleAsideLgCloseClick}

View File

@ -1,6 +1,7 @@
import React, { ReactNode } from 'react'
import { containerMaxW } from '../config'
import Logo from './Logo'
import Link from 'next/link'
type Props = {
children?: ReactNode
@ -15,9 +16,9 @@ export default function FooterBar({ children }: Props) {
<div className="text-center md:text-left mb-6 md:mb-0">
<b>
&copy;{year},{` `}
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
Flatlogic
</a>
<Link href="/">
Floresoftware Ideas
</Link>
.
</b>
{` `}
@ -25,9 +26,9 @@ export default function FooterBar({ children }: Props) {
</div>
<div className="flex item-center md:py-2 gap-4">
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
<Logo className="w-auto h-8 md:h-6 mx-auto" />
</a>
<Link href="/" aria-label="Floresoftware Ideas">
<Logo className="h-9 w-28 rounded-xl object-cover object-center md:h-7 md:w-24" />
</Link>
</div>
</div>
</footer>

View File

@ -2,14 +2,15 @@ import React from 'react'
type Props = {
className?: string
alt?: string
}
export default function Logo({ className = '' }: Props) {
export default function Logo({ className = '', alt = 'Floresoftware Ideas logo' }: Props) {
return (
<img
src={"https://flatlogic.com/logo.svg"}
src="/floresoftware-logo.png"
className={className}
alt={'Flatlogic logo'}>
alt={alt}>
</img>
)
}

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -1,166 +1,158 @@
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 Image from 'next/image';
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 Logo from '../components/Logo';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const services = [
'creacion de tienda virtuales de negocios',
'instalacion de Sistema Windows',
'diseño de paginas web',
'creacion de tienda virtuales de negocios',
];
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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Floresoftware Ideas')}</title>
<meta name="description" content="Taller tecnológico de reparación de móviles con pedidos, pagos, QR, WhatsApp e inteligencia artificial." />
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Floresoftware Ideas app!"/>
<main className="min-h-screen overflow-hidden bg-slate-950 text-white">
<section className="relative isolate px-6 py-8 md:px-10 lg:px-16">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.35),transparent_32%),radial-gradient(circle_at_80%_20%,rgba(217,70,239,0.28),transparent_30%),linear-gradient(135deg,#020617_0%,#06263a_46%,#1e1034_100%)]" />
<div className="absolute left-1/2 top-20 -z-10 h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl" />
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<header className="mx-auto flex max-w-7xl items-center justify-between gap-4 rounded-full border border-white/10 bg-white/10 px-4 py-3 backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="overflow-hidden rounded-2xl border border-white/15 bg-white shadow-lg shadow-cyan-300/20">
<Logo className="h-12 w-20 object-cover object-center" />
</div>
<div>
<p className="text-sm font-black leading-tight">Floresoftware Ideas</p>
<p className="text-xs text-cyan-100/70">Mobile Repair SaaS</p>
</div>
</div>
<div className="flex items-center gap-2">
<BaseButton href="/login" label="Login" color="lightDark" icon={mdiLogin} small />
<BaseButton href="/dashboard" label="Admin" color="info" icon={mdiShieldAccountOutline} small />
</div>
</header>
<div className="mx-auto grid max-w-7xl items-center gap-10 py-16 lg:grid-cols-[1.05fr_0.95fr] lg:py-24">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-cyan-200/20 bg-cyan-300/10 px-4 py-2 text-sm font-bold text-cyan-100">
<span className="h-2 w-2 rounded-full bg-emerald-300 shadow-lg shadow-emerald-300/70" />
Taller moderno con QR, pagos y WhatsApp
</div>
<h1 className="max-w-4xl text-5xl font-black tracking-tight md:text-7xl">
crear app, instalacion de SO Windows, y <span className="bg-gradient-to-r from-cyan-200 via-fuchsia-200 to-emerald-200 bg-clip-text text-transparent">moviles</span>
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-200/80">
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.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/repair-intake" label="Crear pedido ahora" color="success" icon={mdiCellphoneCog} />
<BaseButton href="https://wa.me/5359177041" target="_blank" label="WhatsApp taller" color="info" icon={mdiWhatsapp} outline />
</div>
<div className="mt-8 grid max-w-2xl gap-3 sm:grid-cols-2">
{services.map((service) => (
<div key={service} className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-sm font-semibold text-slate-100 backdrop-blur">
{service}
</div>
))}
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<CardBox className="border-0 bg-white/10 p-0 text-white shadow-2xl shadow-fuchsia-950/40 backdrop-blur-xl" hasComponentLayout>
<div className="rounded-3xl border border-white/10 bg-slate-950/75 p-5">
<div className="mb-5 overflow-hidden rounded-[2rem] border border-cyan-200/20 bg-white shadow-2xl shadow-cyan-500/20">
<Logo className="h-auto w-full object-cover" />
</div>
<div className="mb-5 flex items-center justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-[0.24em] text-cyan-300">Pedido demo</p>
<h2 className="text-2xl font-black">iPhone 11 · Pantalla</h2>
</div>
<span className="rounded-full bg-emerald-300 px-3 py-1 text-xs font-black text-slate-950">Nuevo</span>
</div>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-3xl bg-white/10 p-4">
<p className="text-xs text-slate-400">Pago</p>
<p className="mt-1 text-lg font-black">Transfermóvil</p>
<p className="text-xs text-cyan-100">9205-9598-7916-9812</p>
</div>
<div className="rounded-3xl bg-white/10 p-4">
<p className="text-xs text-slate-400">WhatsApp</p>
<p className="mt-1 text-lg font-black">+5359177041</p>
<p className="text-xs text-fuchsia-100">Mensaje en cola</p>
</div>
</div>
</div>
<div className="my-5 rounded-[2rem] border border-dashed border-cyan-200/40 bg-cyan-300/10 p-6 text-center">
<Image
src="/qr-mobile-order.jpg"
alt="Código QR para crear un pedido desde el móvil"
width={494}
height={532}
className="mx-auto w-full max-w-[220px] rounded-2xl border border-white/80 bg-white p-2 shadow-xl shadow-cyan-500/20"
priority
/>
<p className="mt-4 text-sm font-semibold text-slate-300">Escanea para crear un pedido desde el móvil.</p>
</div>
<div className="space-y-3">
{steps.map((step, index) => (
<div key={step.title} className="flex gap-3 rounded-2xl bg-white/10 p-4">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-fuchsia-300 text-sm font-black text-slate-950">{index + 1}</span>
<div>
<p className="font-black">{step.title}</p>
<p className="text-sm text-slate-300">{step.text}</p>
</div>
</div>
))}
</div>
</div>
</CardBox>
</div>
</section>
<section className="border-y border-white/10 bg-white/[0.03] px-6 py-12 md:px-10 lg:px-16">
<div className="mx-auto grid max-w-7xl gap-6 md:grid-cols-3">
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<svg className="h-8 w-8 text-cyan-300" viewBox="0 0 24 24" fill="currentColor"><path d={mdiRobotExcitedOutline} /></svg>
<h3 className="mt-4 text-xl font-black">Bot con IA</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">Preparado para responder preguntas y convertir chats en solicitudes estructuradas.</p>
</div>
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<svg className="h-8 w-8 text-fuchsia-300" viewBox="0 0 24 24" fill="currentColor"><path d={mdiQrcodeScan} /></svg>
<h3 className="mt-4 text-xl font-black">QR de recepción</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">Un punto de entrada simple para mostrador, redes sociales, tarjetas y stickers.</p>
</div>
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<svg className="h-8 w-8 text-emerald-300" viewBox="0 0 24 24" fill="currentColor"><path d={mdiShieldAccountOutline} /></svg>
<h3 className="mt-4 text-xl font-black">Panel admin</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">Gestiona clientes, equipos, pagos, facturas y estados desde el backoffice.</p>
</div>
</div>
</section>
</main>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -5,6 +5,7 @@ import type { ReactElement } from 'react';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import Logo from '../components/Logo';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
@ -165,6 +166,9 @@ export default function Login() {
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<div className="mb-4 overflow-hidden rounded-3xl border border-cyan-200/30 bg-white shadow-xl shadow-cyan-500/10">
<Logo className="h-auto w-full object-cover" />
</div>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row justify-between'>

View File

@ -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<string, string> = {
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<IntakeForm>(buildInitialValues(currentUser));
const [recentOrders, setRecentOrders] = useState<RecentOrder[]>([]);
const [selectedOrderId, setSelectedOrderId] = useState<string>('');
const [result, setResult] = useState<IntakeResult | null>(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 (
<>
<Head>
<title>{getPageTitle('Crear pedido de reparación')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCellphoneCog} title="Pedido rápido de reparación" main>
<BaseButton href="/repair_orders/repair_orders-list" label="Panel admin" color="info" small />
</SectionTitleLineWithButton>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(360px,0.65fr)]">
<CardBox className="overflow-hidden border-0 bg-gradient-to-br from-slate-950 via-cyan-950 to-fuchsia-950 text-white shadow-2xl shadow-cyan-900/20">
<div className="mb-8 rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur">
<div className="mb-3 flex flex-wrap items-center gap-3">
<span className="rounded-full bg-cyan-400/20 px-3 py-1 text-xs font-bold uppercase tracking-[0.22em] text-cyan-100">Floresoftware Ideas</span>
<span className="rounded-full bg-fuchsia-400/20 px-3 py-1 text-xs font-semibold text-fuchsia-100">Transfermóvil + Enzona</span>
</div>
<h2 className="text-3xl font-black tracking-tight md:text-4xl">Crea una orden, genera referencia de pago y cola de WhatsApp.</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-cyan-50/80">
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.
</p>
</div>
{error && (
<div className="mb-5 rounded-2xl border border-red-300/40 bg-red-500/15 p-4 text-sm text-red-50" role="alert">
{error}
</div>
)}
{result && (
<div className="mb-6 rounded-3xl border border-emerald-300/50 bg-emerald-400/15 p-5 text-emerald-50" role="status">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-emerald-100">Pedido creado</p>
<h3 className="mt-1 text-2xl font-black">{result.order.order_number}</h3>
<p className="mt-1 text-sm text-emerald-50/80">
Pago: {result.payment.provider_label} · Referencia {result.payment.reference_code} · WhatsApp {result.whatsapp.status}
</p>
</div>
<BaseButton href={whatsappHref} target="_blank" label="Enviar a WhatsApp" color="success" icon={mdiWhatsapp} />
</div>
</div>
)}
<form onSubmit={submitIntake} className="grid gap-5 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Nombre del cliente</label>
<input className={fieldClass} value={form.fullName} onChange={(event) => setField('fullName', event.target.value)} placeholder="Ej. Ana Flores" required />
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Teléfono / WhatsApp</label>
<input className={fieldClass} value={form.contactPhone} onChange={(event) => setField('contactPhone', event.target.value)} placeholder="Ej. +53 59177041" required />
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Tipo de equipo</label>
<select className={fieldClass} value={form.deviceType} onChange={(event) => setField('deviceType', event.target.value)}>
<option value="phone">Móvil</option>
<option value="tablet">Tablet</option>
<option value="laptop">Laptop</option>
<option value="other">Otro</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Fuente</label>
<select className={fieldClass} value={form.source} onChange={(event) => setField('source', event.target.value)}>
<option value="public_web">Web pública</option>
<option value="qr">Código QR</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Marca</label>
<input className={fieldClass} value={form.deviceBrand} onChange={(event) => setField('deviceBrand', event.target.value)} placeholder="Samsung, Xiaomi, iPhone..." required />
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Modelo</label>
<input className={fieldClass} value={form.deviceModel} onChange={(event) => setField('deviceModel', event.target.value)} placeholder="A52, Redmi Note 12, 11 Pro..." required />
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Color</label>
<input className={fieldClass} value={form.deviceColor} onChange={(event) => setField('deviceColor', event.target.value)} placeholder="Negro, azul, dorado..." />
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Accesorios recibidos</label>
<input className={fieldClass} value={form.accessoriesReceived} onChange={(event) => setField('accessoriesReceived', event.target.value)} placeholder="Cargador, funda, SIM..." />
</div>
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-semibold text-cyan-50">Estado físico visible</label>
<input className={fieldClass} value={form.deviceCondition} onChange={(event) => setField('deviceCondition', event.target.value)} placeholder="Pantalla rota, mojado, no enciende, golpes..." />
</div>
<div className="md:col-span-2">
<label className="mb-2 block text-sm font-semibold text-cyan-50">Problema a reparar</label>
<textarea className={`${fieldClass} min-h-[132px]`} value={form.problemDescription} onChange={(event) => setField('problemDescription', event.target.value)} placeholder="Cuéntanos qué pasó, desde cuándo falla y qué intentaste hacer." required minLength={12} />
</div>
<div className="md:col-span-2">
<p className="mb-3 text-sm font-semibold text-cyan-50">Pasarela de pago preferida</p>
<div className="grid gap-3 md:grid-cols-2">
{paymentProviders.map((provider) => (
<button
key={provider.value}
type="button"
onClick={() => setField('paymentProvider', provider.value)}
className={`rounded-3xl border p-4 text-left transition ${form.paymentProvider === provider.value ? 'border-cyan-300 bg-cyan-300/20 shadow-lg shadow-cyan-500/20' : 'border-white/10 bg-white/10 hover:border-white/30'}`}
>
<span className="flex items-center gap-2 text-lg font-black"><span></span>{provider.label}</span>
<span className="mt-2 block text-sm leading-5 text-cyan-50/75">{provider.helper}</span>
</button>
))}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-cyan-50">Depósito estimado en CUP (opcional)</label>
<input className={fieldClass} value={form.depositAmount} onChange={(event) => setField('depositAmount', event.target.value)} placeholder="Ej. 500" type="number" min="0" />
</div>
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/10 px-4 py-3 text-sm text-cyan-50">
<input type="checkbox" checked={form.acceptsMarketing} onChange={(event) => setField('acceptsMarketing', event.target.checked)} className="h-5 w-5 rounded border-white/20 text-cyan-500 focus:ring-cyan-300" />
Acepta recibir avisos del pedido y promociones por WhatsApp.
</label>
<div className="md:col-span-2 flex flex-col gap-3 pt-2 sm:flex-row">
<BaseButton type="submit" label={isSubmitting ? 'Creando pedido...' : 'Crear pedido y referencia de pago'} color="success" icon={mdiClipboardCheckOutline} disabled={isSubmitting} />
<BaseButton href="/qr_links/qr_links-list" label="Ver QR existentes" color="info" icon={mdiQrcodeScan} outline />
</div>
</form>
</CardBox>
<div className="space-y-6">
<CardBox className="border-0 bg-white shadow-xl shadow-slate-200/60 dark:bg-dark-900 dark:shadow-none">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-cyan-500">Historial</p>
<h3 className="text-2xl font-black text-slate-900 dark:text-white">Mis últimos pedidos</h3>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-500 dark:bg-dark-800">{recentOrders.length}</span>
</div>
{isLoadingOrders && <p className="rounded-2xl bg-slate-50 p-4 text-sm text-slate-500 dark:bg-dark-800">Cargando pedidos...</p>}
{!isLoadingOrders && recentOrders.length === 0 && (
<div className="rounded-3xl border border-dashed border-slate-300 p-6 text-center dark:border-dark-700">
<p className="text-lg font-bold text-slate-800 dark:text-slate-100">Aún no hay pedidos.</p>
<p className="mt-2 text-sm text-slate-500">Crea el primero y aparecerá aquí con su referencia de pago y estado.</p>
</div>
)}
<div className="space-y-3">
{recentOrders.map((order) => (
<button
key={order.id}
type="button"
onClick={() => setSelectedOrderId(order.id)}
className={`w-full rounded-3xl border p-4 text-left transition ${selectedOrder?.id === order.id ? 'border-cyan-300 bg-cyan-50 dark:border-cyan-700 dark:bg-cyan-950/30' : 'border-slate-200 hover:border-cyan-200 dark:border-dark-700 dark:hover:border-cyan-900'}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-black text-slate-900 dark:text-white">{order.order_number}</p>
<p className="mt-1 text-sm text-slate-500">{order.device?.brand} {order.device?.model}</p>
</div>
<span className="rounded-full bg-fuchsia-100 px-3 py-1 text-xs font-bold text-fuchsia-700 dark:bg-fuchsia-950 dark:text-fuchsia-200">{statusLabels[order.status] || order.status}</span>
</div>
</button>
))}
</div>
</CardBox>
<CardBox className="border-0 bg-slate-950 text-white shadow-xl shadow-slate-900/20">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-2xl bg-cyan-400/20 p-3 text-cyan-100"><span className="sr-only">Pago</span><svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor"><path d={mdiCreditCardOutline} /></svg></div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-cyan-300">Detalle</p>
<h3 className="text-xl font-black">Pedido seleccionado</h3>
</div>
</div>
{selectedOrder ? (
<div className="space-y-4 text-sm text-slate-200">
<div className="rounded-3xl bg-white/10 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-cyan-200">Problema</p>
<p className="mt-2 leading-6">{selectedOrder.problem_description}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl bg-white/10 p-4">
<p className="text-xs text-slate-400">Pago</p>
<p className="mt-1 font-bold">{selectedOrder.payments_repair_order?.[0]?.payment_method?.display_name || 'Pendiente'}</p>
</div>
<div className="rounded-2xl bg-white/10 p-4">
<p className="text-xs text-slate-400">Referencia</p>
<p className="mt-1 font-bold">{selectedOrder.payments_repair_order?.[0]?.reference_code || 'Sin referencia'}</p>
</div>
<div className="rounded-2xl bg-white/10 p-4">
<p className="text-xs text-slate-400">WhatsApp</p>
<p className="mt-1 font-bold">{selectedOrder.whatsapp_notifications_repair_order?.[0]?.status || 'Sin cola'}</p>
</div>
<div className="rounded-2xl bg-white/10 p-4">
<p className="text-xs text-slate-400">Creado</p>
<p className="mt-1 font-bold">{new Date(selectedOrder.createdAt).toLocaleDateString()}</p>
</div>
</div>
<BaseButton href={`/repair_orders/${selectedOrder.id}`} label="Abrir detalle CRUD" color="info" outline />
</div>
) : (
<p className="rounded-3xl border border-dashed border-white/20 p-5 text-slate-300">Selecciona o crea un pedido para ver su detalle.</p>
)}
</CardBox>
</div>
</div>
</SectionMain>
</>
);
};
RepairIntakePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default RepairIntakePage;