feat(F-102): completed feature

This commit is contained in:
chattie
2026-08-21 12:02:15 +02:00
parent e46faa869a
commit 027cacd871
31 changed files with 510 additions and 80 deletions

View File

@@ -85,6 +85,12 @@ function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdi
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{method.maxWeightKg !== null ? `Máx ${method.maxWeightKg} kg` : '—'}
{method.freeShippingMaxWeightKg !== null && (
<span className="block text-xs text-gray-400">gratis hasta {method.freeShippingMaxWeightKg} kg</span>
)}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{method.active ? 'Activo' : 'Inactivo'}
@@ -103,6 +109,10 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
const [description, setDescription] = useState(method?.description ?? '');
const [maxWeight, setMaxWeight] = useState(method?.maxWeightKg != null ? String(method.maxWeightKg) : '');
const [freeMaxWeight, setFreeMaxWeight] = useState(
method?.freeShippingMaxWeightKg != null ? String(method.freeShippingMaxWeightKg) : '',
);
const [active, setActive] = useState(method?.active ?? true);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState('');
@@ -113,12 +123,16 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
const baseCostCents = Math.round(parseFloat(cost) * 100);
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
const descriptionPayload = description.trim() || null;
const maxWeightPayload = maxWeight.trim() ? parseFloat(maxWeight.replace(',', '.')) : null;
const freeMaxWeightPayload = freeMaxWeight.trim() ? parseFloat(freeMaxWeight.replace(',', '.')) : null;
if (method) {
await shippingApi.updateMethod(method.id, {
name,
baseCostCents,
freeShippingThresholdCents: freeThreshold,
description: descriptionPayload,
maxWeightKg: maxWeightPayload,
freeShippingMaxWeightKg: freeMaxWeightPayload,
active,
});
} else {
@@ -128,6 +142,8 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
baseCostCents,
freeShippingThresholdCents: freeThreshold,
description: descriptionPayload,
maxWeightKg: maxWeightPayload,
freeShippingMaxWeightKg: freeMaxWeightPayload,
active,
});
}
@@ -152,6 +168,12 @@ function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
<td className="px-4 py-3">
<input type="number" step="0.001" min="0" value={maxWeight} onChange={e => setMaxWeight(e.target.value)} placeholder="Sin límite"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-xs focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<input type="number" step="0.001" min="0" value={freeMaxWeight} onChange={e => setFreeMaxWeight(e.target.value)} placeholder="Gratis hasta kg"
className="w-full px-3 py-2 mt-1.5 border border-gray-300 rounded-lg text-xs focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
</td>
<td className="px-4 py-3">
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
@@ -284,6 +306,7 @@ export default function ShippingPage() {
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Peso</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
</tr>
@@ -296,7 +319,7 @@ export default function ShippingPage() {
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
)}
{methods.length === 0 && !showMethodForm ? (
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-400 text-sm">
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
</td></tr>
) : methods.map(m => (

View File

@@ -49,12 +49,26 @@ export function PriceStockSection({ productId }: { productId: string }) {
const [savingEan, setSavingEan] = useState(false);
const [eanMsg, setEanMsg] = useState('');
// Peso y compra mínima (F-102)
const [unitWeightKg, setUnitWeightKg] = useState('1');
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
const [savingProductMeta, setSavingProductMeta] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
useEffect(() => {
taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
productsApi
.get(productId)
.then((product) => {
if (cancelled) return;
setUnitWeightKg(String((product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1));
setMinPurchaseQty(String((product as unknown as { minPurchaseQty?: number }).minPurchaseQty ?? 1));
})
.catch(() => {});
productsApi
.getVariants(productId)
.then(async ({ items }) => {
@@ -177,6 +191,30 @@ export function PriceStockSection({ productId }: { productId: string }) {
}
};
const saveProductMeta = async () => {
const weight = parseFloat(unitWeightKg.replace(',', '.'));
const minQty = parseInt(minPurchaseQty, 10);
if (isNaN(weight) || weight <= 0 || weight > 1000) {
setMetaMsg('Peso inválido');
return;
}
if (isNaN(minQty) || minQty < 1 || minQty > 999) {
setMetaMsg('Compra mínima inválida');
return;
}
setSavingProductMeta(true);
setMetaMsg('');
try {
await productsApi.update(productId, { unitWeightKg: weight, minPurchaseQty: minQty });
setMetaMsg('✓');
setTimeout(() => setMetaMsg(''), 3000);
} catch {
setMetaMsg('Error');
} finally {
setSavingProductMeta(false);
}
};
if (loadError) {
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
}
@@ -297,6 +335,41 @@ export function PriceStockSection({ productId }: { productId: string }) {
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (kg)</label>
<input
type="text" inputMode="decimal" value={unitWeightKg}
onChange={(e) => setUnitWeightKg(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
placeholder="1"
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Compra mínima (uds.)</label>
<div className="flex items-center gap-2">
<input
type="number" min={1} max={999} value={minPurchaseQty}
onChange={(e) => setMinPurchaseQty(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50"
/>
{metaMsg && <span className={`text-xs shrink-0 ${metaMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{metaMsg}</span>}
</div>
</div>
<div className="col-span-2 flex items-end">
<p className="text-xs text-gray-400">
El peso del pedido se calcula como cantidad × peso unitario y limita los métodos de envío disponibles.
La compra mínima bloquea en la tienda los pedidos por debajo de esa cantidad.
</p>
</div>
</div>
{extraVariants > 0 && (
<p className="text-xs text-amber-600">
Este producto tiene {extraVariants} variante(s) heredada(s). Se está editando la principal.

View File

@@ -295,7 +295,8 @@ export interface ShippingZone {
}
export interface ShippingMethod {
id: string; zoneId: string; zoneName: string; name: string;
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null; active: boolean;
baseCostCents: number; freeShippingThresholdCents: number | null; description: string | null;
maxWeightKg: number | null; freeShippingMaxWeightKg: number | null; active: boolean;
}
export const shippingApi = {
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
@@ -305,9 +306,9 @@ export const shippingApi = {
api.patch('/api/admin/shipping/zones/' + id, data),
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active?: boolean }) =>
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active?: boolean }) =>
api.post<{ id: string }>('/api/admin/shipping/methods', data),
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; active: boolean }>) =>
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; description?: string | null; maxWeightKg?: number | null; freeShippingMaxWeightKg?: number | null; active: boolean }>) =>
api.patch('/api/admin/shipping/methods/' + id, data),
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
};

File diff suppressed because one or more lines are too long