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