This commit is contained in:
David Arranz 2026-05-28 12:55:50 +02:00
parent 02b10d4f85
commit 0e193ee9ce
23 changed files with 1039 additions and 90 deletions

View File

@ -163,6 +163,9 @@ export class SequelizePaymentMethodRepository
try {
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
mappings: {
isActive: "is_active",
},
searchableFields: [],
sortableFields: ["name"],
enableFullText: true,
@ -170,6 +173,8 @@ export class SequelizePaymentMethodRepository
strictMode: true, // fuerza error si ORDER BY no permitido
});
console.log(query?.where);
query.where = {
...query.where,
company_id: companyId.toString(),

View File

@ -117,6 +117,9 @@ export class SequelizePaymentTermRepository
const criteriaConverter = new CriteriaToSequelizeConverter();
const query = criteriaConverter.convert(criteria, {
searchableFields: [],
mappings: {
isActive: "is_active",
},
sortableFields: ["name"],
enableFullText: true,
database: this.database,

View File

@ -8,7 +8,16 @@ import { z } from "zod/v4";
export const FilterPrimitiveSchema = z.object({
// Campos mínimos ya normalizados por el conversor
field: z.string(),
operator: z.string(),
operator: z.enum([
"CONTAINS",
"NOT_CONTAINS",
"NOT_EQUALS",
"GREATER_THAN",
"GREATER_THAN_OR_EQUAL",
"LOWER_THAN",
"LOWER_THAN_OR_EQUAL",
"EQUALS",
]),
value: z.string(),
});

View File

@ -44,6 +44,7 @@ export const GetProformaByIdAdapter = {
taxes: dto.taxes.map(mapTaxSummary),
paymentMethodId: dto.payment_method?.id ?? null,
paymentTermId: dto.payment_term?.id ?? null,
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),

View File

@ -34,6 +34,7 @@ export interface Proforma {
taxes: ProformaTaxSummary[];
paymentMethodId: string | null;
paymentTermId: string | null;
subtotalAmount: number;

View File

@ -50,7 +50,8 @@ export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpd
hasRetentionPercentage: fiscalDefaults.defaultRetentionPercentage !== null,
retentionPercentage: fiscalDefaults.defaultRetentionPercentage,
paymentMethodId: proforma.paymentMethodId ?? proformaDefaults.paymentMethodId,
paymentMethodId: proforma.paymentMethodId,
paymentTermId: proforma.paymentTermId,
items: proforma.items.map(mapProformaItemsToProformaItemsUpdateForm),
};

View File

@ -9,9 +9,29 @@ import {
import { useMemo } from "react";
export const useUpdateProformaPaymentController = () => {
const paymentMethodsQuery = usePaymentMethodsListQuery();
const paymentMethodsQuery = usePaymentMethodsListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentTermsQuery = usePaymentTermsListQuery();
const paymentTermsQuery = usePaymentTermsListQuery({
criteria: {
filters: [
{
field: "isActive",
operator: "EQUALS",
value: "true",
},
],
},
});
const paymentMethodOptions = useMemo(() => {
return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []);

View File

@ -39,8 +39,8 @@ export interface ProformaTotals {
taxBreakdown: ProformaTaxBreakdown[];
taxTotal: number;
recTotal: number;
taxRecTotal: number;
retentionPercentage: number | null;
retentionAmount: number;

View File

@ -0,0 +1,198 @@
"use client";
import type { CustomerSelectionOption } from "@erp/customers";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CreditCard, Percent, Receipt, Settings, UserIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "../../../../i18n";
import { SelectedRecipientSummary } from "./selected-recipient";
interface EditorSidebarProps {
disabled?: boolean;
readOnly?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onChangeCustomerClick: () => void;
onCreateCustomerClick: () => void;
className?: string;
}
export const EditorSidebar = ({
disabled = false,
readOnly = false,
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
className,
}: EditorSidebarProps) => {
const [isTotalsExpanded, setIsTotalsExpanded] = useState(false);
const { t } = useTranslation();
const formatMoney = (value: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: proforma.config.currency,
}).format(value);
};
const formatPercent = (value: number) => {
return new Intl.NumberFormat("es-ES", {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100);
};
return (
<div className="flex h-full flex-col">
{/* Secciones en acordeon */}
<div className="flex-1 overflow-y-auto">
<Accordion className="divide-y" collapsible defaultValue="client" type="single">
{/* Cliente */}
<AccordionItem className="border-none px-4" value="client">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10">
<UserIcon className="size-4 text-primary" />
</div>
<div className="text-left">
<div className="text-sm font-medium">Cliente</div>
{t(
"form_groups.proformas.customer.description",
"Cliente destinatario de la proforma"
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
</AccordionContent>
</AccordionItem>
{/* Impuestos */}
<AccordionItem className="border-none px-4" value="taxes">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-sky-100 dark:bg-sky-900/30">
<Percent className="size-4 text-sky-600 dark:text-sky-400" />
</div>
<div className="text-left">
<div className="text-sm font-medium">Impuestos</div>
<div className="text-xs text-muted-foreground">IVA 21%</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent />
</AccordionItem>
{/* Configuracion */}
<AccordionItem className="border-none px-4" value="config">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Settings className="size-4 text-muted-foreground" />
</div>
<div className="text-left">
<div className="text-sm font-medium">Configuracion</div>
<div className="text-xs text-muted-foreground">Estado y moneda</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent />
</AccordionItem>
{/* Condiciones de pago */}
<AccordionItem className="border-none px-4" value="payment">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
<CreditCard className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div className="text-left">
<div className="text-sm font-medium">Condiciones de pago</div>
<div className="text-xs text-muted-foreground">Forma y vencimiento</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent>{""}</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* Resumen de totales compacto - colapsable */}
<div className="shrink-0 border-t bg-background"> {""}</div>
</div>
);
};
interface TotalsRowProps {
label: string;
value: string;
className?: string;
small?: boolean;
variant?: "default" | "discount" | "tax";
}
function TotalsRow({ label, value, className, small, variant = "default" }: TotalsRowProps) {
const valueColorClass = {
default: "text-foreground",
discount: "text-amber-700 dark:text-amber-400",
tax: "text-sky-700 dark:text-sky-400",
};
return (
<div className={cn("flex items-center justify-between gap-4", className)}>
<span className={cn("font-medium text-muted-foreground", small ? "text-xs" : "text-sm")}>
{label}
</span>
<span
className={cn(
"font-mono tabular-nums",
small ? "text-xs" : "text-sm font-semibold",
valueColorClass[variant]
)}
>
{value}
</span>
</div>
);
}
// Version compacta del sidebar para movil (solo totales)
export function MobileTotalsBadge({
total,
currency = "EUR",
onClick,
}: {
total: number;
currency?: string;
onClick?: () => void;
}) {
const formatMoney = (value: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency,
}).format(value);
};
return (
<button
className="flex items-center gap-2 rounded-full bg-primary px-4 py-2 text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95"
onClick={onClick}
>
<Receipt className="size-4" />
<span className="font-mono text-sm font-bold">{formatMoney(total)}</span>
</button>
);
}

View File

@ -1,3 +1,8 @@
export * from "./editor-sidebar";
export * from "./line-editor";
export * from "./new-proforma-totals-summary";
export * from "./proforma-compact-totals";
export * from "./proforma-line-editor";
export * from "./proforma-totals-summary";
export * from "./proforma-update-header";
export * from "./selected-recipient";

View File

@ -0,0 +1,284 @@
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
import { Card, CardContent, Separator } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Percent, Receipt, Tag } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaTotals } from "../../entities";
interface NewProformaTotalsSummaryProps {
totals: ProformaTotals;
currency?: string;
showRec?: boolean;
showRetention?: boolean;
globalDiscountField?: ReactNode;
disabled?: boolean;
className?: string;
layout?: "vertical" | "horizontal";
}
export const NewProformaTotalsSummary = ({
totals,
currency = "EUR",
showRec = false,
showRetention = false,
globalDiscountField,
disabled = false,
className,
layout = "vertical",
}: NewProformaTotalsSummaryProps) => {
const { t } = useTranslation();
const formatMoney = (value: number): string => {
return MoneyHelper.formatCurrency(value, 2, currency);
};
const formatPercent = (value: number): string => {
return PercentageHelper.formatPercent(value);
};
const retentionLabel =
totals.retentionPercentage === null
? "Total retenciones"
: `Total retenciones ${formatPercent(totals.retentionPercentage)}`;
const isHorizontal = layout === "horizontal";
return (
<Card
className={cn(
"w-full overflow-hidden",
!isHorizontal && "max-w-md",
disabled && "opacity-60",
className
)}
>
{/* Header */}
<div className="flex items-center gap-3 px-5 pb-4">
<div className="flex size-9 items-center justify-center rounded-lg">
<Receipt className="size-5 text-primary" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground">Resumen de totales</h3>
<p className="text-sm text-muted-foreground">Desglose de la factura</p>
</div>
</div>
<CardContent className={cn("p-5", isHorizontal ? "space-y-4" : "space-y-6")}>
{/* Layout horizontal: grid de 3 columnas en pantallas grandes */}
<div className={cn(isHorizontal ? "grid gap-4 lg:grid-cols-3 lg:gap-6" : "space-y-6")}>
{/* Columna 1: Subtotal + Descuentos */}
<div className={cn(isHorizontal ? "space-y-4" : "space-y-6")}>
{/* Subtotal */}
<div className="rounded-lg bg-muted/50 px-4 py-3">
<TotalsRow
label="Subtotal líneas"
strong
value={formatMoney(totals.subtotalBeforeDiscounts)}
/>
</div>
{/* Sección Descuentos */}
<section className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="flex items-center gap-2 border-b border-amber-200 px-4 py-3 dark:border-amber-900/50">
<Tag className="size-4 text-amber-600 dark:text-amber-500" />
<span className="text-sm font-semibold text-amber-700 dark:text-amber-400">
Descuentos
</span>
</div>
<div className="space-y-3 p-4">
<TotalsRow
label="Descuento en líneas"
value={`-${formatMoney(totals.lineDiscountTotal)}`}
variant="discount"
/>
{globalDiscountField && (
<div className="rounded-md border border-amber-200 bg-white p-3 dark:border-amber-900/50 dark:bg-background">
<label className="mb-2 block text-xs font-medium text-muted-foreground">
Descuento global
</label>
{globalDiscountField}
</div>
)}
<TotalsRow
description={formatPercent(totals.globalDiscountPercentage)}
label="Descuento global"
value={`-${formatMoney(totals.globalDiscountAmount)}`}
variant="discount"
/>
<Separator className="bg-amber-200 dark:bg-amber-900/50" />
<TotalsRow
label="Total descuentos"
strong
value={`-${formatMoney(totals.totalDiscountAmount)}`}
variant="discount"
/>
</div>
</section>
</div>
{/* Columna 2: Base Imponible + Impuestos */}
<div className={cn(isHorizontal ? "space-y-4" : "space-y-6")}>
{/* Base Imponible */}
<div className="rounded-lg bg-muted/50 px-4 py-3">
<TotalsRow label="Base imponible" strong value={formatMoney(totals.taxableBase)} />
</div>
{/* Sección Impuestos */}
<section className="rounded-lg border border-primary-200 bg-primary-50 dark:border-primary-900/50 dark:bg-primary-950/20">
<div className="flex items-center gap-2 border-b border-primary-200 px-4 py-3 dark:border-primary-900/50">
<Percent className="size-4 text-primary-600 dark:text-primary-500" />
<span className="text-sm font-semibold text-primary-700 dark:text-primary-400">
Impuestos
</span>
</div>
<div className="space-y-3 p-4">
{totals.taxBreakdown.length > 0 ? (
totals.taxBreakdown.map((tax) => {
const key = `${tax.taxPercentage}:${tax.recPercentage ?? "none"}`;
return (
<div className="space-y-2" key={key}>
<TotalsRow
label={`IVA ${formatPercent(tax.taxPercentage)}`}
value={formatMoney(tax.taxAmount)}
variant="tax"
/>
{showRec && tax.recPercentage !== null && (
<TotalsRow
label={`Recargo equiv. ${formatPercent(tax.recPercentage)}`}
value={formatMoney(tax.recAmount)}
variant="tax"
/>
)}
</div>
);
})
) : (
<p className="text-sm text-muted-foreground">Sin impuestos aplicados</p>
)}
<Separator className="bg-primary-200 dark:bg-primary-900/50" />
<TotalsRow
label="Total impuestos"
strong
value={formatMoney(totals.taxTotal)}
variant="tax"
/>
{showRec && (
<TotalsRow
label="Total recargo equiv."
strong
value={formatMoney(totals.recTotal)}
variant="tax"
/>
)}
</div>
</section>
</div>
{/* Columna 3: Total Final (y retenciones si aplica) */}
<div className={cn(isHorizontal ? "flex flex-col justify-start space-y-4" : "space-y-6")}>
{/* Retenciones */}
{
<div className="rounded-lg bg-muted/50 px-4 py-3">
<TotalsRow
label={retentionLabel}
strong
value={`-${formatMoney(totals.retentionAmount)}`}
/>
</div>
}
{/* Spacer para empujar el total hacia abajo en horizontal */}
{/* isHorizontal && <div className="flex-1" /> */}
{/* Total Final */}
<div className="rounded-xl bg-gradient-to-r from-primary to-primary/90 p-4 shadow-lg">
<div
className={cn(
"flex items-center justify-between",
isHorizontal && "lg:flex-col lg:items-start lg:gap-2"
)}
>
<span className="text-sm font-semibold text-primary-foreground/90">
Total factura
</span>
<span
className={cn(
"font-mono font-bold tracking-tight text-primary-foreground",
isHorizontal ? "text-2xl lg:text-3xl" : "text-2xl"
)}
>
{formatMoney(totals.total)}
</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
};
interface TotalsRowProps {
label: string;
value: string;
className?: string;
description?: string;
strong?: boolean;
variant?: "default" | "discount" | "tax";
}
const TotalsRow = ({
label,
value,
description,
className,
strong = false,
variant = "default",
}: TotalsRowProps) => {
const valueColorClass = {
default: strong ? "text-foreground" : "text-muted-foreground",
discount: "text-amber-700 dark:text-amber-400",
tax: "text-primary-700 dark:text-primary-400",
};
return (
<div className={cn("flex items-start justify-between gap-4", className)}>
<div className="min-w-0">
<div
className={cn(
"text-sm",
strong ? "font-semibold text-foreground" : "font-medium text-muted-foreground"
)}
>
{label}
</div>
{description && <div className="text-xs text-muted-foreground">{description}</div>}
</div>
<div
className={cn(
"shrink-0 text-right font-mono text-sm tabular-nums",
strong ? "font-bold" : "font-medium",
valueColorClass[variant]
)}
>
{value}
</div>
</div>
);
};

View File

@ -0,0 +1,266 @@
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
import {
Button,
Card,
CardContent,
CardFooter,
Item,
ItemContent,
ItemMedia,
ItemTitle,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ChevronDown,
ChevronUp,
CircleMinus,
LandmarkIcon,
PercentIcon,
ReceiptTextIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaTotals } from "../../entities";
interface ProformaCompactTotalsProps {
totals: ProformaTotals;
currency?: string;
showRec?: boolean;
showRetention?: boolean;
globalDiscountField?: ReactNode;
disabled?: boolean;
className?: string;
}
export const ProformaCompactTotals = ({
totals,
currency = "EUR",
showRec = false,
showRetention = false,
globalDiscountField,
disabled = false,
className,
}: ProformaCompactTotalsProps) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const formatMoney = (value: number): string => {
return MoneyHelper.formatCurrency(value, 2, currency);
};
const formatPercent = (value: number): string => {
return PercentageHelper.formatPercent(value);
};
return (
<Card className={cn("rounded-none bg-muted-foreground p-0 gap-0", className)}>
{/* Panel expandible con desglose */}
<CardContent
className={cn(
"grid overflow-hidden transition-all duration-200 bg-background",
isExpanded ? "grid-rows-[1fr] py-6" : "grid-rows-[0fr] py-0"
)}
>
<div className="overflow-hidden">
<div className="grid max-w-3xl gap-4 xl:max-w-5xl xl:gap-8 lg:grid-cols-3 text-sm 2xl:gap-16">
{/* Descuentos */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-teal-700 dark:text-teal-400">
<PercentIcon className="size-3.5" />
Descuentos
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">En lineas</span>
<span className="tabular-nums">-{formatMoney(totals.lineDiscountTotal)}</span>
</div>
<div className="flex justify-between">
<div>
<span className="text-muted-foreground">Global</span>
<span> ({formatPercent(totals.globalDiscountPercentage)})</span>
</div>
<span className="tabular-nums">-{formatMoney(totals.globalDiscountAmount)}</span>
</div>
<Separator className={"bg-foreground"} />
<div className="flex justify-between pt-1 font-medium">
<span>Total descuentos</span>
<span className="tabular-nums text-teal-700 dark:text-teal-400">
-{formatMoney(totals.totalDiscountAmount)}
</span>
</div>
</div>
</div>
{/* Impuestos */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-orange-700 dark:text-orange-400">
<LandmarkIcon className="size-3.5" />
Impuestos
</div>
<div className="space-y-1">
{totals.taxBreakdown.map((tax) => {
const key = `${tax.taxPercentage}:${tax.recPercentage ?? "none"}`;
return (
<div key={key}>
<div className="flex justify-between">
<div>
<span className="text-muted-foreground">Impuesto</span>
<span> ({formatPercent(tax.taxPercentage)})</span>
</div>
<span className="tabular-nums">{formatMoney(tax.taxAmount)}</span>
</div>
{showRec && tax.recPercentage !== null ? (
<div className="flex justify-between">
<div>
<span className="text-muted-foreground">
{t("proformas.update.totals.recPercentage", "Rec. equiv.")}
</span>
<span> ({formatPercent(tax.recPercentage)})</span>
</div>
<span className="tabular-nums">{formatMoney(totals.recTotal)}</span>
</div>
) : null}
</div>
);
})}
<Separator className={"bg-foreground"} />
<div className="flex justify-between pt-1 font-medium ">
<span>Total impuestos</span>
<span className="tabular-nums text-orange-700 dark:text-orange-400">
{formatMoney(totals.taxRecTotal)}
</span>
</div>
</div>
</div>
{/* Retenciones */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-medium text-muted-foreground">
<CircleMinus className="size-3.5" />
Retenciones
</div>
<div className="space-y-1">
{totals.retentionPercentage ? (
<>
<div className="flex justify-between">
<div>
<span className="text-muted-foreground">IRPF</span>
<span> ({formatPercent(totals.retentionPercentage)})</span>
</div>
<span className="tabular-nums">-{formatMoney(totals.retentionAmount)}</span>
</div>
<Separator className={"bg-foreground"} />
<div className="flex justify-between pt-1 font-medium">
<span>Total retenciones</span>
<span className="tabular-nums">-{formatMoney(totals.retentionAmount)}</span>
</div>
</>
) : (
<div className="text-muted-foreground">Sin retenciones</div>
)}
</div>
</div>
</div>
</div>
</CardContent>
{/* Barra principal de totales - siempre visible */}
<CardFooter className="flex items-center justify-between gap-4 bg-muted rounded-none py-3">
{/* Boton expandir */}
<Button onClick={() => setIsExpanded(!isExpanded)} size="default" variant="outline">
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
</Button>
{/* Totales en linea */}
<div className="flex items-center gap-8 text-sm">
<div className="hidden items-center gap-2 xl:flex">
<span className="text-muted-foreground">
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
</span>
<span className="font-medium tabular-nums">
{formatMoney(totals.subtotalBeforeDiscounts)}
</span>
</div>
<div className="hidden items-center gap-2 xl:flex">
<Tooltip>
<TooltipTrigger render={<PercentIcon className="text-teal-700 size-4" />} />
<TooltipContent>
{t("proformas.update.totals.totalDiscountAmount", "Total descuentos")}
</TooltipContent>
</Tooltip>
<span className="sr-only">
{t("proformas.update.totals.totalDiscountAmount", "Dtos")}
</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">{`-${formatMoney(totals.totalDiscountAmount)}`}</span>
</div>
<div className="hidden items-center gap-2 xl:flex">
<span className="text-muted-foreground text-nowrap">
{t("proformas.update.totals.taxableBase", "Base imp.")}
</span>
<span className="font-medium tabular-nums">{formatMoney(totals.taxableBase)}</span>
</div>
<div className="hidden items-center gap-2 xl:flex">
<Tooltip>
<TooltipTrigger render={<LandmarkIcon className="text-orange-700 size-4" />} />
<TooltipContent>
{t("proformas.update.totals.taxTotal", "Total impuestos")}
</TooltipContent>
</Tooltip>
<span className="text-muted-foreground sr-only">
{t("proformas.update.totals.taxTotal", "Impuestos")}
</span>
<span className="font-medium tabular-nums text-orange-700 dark:text-orange-400">
{formatMoney(totals.taxRecTotal)}
</span>
</div>
{showRetention ? (
<div className="hidden items-center gap-2 xl:flex">
<Tooltip>
<TooltipTrigger render={<CircleMinus className="size-4" />} />
<TooltipContent>
{t("proformas.update.totals.retentionAmount", "Total retenciones")}
</TooltipContent>
</Tooltip>
<span className="sr-only">
{t("proformas.update.totals.retentionAmount", "Retenc.")}
</span>
<span className="font-medium tabular-nums">
{`-${formatMoney(totals.retentionAmount)}`}
</span>
</div>
) : null}
{/* Total destacado */}
<Item className="bg-muted-foreground">
<ItemMedia className="text-muted">
<ReceiptTextIcon className="size-5" />
</ItemMedia>
<ItemContent className="items-end">
<ItemTitle className="text-base gap-2 text-muted">
<span className="font-medium">Total</span>{" "}
<span className="font-bold tabular-nums">{formatMoney(totals.total)}</span>
</ItemTitle>
</ItemContent>
</Item>
</div>
</CardFooter>
</Card>
);
};

View File

@ -1,6 +1,5 @@
import {
AmountField,
CheckboxField,
LineDescriptionField,
PercentageField,
QuantityField,
@ -66,12 +65,6 @@ export const ProformaLineEditor = ({
const { t } = useTranslation();
const columns: LineEditorColumn<ProformaItemField>[] = [
{
id: "isValued",
header: t("form_fields.items.description.is_valued", "¿Valorado?"),
headClassName: "w-[100px] text-right",
cell: ({ index }) => <CheckboxField name={`items.${index}.isValued`} />,
},
{
id: "description",
header: t("form_fields.items.description.label", "Descripción"),
@ -101,7 +94,7 @@ export const ProformaLineEditor = ({
{
id: "unitAmount",
header: t("form_fields.items.unit_amount.label", "Importe unitario"),
headClassName: "w-[120px] text-right",
headClassName: "text-right",
cell: ({ index }) => (
<AmountField
inputClassName="border-none"
@ -114,7 +107,7 @@ export const ProformaLineEditor = ({
{
id: "itemDiscountPercentage",
header: t("form_fields.items.discount_percentage.label", "Dto (%)"),
headClassName: "w-[100px] text-left",
headClassName: "text-left w-20",
cell: ({ index }) => (
<PercentageField
inputClassName="border-none"
@ -148,7 +141,7 @@ export const ProformaLineEditor = ({
id: "total",
header: t("form_fields.items.total.label", "Total"),
headClassName: "w-[120px] text-right",
className: "text-right font-medium tabular-nums pt-4",
className: "text-right font-semibold tabular-nums pt-4",
cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
},
];

View File

@ -74,7 +74,7 @@ export const ProformaTotalsSummary = ({
value={`-${formatMoney(totals.lineDiscountTotal)}`}
/>
{globalDiscountField ? <div>{globalDiscountField}</div> : null}
{globalDiscountField ? <>{globalDiscountField}</> : null}
<TotalsRow
description={formatPercent(totals.globalDiscountPercentage)}
@ -192,7 +192,7 @@ const TotalsRow = ({ label, value, description, className, strong = false }: Tot
<div className="min-w-0">
<div
className={cn(
"text-sm",
"text-base",
strong ? "font-bold text-foreground" : "font-medium text-muted-foreground"
)}
>
@ -204,8 +204,8 @@ const TotalsRow = ({ label, value, description, className, strong = false }: Tot
<div
className={cn(
"shrink-0 text-right font-mono tabular-nums",
strong ? "text-base font-bold" : "text-sm font-medium"
"shrink-0 text-right tabular-nums text-base",
strong ? "font-bold" : "font-medium"
)}
>
{value}

View File

@ -0,0 +1,97 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { ArrowLeftIcon, KeyboardIcon, MoreHorizontalIcon, SaveIcon, XIcon } from "lucide-react";
export interface ProformaUpdateHeaderProps {
onSave?: () => void;
onCancel?: () => void;
disabled?: boolean;
readOnly?: boolean;
hasChanges?: boolean;
}
export const ProformaUpdateHeader = ({
onSave,
onCancel,
disabled = false,
readOnly = false,
hasChanges = false,
}: ProformaUpdateHeaderProps) => {
return (
<header className="sticky top-0 z-20">
<div className="flex h-14 items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Button onClick={onCancel} size="icon" variant="outline">
<ArrowLeftIcon className="size-5" />
</Button>
<div className="flex items-center gap-2">
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl">
Editar proforma
</h1>
{hasChanges && <span className="size-2 rounded-full bg-amber-500" />}
</div>
</div>
<div className="flex items-center gap-2">
{/* Atajo de teclado info */}
<Tooltip>
<TooltipTrigger
render={
<Button className="hidden sm:flex" size="icon" variant="ghost">
<KeyboardIcon className="size-4" />
</Button>
}
/>
<TooltipContent className="max-w-xs" side="bottom">
<div className="space-y-1 text-xs">
<div>
<kbd className="rounded bg-muted px-1">Ctrl+S</kbd> Guardar
</div>
<div>
<kbd className="rounded bg-muted px-1">Esc</kbd> Cancelar
</div>
</div>
</TooltipContent>
</Tooltip>
<Button className="hidden sm:flex" onClick={onCancel} variant="ghost">
<XIcon className="mr-2 size-4" />
Cancelar
</Button>
<Button onClick={onSave}>
<SaveIcon className="mr-2 size-4" />
Guardar
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost">
<MoreHorizontalIcon className="size-5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem>Duplicar proforma</DropdownMenuItem>
<DropdownMenuItem>Exportar PDF</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Eliminar</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
};

View File

@ -1,11 +1,11 @@
// packages/rdx-ui/src/components/feedback/info-alert.tsx
import { Alert, AlertDescription, AlertTitle } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { InfoIcon } from "lucide-react";
import { AlertCircleIcon } from "lucide-react";
import type * as React from "react";
type ProformaInfoAlertProps = {
title: string;
title?: string;
description?: string;
children?: React.ReactNode;
className?: string;
@ -26,27 +26,32 @@ export const ProformaInfoAlert = ({
return (
<Alert
className={cn(
"relative flex items-start gap-3 rounded-md border border-primary-200 bg-primary-100 px-4 py-3 text-primary-950 shadow-none",
"flex items-center gap-2 border-t bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200",
className
)}
>
<div className={cn("mt-0.5 flex shrink-0 items-center justify-center", iconClassName)}>
<InfoIcon className="size-10 text-primary-600" />
<AlertCircleIcon className="size-4 shrink-0" />
</div>
<div className="min-w-0 flex-1">
{title && (
<AlertTitle
className={cn(
"mb-0.5 text-base font-semibold leading-none text-primary-800",
"mb-0.5 text-sm font-semibold leading-none text-amber-800 dark:text-amber-200",
titleClassName
)}
>
{title}
</AlertTitle>
)}
{(description || children) && (
<AlertDescription
className={cn("text-sm leading-5 text-foreground", descriptionClassName)}
className={cn(
"text-sm leading-5 text-amber-800 dark:text-amber-200",
descriptionClassName
)}
>
{description ?? children}
</AlertDescription>

View File

@ -38,7 +38,6 @@ export const ProformaUpdatePaymentEditor = ({
<FormSectionGrid className="w-full">
<SelectField
className="col-span-full"
disabled={disabled}
inputClassName="bg-background"
items={paymentMethodOptions}
label={t("form_fields.proformas.payment_method.label", "Forma de pago")}
@ -47,7 +46,6 @@ export const ProformaUpdatePaymentEditor = ({
"form_fields.proformas.payment_method.placeholder",
"Selecciona una forma de pago"
)}
readOnly={readOnly}
/>
<SelectField

View File

@ -25,22 +25,20 @@ export const ProformaUpdateSettingsEditor = ({
icon={<Settings2Icon className="size-5" />}
title={t("form_groups.proformas.settings.title", "Configuración")}
>
<FormSectionGrid className="w-full">
<FormSectionGrid className="md:grid-cols-1">
<SelectField
className="col-span-full"
disabled={disabled}
inputClassName="bg-background"
label={t("form_fields.proformas.status.label")}
label={t("form_fields.proformas.status.label", "Estado")}
name="status"
placeholder={t("form_fields.proformas.status.placeholder")}
readOnly={readOnly}
/>
<SelectField
className="col-span-full"
disabled={disabled}
inputClassName="bg-background"
label={t("form_fields.proformas.currency_code.label")}
label={t("form_fields.proformas.currency_code.label", "Moneda")}
name="currencyCode"
placeholder={t("form_fields.proformas.currency_code.placeholder")}
readOnly={readOnly}

View File

@ -3,7 +3,6 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { PercentageField } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { Button } from "@repo/shadcn-ui/components";
import {
ProformaUpdatePaymentEditor,
@ -18,7 +17,8 @@ import type {
UseUpdateProformaTaxControllerResult,
UseUpdateProformaTotalsControllerResult,
} from "../../controllers";
import { ProformaTotalsSummary } from "../blocks";
import { EditorSidebar, ProformaCompactTotals, ProformaTotalsSummary } from "../blocks";
import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary";
import { ProformaInfoAlert } from "../components";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
@ -63,28 +63,51 @@ export const ProformaUpdateEditorForm = ({
return (
<form
className="space-y-6 space-x-6 2xl:space-y-12"
className="space-y-6 2xl:space-y-12"
id={formId}
noValidate
onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit}
>
<ProformaInfoAlert
description="Esta proforma no tiene validez fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente."
title="Información importante"
/>
<div className="flex flex-row">
<div className="basis-3/4 space-x-4 space-y-4 2xl:space-y-6 2xl:space-x-6">
<div className="grid grid-cols-1 2xl:grid-cols-12 gap-4">
<ProformaUpdateHeaderEditor className="2xl:col-span-8" disabled={isSubmitting} />
{/* Alerta informativa */}
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Esta proforma no tiene validez fiscal.
Puedes convertirla en factura cuando sea aceptada por el cliente.
</ProformaInfoAlert>
<ProformaUpdateRecipientEditor
className="xl:col-span-4"
{/* Contenido principal */}
<div className="flex flex-1 overflow-hidden hidden">
{/* Área principal */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto space-y-6 p-4 pb-24 lg:p-6">
{/* Formulario de datos básicos */}
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
{/* Tabla de líneas */}
<ProformaUpdateItemsEditor
disabled={isSubmitting}
itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl}
/>
</div>
</main>
{/* Sidebar - desktop */}
<aside className="hidden w-96 shrink-0 border-none bg-transparent lg:block">
<EditorSidebar
className="2xl:col-span-1"
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
</aside>
</div>
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-4 2xl:gap-6">
<div className="2xl:col-span-3 space-y-4 gap-4 2xl:gap-6 2xl:space-y-6">
<div className="grid 2xl:grid-cols-3 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6">
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
</div>
<ProformaUpdateItemsEditor
@ -93,12 +116,13 @@ export const ProformaUpdateEditorForm = ({
taxCtrl={taxCtrl}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
<div className="grid 2xl:grid-cols-1 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6">
<ProformaTotalsSummary
className="md:col-span-8"
className="hidden"
currency={currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={isSubmitting}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
@ -111,9 +135,35 @@ export const ProformaUpdateEditorForm = ({
/>
</div>
</div>
<div className="basis-1/4 space-x-4 space-y-4 2xl:space-y-6 2xl:space-x-6">
<div className="2xl:col-span-1 space-y-4 2xl:space-y-6">
<ProformaUpdateRecipientEditor
className="2xl:col-span-1"
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
<ProformaUpdateTaxEditor className="2xl:col-span-1" taxCtrl={taxCtrl} />
<NewProformaTotalsSummary
currency={currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={isSubmitting}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
layout="vertical"
showRec={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetentionPercentage}
totals={totalsCtrl.totals}
/>
<ProformaUpdateSettingsEditor className="w-full bg-secondary ring-0" />
<ProformaUpdateTaxEditor className="w-full bg-secondary ring-0" taxCtrl={taxCtrl} />
<ProformaUpdatePaymentEditor
className="w-full bg-secondary ring-0"
disabled={isSubmitting || paymentCtrl.isLoading}
@ -122,16 +172,24 @@ export const ProformaUpdateEditorForm = ({
/>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
{t("common.reset", "Restablecer")}
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? t("common.saving", "Guardando...") : t("common.save", "Guardar")}
</Button>
</div>
{/* Footer fijo con totales */}
<footer className="sticky bottom-0 z-30 bg-transparent">
<ProformaCompactTotals
currency={currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={isSubmitting}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
showRec={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetentionPercentage}
totals={totalsCtrl.totals}
/>
</footer>
</form>
);
};

View File

@ -28,14 +28,13 @@ export const ProformaUpdateHeaderEditor = ({
return (
<FormSectionCard
className={className}
description={t("form_groups.proformas.basic_info.description")}
disabled={disabled}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.proformas.basic_info.title")}
>
<FormSectionGrid>
<SelectField
className="md:col-span-3"
className="md:col-span-1"
disabled={disabled}
label={t("form_fields.proformas.series.label")}
name="series"
@ -44,22 +43,31 @@ export const ProformaUpdateHeaderEditor = ({
/>
<TextField
className="md:col-span-6"
className="md:col-span-2"
disabled={disabled}
label={t("form_fields.proformas.invoice_number.label")}
maxLength={16}
name="reference"
placeholder={t("form_fields.proformas.invoice_number.placeholder")}
readOnly={readOnly}
readOnly={true}
rightIcon={
<Badge className="text-sm" variant="secondary">
Autom.
</Badge>
}
/>
<TextField
className="md:col-span-5"
disabled={disabled}
label={t("form_fields.proformas.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.proformas.reference.placeholder")}
readOnly={readOnly}
/>
<DatePickerField
className="md:col-span-3 md:col-start-1"
className="md:col-span-2"
disabled={disabled}
label={t("form_fields.proformas.invoice_date.label")}
name="invoiceDate"
@ -69,7 +77,7 @@ export const ProformaUpdateHeaderEditor = ({
/>
<DatePickerField
className="md:col-span-3"
className="md:col-span-2"
disabled={disabled}
label={t("form_fields.proformas.operation_date.label")}
name="operationDate"
@ -77,16 +85,6 @@ export const ProformaUpdateHeaderEditor = ({
readOnly={readOnly}
/>
<TextField
className="md:col-span-6"
disabled={disabled}
label={t("form_fields.proformas.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.proformas.reference.placeholder")}
readOnly={readOnly}
/>
<TextField
className="md:col-span-12"
disabled={disabled}
@ -96,9 +94,8 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.description.placeholder")}
readOnly={readOnly}
/>
<TextAreaField
className="md:col-span-12"
className="md:col-span-12 hidden"
disabled={disabled}
label={t("form_fields.proformas.notes.label")}
maxLength={256}

View File

@ -22,10 +22,9 @@ export const ProformaUpdateItemsEditor = ({
return (
<FormSectionCard
description={t("form_groups.items.description")}
disabled={disabled}
icon={<ListIcon className="size-5" />}
title={t("form_groups.items.title")}
icon={<ListIcon className="size-3" />}
title={t("form_groups.items.title", "Líneas de detalle")}
>
<ProformaLineEditor
addItemAtStart={itemsCtrl.addItemAtStart}

View File

@ -10,6 +10,7 @@ import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
import { ProformaUpdateHeader } from "../blocks";
import { ProformaUpdateSkeleton } from "../components";
import { ProformaUpdateEditorForm } from "../editors";
@ -58,6 +59,8 @@ export const ProformaUpdatePage = () => {
</AppContent>
);
updateCtrl.form.d;
return (
<FormProvider {...updateCtrl.form}>
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
@ -85,6 +88,10 @@ export const ProformaUpdatePage = () => {
</AppHeader>
<AppContent className="mx-auto max-w-[100rem]">
<ProformaUpdateHeader
disabled={updateCtrl.isUpdating}
hasChanges={updateCtrl.form.formState.isDirty}
/>
{updateCtrl.isUpdateError && (
<ErrorAlert
message={

View File

@ -40,6 +40,8 @@ export const calculateProformaTotalsFromLines = ({
const recTotal = taxBreakdown.reduce((acc, item) => acc + item.recAmount, 0);
const taxRecTotal = roundMoney(taxTotal + recTotal);
const normalizedRetentionPercentage =
retentionPercentage === null ? null : toCalculationNumber(retentionPercentage);
@ -67,6 +69,8 @@ export const calculateProformaTotalsFromLines = ({
recTotal: roundMoney(recTotal),
taxRecTotal: roundMoney(taxRecTotal),
retentionPercentage: normalizedRetentionPercentage,
retentionAmount: roundMoney(retentionAmount),