feat(F-072): completed feature
This commit is contained in:
@@ -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),
|
||||
),
|
||||
)
|
||||
: '—'}
|
||||
|
||||
@@ -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 >30%, ámbar 10-30%, rojo <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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user