feat(F-072): completed feature

This commit is contained in:
chattie
2026-08-19 19:22:08 +02:00
parent 9bdcc8e20e
commit 352033e3fc
16 changed files with 567 additions and 77 deletions

View File

@@ -13,7 +13,7 @@ interface VariantRow {
editingPrice: boolean;
stockValue: string;
priceValue: string;
vatRate: 'general' | 'reduced';
vatRate: 'general' | 'reduced' | 'super-reduced';
}
function formatCents(cents: number): string {
@@ -282,7 +282,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
if (!r) return null;
const grossPrice = r.price
? (r.price.netUnitAmountCents * (r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
? (r.price.netUnitAmountCents * (r.price.vatRate === 'super-reduced' ? 1.04 : r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
: null;
return (
@@ -341,7 +341,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
...prev,
[variant.id]: {
...prev[variant.id],
vatRate: e.target.value as 'general' | 'reduced',
vatRate: e.target.value as 'general' | 'reduced' | 'super-reduced',
},
}))
}
@@ -349,10 +349,11 @@ export function InventorySection({ productId }: InventorySectionProps) {
>
<option value="general">21% (general)</option>
<option value="reduced">10% (reducido)</option>
<option value="super-reduced">4% (superreducido)</option>
</select>
) : (
<span className="text-xs text-gray-500">
{r.price?.vatRate === 'reduced' ? '10%' : '21%'}
{r.price?.vatRate === 'super-reduced' ? '4%' : r.price?.vatRate === 'reduced' ? '10%' : '21%'}
</span>
)}
</td>
@@ -449,7 +450,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
? formatCents(
Math.round(
rows[variants[0].id].price!.netUnitAmountCents *
(rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
(rows[variants[0].id].vatRate === 'super-reduced' ? 1.04 : rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
),
)
: '—'}

View File

@@ -1,12 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import { productsApi, pricingApi } from '@/lib/api-client';
import { productsApi, pricingApi, taxApi, type TaxRate } from '@/lib/api-client';
import type { ProductVariant, VariantPrice } from '@/types';
const VAT_GENERAL = 1.21;
const VAT_REDUCED = 1.10;
/** Convierte céntimos a string de euros con 2 decimales ("12.00"). */
function centsToEur(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;
@@ -16,15 +17,6 @@ function eurToCents(input: string): number {
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 {
return Math.round(netCents * (vatRate === 'general' ? VAT_GENERAL : VAT_REDUCED));
}
function calcMarginBruto(grossCents: number, costCents: number): number {
if (grossCents === 0) return 0;
return Math.round(((grossCents - costCents) / grossCents) * 100);
@@ -46,7 +38,8 @@ export function PricingSection({ productId }: { productId: string }) {
const [net, setNet] = useState<Record<string, string>>({});
const [offer, setOffer] = useState<Record<string, string>>({});
const [cost, setCost] = useState<Record<string, string>>({});
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced'>>({});
const [activeTaxRates, setActiveTaxRates] = useState<TaxRate[]>([]);
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced' | 'super-reduced'>>({});
useEffect(() => {
if (!productId) { setLoadingVariants(false); return; }
@@ -74,14 +67,21 @@ export function PricingSection({ productId }: { productId: string }) {
setNet(prev => ({ ...prev, [v.id]: '0.00' }));
setOffer(prev => ({ ...prev, [v.id]: '' }));
setCost(prev => ({ ...prev, [v.id]: '' }));
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
setVatRate(prev => ({ ...prev, [v.id]: (activeTaxRates[0]?.appliesTo ?? 'general') as 'general' | 'reduced' | 'super-reduced' }));
})
.finally(() => {
done++;
if (done >= variants.length) setLoadingPrices(false);
});
}
}, [variants]);
}, [variants, activeTaxRates]);
// Fetch active tax rates on mount
useEffect(() => {
taxApi.list().then(({ items }) => {
setActiveTaxRates(items.filter(r => r.active));
}).catch(() => {});
}, []);
const savePrice = async (variantId: string) => {
const netCents = eurToCents(net[variantId] ?? '0');
@@ -135,7 +135,9 @@ export function PricingSection({ productId }: { productId: string }) {
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 activeRate = activeTaxRates.find(r => r.appliesTo === vr);
const ratePercent = activeRate?.ratePercent ?? 21;
const grossCents = Math.round(netCents * (1 + ratePercent / 100));
const grossEur = centsToEur(grossCents);
const marginBruto = calcMarginBruto(grossCents, costCents);
const editing = saving === v.id;
@@ -169,7 +171,7 @@ export function PricingSection({ productId }: { productId: string }) {
disabled={editing}
onChange={e => {
const grossInputCents = eurToCents(e.target.value);
const newNetCents = Math.round(grossInputCents / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
const newNetCents = Math.round(grossInputCents / (1 + ratePercent / 100));
setNet(prev => ({ ...prev, [v.id]: centsToEur(newNetCents) }));
}}
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]"
@@ -197,11 +199,20 @@ export function PricingSection({ productId }: { productId: string }) {
<select
value={vatRate[v.id] ?? 'general'}
disabled={editing}
onChange={e => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' }))}
onChange={e => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' | 'super-reduced' }))}
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
>
<option value="general">21% gen.</option>
<option value="reduced">10% red.</option>
{activeTaxRates.length === 0 && (
<>
<option value="general">21% gen.</option>
<option value="reduced">10% red.</option>
</>
)}
{activeTaxRates.map(r => (
<option key={r.appliesTo} value={r.appliesTo}>
{r.name} ({r.ratePercent}%)
</option>
))}
</select>
</td>
@@ -260,7 +271,7 @@ export function PricingSection({ productId }: { productId: string }) {
<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>
<p><strong>Margen bruto:</strong> (PVP Coste) ÷ PVP × 100. Verde &gt;30%, ámbar 10-30%, rojo &lt;10%.</p>
<p><strong>IVA:</strong> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
<p><strong>IVA:</strong> los tipos se cargan desde Configuración Tipos impositivos. Solo los tipos activos aparecen en la lista. Cambia el tipo de IVA de un producto editando la variante.</p>
</div>
</div>
);

View File

@@ -165,7 +165,7 @@ export const pricingApi = {
setVariantPrice: (
id: string,
netUnitAmountCents: number,
vatRate: 'general' | 'reduced',
vatRate: 'general' | 'reduced' | 'super-reduced',
offerCents?: number | null,
costCents?: number | null,
) =>

View File

@@ -51,7 +51,7 @@ export interface VariantPrice {
netUnitAmountCents: number;
offerCents: number | null;
costCents: number | null;
vatRate: 'general' | 'reduced';
vatRate: 'general' | 'reduced' | 'super-reduced';
currency: string;
}