diff --git a/backlog/features.json b/backlog/features.json index 965d7fd..f410f13 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7554,7 +7554,7 @@ "qa": true, "close": true }, - "completed_at": "2026-08-23T07:02:05Z" + "completed_at": "2026-08-24T13:40:01Z" }, { "id": "F-201", diff --git a/project/apps/admin/next.config.ts b/project/apps/admin/next.config.ts index 134d343..bb432c5 100644 --- a/project/apps/admin/next.config.ts +++ b/project/apps/admin/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93', 'localhost'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'admin-mv.rikrdo.com'], // Keep Turbopack rooted at this app. The repository also contains the // legacy project/frontend/package-lock.json; without an explicit root, // Next.js 16 may infer the wrong workspace during production builds. diff --git a/project/apps/admin/package.json b/project/apps/admin/package.json index ef2c57f..8562ac6 100644 --- a/project/apps/admin/package.json +++ b/project/apps/admin/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3004", + "dev": "next dev --port 3001", "build": "next build", - "start": "next start --port 3004", + "start": "next start --port 3001", "lint": "eslint .", "typecheck": "tsc --noEmit" }, diff --git a/project/apps/admin/src/app/(dashboard)/orders/page.tsx b/project/apps/admin/src/app/(dashboard)/orders/page.tsx index 2a0d1af..17ac67f 100644 --- a/project/apps/admin/src/app/(dashboard)/orders/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -153,7 +153,7 @@ export default function OrdersPage() { - {['ID', 'Fecha', 'Total', 'Estado'].map((h) => ( + {['ID', 'Fecha', 'Cliente', 'Total', 'Estado'].map((h) => ( diff --git a/project/apps/admin/src/components/ServerLogViewer.tsx b/project/apps/admin/src/components/ServerLogViewer.tsx index c304c59..1b246c8 100644 --- a/project/apps/admin/src/components/ServerLogViewer.tsx +++ b/project/apps/admin/src/components/ServerLogViewer.tsx @@ -7,6 +7,14 @@ interface LogEntry { levelName: string; msg: string; err?: Record; + method?: string; + url?: string; + statusCode?: number; + durationMs?: number; + ip?: string; + remoteIp?: string; + userAgent?: string; + referer?: string | null; raw: string; } @@ -39,6 +47,13 @@ function parseLine(line: string): LogEntry { levelName: LEVEL_NAMES[level] ?? 'INFO', msg: obj.msg ?? '', err: obj.err, + method: obj.method, + url: obj.url, + statusCode: obj.statusCode, + durationMs: obj.durationMs, + ip: obj.ip ?? obj.remoteIp, + userAgent: obj.userAgent, + referer: obj.referer, raw: line, }; } catch { @@ -206,6 +221,44 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se {/* Message */}
+ +

{o.email || o.customerName || 'โ€”'}

+ {o.email &&

{o.email}

} +

{formatPrice(o.totalCents)}

+ {/* Extra fields for HTTP request logs */} + {entry.method && entry.url && ( + + = 500 ? 'text-red-400' : + entry.statusCode && entry.statusCode >= 400 ? 'text-amber-400' : + entry.statusCode && entry.statusCode >= 300 ? 'text-blue-400' : + 'text-green-400' + }`}>{entry.method} + {entry.url} + {entry.statusCode && ( + = 500 ? 'text-red-400' : + entry.statusCode >= 400 ? 'text-amber-400' : + entry.statusCode >= 300 ? 'text-blue-400' : + 'text-green-400' + }`}>{entry.statusCode} + )} + {entry.durationMs !== undefined && ( + {entry.durationMs}ms + )} + ยท + {entry.ip && ( + ๐ŸŒ {entry.ip} + )} + {entry.referer && ( + โ† {entry.referer.replace(/^https?:\/\//, '').slice(0, 60)} + )} + {entry.userAgent && ( + + {entry.userAgent.includes('Postman') ? '๐Ÿ“ฎ Postman' : + entry.userAgent.includes('curl') ? '๐Ÿ“ก curl' : + entry.userAgent.includes('node') ? 'โš™๏ธ Node' : + '๐Ÿ–ฅ๏ธ Browser'} + + )} + + )} {entry.msg} {entry.err && ( diff --git a/project/apps/pos/.env.example b/project/apps/pos/.env.example new file mode 100644 index 0000000..d2a5e83 --- /dev/null +++ b/project/apps/pos/.env.example @@ -0,0 +1,8 @@ +# URL del backend para el proxy del POS. +# En producciรณn (detrรกs de Traefik): no es necesario, usa el mismo origen. +# En desarrollo local o por IP LAN: REQUIRED โ€” el proxy no sabe dรณnde estรก el backend. +# Copiar a .env.local y ajustar segรบn tu entorno: +# Dev local: POS_BACKEND_URL=http://localhost:3000 +# Dev por IP: POS_BACKEND_URL=http://192.168.18.93:3000 +# Producciรณn: dejar vacรญo (Traefik sirve todo en el mismo dominio) +# POS_BACKEND_URL=http://localhost:3000 diff --git a/project/apps/pos/next.config.ts b/project/apps/pos/next.config.ts index 1c6bf50..c4d8103 100644 --- a/project/apps/pos/next.config.ts +++ b/project/apps/pos/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93', 'localhost'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'tpv-mv.rikrdo.com'], turbopack: { root: __dirname, }, diff --git a/project/apps/pos/package.json b/project/apps/pos/package.json index 30be720..2b06c5a 100644 --- a/project/apps/pos/package.json +++ b/project/apps/pos/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3006", + "dev": "next dev --port 3002", "build": "next build", - "start": "next start --port 3006" + "start": "next start --port 3002" }, "dependencies": { "next": "^16.3.1", diff --git a/project/apps/pos/public/images/logo-main.png b/project/apps/pos/public/images/logo-main.png new file mode 100644 index 0000000..ab3283d Binary files /dev/null and b/project/apps/pos/public/images/logo-main.png differ diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index 4d387a8..9d32691 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -111,7 +111,10 @@ export default function RegisterPage() { const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); const [pendingSales, setPendingSales] = useState([]); + const [todaySales, setTodaySales] = useState([]); const [loadingPending, setLoadingPending] = useState(false); + const [salesTab, setSalesTab] = useState<'pending' | 'day'>('day'); + const [printingSaleId, setPrintingSaleId] = useState(null); const [restPaymentFor, setRestPaymentFor] = useState(null); const [processingRest, setProcessingRest] = useState(false); const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>( @@ -203,13 +206,38 @@ export default function RegisterPage() { } }, [config?.terminal?.id, config?.session?.status]); + // Load today's completed sales for the sidebar + const loadTodaySales = useCallback(async () => { + if (!config?.terminal) { + setTodaySales([]); + return; + } + try { + const data = await posApi.listSales<{ items: PosPendingSale[] }>({ + terminalId: config.terminal.id, + }); + // Filter to today's date (completed or any state) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayItems = (data.items ?? []).filter((sale) => { + const saleDate = new Date(sale.createdAt); + saleDate.setHours(0, 0, 0, 0); + return saleDate.getTime() === today.getTime(); + }); + setTodaySales(todayItems); + } catch { + setTodaySales([]); + } + }, [config?.terminal?.id]); + // POS-FIX-9: removed 10s polling โ€” only manual refresh via button // POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id) useEffect(() => { if (!config?.terminal?.id || config.session?.status !== 'OPEN') return; void loadPendingSales(); - }, [config?.terminal?.id, config?.session?.status, loadPendingSales]); + void loadTodaySales(); + }, [config?.terminal?.id, config?.session?.status, loadPendingSales, loadTodaySales]); useEffect(() => { if (!config?.session || config.session.status !== 'OPEN') return; @@ -580,6 +608,26 @@ export default function RegisterPage() { } }; + // POS-FIX-12: reprint ticket โ€” load receipt modal for printing + const reprintSale = async (sale: PosPendingSale) => { + setPrintingSaleId(sale.id); + setError(''); + try { + const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id); + // Show the receipt modal for printing + setReceipt(result.receipt); + // Also trigger print immediately after modal renders + setTimeout(() => { + const printBtn = document.querySelector('button[class*="bg-\[\#2D6A4F\]"]') as HTMLButtonElement | null; + if (printBtn) printBtn.click(); + }, 100); + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo reimprimir el ticket'); + } finally { + setPrintingSaleId(null); + } + }; + // POS-FIX-8: recover a parked sale into the current cart const recoverSale = async (sale: PosPendingSale) => { // POS-FIX-9: if cart has items, show merge dialog instead of auto-merge @@ -855,71 +903,264 @@ export default function RegisterPage() { return (
{/* POS-FIX-4: product added toast */} diff --git a/project/apps/pos/src/app/api/[...path]/route.ts b/project/apps/pos/src/app/api/[...path]/route.ts index fd78a69..0380862 100644 --- a/project/apps/pos/src/app/api/[...path]/route.ts +++ b/project/apps/pos/src/app/api/[...path]/route.ts @@ -1,11 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; -// Use same host as browser to preserve cookies. In production, use the actual backend URL. +// Resuelve la URL del backend: +// 1. POS_BACKEND_URL: explรญcito (dev por IP LAN, prod con traefik en otro host) +// 2. Mismo origen que el navegador: funciona en dev local y prod detrรกs de traefik +// (traefik sirve frontend+backend en el mismo dominio, routando /api/* al backend) function getBackendUrl(request: NextRequest): string { - if (process.env.POS_BACKEND_URL) { - return `${process.env.POS_BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`; + const backendUrl = process.env.POS_BACKEND_URL; + if (backendUrl) { + const base = backendUrl.replace(/\/$/, ''); + return `${base}/${apiPath(request)}${request.nextUrl.search}`; } - // Dev: use same host/port as browser to keep cookies working + // Fallback: mismo origen que el navegador (dev local y prod traefik) const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol; const host = request.headers.get('x-forwarded-host') ?? request.nextUrl.host; return `${protocol}//${host}/${apiPath(request)}${request.nextUrl.search}`; diff --git a/project/apps/pos/src/components/ReceiptModal.tsx b/project/apps/pos/src/components/ReceiptModal.tsx index 493ed2d..a96ade4 100644 --- a/project/apps/pos/src/components/ReceiptModal.tsx +++ b/project/apps/pos/src/components/ReceiptModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { posApi } from '@/lib/api-client'; import { formatPrice } from '@/lib/money'; import type { PosReceipt } from '@/types/checkout'; @@ -10,6 +10,7 @@ interface ReceiptModalProps { initialEmail?: string; onDelivered: () => void; onReturn?: (orderId: string, receipt: PosReceipt) => void; + showActions?: boolean; // false when used just for printing } export default function ReceiptModal({ @@ -17,13 +18,23 @@ export default function ReceiptModal({ initialEmail = '', onDelivered, onReturn, + showActions = true, }: ReceiptModalProps) { + const articleRef = useRef(null); const [email, setEmail] = useState(initialEmail); const [sending, setSending] = useState(false); const [error, setError] = useState(''); const printReceipt = () => { + // Hide everything except the ticket content before printing + document.querySelectorAll('.no-print').forEach((el) => { + (el as HTMLElement).style.visibility = 'hidden'; + }); window.print(); + // Restore after printing + document.querySelectorAll('.no-print').forEach((el) => { + (el as HTMLElement).style.visibility = ''; + }); onDelivered(); }; @@ -49,161 +60,194 @@ export default function ReceiptModal({ aria-labelledby="receipt-title" >
-
-
- {receipt.header &&

{receipt.header}

} -

- {receipt.company.name} -

- {receipt.company.address &&

{receipt.company.address}

} - {receipt.company.taxId &&

NIF/CIF: {receipt.company.taxId}

} - {(receipt.company.phone || receipt.company.email) && ( -

{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' ยท ')}

- )} -
- -
-

- Ticket: {receipt.receiptNumber} -

-

- Fecha: {new Date(receipt.issuedAt).toLocaleString('es-ES')} -

-

- Terminal: {receipt.terminal.name} -

-

- Cajero: {receipt.cashier} -

-
- - - - - - - - - - - {receipt.items.map((item, index) => ( - - - - - - ))} - -
ArtรญculoCant.Subtotal
- {item.name} - - {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' ยท libre' : ''} - - {item.discountCents > 0 && ( - - Dto. โˆ’{formatPrice(item.discountCents)} - - )} - {item.quantity}{formatPrice(item.totalCents)}
- -
-

- Subtotal - {formatPrice(receipt.subtotalCents)} -

- {receipt.discountCents > 0 && ( -

- Descuentos - โˆ’{formatPrice(receipt.discountCents)} -

- )} -

- IVA - {formatPrice(receipt.taxCents)} -

-

- Total - {formatPrice(receipt.totalCents)} -

-
- -
-

Formas de pago

- {receipt.payments.map((payment, index) => ( -
- {payment.methodLabel} - {formatPrice(payment.amountCents)} - {payment.tenderedCents !== null && ( - - Entregado {formatPrice(payment.tenderedCents)} - - )} - {payment.changeCents > 0 && ( - Cambio {formatPrice(payment.changeCents)} - )} + {/* Ticket content - only this gets printed */} +
+
+
+ {/* Logo */} +
+ Logo
- ))} - {receipt.changeCents > 0 && ( -

- Total a devolver - {formatPrice(receipt.changeCents)} + {receipt.header &&

{receipt.header}

} +

+ {receipt.company.name} +

+ {receipt.company.address &&

{receipt.company.address}

} + {receipt.company.taxId &&

NIF/CIF: {receipt.company.taxId}

} + {(receipt.company.phone || receipt.company.email) && ( +

{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' ยท ')}

+ )} +
+ +
+

+ Ticket: {receipt.receiptNumber}

- )} -
+

+ Fecha: {new Date(receipt.issuedAt).toLocaleString('es-ES')} +

+

+ Terminal: {receipt.terminal.name} +

+

+ Cajero: {receipt.cashier} +

+
-
-

{receipt.returnPolicy}

- {receipt.footer &&

{receipt.footer}

} -
- + + + + + + + + + + {receipt.items.map((item, index) => ( + + + + + + ))} + +
ArtรญculoCant.Subtotal
+ {item.name} + + {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' ยท libre' : ''} + + {item.discountCents > 0 && ( + + Dto. โˆ’{formatPrice(item.discountCents)} + + )} + {item.quantity}{formatPrice(item.totalCents)}
-
-

- Imprime o envรญa el ticket para preparar la siguiente venta. -

- - {onReturn && !receipt.isReturn && ( +
+

+ Subtotal + {formatPrice(receipt.subtotalCents)} +

+ {receipt.discountCents > 0 && ( +

+ Descuentos + โˆ’{formatPrice(receipt.discountCents)} +

+ )} +

+ IVA + {formatPrice(receipt.taxCents)} +

+

+ Total + {formatPrice(receipt.totalCents)} +

+
+ +
+

Formas de pago

+ {receipt.payments.map((payment, index) => ( +
+ {payment.methodLabel} + {formatPrice(payment.amountCents)} + {payment.tenderedCents !== null && ( + + Entregado {formatPrice(payment.tenderedCents)} + + )} + {payment.changeCents > 0 && ( + Cambio {formatPrice(payment.changeCents)} + )} +
+ ))} + {receipt.changeCents > 0 && ( +

+ Total a devolver + {formatPrice(receipt.changeCents)} +

+ )} +
+ +
+

{receipt.returnPolicy}

+ {receipt.footer &&

{receipt.footer}

} +
+ +
+ + {/* Actions - hidden when printing */} + {showActions && ( +
+

+ Imprime o envรญa el ticket para preparar la siguiente venta. +

- )} -
- - 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]" - /> - -
- {error && ( -

- {error} -

- )} -
+ {onReturn && !receipt.isReturn && ( + + )} +
+ + setEmail(event.target.value)} + placeholder="cliente@email.es" + className="no-print min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]" + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ )} + + {/* Print-only styles */} + ); } diff --git a/project/apps/pos/src/lib/api-client.ts b/project/apps/pos/src/lib/api-client.ts index 112b2f9..d810102 100644 --- a/project/apps/pos/src/lib/api-client.ts +++ b/project/apps/pos/src/lib/api-client.ts @@ -3,11 +3,12 @@ const API = '/api'; async function apiFetch(path: string, init?: RequestInit): Promise { + const hasBody = init?.body !== undefined && init.body !== null && init.body !== ''; const res = await fetch(`${API}${path}`, { ...init, credentials: 'include', headers: { - 'Content-Type': 'application/json', + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), ...(init?.headers ?? {}), }, }); @@ -102,6 +103,12 @@ export const posApi = { method: 'POST', body: JSON.stringify({ email }), }), + /** Print receipt for any sale (pending, completed, refunded). */ + printReceipt: (orderId: string) => + apiFetch<{ url: string }>(`/pos/sales/${encodeURIComponent(orderId)}/print`), + /** Get full receipt for a sale (used for returns). */ + getReceipt: (orderId: string) => + apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/receipt`), }; export const authApi = { diff --git a/project/frontend/next.config.ts b/project/frontend/next.config.ts index 6d47eae..9fea957 100644 --- a/project/frontend/next.config.ts +++ b/project/frontend/next.config.ts @@ -5,7 +5,7 @@ import type { NextConfig } from 'next'; const frontendRoot = dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'shop-mv.rikrdo.com'], turbopack: { root: frontendRoot, }, diff --git a/project/migrations/060_inventory_allow_cancel_operation.js b/project/migrations/060_inventory_allow_cancel_operation.js new file mode 100644 index 0000000..6bdbce3 --- /dev/null +++ b/project/migrations/060_inventory_allow_cancel_operation.js @@ -0,0 +1,22 @@ +'use strict'; + +exports.shorthands = undefined; + +exports.up = (pgm) => { + // Allow 'cancel' operation in inventory_movements for POS-FIX-8 (delete parked sale restores stock) + pgm.sql(` + ALTER TABLE inventory_movements + DROP CONSTRAINT IF EXISTS inventory_movements_operation_check, + ADD CONSTRAINT inventory_movements_operation_check + CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text, 'cancel'::text])) + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + ALTER TABLE inventory_movements + DROP CONSTRAINT IF EXISTS inventory_movements_operation_check, + ADD CONSTRAINT inventory_movements_operation_check + CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text)) + `); +}; diff --git a/project/scripts/monolith.sh b/project/scripts/monolith.sh index 190684b..fe521eb 100755 --- a/project/scripts/monolith.sh +++ b/project/scripts/monolith.sh @@ -28,14 +28,15 @@ Commands: start Install/build when needed, migrate, and start every service restart Stop managed processes, then start every service status Show PID, process state, HTTP status, and URLs + check Verify all services are responding (exit 0 if all OK) stop Gracefully stop every managed HTTP process logs Follow all service logs (Ctrl-C exits without stopping services) urls Print localhost and LAN URLs watch Auto-respawn any dead service every WATCH_INTERVAL seconds Environment overrides: - LAN_IP, BACKEND_PORT, FRONTEND_PORT, ADMIN_PORT, STOREFRONT_PORT - MDV_RUNTIME_DIR, START_TIMEOUT + LAN_IP, BACKEND_PORT, ADMIN_PORT, TPV_PORT, FRONTEND_PORT, STOREFRONT_PORT + MDV_RUNTIME_DIR, START_TIMEOUT, WATCH_INTERVAL EOF } @@ -44,7 +45,7 @@ if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then exit 2 fi case "$ACTION" in - start|restart|status|stop|logs|urls|watch) ;; + start|restart|status|check|stop|logs|urls|watch) ;; *) usage >&2; exit 2 ;; esac @@ -96,6 +97,17 @@ service_url() { printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")" } +service_domain() { + # Returns the subdomain for Traefik/production access + case "$1" in + backend) echo "api-mv.rikrdo.com" ;; + admin) echo "admin-mv.rikrdo.com" ;; + tpv) echo "tpv-mv.rikrdo.com" ;; + frontend) echo "shop-mv.rikrdo.com" ;; + storefront) echo "seo-mv.rikrdo.com" ;; + esac +} + pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; } log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; } @@ -387,20 +399,71 @@ watch_loop() { done } +check_services() { + # Verify all services are responding with HTTP 2xx or 3xx + # Exit 0 if all OK, exit 1 if any service is down + local service url code failed=0 + echo "Checking all services..." + for service in "${SERVICES[@]}"; do + url="$(service_url "$service")" + code="$(curl --max-time 5 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)" + if [[ "$code" =~ ^[23] ]]; then + echo "[OK] $service ($code) โ€” $url" + else + echo "[FAIL] $service (HTTP $code) โ€” $url" + failed=1 + fi + done + echo + if [[ $failed -eq 0 ]]; then + echo "All services are up!" + return 0 + else + echo "Some services are down." + return 1 + fi +} + print_urls() { + # Detect if Traefik/production domains are reachable + local api_domain="$(service_domain backend)" + local admin_domain="$(service_domain admin)" + local tpv_domain="$(service_domain tpv)" + local shop_domain="$(service_domain frontend)" + local seo_domain="$(service_domain storefront)" + cat <