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",
"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": {
"quantity": {
"label": "Quantity",

View File

@ -67,6 +67,36 @@
"placeholder": "Seleccionar una fecha",
"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": {
"quantity": {
"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
import { AgGridReact } from "ag-grid-react";
import { useCustomerInvoicesQuery } from "../hooks";
import { useTranslation } from "../i18n";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
// 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-invoices-layout";
export * from "./customer-invoices-list-grid";

View File

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

View File

@ -9,7 +9,7 @@ import {
PopoverContent,
PopoverTrigger,
} 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 { format, isValid, parse } from "date-fns";
@ -60,16 +60,23 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
const handleInputChange = (value: string) => {
setInputValue(value);
setInputError(null); // Reset error on typing
setInputError(null);
};
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)) {
field.onChange(parsed.toISOString());
setInputError(null);
} 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}
/>
<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 ? (
<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>
@ -128,6 +149,9 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
field.onChange(iso);
setInputValue(formatDateFn(iso));
setInputError(null);
} else {
field.onChange(undefined);
setInputValue("");
}
}}
initialFocus
@ -136,15 +160,22 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
)}
</Popover>
<p
className={cn(
"text-xs",
inputError ? "text-destructive" : "text-muted-foreground",
!description && !inputError && "invisible"
)}
>
{inputError || description || "\u00A0"}
</p>
{isReadOnly && (
<p className='text-xs text-muted-foreground italic mt-1 flex items-center gap-1'>
<LockIcon className='w-3 h-3' /> {t("common.readOnly") || "Solo lectura"}
</p>
)}
{(inputError || description) && (
<p
className={cn(
"text-xs mt-1",
inputError ? "text-destructive" : "text-muted-foreground"
)}
>
{inputError || description}
</p>
)}
<FormMessage />
</FormItem>

View File

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