feat(F-111): completed feature

This commit is contained in:
chattie
2026-08-21 10:10:35 +02:00
parent 18f605591b
commit b87f4f0c85
13 changed files with 331 additions and 26 deletions

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import type { Order, OrderState, Product } from '@/types';
import type { Order, OrderState, Product, OrderHistoryEvent } from '@/types';
import { ordersApi, productsApi } from '@/lib/api-client';
const STATE_LABELS: Record<OrderState, string> = {
@@ -87,12 +87,29 @@ export default function OrderDetailPage() {
const [addingProductId, setAddingProductId] = useState<string | null>(null);
const [addingVariants, setAddingVariants] = useState<{ id: string; sku: string }[]>([]);
// Envío e historial (F-111)
const [history, setHistory] = useState<OrderHistoryEvent[]>([]);
const [shippingTracking, setShippingTracking] = useState('');
const [shippingNote, setShippingNote] = useState('');
const [savingShipping, setSavingShipping] = useState(false);
const [shippingMsg, setShippingMsg] = useState<{ ok: boolean; text: string } | null>(null);
const loadHistory = useCallback(async () => {
try {
const result = await ordersApi.history(id);
setHistory(result.items ?? []);
} catch {
setHistory([]);
}
}, [id]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await ordersApi.get(id);
setOrder(data);
setShippingTracking(data.trackingNumber ?? '');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar');
} finally {
@@ -100,7 +117,7 @@ export default function OrderDetailPage() {
}
}, [id]);
useEffect(() => { load(); }, [load]);
useEffect(() => { load(); loadHistory(); }, [load, loadHistory]);
const handleTransition = async (nextState: OrderState) => {
if (nextState === 'SHIPPED' && !trackingInput.trim()) return;
@@ -108,9 +125,11 @@ export default function OrderDetailPage() {
try {
const updated = await ordersApi.transition(id, nextState, trackingInput);
setOrder(updated);
setShippingTracking(updated.trackingNumber ?? '');
setShowConfirm(null);
setConfirmReason('');
setTrackingInput('');
loadHistory();
if (updated.notified) {
setNotification({ ok: true, message: 'Cliente notificado por email.' });
} else {
@@ -228,6 +247,29 @@ export default function OrderDetailPage() {
}
};
const handleSaveShipping = async () => {
const tracking = shippingTracking.trim();
if (!tracking) {
setShippingMsg({ ok: false, text: 'Indica un número de seguimiento' });
return;
}
setSavingShipping(true);
setShippingMsg(null);
try {
const updated = await ordersApi.updateShipping(id, tracking, shippingNote);
setOrder(updated);
setShippingTracking(updated.trackingNumber ?? tracking);
setShippingNote('');
setShippingMsg({ ok: true, text: 'Envío actualizado y registrado en el historial' });
loadHistory();
setTimeout(() => setShippingMsg(null), 4000);
} catch (err) {
setShippingMsg({ ok: false, text: err instanceof Error ? err.message : 'Error al guardar envío' });
} finally {
setSavingShipping(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-64">
@@ -447,6 +489,41 @@ export default function OrderDetailPage() {
{/* Sidebar */}
<div className="space-y-6">
{/* Envío */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Envío</h2>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
Número de seguimiento
</label>
<input
type="text"
value={shippingTracking}
onChange={(e) => setShippingTracking(e.target.value)}
placeholder="p. ej. PQ123456789ES"
maxLength={120}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<input
type="text"
value={shippingNote}
onChange={(e) => setShippingNote(e.target.value)}
placeholder="Nota interna (opcional)"
maxLength={500}
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<button
onClick={handleSaveShipping}
disabled={savingShipping || !shippingTracking.trim()}
className="mt-3 w-full px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{savingShipping ? 'Guardando…' : 'Guardar envío'}
</button>
{shippingMsg && (
<p className={`mt-2 text-xs ${shippingMsg.ok ? 'text-green-600' : 'text-red-600'}`}>{shippingMsg.text}</p>
)}
<p className="mt-2 text-xs text-gray-400">Cada cambio queda registrado en el historial del pedido.</p>
</div>
{/* Totals */}
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
@@ -491,6 +568,21 @@ export default function OrderDetailPage() {
</p>
</div>
</div>
{[...history].reverse().map((event) => (
<div key={event.id} className="flex gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${event.eventType === 'SHIPPING_UPDATE' ? 'bg-purple-400' : 'bg-blue-400'}`} />
<div className="min-w-0">
<p className="text-sm text-gray-800 break-words">{event.message}</p>
<p className="text-xs text-gray-400">
{new Date(event.createdAt).toLocaleString('es-ES')}
{event.actorEmail ? ` · ${event.actorEmail}` : ''}
</p>
</div>
</div>
))}
{history.length === 0 && (
<p className="text-xs text-gray-400">Sin eventos registrados todavía.</p>
)}
</div>
</div>
</div>

View File

@@ -113,6 +113,13 @@ export const ordersApi = {
),
editItems: (id: string, items: { variantId: string; quantity: number }[]) =>
api.put<import('@/types').Order>(`/api/orders/${id}/items`, { items }),
updateShipping: (id: string, trackingNumber: string, note?: string) =>
api.post<import('@/types').Order>(
`/api/orders/${id}/shipping`,
note?.trim() ? { trackingNumber, note } : { trackingNumber },
),
history: (id: string) =>
api.get<{ items: import('@/types').OrderHistoryEvent[] }>(`/api/orders/${id}/history`),
};
// ── Customers ─────────────────────────────────────────────────────────────────

View File

@@ -115,6 +115,15 @@ export interface Order {
updatedAt: string;
}
export interface OrderHistoryEvent {
id: string;
eventType: 'STATE_CHANGE' | 'SHIPPING_UPDATE' | string;
message: string;
trackingNumber: string | null;
actorEmail: string | null;
createdAt: string;
}
export interface OrderSummary {
id: string;
userId: string;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
/**
* Order history log. Every shipping/tracking change and admin state transition
* is recorded so the order detail can show a full audit trail.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS orders_order_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders_orders(id) ON DELETE CASCADE,
event_type text NOT NULL,
message text NOT NULL,
tracking_number text NULL,
actor_email text NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
`);
pgm.sql(
`CREATE INDEX IF NOT EXISTS idx_orders_order_history_order ON orders_order_history (order_id, created_at)`,
);
};
export const down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS orders_order_history`);
};

View File

@@ -12,7 +12,7 @@ import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.
import type { OrderState } from '../domain/order.js';
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
import { sendOrderStatusEmail } from '../infrastructure/order-status-mailer.js';
import { sendOrderStatusEmail, ORDER_STATE_LABELS } from '../infrastructure/order-status-mailer.js';
export interface OrdersRoutesDeps {
pool: pg.Pool;
@@ -275,10 +275,92 @@ export async function registerOrdersRoutes(
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
request.log.warn({ err: error, orderId: order.id }, 'order_status_email_failed');
}
await recordOrderHistory(
deps.pool,
order.id,
'STATE_CHANGE',
`Estado cambiado a ${ORDER_STATE_LABELS[order.state] ?? order.state}${order.trackingNumber ? ` · Tracking: ${order.trackingNumber}` : ''}`,
order.trackingNumber ?? null,
user.email,
);
return reply.send({ ...serializeOrder(order), notified, notificationError: notificationError || undefined });
},
);
// ── Gestión de envío (admin): tracking + historial ────────────────────────
const shippingSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Update order shipping (admin)',
description: 'Actualiza el número de seguimiento del pedido y registra el cambio en el historial.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.post('/orders/:id/shipping', { schema: shippingSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const { trackingNumber, note } = parseJson(
z.object({
trackingNumber: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
}),
request.body,
);
const existing = await service.getOrderAdmin(id);
if (!existing) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
await deps.pool.query(
`UPDATE orders_orders SET tracking_number = $2, updated_at = now() WHERE id = $1`,
[id, trackingNumber],
);
const message = `Seguimiento actualizado: ${trackingNumber}${note ? ` · Nota: ${note}` : ''}`;
await recordOrderHistory(deps.pool, id, 'SHIPPING_UPDATE', message, trackingNumber, user.email);
const updated = await service.getOrderAdmin(id);
return reply.send(serializeOrder(updated ?? { ...existing, trackingNumber }));
});
const historySchema: FastifySchema = {
tags: ['Orders'],
summary: 'Order history (admin)',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/orders/:id/history', { schema: historySchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const result = await deps.pool.query<{
id: string;
event_type: string;
message: string;
tracking_number: string | null;
actor_email: string | null;
created_at: Date;
}>(
`SELECT id, event_type, message, tracking_number, actor_email, created_at
FROM orders_order_history WHERE order_id = $1 ORDER BY created_at ASC, id ASC`,
[id],
);
return reply.send({
items: result.rows.map((row) => ({
id: row.id,
eventType: row.event_type,
message: row.message,
trackingNumber: row.tracking_number,
actorEmail: row.actor_email,
createdAt: row.created_at,
})),
});
});
const editItemsSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Edit order items (admin)',
@@ -454,3 +536,26 @@ function serializeOrder(order: {
updatedAt: order.updatedAt.toISOString(),
};
}
/**
* Registra un evento en el historial del pedido. Los fallos se loguean pero
* nunca bloquean la operación principal (transición o actualización de envío).
*/
async function recordOrderHistory(
pool: pg.Pool,
orderId: string,
eventType: string,
message: string,
trackingNumber: string | null,
actorEmail: string,
): Promise<void> {
try {
await pool.query(
`INSERT INTO orders_order_history (order_id, event_type, message, tracking_number, actor_email)
VALUES ($1, $2, $3, $4, $5)`,
[orderId, eventType, message, trackingNumber, actorEmail],
);
} catch {
// Historial es best-effort: no debe romper la operación principal.
}
}