.
This commit is contained in:
parent
02b10d4f85
commit
0e193ee9ce
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ export interface Proforma {
|
||||
taxes: ProformaTaxSummary[];
|
||||
|
||||
paymentMethodId: string | null;
|
||||
paymentTermId: string | null;
|
||||
|
||||
subtotalAmount: number;
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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 ?? []);
|
||||
|
||||
@ -39,8 +39,8 @@ export interface ProformaTotals {
|
||||
taxBreakdown: ProformaTaxBreakdown[];
|
||||
|
||||
taxTotal: number;
|
||||
|
||||
recTotal: number;
|
||||
taxRecTotal: number;
|
||||
|
||||
retentionPercentage: number | null;
|
||||
retentionAmount: number;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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">
|
||||
<AlertTitle
|
||||
className={cn(
|
||||
"mb-0.5 text-base font-semibold leading-none text-primary-800",
|
||||
titleClassName
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</AlertTitle>
|
||||
{title && (
|
||||
<AlertTitle
|
||||
className={cn(
|
||||
"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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,29 +63,52 @@ 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}
|
||||
onChangeCustomerClick={onChangeCustomerClick}
|
||||
onCreateCustomerClick={onCreateCustomerClick}
|
||||
selectedCustomer={selectedCustomer}
|
||||
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
|
||||
disabled={isSubmitting}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user