feat(F-048): completed feature
This commit is contained in:
@@ -113,7 +113,7 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
setName(v);
|
||||
if (!slugManual) setSlug(slugify(v));
|
||||
if (!seoTitleManual) setSeoTitle(v);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en mercadodevida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -273,16 +273,16 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
|
||||
<label key={key}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
|
||||
<button key={key} type="button" onClick={() => toggleAttr(key)}
|
||||
aria-pressed={attributes.includes(key)}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm text-left ${
|
||||
attributes.includes(key)
|
||||
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||
}`}>
|
||||
<input type="checkbox" checked={attributes.includes(key)}
|
||||
onChange={() => toggleAttr(key)} className="hidden" />
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${attributes.includes(key) ? 'bg-[#2D6A4F]' : 'bg-gray-300'}`} />
|
||||
{label}
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,19 @@ import type { ProductVariant, VariantPrice } from '@/types';
|
||||
const VAT_GENERAL = 1.21;
|
||||
const VAT_REDUCED = 1.10;
|
||||
|
||||
function fmt(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
// ── FIX-17: trabajo en euros con 2 decimales, acepta coma o punto ────────────
|
||||
/** Convierte una entrada de usuario ("12", "12,30", "12.30") a céntimos. */
|
||||
function eurToCents(input: string): number {
|
||||
if (!input) return 0;
|
||||
const normalized = String(input).replace(',', '.');
|
||||
const val = parseFloat(normalized);
|
||||
if (isNaN(val)) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
|
||||
/** Convierte céntimos a string de euros con 2 decimales ("12.00"). */
|
||||
function centsToEur(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
|
||||
@@ -23,7 +34,7 @@ interface PricingSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PricingSection({ productId }: PricingSectionProps) {
|
||||
export function PricingSection({ productId }: { productId: string }) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [loadingPrices, setLoadingPrices] = useState(true);
|
||||
@@ -31,7 +42,7 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Edit state per variant
|
||||
// Edit state per variant (valores en EUROS como string, p.ej. "12.00")
|
||||
const [net, setNet] = useState<Record<string, string>>({});
|
||||
const [offer, setOffer] = useState<Record<string, string>>({});
|
||||
const [cost, setCost] = useState<Record<string, string>>({});
|
||||
@@ -54,13 +65,13 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
pricingApi.getVariantPrice(v.id)
|
||||
.then((p) => {
|
||||
setPrices(prev => ({ ...prev, [v.id]: p }));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? centsToEur(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? centsToEur(p.costCents) : '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
|
||||
})
|
||||
.catch(() => {
|
||||
setNet(prev => ({ ...prev, [v.id]: '0' }));
|
||||
setNet(prev => ({ ...prev, [v.id]: '0.00' }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
|
||||
@@ -73,23 +84,17 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
}, [variants]);
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const netCents = parseInt(net[variantId] ?? '0', 10);
|
||||
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
|
||||
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
|
||||
if (isNaN(netCents) || netCents < 0) return;
|
||||
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
|
||||
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
|
||||
const netCents = eurToCents(net[variantId] ?? '0');
|
||||
const offerCentsVal = offer[variantId] ? eurToCents(offer[variantId]) : null;
|
||||
const costCentsVal = cost[variantId] ? eurToCents(cost[variantId]) : null;
|
||||
if (netCents < 0) return;
|
||||
if (offerCentsVal !== null && offerCentsVal < 0) return;
|
||||
if (costCentsVal !== null && costCentsVal < 0) return;
|
||||
setSaving(variantId);
|
||||
setMsg(prev => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
|
||||
if (offerCentsVal !== null) {
|
||||
// set offer via separate update
|
||||
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
|
||||
} else {
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
}
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
|
||||
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
@@ -127,11 +132,11 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map(v => {
|
||||
const p = prices[v.id];
|
||||
const netCents = parseInt(net[v.id] ?? '0', 10);
|
||||
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
|
||||
const netCents = eurToCents(net[v.id] ?? '0');
|
||||
const costCents = cost[v.id] ? eurToCents(cost[v.id]) : 0;
|
||||
const vr = vatRate[v.id] ?? 'general';
|
||||
const grossCents = calcGross(netCents, vr);
|
||||
const grossEur = centsToEur(grossCents);
|
||||
const marginBruto = calcMarginBruto(grossCents, costCents);
|
||||
const editing = saving === v.id;
|
||||
|
||||
@@ -144,12 +149,12 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={cost[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -159,15 +164,15 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={grossCents}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={grossEur}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const gross = parseInt(e.target.value, 10) || 0;
|
||||
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
|
||||
const grossInputCents = eurToCents(e.target.value);
|
||||
const newNetCents = Math.round(grossInputCents / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(newNetCents) }));
|
||||
}}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -177,12 +182,12 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={offer[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="—"
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -216,11 +221,11 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="number" min={0} step={1}
|
||||
value={netCents}
|
||||
type="number" min={0} step="0.01" inputMode="decimal"
|
||||
value={net[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -250,6 +255,7 @@ export function PricingSection({ productId }: PricingSectionProps) {
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p><strong>Formato:</strong> introduce los precios en euros con dos decimales (12 o 12,50 o 12.50).</p>
|
||||
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
|
||||
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
|
||||
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
|
||||
|
||||
Reference in New Issue
Block a user