feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -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>

View File

@@ -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>