Facturas de cliente

This commit is contained in:
David Arranz 2025-07-10 12:45:59 +02:00
parent 1d1f412e6c
commit 645d4e8adb
8 changed files with 203 additions and 17 deletions

View File

@ -67,6 +67,37 @@
"placeholder": "Select a date", "placeholder": "Select a date",
"description": "Invoice operation date" "description": "Invoice operation date"
}, },
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"desc": "Invoice subtotal"
},
"discount": {
"label": "Discount (%)",
"placeholder": "",
"desc": "Percentage discount"
},
"discount_price": {
"label": "Discount price",
"placeholder": "",
"desc": "Percentage discount price"
},
"tax": {
"label": "Tax (%)",
"placeholder": "",
"desc": "Percentage Tax"
},
"tax_price": {
"label": "Tax price",
"placeholder": "",
"desc": "Percentage tax price"
},
"total_price": {
"label": "Total price",
"placeholder": "",
"desc": "Invoice total price"
},
"items": { "items": {
"quantity": { "quantity": {
"label": "Quantity", "label": "Quantity",

View File

@ -67,6 +67,36 @@
"placeholder": "Seleccionar una fecha", "placeholder": "Seleccionar una fecha",
"description": "Fecha de intervención de los trabajos" "description": "Fecha de intervención de los trabajos"
}, },
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Porcentaje de descuento"
},
"discount_price": {
"label": "Imp. descuento",
"placeholder": "",
"desc": "Importe del descuento"
},
"tax": {
"label": "IVA (%)",
"placeholder": "",
"desc": "Porcentaje de IVA"
},
"tax_price": {
"label": "Imp. IVA",
"placeholder": "",
"desc": "Importe del IVA"
},
"total_price": {
"label": "Imp. total",
"placeholder": "",
"description": "Importe total con el descuento ya aplicado"
},
"items": { "items": {
"quantity": { "quantity": {
"label": "Cantidad", "label": "Cantidad",

View File

@ -0,0 +1,88 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../i18n";
import { formatCurrency } from "../pages/create/utils";
export const CustomerInvoicePricesCard = () => {
const { t } = useTranslation();
const { register, formState, control, watch } = useFormContext();
/*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] });
const totals = calculateQuoteTotals(pricesWatch);
const subtotal_price = formatNumber(totals.subtotalPrice);
const discount_price = formatNumber(totals.discountPrice);
const tax_price = formatNumber(totals.taxesPrice);
const total_price = formatNumber(totals.totalPrice);*/
const currency_symbol = watch("currency");
return (
<Card>
<CardHeader>
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className='flex flex-row items-end gap-2 p-4'>
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
<CardDescription className='text-sm'>
{t("form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.discount_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.tax_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-2xl tabular-nums'>
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>{" "}
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-0'>
<CardDescription className='text-sm font-semibold text-right text-foreground'>
{t("form_fields.total_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-3xl tabular-nums'>
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -12,6 +12,7 @@ import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS // Core CSS
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { useCustomerInvoicesQuery } from "../hooks"; import { useCustomerInvoicesQuery } from "../hooks";
import { useTranslation } from "../i18n";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge"; import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
// Create new GridExample component // Create new GridExample component

View File

@ -1,3 +1,4 @@
export * from "./customer-invoice-prices-card";
export * from "./customer-invoice-status-badge"; export * from "./customer-invoice-status-badge";
export * from "./customer-invoices-layout"; export * from "./customer-invoices-layout";
export * from "./customer-invoices-list-grid"; export * from "./customer-invoices-list-grid";

View File

@ -35,6 +35,7 @@ import {
import { format } from "date-fns"; import { format } from "date-fns";
import { es } from "date-fns/locale"; import { es } from "date-fns/locale";
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react"; import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
import { CustomerInvoicePricesCard } from "../../components";
import { CustomerInvoiceItemsCardEditor } from "../../components/items"; import { CustomerInvoiceItemsCardEditor } from "../../components/items";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerInvoiceData } from "./customer-invoice.schema"; import { CustomerInvoiceData } from "./customer-invoice.schema";
@ -464,6 +465,9 @@ export const CustomerInvoiceEditForm = ({
))} ))}
</CardContent> </CardContent>
</Card> </Card>
<CustomerInvoicePricesCard />
{/* Configuración de Impuestos */} {/* Configuración de Impuestos */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -9,7 +9,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { CalendarIcon, LockIcon } from "lucide-react"; import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { format, isValid, parse } from "date-fns"; import { format, isValid, parse } from "date-fns";
@ -60,16 +60,23 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
setInputValue(value); setInputValue(value);
setInputError(null); // Reset error on typing setInputError(null);
}; };
const validateAndSetDate = () => { const validateAndSetDate = () => {
const parsed = parse(inputValue, parseDateFormat, new Date()); const trimmed = inputValue.trim();
if (!trimmed) {
field.onChange(undefined);
setInputError(null);
return;
}
const parsed = parse(trimmed, parseDateFormat, new Date());
if (isValid(parsed)) { if (isValid(parsed)) {
field.onChange(parsed.toISOString()); field.onChange(parsed.toISOString());
setInputError(null); setInputError(null);
} else { } else {
setInputError(t("common.invalid_date") || "Fecha no válida"); setInputError(t("common.invalidDate") || "Fecha no válida");
} }
}; };
@ -106,11 +113,25 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
)} )}
placeholder={placeholder} placeholder={placeholder}
/> />
<div className='absolute inset-y-0 right-2 flex items-center pointer-events-none'> <div className='absolute inset-y-0 right-2 flex items-center gap-2 pr-1'>
{!isReadOnly && !required && inputValue && (
<button
type='button'
onClick={() => {
setInputValue("");
field.onChange(undefined);
setInputError(null);
}}
aria-label={t("common.clearDate") || "Limpiar fecha"}
className='text-muted-foreground hover:text-foreground focus:outline-none'
>
<XIcon className='w-4 h-4' />
</button>
)}
{isReadOnly ? ( {isReadOnly ? (
<LockIcon className='h-4 w-4 text-muted-foreground' /> <LockIcon className='w-4 h-4 text-muted-foreground' />
) : ( ) : (
<CalendarIcon className='h-4 w-4 text-muted-foreground' /> <CalendarIcon className='w-4 h-4 text-muted-foreground' />
)} )}
</div> </div>
</div> </div>
@ -128,6 +149,9 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
field.onChange(iso); field.onChange(iso);
setInputValue(formatDateFn(iso)); setInputValue(formatDateFn(iso));
setInputError(null); setInputError(null);
} else {
field.onChange(undefined);
setInputValue("");
} }
}} }}
initialFocus initialFocus
@ -136,15 +160,22 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
)} )}
</Popover> </Popover>
<p {isReadOnly && (
className={cn( <p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
"text-xs", <LockIcon className='w-3 h-3' /> {t("common.readOnly") || "Solo lectura"}
inputError ? "text-destructive" : "text-muted-foreground", </p>
!description && !inputError && "invisible" )}
)}
> {(inputError || description) && (
{inputError || description || "\u00A0"} <p
</p> className={cn(
"text-xs mt-1",
inputError ? "text-destructive" : "text-muted-foreground"
)}
>
{inputError || description}
</p>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -1,5 +1,5 @@
import { useTranslation } from "@repo/rdx-ui/locales/i18n.ts";
import { JSX } from "react"; import { JSX } from "react";
import { useTranslation } from "react-i18next";
import { LoadingIndicator } from "./loading-indicator.tsx"; import { LoadingIndicator } from "./loading-indicator.tsx";
export type LoadingOverlayProps = { export type LoadingOverlayProps = {