Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f78da17780 | ||
|
|
a166cd6619 | ||
|
|
50d4397e09 | ||
|
|
4c38294fd8 | ||
|
|
29cc743de4 | ||
|
|
c01569b8e3 | ||
|
|
166ce78504 | ||
|
|
f3c47a0aab |
BIN
assets/pasted-20260529-190502-23b2e942.png
Normal file
BIN
assets/pasted-20260529-190502-23b2e942.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/pasted-20260529-194319-f73b7011.jpg
Normal file
BIN
assets/pasted-20260529-194319-f73b7011.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
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 ai_chat_messagesRoutes = require('./routes/ai_chat_messages');
|
||||||
|
|
||||||
const qr_linksRoutes = require('./routes/qr_links');
|
const qr_linksRoutes = require('./routes/qr_links');
|
||||||
|
const repairIntakeRoutes = require('./routes/repair_intake');
|
||||||
|
|
||||||
|
|
||||||
const getBaseUrl = (url) => {
|
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/qr_links', passport.authenticate('jwt', {session: false}), qr_linksRoutes);
|
||||||
|
|
||||||
|
app.use('/api/repair-intake', passport.authenticate('jwt', { session: false }), repairIntakeRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'/api/openai',
|
'/api/openai',
|
||||||
passport.authenticate('jwt', { session: false }),
|
passport.authenticate('jwt', { session: false }),
|
||||||
|
|||||||
203
backend/src/routes/repair_intake.js
Normal file
203
backend/src/routes/repair_intake.js
Normal 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;
|
||||||
BIN
frontend/public/floresoftware-logo.png
Normal file
BIN
frontend/public/floresoftware-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/qr-mobile-order.jpg
Normal file
BIN
frontend/public/qr-mobile-order.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
import { mdiLogout, mdiClose } from '@mdi/js'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
import Logo from './Logo'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
@ -37,12 +38,10 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
<div
|
<div
|
||||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
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">
|
<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" />
|
||||||
<b className="font-black">Floresoftware Ideas</b>
|
<span className="truncate text-sm font-black leading-tight">Floresoftware Ideas</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
className="hidden lg:inline-block xl:hidden p-3"
|
||||||
onClick={handleAsideLgCloseClick}
|
onClick={handleAsideLgCloseClick}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { containerMaxW } from '../config'
|
import { containerMaxW } from '../config'
|
||||||
import Logo from './Logo'
|
import Logo from './Logo'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@ -15,9 +16,9 @@ export default function FooterBar({ children }: Props) {
|
|||||||
<div className="text-center md:text-left mb-6 md:mb-0">
|
<div className="text-center md:text-left mb-6 md:mb-0">
|
||||||
<b>
|
<b>
|
||||||
©{year},{` `}
|
©{year},{` `}
|
||||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
<Link href="/">
|
||||||
Flatlogic
|
Floresoftware Ideas
|
||||||
</a>
|
</Link>
|
||||||
.
|
.
|
||||||
</b>
|
</b>
|
||||||
{` `}
|
{` `}
|
||||||
@ -25,9 +26,9 @@ export default function FooterBar({ children }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex item-center md:py-2 gap-4">
|
<div className="flex item-center md:py-2 gap-4">
|
||||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
<Link href="/" aria-label="Floresoftware Ideas">
|
||||||
<Logo className="w-auto h-8 md:h-6 mx-auto" />
|
<Logo className="h-9 w-28 rounded-xl object-cover object-center md:h-7 md:w-24" />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -2,14 +2,15 @@ import React from 'react'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
|
alt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Logo({ className = '' }: Props) {
|
export default function Logo({ className = '', alt = 'Floresoftware Ideas logo' }: Props) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={"https://flatlogic.com/logo.svg"}
|
src="/floresoftware-logo.png"
|
||||||
className={className}
|
className={className}
|
||||||
alt={'Flatlogic logo'}>
|
alt={alt}>
|
||||||
</img>
|
</img>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/repair-intake',
|
||||||
|
label: 'Nuevo pedido rápido',
|
||||||
|
icon: icon.mdiCellphoneCog,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,166 +1,158 @@
|
|||||||
|
import { mdiCellphoneCog, mdiLogin, mdiQrcodeScan, mdiRobotExcitedOutline, mdiShieldAccountOutline, mdiWhatsapp } from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Image from 'next/image';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
import Logo from '../components/Logo';
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
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() {
|
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 (
|
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>
|
<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>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className="min-h-screen overflow-hidden bg-slate-950 text-white">
|
||||||
<div
|
<section className="relative isolate px-6 py-8 md:px-10 lg:px-16">
|
||||||
className={`flex ${
|
<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%)]" />
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="absolute left-1/2 top-20 -z-10 h-72 w-72 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||||
} min-h-screen w-full`}
|
|
||||||
>
|
<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">
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<div className="flex items-center gap-3">
|
||||||
? imageBlock(illustrationImage)
|
<div className="overflow-hidden rounded-2xl border border-white/15 bg-white shadow-lg shadow-cyan-300/20">
|
||||||
: null}
|
<Logo className="h-12 w-20 object-cover object-center" />
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
</div>
|
||||||
? videoBlock(illustrationVideo)
|
<div>
|
||||||
: null}
|
<p className="text-sm font-black leading-tight">Floresoftware Ideas</p>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<p className="text-xs text-cyan-100/70">Mobile Repair SaaS</p>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
</div>
|
||||||
<CardBoxComponentTitle title="Welcome to your Floresoftware Ideas app!"/>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 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">
|
<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>
|
{steps.map((step, index) => (
|
||||||
<p className='text-center '>For guides and documentation please check
|
<div key={step.title} className="flex gap-3 rounded-2xl bg-white/10 p-4">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</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>
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { ReactElement } from 'react';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import Logo from '../components/Logo';
|
||||||
import BaseIcon from "../components/BaseIcon";
|
import BaseIcon from "../components/BaseIcon";
|
||||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
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'>
|
<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>
|
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||||
|
|
||||||
<div className='flex flex-row justify-between'>
|
<div className='flex flex-row justify-between'>
|
||||||
|
|||||||
385
frontend/src/pages/repair-intake.tsx
Normal file
385
frontend/src/pages/repair-intake.tsx
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user