Facturas de cliente

This commit is contained in:
David Arranz 2025-10-14 15:46:57 +02:00
parent d971411757
commit 606601026a
18 changed files with 266 additions and 190 deletions

View File

@ -182,7 +182,7 @@
},
{
"name": "REC 5,2%",
"name": "Rec. de equivalencia 5,2%",
"code": "rec_5_2",
"value": "520",
"scale": "2",
@ -191,7 +191,7 @@
"aeat_code": "51"
},
{
"name": "REC 1,75%",
"name": "Rec. de equivalencia 1,75%",
"code": "rec_1_75",
"value": "175",
"scale": "2",
@ -200,7 +200,7 @@
"aeat_code": "52"
},
{
"name": "REC 1,4%",
"name": "Rec. de equivalencia 1,4%",
"code": "rec_1_4",
"value": "140",
"scale": "2",
@ -209,7 +209,7 @@
"aeat_code": null
},
{
"name": "REC 1%",
"name": "Rec. de equivalencia 1%",
"code": "rec_1",
"value": "100",
"scale": "2",
@ -218,7 +218,7 @@
"aeat_code": null
},
{
"name": "REC 0,62%",
"name": "Rec. de equivalencia 0,62%",
"code": "rec_0_62",
"value": "62",
"scale": "2",
@ -227,7 +227,7 @@
"aeat_code": null
},
{
"name": "REC 0,5%",
"name": "Rec. de equivalencia 0,5%",
"code": "rec_0_5",
"value": "50",
"scale": "2",
@ -236,7 +236,7 @@
"aeat_code": null
},
{
"name": "REC 0,26%",
"name": "Rec. de equivalencia 0,26%",
"code": "rec_0_26",
"value": "26",
"scale": "2",
@ -245,7 +245,7 @@
"aeat_code": null
},
{
"name": "REC 0%",
"name": "Rec. de equivalencia 0%",
"code": "rec_0",
"value": "0",
"scale": "2",

View File

@ -110,18 +110,17 @@
<body>
<header id="header">
<aside class="flex items-start mb-4 w-full ">
<header>
<aside class="flex items-start mb-4 w-full">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="flex flex-col items-start text-left" style="flex:0 0 70%;">
<div class="w-[70%] flex flex-col items-start text-left">
<img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full">
<div class="p-1 ">
<p>Nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="p-1 ml-28">
<div class="p-1 ml-9">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
@ -131,10 +130,10 @@
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right h-full">
<div class="ml-auto flex flex-col items-end text-right">
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" />
<div class="not-italic text-xs leading-tight h-full">
<div class="not-italic text-xs leading-tight">
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
@ -145,8 +144,8 @@
</header>
<main id="main">
<h1>FACTURA PROFORMA</h1>
<section id="details" class="border-b border-black ">
<div class="relative pt-0 border-b border-black">
<!-- Badge TOTAL decorado con imagen -->
<div class="absolute -top-9 right-0">
@ -226,11 +225,15 @@
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#if taxes_amount }}
<tr>
<td class="px-4 text-right">IVA&nbsp;21%</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{else}}
<!-- iva 0-->
{{/if}}
<tr class="">
<td class="px-4 text-right accent-color">
Total&nbsp;factura
@ -245,12 +248,12 @@
</section>
</main>
<footer id="footer" class="mt-4">
<aside>
<p class="text-left"><strong>Factura Proforma.</strong>
Este documento es de carácter informativo y no tiene validez contable ni fiscal. Contiene precios y condiciones
de venta sujetos a confirmación del cliente. Solo adquirirá validez como factura definitiva una vez aceptados
dichos términos.</p>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
| CIF: B83999441 -
Rodax Software S.L.</p>
</aside>
</footer>

View File

@ -110,17 +110,18 @@
<body>
<header>
<aside class="flex items-start mb-4 w-full">
<header id="header">
<aside class="flex items-start mb-4 w-full ">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
<div class="w-[70%] flex flex-col items-start text-left">
<div class="flex flex-col items-start text-left" style="flex:0 0 70%;">
<img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
<div class="flex w-full">
<div class="p-1 ">
<p>Factura nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p>Nº:<strong>&nbsp;{{invoice_number}}</strong></p>
<p><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div>
<div class="p-1 ml-9">
<div class="p-1 ml-28">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p>
@ -130,10 +131,10 @@
</div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
<div class="ml-auto flex flex-col items-end text-right">
<div class="ml-auto flex flex-col items-end text-right h-full">
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" />
<div class="not-italic text-xs leading-tight">
<div class="not-italic text-xs leading-tight h-full">
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
@ -144,8 +145,8 @@
</header>
<main id="main">
<h1>FACTURA PROFORMA</h1>
<section id="details" class="border-b border-black ">
<div class="relative pt-0 border-b border-black">
<!-- Badge TOTAL decorado con imagen -->
<div class="absolute -top-9 right-0">
@ -225,15 +226,11 @@
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td>
</tr>
{{#if taxes_amount }}
<tr>
<td class="px-4 text-right">IVA&nbsp;21%</td>
<td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td>
</tr>
{{else}}
<!-- iva 0-->
{{/if}}
<tr class="">
<td class="px-4 text-right accent-color">
Total&nbsp;factura
@ -248,12 +245,12 @@
</section>
</main>
<footer id="footer" class="mt-4">
<aside>
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
| CIF: B83999441 -
Rodax Software S.L.</p>
<p class="text-left"><strong>Factura Proforma.</strong>
Este documento es de carácter informativo y no tiene validez contable ni fiscal. Contiene precios y condiciones
de venta sujetos a confirmación del cliente. Solo adquirirá validez como factura definitiva una vez aceptados
dichos términos.</p>
</aside>
</footer>

View File

@ -65,7 +65,7 @@
"form_groups": {
"customer": {
"title": "Customer",
"description": "Select the customer for this invoice"
"description": "Select the customer for this invoice."
},
"items": {
"title": "Invoice details",
@ -77,7 +77,7 @@
},
"totals": {
"title": "Invoice totals",
"description": ""
"description": "Breakdown of invoice amounts with discounts and taxes."
},
"tax_resume": {
"title": "Resumen de impuestos",

View File

@ -78,7 +78,7 @@
},
"totals": {
"title": "Totales de la factura",
"description": ""
"description": "Desglose de los importes de la factura con descuentos e impuestos."
},
"preferences": {
"title": "Preferencias",

View File

@ -4,8 +4,6 @@ import { cn } from '@repo/shadcn-ui/lib/utils';
import { InvoiceFormData } from "../../schemas";
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
import { InvoiceItems } from './invoice-items-editor';
import { InvoiceNotes } from './invoice-notes';
import { InvoiceTaxSummary } from './invoice-tax-summary';
import { InvoiceTotals } from './invoice-totals';
import { InvoiceRecipient } from "./recipient";
@ -27,33 +25,27 @@ export const CustomerInvoiceEditForm = ({
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={cn("space-y-6", className)}>
<div className='w-full'>
<div className='w-full grid grid-cols-4'>
<div className="col-span-3">
<InvoiceBasicInfoFields className="flex flex-col" />
</div>
<div className='col-span-1'>
<InvoiceRecipient className="flex flex-col" />
</div>
</div>
<div className="mx-auto grid w-full grid-cols-1 grid-flow-col gap-6 lg:grid-cols-2 items-stretch">
<div className="lg:col-start-1 lg:row-span-2 h-full">
<InvoiceBasicInfoFields className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceRecipient className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceNotes className="h-full flex flex-col" />
</div>
<div className="lg:col-start-1 lg:col-span-full h-full">
<div className="mx-auto grid w-full grid-cols-1 grid-flow-col gap-6 lg:grid-cols-4 items-stretch">
<div className="lg:col-start-1 lg:col-span-3 h-full">
<InvoiceItems className="h-full flex flex-col" />
</div>
<div className="h-full lg:col-start-1">
<InvoiceTaxSummary className="h-full flex flex-col" />
</div>
<div className="h-full ">
<InvoiceTotals className="h-full flex flex-col" />
<InvoiceTotals />
</div>
</div>

View File

@ -19,15 +19,16 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
return (
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<FileTextIcon className='size-5' /> {t("form_groups.basic_into.title")}
<Fieldset {...props} className='border n'>
<Legend>
<FileTextIcon className='size-4 stroke-2' />{t("form_groups.basic_into.title")}
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-x-6 xl:grid-cols-2'>
<FieldGroup className='grid grid-cols-1'>
<Field>
<TextField
className='hidden'
control={control}
name='invoice_number'
readOnly
@ -48,6 +49,17 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
/>
</Field>
<Field>
<DatePickerInputField
control={control}
numberOfMonths={2}
name='operation_date'
label={t("form_fields.operation_date.label")}
placeholder={t("form_fields.operation_date.placeholder")}
description={t("form_fields.operation_date.description")}
/>
</Field>
<Field >
<TextField
typePreset='text'
@ -59,16 +71,7 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
/>
</Field>
<Field>
<DatePickerInputField
control={control}
numberOfMonths={2}
name='operation_date'
label={t("form_fields.operation_date.label")}
placeholder={t("form_fields.operation_date.placeholder")}
description={t("form_fields.operation_date.description")}
/>
</Field>
<Field>
<TextField

View File

@ -1,28 +1,25 @@
import { Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { Package } from "lucide-react";
import { Rows4Icon } from "lucide-react";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { Description, FieldGroup, Fieldset, Legend } from '@repo/rdx-ui/components';
import { ComponentProps } from 'react';
import { useTranslation } from '../../i18n';
import { ItemsEditor } from "./items";
export const InvoiceItems = ({ className, ...props }: ComponentProps<"div">) => {
export const InvoiceItems = ({ className, ...props }: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
return (
<Card className={cn("border-none shadow-none", className)} {...props}>
<CardHeader>
<div className='flex items-center justify-between'>
<CardTitle className='text-lg font-medium flex items-center gap-2'>
<Package className='size-5' />
{t('form_groups.items.title')}
</CardTitle>
</div>
</CardHeader>
<CardContent className='overflow-auto'>
<Fieldset {...props}>
<Legend>
<Rows4Icon className='size-6 text-muted-foreground' />{t('form_groups.items.title')}
</Legend>
<Description>{t("form_groups.items.description")}</Description>
<FieldGroup className='grid grid-cols-1'>
<ItemsEditor />
</CardContent>
</Card>
</FieldGroup>
</Fieldset>
);
};

View File

@ -11,8 +11,8 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
return (
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<StickyNoteIcon className='size-5' /> {t("form_groups.basic_into.title")}
<Legend>
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_into.title")}
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>

View File

@ -1,82 +1,150 @@
import { formatCurrency } from '@erp/core';
import { formatCurrency } from "@erp/core";
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Input, Label, Separator } from "@repo/shadcn-ui/components";
import { CalculatorIcon } from "lucide-react";
import { ComponentProps } from 'react';
import { Controller, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../context';
import { Separator } from "@repo/shadcn-ui/components";
import { ReceiptIcon } from "lucide-react";
import { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from "../../context";
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
import { PercentageInputField } from "./items/percentage-input-field";
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control, getValues } = useFormContext<InvoiceFormData>();
const { currency_code, language_code } = useInvoiceContext();
const { currency_code, language_code, readOnly, taxCatalog } = useInvoiceContext();
const displayTaxes = useWatch({
control,
name: "taxes",
defaultValue: [],
});
return (
<Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'>
<CalculatorIcon className='size-5' /> {t("form_groups.totals.title")}
<Legend>
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
</Legend>
<Description>{t("form_groups.totals.description")}</Description>
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
<div className='flex justify-between items-center'>
<Label className='text-sm'>Subtotal</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
{/* Sección: Subtotal y Descuentos */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-base">Subtotal</span>
<span className="font-bold text-lg tabular-nums pr-4">
{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
</div>
</div>
<div className='flex justify-between items-center gap-4'>
<Label className='text-sm'>Descuento global (%)</Label>
<div className='flex items-center gap-2'>
<Controller
control={control}
name={"discount_percentage"}
render={({
field, fieldState
}) => (
<Input
readOnly={false}
value={field.value}
onChange={field.onChange}
disabled={fieldState.isValidating}
onBlur={field.onBlur}
className='w-20 text-right'
/>)}
/>
<Separator />
<div className="space-y-1.5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Descuento global</h3>
<div className="rounded-lg bg-accent/30 p-4 space-y-2.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className='text-sm font-medium'>Descuento global</span>
<PercentageInputField
control={control}
name={"discount_percentage"}
readOnly={readOnly}
inputId={"header-discount-percentage"}
showSuffix={true}
className='w-20 h-9 text-right tabular-nums'
/>
</div>
<span className="font-medium text-destructive tabular-nums">-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}</span>
</div>
</div>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm'>Importe del descuento</Label>
<span className='font-medium text-destructive tabular-nums'>
-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}
</span>
</div>
<Separator />
<Separator className='bg-muted-foreground' />
<div className='flex justify-between items-center'>
<Label className='text-sm'>Base imponible</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}</span>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm'>Total de impuestos</Label>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
</div>
<Separator className='bg-muted-foreground h-0.5' />
<div className='flex justify-between items-center'>
<Label className='text-xl font-semibold'>Total de la factura</Label>
<span className='text-xl font-bold text-primary tabular-nums'>
{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
{/* Sección: Base Imponible */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-base">Base imponible</span>
<span className="font-bold text-lg tabular-nums pr-4">
{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
</span>
</div>
</div>
<Separator />
{/* Sección: Impuestos */}
<div className="space-y-1.5">
<h3
className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"
>
Impuestos y retenciones
</h3>
{taxCatalog.groups().map((group) => {
// Filtra impuestos de ese grupo
const taxesInGroup = displayTaxes?.filter((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
() => undefined
);
return tax?.group === group;
});
// Si el grupo no tiene impuestos, no renderiza nada
if (taxesInGroup?.length === 0) return null;
return (
<div key={`tax-group-${group}`} className="rounded-lg bg-accent/30 p-4 space-y-1.5">
{taxesInGroup?.map((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
() => undefined
);
return (
<div
key={`${group}:${item.tax_code}`}
className="flex items-center justify-between"
>
<span className="text-sm font-medium">{tax?.name}</span>
<span className="font-medium tabular-nums">
{formatCurrency(
item.taxes_amount,
2,
currency_code,
language_code
)}
</span>
</div>
);
})}
</div>
);
})}
<div className="flex items-center justify-between text-base mt-3">
<span className="font-semibold text-base">Total de impuestos</span>
<span className="font-bold text-lg tabular-nums pr-4">{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
</div>
</div>
<Separator className='bg-foreground' />
<div className="space-y-1.5">
<div className="rounded-lg bg-primary p-4">
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-background/90">Total de la factura</span>
<span className="text-2xl font-bold text-background">{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}</span>
</div>
</div>
</div>
</FieldGroup>
</Fieldset>
</Fieldset >
);
};

View File

@ -1,10 +1,11 @@
import { useMoney } from '@erp/core/hooks';
import { formatCurrency } from '@erp/core';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
HoverCard, HoverCardContent, HoverCardTrigger
} from "@repo/shadcn-ui/components";
import { PropsWithChildren } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useTranslation } from "../../../i18n";
@ -16,15 +17,25 @@ type HoverCardTotalsSummaryProps = PropsWithChildren & {
* Muestra un desglose financiero del total de línea.
* Lee directamente los importes del formulario vía react-hook-form.
*/
// Aparcado por ahora
export const HoverCardTotalsSummary = ({
children,
rowIndex,
}: HoverCardTotalsSummaryProps) => <>{children}</>
const HoverCardTotalsSummary2 = ({
children,
rowIndex,
}: HoverCardTotalsSummaryProps) => {
const { t } = useTranslation();
const { formatCurrency } = useMoney();
const { control } = useFormContext();
const { currency_code, language_code } = useInvoiceContext();
// 👀 Observar los valores actuales del formulario
// Observar los valores actuales del formulario
const [subtotal, discountPercentage, discountAmount, taxableBase, total] =
useWatch({
control,
@ -48,7 +59,7 @@ export const HoverCardTotalsSummary = ({
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.subtotal_amount")}:
</span>
<span className="font-mono">{formatCurrency(subtotal)}</span>
<span className="font-mono tabular-nums">{formatCurrency(subtotal, 4, currency_code, language_code)}</span>
</div>
{/* Descuento (si aplica) */}
@ -65,8 +76,8 @@ export const HoverCardTotalsSummary = ({
: 0}
%):
</span>
<span className="font-mono text-destructive">
-{formatCurrency(discountAmount)}
<span className="font-mono tabular-nums text-destructive">
-{formatCurrency(discountAmount, 4, currency_code, language_code)}
</span>
</div>
)}
@ -76,8 +87,8 @@ export const HoverCardTotalsSummary = ({
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.taxable_amount")}:
</span>
<span className="font-mono font-medium">
{formatCurrency(taxableBase)}
<span className="font-mono tabular-nums font-medium">
{formatCurrency(taxableBase, 4, currency_code, language_code)}
</span>
</div>
@ -86,7 +97,7 @@ export const HoverCardTotalsSummary = ({
<span>
{t("components.hover_card_totals_summary.fields.total_amount")}:
</span>
<span className="font-mono">{formatCurrency(total)}</span>
<span className="font-mono tabular-nums">{formatCurrency(total, 4, currency_code, language_code)}</span>
</div>
</div>
);

View File

@ -103,6 +103,7 @@ export const ItemRow = ({
data-row-index={rowIndex}
data-col-index={4}
data-cell-focus
className='font-medium'
/>
</TableCell>
@ -119,6 +120,7 @@ export const ItemRow = ({
data-row-index={rowIndex}
data-col-index={5}
data-cell-focus
className='font-medium'
/>
</TableCell>
@ -133,6 +135,7 @@ export const ItemRow = ({
data-row-index={rowIndex}
data-col-index={6}
data-cell-focus
className='font-medium'
/>
</TableCell>
@ -163,6 +166,7 @@ export const ItemRow = ({
inputId={`total-amount-${rowIndex}`}
currencyCode={currency_code}
languageCode={language_code}
className='font-semibold'
/>
</HoverCardTotalsSummary>
</TableCell>

View File

@ -28,7 +28,7 @@ export function PageHeader({ icon, title, description, status, rightSlot, classN
{icon && <div className='shrink-0'>{icon}</div>}
<div>
<div className='flex items-center gap-3'>
<h1 className='text-2xl font-semibold text-foreground'>{title}</h1>
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
{status && <CustomerInvoiceStatusBadge status={status} />}
</div>
{description && <p className='text-sm text-muted-foreground'>{description}</p>}

View File

@ -9,6 +9,7 @@ export type InvoiceContextValue = {
language_code: string;
is_proforma: boolean;
readOnly: boolean;
taxCatalog: TaxCatalogProvider;
changeLanguage: (lang: string) => void;
@ -26,18 +27,20 @@ export interface InvoiceProviderParams {
language_code?: string; // default "es"
currency_code?: string; // default "EUR"
is_proforma?: boolean; // default 'true'
readOnly?: boolean; // default 'false'
children: React.ReactNode;
}
export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, company_id, status: initialStatus = "draft", language_code: initialLang = "es",
currency_code: initialCurrency = "EUR",
currency_code: initialCurrency = "EUR", readOnly: initialReadOnly = false,
is_proforma: initialProforma = true, children }: PropsWithChildren<InvoiceProviderParams>) => {
// Estado interno local para campos dinámicos
const [language_code, setLanguage] = useState(initialLang);
const [currency_code, setCurrency] = useState(initialCurrency);
const [is_proforma, setIsProforma] = useState(initialProforma);
const [readOnly, setReadOnly] = useState(initialReadOnly);
const [status] = useState(initialStatus);
const [taxCatalog] = useState(initialTaxCatalog);
@ -45,6 +48,7 @@ export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, com
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
const setCurrencyMemo = useCallback((currency_code: string) => setCurrency(currency_code), []);
const setIsProformaMemo = useCallback((is_proforma: boolean) => setIsProforma(is_proforma), []);
const setReadOnlyMemo = useCallback((readOnly: boolean) => setReadOnly(readOnly), []);
const value = useMemo<InvoiceContextValue>(() => {
@ -57,12 +61,14 @@ export const InvoiceProvider = ({ taxCatalog: initialTaxCatalog, invoice_id, com
is_proforma,
taxCatalog,
readOnly,
changeLanguage: setLanguageMemo,
changeCurrency: setCurrencyMemo,
changeIsProforma: setIsProformaMemo
changeIsProforma: setIsProformaMemo,
setReadOnly: setReadOnlyMemo,
}
}, [company_id, invoice_id, status, language_code, currency_code, is_proforma, taxCatalog, setLanguageMemo, setCurrencyMemo, setIsProformaMemo]);
}, [readOnly, company_id, invoice_id, status, language_code, currency_code, is_proforma, taxCatalog, setLanguageMemo, setCurrencyMemo, setIsProformaMemo, setReadOnlyMemo]);
return <InvoiceContext.Provider value={value}>{children}</InvoiceContext.Provider>;
};

View File

@ -81,13 +81,9 @@ export function useInvoiceAutoRecalc(
// Observamos el formulario esperando cualquier cambio
React.useEffect(() => {
console.log("recalculo algo?");
if (!isDirty || isLoading || isSubmitting) return;
const subscription = watch((formData, { name, type }) => {
console.log(name, type);
const items = (formData?.items || []) as InvoiceItemFormData[];
const header_discount_percentage = formData?.discount_percentage || 0;
@ -119,13 +115,10 @@ export function useInvoiceAutoRecalc(
// 2. Cambio puntual de una línea
if (name?.startsWith("items.") && type === "change") {
console.log("2. items!");
const index = Number(name.split(".")[1]);
const field = name.split(".")[2];
if (["quantity", "unit_amount", "discount_percentage", "tax_codes"].includes(field)) {
console.log("2.1. recalculo items!");
const item = items[index] as InvoiceItemFormData;
const prevTotals = itemCache.current.get(index);
const newTotals = calculateItemTotals(item, header_discount_percentage);
@ -134,14 +127,7 @@ export function useInvoiceAutoRecalc(
const itemHasChanges =
!prevTotals || JSON.stringify(prevTotals) !== JSON.stringify(newTotals);
console.log(
JSON.stringify(prevTotals),
JSON.stringify(newTotals),
itemHasChanges ? "hay cambios" : "no hay cambios"
);
if (!itemHasChanges) {
console.log("No hay cambios, me voy!!!");
return;
}
@ -154,8 +140,6 @@ export function useInvoiceAutoRecalc(
// Recalcular totales de factura
const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage);
console.log(invoiceTotals);
// Estableer valores en cabecera
setInvoiceTotals(form, invoiceTotals);

View File

@ -5,7 +5,7 @@ export const Fieldset = ({ className, children, ...props }: React.ComponentProps
<fieldset
data-slot='fieldset'
className={cn(
" *:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6 bg-card rounded-xl p-6",
"bg-card rounded-xl p-6",
className
)}
{...props}
@ -37,7 +37,7 @@ export const Legend = ({ className, children, ...props }: React.ComponentProps<"
<div
data-slot='legend'
className={cn(
"text-base/6 font-semibold data-disabled:opacity-50 sm:text-sm/6 text-foreground",
"text-sm flex items-center gap-2 text-muted-foreground font-medium",
className
)}
{...props}
@ -49,7 +49,7 @@ export const Legend = ({ className, children, ...props }: React.ComponentProps<"
export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => (
<p
data-slot='text'
className={cn("text-base/6 sm:text-sm/6 text-muted-foreground", className)}
className={cn("text-base text-muted-foreground", className)}
{...props}
>
{children}

View File

@ -148,5 +148,6 @@ export {
FormItem,
FormLabel,
FormMessage,
useFormField,
useFormField
};

View File

@ -1,5 +1,9 @@
/*@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");*/
/*@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=PT+Mono&family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Slab:wght@100..900&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&display=swap");*/
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Noto+Serif:ital,wght@0,100..900;1,100..900&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@ -40,9 +44,9 @@
}
:root {
--font-sans: "Inter", ui-sans-serif, sans-serif, system-ui;
--font-serif: "PT Serif", ui-serif, serif;
--font-mono: "PT Mono", ui-monospace, monospace;
--font-sans: "Noto Sans", ui-sans-serif, sans-serif, system-ui;
--font-serif: "Noto Serif", ui-serif, serif;
--font-mono: "Noto Sans Mono", ui-monospace, monospace;
--background: oklch(1 0 0);
--foreground: oklch(13.636% 0.02685 282.25);
@ -60,7 +64,7 @@
--accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.583 0.2387 28.4765);
--destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.9173 0.0067 286.2663);
--border: oklch(0.9273 0.0067 286.2663);
--input: oklch(0.9173 0.0067 286.2663);
--ring: oklch(0.6187 0.2067 259.2316);
--chart-1: oklch(0.6471 0.2173 36.8511);
@ -76,7 +80,13 @@
--sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081);
--sidebar-border: oklch(0.9173 0.0067 286.2663);
--sidebar-ring: oklch(0.623 0.214 259.815);
--radius: 0.25rem;
--radius: 0.40rem;
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-opacity: 0.1;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);
@ -86,7 +96,7 @@
--shadow-xl: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 1px 1px 6px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--spacing: 0.20rem;
}
.dark {