feat(F-186): completed feature
This commit is contained in:
95
project/apps/pos/src/components/FreeItemModal.tsx
Normal file
95
project/apps/pos/src/components/FreeItemModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FreeItemModalProps {
|
||||
onAdd: (item: { name: string; unitPriceCents: number }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FreeItemModal({ onAdd, onClose }: FreeItemModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const normalized = price.trim().replace(',', '.');
|
||||
const unitPriceCents = /^\d+(?:\.\d{1,2})?$/.test(normalized)
|
||||
? Math.round(Number(normalized) * 100)
|
||||
: 0;
|
||||
if (!name.trim() || unitPriceCents <= 0) {
|
||||
setError('Indica un nombre y un precio positivo');
|
||||
return;
|
||||
}
|
||||
onAdd({ name: name.trim(), unitPriceCents });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="free-item-title"
|
||||
>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#2D6A4F]">Sin producto de almacén</p>
|
||||
<h2 id="free-item-title" className="text-2xl font-bold text-gray-900">
|
||||
Artículo libre
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Nombre o servicio
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
className="mt-1 min-h-12 w-full rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]"
|
||||
placeholder="Ej. Asesoría nutricional"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Precio (€)
|
||||
<input
|
||||
value={price}
|
||||
onChange={(event) => {
|
||||
setPrice(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
inputMode="decimal"
|
||||
className="mt-1 min-h-12 w-full rounded-xl border-2 border-gray-200 px-4 text-xl font-bold outline-none focus:border-[#2D6A4F]"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="text-sm font-medium text-red-600" aria-live="polite">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white">
|
||||
Añadir al ticket
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
project/apps/pos/src/components/PaymentModal.tsx
Normal file
168
project/apps/pos/src/components/PaymentModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { formatPrice } from '@/lib/money';
|
||||
import type { PaymentAllocation, PaymentMethod } from '@/types/checkout';
|
||||
|
||||
interface PaymentModalProps {
|
||||
method: PaymentMethod;
|
||||
remainingCents: number;
|
||||
onAdd: (allocation: PaymentAllocation) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentModal({
|
||||
method,
|
||||
remainingCents,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: PaymentModalProps) {
|
||||
const [mode, setMode] = useState<'total' | 'partial'>('total');
|
||||
const [amount, setAmount] = useState(centsToInput(remainingCents));
|
||||
const [tendered, setTendered] = useState(centsToInput(remainingCents));
|
||||
const [error, setError] = useState('');
|
||||
const amountCents = useMemo(() => parseEuros(amount), [amount]);
|
||||
const tenderedCents = useMemo(() => parseEuros(tendered), [tendered]);
|
||||
const changeCents =
|
||||
method.kind === 'cash' && tenderedCents >= amountCents ? tenderedCents - amountCents : 0;
|
||||
|
||||
const chooseMode = (next: 'total' | 'partial') => {
|
||||
setMode(next);
|
||||
setError('');
|
||||
if (next === 'total') {
|
||||
setAmount(centsToInput(remainingCents));
|
||||
setTendered(centsToInput(remainingCents));
|
||||
} else {
|
||||
setAmount('');
|
||||
setTendered('');
|
||||
}
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
if (amountCents <= 0 || amountCents > remainingCents) {
|
||||
setError('El importe debe ser positivo y no superar lo pendiente');
|
||||
return;
|
||||
}
|
||||
if (method.kind === 'cash' && tenderedCents < amountCents) {
|
||||
setError('El efectivo entregado debe cubrir el importe aplicado');
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
id: crypto.randomUUID(),
|
||||
methodCode: method.code,
|
||||
methodLabel: method.label,
|
||||
kind: method.kind,
|
||||
amountCents,
|
||||
...(method.kind === 'cash' ? { tenderedCents } : {}),
|
||||
changeCents,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="payment-title"
|
||||
>
|
||||
<div className="w-full max-w-md space-y-5 rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#2D6A4F]">
|
||||
Pendiente {formatPrice(remainingCents)}
|
||||
</p>
|
||||
<h2 id="payment-title" className="text-2xl font-bold text-gray-900">
|
||||
{method.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="min-h-12 min-w-12 rounded-xl bg-gray-100 text-xl"
|
||||
aria-label="Cerrar pago"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseMode('total')}
|
||||
className={`min-h-14 rounded-xl border-2 font-bold ${mode === 'total' ? 'border-[#2D6A4F] bg-green-50 text-[#2D6A4F]' : 'border-gray-200'}`}
|
||||
>
|
||||
Paga el total
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseMode('partial')}
|
||||
className={`min-h-14 rounded-xl border-2 font-bold ${mode === 'partial' ? 'border-[#2D6A4F] bg-green-50 text-[#2D6A4F]' : 'border-gray-200'}`}
|
||||
>
|
||||
Paga una parte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Importe que se aplica (€)
|
||||
<input
|
||||
value={amount}
|
||||
onChange={(event) => {
|
||||
setAmount(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
inputMode="decimal"
|
||||
readOnly={mode === 'total'}
|
||||
autoFocus={mode === 'partial'}
|
||||
className="mt-1 w-full rounded-xl border-2 border-gray-200 px-4 py-3 text-2xl font-bold outline-none focus:border-[#2D6A4F] read-only:bg-gray-50"
|
||||
aria-describedby="payment-error"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{method.kind === 'cash' && (
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Efectivo entregado (€)
|
||||
<input
|
||||
value={tendered}
|
||||
onChange={(event) => {
|
||||
setTendered(event.target.value);
|
||||
setError('');
|
||||
}}
|
||||
inputMode="decimal"
|
||||
className="mt-1 w-full rounded-xl border-2 border-gray-200 px-4 py-3 text-2xl font-bold outline-none focus:border-[#2D6A4F]"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{method.kind === 'cash' && tenderedCents >= amountCents && amountCents > 0 && (
|
||||
<div className="flex items-center justify-between rounded-xl bg-amber-50 p-4 text-amber-900">
|
||||
<span className="font-semibold">A devolver</span>
|
||||
<strong className="text-2xl">{formatPrice(changeCents)}</strong>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p id="payment-error" className="text-sm font-medium text-red-600" aria-live="polite">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] px-5 font-bold text-white hover:bg-[#1B4332]"
|
||||
>
|
||||
Añadir pago
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseEuros(value: string): number {
|
||||
const normalized = value.trim().replace(',', '.');
|
||||
if (!/^\d+(?:\.\d{0,2})?$/.test(normalized)) return 0;
|
||||
return Math.round(Number(normalized) * 100);
|
||||
}
|
||||
|
||||
function centsToInput(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
198
project/apps/pos/src/components/ReceiptModal.tsx
Normal file
198
project/apps/pos/src/components/ReceiptModal.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { posApi } from '@/lib/api-client';
|
||||
import { formatPrice } from '@/lib/money';
|
||||
import type { PosReceipt } from '@/types/checkout';
|
||||
|
||||
interface ReceiptModalProps {
|
||||
receipt: PosReceipt;
|
||||
initialEmail?: string;
|
||||
onDelivered: () => void;
|
||||
}
|
||||
|
||||
export default function ReceiptModal({
|
||||
receipt,
|
||||
initialEmail = '',
|
||||
onDelivered,
|
||||
}: ReceiptModalProps) {
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const printReceipt = () => {
|
||||
window.print();
|
||||
onDelivered();
|
||||
};
|
||||
|
||||
const emailReceipt = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSending(true);
|
||||
setError('');
|
||||
try {
|
||||
await posApi.emailReceipt(receipt.orderId, email.trim());
|
||||
onDelivered();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo enviar el ticket');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] overflow-y-auto bg-black/50 p-4 print:static print:bg-white print:p-0"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="receipt-title"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl print:max-w-none print:rounded-none print:p-0 print:shadow-none">
|
||||
<article className="space-y-4 text-sm text-gray-900">
|
||||
<header className="border-b border-dashed border-gray-400 pb-4 text-center">
|
||||
{receipt.header && <p className="font-semibold">{receipt.header}</p>}
|
||||
<h2 id="receipt-title" className="text-2xl font-bold">
|
||||
{receipt.company.name}
|
||||
</h2>
|
||||
{receipt.company.address && <p>{receipt.company.address}</p>}
|
||||
{receipt.company.taxId && <p>NIF/CIF: {receipt.company.taxId}</p>}
|
||||
{(receipt.company.phone || receipt.company.email) && (
|
||||
<p>{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' · ')}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<p>
|
||||
<strong>Ticket:</strong> {receipt.receiptNumber}
|
||||
</p>
|
||||
<p className="text-right">
|
||||
<strong>Fecha:</strong> {new Date(receipt.issuedAt).toLocaleString('es-ES')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Terminal:</strong> {receipt.terminal.name}
|
||||
</p>
|
||||
<p className="text-right">
|
||||
<strong>Cajero:</strong> {receipt.cashier}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead className="border-y border-dashed border-gray-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th className="py-2">Artículo</th>
|
||||
<th className="py-2 text-right">Cant.</th>
|
||||
<th className="py-2 text-right">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receipt.items.map((item, index) => (
|
||||
<tr key={`${item.sku}-${index}`} className="border-b border-gray-100 align-top">
|
||||
<td className="py-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
|
||||
</span>
|
||||
{item.discountCents > 0 && (
|
||||
<span className="block text-xs text-red-600">
|
||||
Dto. −{formatPrice(item.discountCents)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-right font-medium">{formatPrice(item.totalCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="ml-auto w-full max-w-xs space-y-1">
|
||||
<p className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(receipt.subtotalCents)}</span>
|
||||
</p>
|
||||
{receipt.discountCents > 0 && (
|
||||
<p className="flex justify-between text-red-600">
|
||||
<span>Descuentos</span>
|
||||
<span>−{formatPrice(receipt.discountCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="flex justify-between">
|
||||
<span>IVA</span>
|
||||
<span>{formatPrice(receipt.taxCents)}</span>
|
||||
</p>
|
||||
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(receipt.totalCents)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="border-y border-dashed border-gray-400 py-3">
|
||||
<h3 className="mb-2 font-bold">Formas de pago</h3>
|
||||
{receipt.payments.map((payment, index) => (
|
||||
<div key={`${payment.methodCode}-${index}`} className="flex justify-between">
|
||||
<span>{payment.methodLabel}</span>
|
||||
<span>{formatPrice(payment.amountCents)}</span>
|
||||
{payment.tenderedCents !== null && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Entregado {formatPrice(payment.tenderedCents)}
|
||||
</span>
|
||||
)}
|
||||
{payment.changeCents > 0 && (
|
||||
<strong>Cambio {formatPrice(payment.changeCents)}</strong>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{receipt.changeCents > 0 && (
|
||||
<p className="mt-2 flex justify-between rounded bg-amber-50 p-2 text-lg font-bold">
|
||||
<span>Total a devolver</span>
|
||||
<span>{formatPrice(receipt.changeCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="space-y-2 text-center text-xs">
|
||||
<p>{receipt.returnPolicy}</p>
|
||||
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>}
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<div className="mt-6 space-y-3 border-t pt-5 print:hidden">
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
Imprime o envía el ticket para preparar la siguiente venta.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={printReceipt}
|
||||
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
|
||||
>
|
||||
🖨️ Imprimir ticket
|
||||
</button>
|
||||
<form onSubmit={emailReceipt} className="flex gap-2">
|
||||
<label className="sr-only" htmlFor="receipt-email">
|
||||
Email del ticket
|
||||
</label>
|
||||
<input
|
||||
id="receipt-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="cliente@email.es"
|
||||
className="min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]"
|
||||
/>
|
||||
<button
|
||||
disabled={sending}
|
||||
className="min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Enviando…' : '✉ Enviar'}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="text-sm font-medium text-red-600" aria-live="polite">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user