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

View File

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

View File

@ -110,17 +110,18 @@
<body> <body>
<header> <header id="header">
<aside class="flex items-start mb-4 w-full"> <aside class="flex items-start mb-4 w-full ">
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda --> <!-- 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" /> <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="flex w-full">
<div class="p-1 "> <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><span>Fecha:<strong>&nbsp;{{invoice_date}}</strong></p>
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
</div> </div>
<div class="p-1 ml-9"> <div class="p-1 ml-28">
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2> <h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
<p>{{recipient.tin}}</p> <p>{{recipient.tin}}</p>
<p>{{recipient.street}}</p> <p>{{recipient.street}}</p>
@ -130,10 +131,10 @@
</div> </div>
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO --> <!-- 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" <img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
class="block h-5 w-auto md:h-8 mb-1" /> 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>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="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" <p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
@ -144,8 +145,8 @@
</header> </header>
<main id="main"> <main id="main">
<h1>FACTURA PROFORMA</h1>
<section id="details" class="border-b border-black "> <section id="details" class="border-b border-black ">
<div class="relative pt-0 border-b border-black"> <div class="relative pt-0 border-b border-black">
<!-- Badge TOTAL decorado con imagen --> <!-- Badge TOTAL decorado con imagen -->
<div class="absolute -top-9 right-0"> <div class="absolute -top-9 right-0">
@ -225,15 +226,11 @@
<td class="w-5">&nbsp;</td> <td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxable_amount}}</td> <td class="px-4 text-right">{{taxable_amount}}</td>
</tr> </tr>
{{#if taxes_amount }}
<tr> <tr>
<td class="px-4 text-right">IVA&nbsp;21%</td> <td class="px-4 text-right">IVA&nbsp;21%</td>
<td class="w-5">&nbsp;</td> <td class="w-5">&nbsp;</td>
<td class="px-4 text-right">{{taxes_amount}}</td> <td class="px-4 text-right">{{taxes_amount}}</td>
</tr> </tr>
{{else}}
<!-- iva 0-->
{{/if}}
<tr class=""> <tr class="">
<td class="px-4 text-right accent-color"> <td class="px-4 text-right accent-color">
Total&nbsp;factura Total&nbsp;factura
@ -248,12 +245,12 @@
</section> </section>
</main> </main>
<footer id="footer" class="mt-4"> <footer id="footer" class="mt-4">
<aside> <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 <p class="text-left"><strong>Factura Proforma.</strong>
| CIF: B83999441 - Este documento es de carácter informativo y no tiene validez contable ni fiscal. Contiene precios y condiciones
Rodax Software S.L.</p> de venta sujetos a confirmación del cliente. Solo adquirirá validez como factura definitiva una vez aceptados
dichos términos.</p>
</aside> </aside>
</footer> </footer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,8 +11,8 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
return ( return (
<Fieldset {...props}> <Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'> <Legend>
<StickyNoteIcon className='size-5' /> {t("form_groups.basic_into.title")} <StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_into.title")}
</Legend> </Legend>
<Description>{t("form_groups.basic_into.description")}</Description> <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 { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Input, Label, Separator } from "@repo/shadcn-ui/components"; import { Separator } from "@repo/shadcn-ui/components";
import { CalculatorIcon } from "lucide-react"; import { ReceiptIcon } from "lucide-react";
import { ComponentProps } from 'react'; import { ComponentProps } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from '../../context'; import { useInvoiceContext } from "../../context";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas"; import { InvoiceFormData } from "../../schemas";
import { PercentageInputField } from "./items/percentage-input-field";
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { control, getValues } = useFormContext<InvoiceFormData>(); 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 ( return (
<Fieldset {...props}> <Fieldset {...props}>
<Legend className='flex items-center gap-2 text-foreground'> <Legend>
<CalculatorIcon className='size-5' /> {t("form_groups.totals.title")} <ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
</Legend> </Legend>
<Description>{t("form_groups.totals.description")}</Description> <Description>{t("form_groups.totals.description")}</Description>
<FieldGroup className='grid grid-cols-1'> <FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'> {/* Sección: Subtotal y Descuentos */}
<div className='flex justify-between items-center'> <div className="space-y-1.5">
<Label className='text-sm'>Subtotal</Label> <div className="flex items-center justify-between">
<span className='font-medium tabular-nums'>{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span> <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>
<div className='flex justify-between items-center gap-4'> <Separator />
<Label className='text-sm'>Descuento global (%)</Label>
<div className='flex items-center gap-2'> <div className="space-y-1.5">
<Controller <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Descuento global</h3>
control={control}
name={"discount_percentage"} <div className="rounded-lg bg-accent/30 p-4 space-y-2.5">
render={({ <div className="flex items-center justify-between gap-4">
field, fieldState <div className="flex items-center gap-3">
}) => ( <span className='text-sm font-medium'>Descuento global</span>
<Input <PercentageInputField
readOnly={false} control={control}
value={field.value} name={"discount_percentage"}
onChange={field.onChange} readOnly={readOnly}
disabled={fieldState.isValidating} inputId={"header-discount-percentage"}
onBlur={field.onBlur} showSuffix={true}
className='w-20 text-right' 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>
</div>
<div className='flex justify-between items-center'> <Separator />
<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 className='bg-muted-foreground' /> {/* Sección: Base Imponible */}
<div className="space-y-1.5">
<div className='flex justify-between items-center'> <div className="flex items-center justify-between">
<Label className='text-sm'>Base imponible</Label> <span className="font-semibold text-base">Base imponible</span>
<span className='font-medium tabular-nums'>{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}</span> <span className="font-bold text-lg tabular-nums pr-4">
</div> {formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
<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)}
</span> </span>
</div> </div>
</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> </FieldGroup>
</Fieldset> </Fieldset >
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ export const Fieldset = ({ className, children, ...props }: React.ComponentProps
<fieldset <fieldset
data-slot='fieldset' data-slot='fieldset'
className={cn( 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 className
)} )}
{...props} {...props}
@ -37,7 +37,7 @@ export const Legend = ({ className, children, ...props }: React.ComponentProps<"
<div <div
data-slot='legend' data-slot='legend'
className={cn( 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 className
)} )}
{...props} {...props}
@ -49,7 +49,7 @@ export const Legend = ({ className, children, ...props }: React.ComponentProps<"
export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => ( export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => (
<p <p
data-slot='text' 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} {...props}
> >
{children} {children}

View File

@ -148,5 +148,6 @@ export {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, 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=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 "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@ -40,9 +44,9 @@
} }
:root { :root {
--font-sans: "Inter", ui-sans-serif, sans-serif, system-ui; --font-sans: "Noto Sans", ui-sans-serif, sans-serif, system-ui;
--font-serif: "PT Serif", ui-serif, serif; --font-serif: "Noto Serif", ui-serif, serif;
--font-mono: "PT Mono", ui-monospace, monospace; --font-mono: "Noto Sans Mono", ui-monospace, monospace;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(13.636% 0.02685 282.25); --foreground: oklch(13.636% 0.02685 282.25);
@ -60,7 +64,7 @@
--accent-foreground: oklch(0.3729 0.0306 259.7328); --accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.583 0.2387 28.4765); --destructive: oklch(0.583 0.2387 28.4765);
--destructive-foreground: oklch(1.0 0 0); --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); --input: oklch(0.9173 0.0067 286.2663);
--ring: oklch(0.6187 0.2067 259.2316); --ring: oklch(0.6187 0.2067 259.2316);
--chart-1: oklch(0.6471 0.2173 36.8511); --chart-1: oklch(0.6471 0.2173 36.8511);
@ -76,7 +80,13 @@
--sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081); --sidebar-accent-foreground: oklch(0.2069 0.0098 285.5081);
--sidebar-border: oklch(0.9173 0.0067 286.2663); --sidebar-border: oklch(0.9173 0.0067 286.2663);
--sidebar-ring: oklch(0.623 0.214 259.815); --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-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 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); --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-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); --shadow-2xl: 1px 1px 6px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em; --tracking-normal: 0em;
--spacing: 0.25rem; --spacing: 0.20rem;
} }
.dark { .dark {