feat(F-111): completed feature
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user