.
This commit is contained in:
parent
482dd5ef56
commit
6adb538aa4
@ -1,6 +1,6 @@
|
|||||||
import { DateHelper } from "@erp/rdx-utils";
|
|
||||||
import { ReactQRCode } from "@lglab/react-qr-code";
|
import { ReactQRCode } from "@lglab/react-qr-code";
|
||||||
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
|
||||||
|
import { DateHelper } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../../i18n";
|
||||||
|
import type { ProformaList, ProformaListRow } from "../../../../shared";
|
||||||
|
|
||||||
|
interface ProformasGridProps {
|
||||||
|
data?: ProformaList;
|
||||||
|
loading: boolean;
|
||||||
|
fetching?: boolean;
|
||||||
|
|
||||||
|
columns: ColumnDef<ProformaListRow, unknown>[];
|
||||||
|
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (pageIndex: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
|
||||||
|
onRowClick?: (proformaId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformasGrid = ({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
fetching,
|
||||||
|
columns,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onRowClick,
|
||||||
|
}: ProformasGridProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { items, totalItems } = data || { items: [], totalItems: 0 };
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SkeletonDataTable
|
||||||
|
columns={columns.length}
|
||||||
|
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
|
||||||
|
rows={Math.max(6, pageSize)}
|
||||||
|
showFooter
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto space-y-4">
|
||||||
|
{/*
|
||||||
|
* ─── CAPA DE FADE ──────────────────────────────────────────────────────
|
||||||
|
* Div absolutamente posicionado sobre el área scrollable.
|
||||||
|
* - pointer-events: none → no bloquea interacciones.
|
||||||
|
* - linear-gradient → de transparente a bg-card (blanco/oscuro).
|
||||||
|
* - z-10 → queda por DEBAJO de la columna sticky (z-20).
|
||||||
|
* - Solo visible cuando aún hay contenido a la derecha.
|
||||||
|
* ────────────────────────────────────────────────────────────────────────
|
||||||
|
*/}
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
enablePagination
|
||||||
|
enableRowSelection
|
||||||
|
manualPagination
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
//onRowClick={(row) => onRowClick?.(row.id)}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalItems={totalItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -45,29 +45,18 @@ export const ProformasGrid = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto space-y-4">
|
<DataTable
|
||||||
{/*
|
columns={columns}
|
||||||
* ─── CAPA DE FADE ──────────────────────────────────────────────────────
|
data={items}
|
||||||
* Div absolutamente posicionado sobre el área scrollable.
|
enablePagination
|
||||||
* - pointer-events: none → no bloquea interacciones.
|
enableRowSelection
|
||||||
* - linear-gradient → de transparente a bg-card (blanco/oscuro).
|
manualPagination
|
||||||
* - z-10 → queda por DEBAJO de la columna sticky (z-20).
|
onPageChange={onPageChange}
|
||||||
* - Solo visible cuando aún hay contenido a la derecha.
|
onPageSizeChange={onPageSizeChange}
|
||||||
* ────────────────────────────────────────────────────────────────────────
|
//onRowClick={(row) => onRowClick?.(row.id)}
|
||||||
*/}
|
pageIndex={pageIndex}
|
||||||
<DataTable
|
pageSize={pageSize}
|
||||||
columns={columns}
|
totalItems={totalItems}
|
||||||
data={items}
|
/>
|
||||||
enablePagination
|
|
||||||
enableRowSelection
|
|
||||||
manualPagination
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onPageSizeChange={onPageSizeChange}
|
|
||||||
//onRowClick={(row) => onRowClick?.(row.id)}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageSize={pageSize}
|
|
||||||
totalItems={totalItems}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { DateHelper } from "@repo/rdx-utils";
|
import { DateHelper } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
@ -34,6 +35,7 @@ type GridActionHandlers = {
|
|||||||
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
|
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
|
||||||
onLinkedInvoiceClick?: (proforma: ProformaListRow) => void;
|
onLinkedInvoiceClick?: (proforma: ProformaListRow) => void;
|
||||||
getNextStatus?: (proforma: ProformaListRow) => ProformaStatus | null;
|
getNextStatus?: (proforma: ProformaListRow) => ProformaStatus | null;
|
||||||
|
|
||||||
canIssue?: (proforma: ProformaListRow) => boolean;
|
canIssue?: (proforma: ProformaListRow) => boolean;
|
||||||
canEdit?: (proforma: ProformaListRow) => boolean;
|
canEdit?: (proforma: ProformaListRow) => boolean;
|
||||||
canDelete?: (proforma: ProformaListRow) => boolean;
|
canDelete?: (proforma: ProformaListRow) => boolean;
|
||||||
@ -44,6 +46,306 @@ export function useProformasGridColumns(
|
|||||||
): ColumnDef<ProformaListRow, unknown>[] {
|
): ColumnDef<ProformaListRow, unknown>[] {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Seleccionar todo"
|
||||||
|
checked={
|
||||||
|
table.getIsAllPageRowsSelected() ||
|
||||||
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select row"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "series",
|
||||||
|
header: "Serie",
|
||||||
|
enableHiding: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invoiceNumber",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="px-0"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Num.
|
||||||
|
<ArrowUpDownIcon className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableHiding: false,
|
||||||
|
meta: {
|
||||||
|
cellClassName: "font-medium",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Estado",
|
||||||
|
enableHiding: true,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const proforma = row.original;
|
||||||
|
|
||||||
|
const isIssued = proforma.status === "issued";
|
||||||
|
const invoiceId = proforma.linkedInvoiceId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProformaStatusBadge status={proforma.status} />
|
||||||
|
{/* Enlace discreto a factura real */}
|
||||||
|
{isIssued && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="size-6 text-foreground hover:text-primary"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<a href={`/facturas/${invoiceId}`}>
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
<span className="sr-only">Ver factura {invoiceId}</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Ver factura {invoiceId}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cliente
|
||||||
|
{
|
||||||
|
accessorKey: "recipientName",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="px-0"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cliente
|
||||||
|
<ArrowUpDownIcon className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const proforma = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:gap-2 truncate">
|
||||||
|
<span className="font-medium">{proforma.recipient.name}</span>
|
||||||
|
<span className="text-muted-foreground">{proforma.recipient.tin}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
cellClassName: "max-w-128",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "invoiceDate",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="px-0"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Fecha prof.
|
||||||
|
<ArrowUpDownIcon className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => DateHelper.format(row.original.invoiceDate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "reference",
|
||||||
|
header: "Referencia",
|
||||||
|
enableHiding: true,
|
||||||
|
cell: ({ row }) => <div className="truncate">{row.original.reference}</div>,
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden lg:table-cell max-w-16",
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
accessorKey: "subtotalAmountFmt",
|
||||||
|
header: () => "Subtotal",
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
|
||||||
|
headerClassName: "hidden 2xl:table-cell text-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "totalDiscountAmountFmt",
|
||||||
|
header: "Dtos",
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
|
||||||
|
headerClassName: "hidden 2xl:table-cell text-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "taxableAmountFmt",
|
||||||
|
header: "Base Imp.",
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
|
||||||
|
headerClassName: "hidden 2xl:table-cell text-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "taxesAmountFmt",
|
||||||
|
header: "Impuestos",
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden 2xl:table-cell text-right tabular-nums font-medium",
|
||||||
|
headerClassName: "hidden 2xl:table-cell text-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "totalAmountFmt",
|
||||||
|
header: "Total",
|
||||||
|
meta: {
|
||||||
|
cellClassName: "hidden xl:table-cell text-right tabular-nums font-semibold",
|
||||||
|
headerClassName: "hidden xl:table-cell text-right",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
meta: {
|
||||||
|
isActionsColumn: true,
|
||||||
|
headerClassName: "text-right",
|
||||||
|
},
|
||||||
|
header: "Acciones",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const proforma = row.original;
|
||||||
|
const isIssued = proforma.status === PROFORMA_STATUS.ISSUED;
|
||||||
|
const isApproved = proforma.status === PROFORMA_STATUS.APPROVED;
|
||||||
|
const availableTransitions =
|
||||||
|
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
{!isIssued && actionHandlers.onEditClick && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => actionHandlers.onEditClick?.(proforma)}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
<span className="sr-only">Editar</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Editar</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cambiar estado */}
|
||||||
|
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && (
|
||||||
|
<TooltipProvider key={availableTransitions[0]}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() =>
|
||||||
|
actionHandlers.onChangeStatusClick?.(proforma, availableTransitions[0])
|
||||||
|
}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
<span className="sr-only">Cambiar estado</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>
|
||||||
|
Cambiar a {t(`catalog.proformas.status.${availableTransitions[0]}.label`)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Emitir factura: solo si approved */}
|
||||||
|
{!isIssued && isApproved && actionHandlers.onIssueClick && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => actionHandlers.onIssueClick?.(proforma)}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Emitir a factura</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Eliminar */}
|
||||||
|
{!isIssued && actionHandlers.onDeleteClick && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
className="size-8 text-destructive/75 hover:text-destructive cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
actionHandlers.onDeleteClick?.(proforma);
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Eliminar</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, actionHandlers]
|
||||||
|
);
|
||||||
|
|
||||||
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
|
return React.useMemo<ColumnDef<ProformaListRow, unknown>[]>(
|
||||||
() => [
|
() => [
|
||||||
/*{
|
/*{
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
import { PageHeader } from "@erp/core/components";
|
|
||||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks";
|
|
||||||
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
|
||||||
import { useId, useMemo } from "react";
|
|
||||||
import { type FieldErrors, FormProvider } from "react-hook-form";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
|
||||||
import { ProformaDtoAdapter } from "../../adapters";
|
|
||||||
import { useUpdateProforma } from "../../shared/hooks/use-proforma-update-mutation";
|
|
||||||
import {
|
|
||||||
type Proforma,
|
|
||||||
type ProformaFormData,
|
|
||||||
ProformaFormSchema,
|
|
||||||
defaultProformaFormData,
|
|
||||||
} from "../../types";
|
|
||||||
|
|
||||||
import { useProformaContext } from "./context";
|
|
||||||
import { ProformaUpdateForm } from "./proforma-update-form";
|
|
||||||
|
|
||||||
export type ProformaUpdateCompProps = {
|
|
||||||
proforma: Proforma;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProformaUpdateComp = ({ proforma: proformaData }: ProformaUpdateCompProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const formId = useId();
|
|
||||||
|
|
||||||
const context = useProformaContext();
|
|
||||||
const { proforma_id } = context;
|
|
||||||
|
|
||||||
const isPending = !proformaData;
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutate,
|
|
||||||
isPending: isUpdating,
|
|
||||||
isError: isUpdateError,
|
|
||||||
error: updateError,
|
|
||||||
} = useUpdateProforma();
|
|
||||||
|
|
||||||
const initialValues = useMemo(() => {
|
|
||||||
return proformaData
|
|
||||||
? ProformaDtoAdapter.fromDto(proformaData, context)
|
|
||||||
: defaultProformaFormData;
|
|
||||||
}, [proformaData, context]);
|
|
||||||
|
|
||||||
const form = useHookForm<ProformaFormData>({
|
|
||||||
resolverSchema: ProformaFormSchema,
|
|
||||||
initialValues,
|
|
||||||
disabled: !proformaData || isUpdating,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (formData: ProformaFormData) => {
|
|
||||||
const dto = ProformaDtoAdapter.toDto(formData, context);
|
|
||||||
mutate(
|
|
||||||
{ id: proforma_id, data: dto as Partial<ProformaFormData> },
|
|
||||||
{
|
|
||||||
onSuccess: () =>
|
|
||||||
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message")),
|
|
||||||
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () =>
|
|
||||||
form.reset((proformaData as unknown as ProformaFormData) ?? defaultProformaFormData);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (errors: FieldErrors<ProformaFormData>) => {
|
|
||||||
console.error("Errores en el formulario:", errors);
|
|
||||||
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
|
||||||
<AppHeader>
|
|
||||||
<PageHeader
|
|
||||||
backIcon
|
|
||||||
description={t("pages.edit.description")}
|
|
||||||
rightSlot={
|
|
||||||
<UpdateCommitButtonGroup
|
|
||||||
cancel={{ formId, to: "/proformas/list" }}
|
|
||||||
isLoading={isPending}
|
|
||||||
submit={{
|
|
||||||
formId,
|
|
||||||
variant: "default",
|
|
||||||
disabled: isPending,
|
|
||||||
label: t("pages.proformas.edit.actions.save_draft"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={`${t("pages.proformas.edit.title")} #${proformaData.invoice_number}`}
|
|
||||||
/>
|
|
||||||
</AppHeader>
|
|
||||||
|
|
||||||
<AppContent>
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<ProformaUpdateForm
|
|
||||||
className="bg-white rounded-xl border shadow-xl max-w-full"
|
|
||||||
formId={formId}
|
|
||||||
onError={handleError}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
</AppContent>
|
|
||||||
</UnsavedChangesProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import { FormDebug } from "@erp/core/components";
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import { type FieldErrors, useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import type { ProformaFormData } from "../../types";
|
|
||||||
|
|
||||||
import { ProformaBasicInfoFields, ProformaItems, ProformaRecipient, ProformaTotals } from "./ui";
|
|
||||||
|
|
||||||
interface ProformaUpdateFormProps {
|
|
||||||
formId: string;
|
|
||||||
onSubmit: (data: ProformaFormData) => void;
|
|
||||||
onError: (errors: FieldErrors<ProformaFormData>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProformaUpdateForm = ({
|
|
||||||
formId,
|
|
||||||
onSubmit,
|
|
||||||
onError,
|
|
||||||
className,
|
|
||||||
}: ProformaUpdateFormProps) => {
|
|
||||||
const form = useFormContext<ProformaFormData>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
id={formId}
|
|
||||||
noValidate
|
|
||||||
onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
form.handleSubmit(onSubmit, onError)(event);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormDebug />
|
|
||||||
|
|
||||||
<section className={cn("space-y-6 p-6", className)}>
|
|
||||||
<div className="w-full bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
||||||
<ProformaRecipient className="flex flex-col" />
|
|
||||||
<ProformaBasicInfoFields className="flex flex-col lg:col-span-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
<ProformaItems />
|
|
||||||
</div>
|
|
||||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2">
|
|
||||||
<ProformaTotals className="lg:col-start-2" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full" />
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./proforma-to-proforma-update-form.adapter";
|
export * from "./map-proforma-form-to-commercial-document-lines";
|
||||||
export * from "./proforma-to-selected-customer.adapter";
|
export * from "./map-proforma-to-proforma-update-form.adapter";
|
||||||
|
export * from "./map-proforma-to-selected-customer.adapter";
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
import type { CommercialDocumentLineInput, ProformaUpdateForm } from "../entities";
|
||||||
|
|
||||||
|
export const mapProformaFormToCommercialDocumentLines = (
|
||||||
|
form: ProformaUpdateForm
|
||||||
|
): CommercialDocumentLineInput[] => {
|
||||||
|
return form.items
|
||||||
|
.filter((item) => item.isValued)
|
||||||
|
.map((item) => ({
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitAmount: item.unitAmount,
|
||||||
|
itemDiscountPercentage: item.itemDiscountPercentage,
|
||||||
|
taxPercentage: item.taxPercentage,
|
||||||
|
equivalenceSurchargePercentage: item.equivalenceSurchargePercentage,
|
||||||
|
}));
|
||||||
|
};
|
||||||
@ -15,9 +15,15 @@ export const mapProformaItemsToProformaItemsUpdateForm = (
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
position: item.position,
|
position: item.position,
|
||||||
isValued: item.isValued,
|
isValued: item.isValued,
|
||||||
description: item.description,
|
|
||||||
quantity: item.quantity,
|
description: item.description ?? null,
|
||||||
unitAmount: item.unitAmount,
|
|
||||||
itemDiscountPercentage: item.itemDiscountPercentage,
|
quantity: item.quantity ?? null,
|
||||||
|
unitAmount: item.unitAmount ?? null,
|
||||||
|
|
||||||
|
itemDiscountPercentage: item.itemDiscountPercentage ?? null,
|
||||||
|
|
||||||
|
taxPercentage: item.ivaPercentage ?? null,
|
||||||
|
equivalenceSurchargePercentage: item.recPercentage ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import type { Proforma, ProformaItem } from "../../shared";
|
||||||
|
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
|
||||||
|
|
||||||
|
import { mapProformaItemsToProformaItemsUpdateForm } from "./map-proforma-items-to-proforma-items-update-form.adapter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea una proforma a un formulario de actualización de proforma.
|
||||||
|
*/
|
||||||
|
export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpdateForm => {
|
||||||
|
const taxMode = inferProformaTaxMode(proforma.items);
|
||||||
|
|
||||||
|
const defaultTaxSummary = proforma.taxes[0];
|
||||||
|
const firstTaxableItem = getFirstTaxableItem(proforma.items);
|
||||||
|
|
||||||
|
const defaultTaxPercentage =
|
||||||
|
defaultTaxSummary?.ivaPercentage ?? firstTaxableItem?.ivaPercentage ?? null;
|
||||||
|
|
||||||
|
const defaultEquivalenceSurchargePercentage =
|
||||||
|
defaultTaxSummary?.recPercentage ?? firstTaxableItem?.recPercentage ?? null;
|
||||||
|
|
||||||
|
const defaultRetentionPercentage =
|
||||||
|
defaultTaxSummary?.retentionPercentage ?? firstTaxableItem?.retentionPercentage ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: proforma.series ?? "",
|
||||||
|
|
||||||
|
invoiceDate: proforma.invoiceDate ?? "",
|
||||||
|
operationDate: proforma.operationDate ?? "",
|
||||||
|
|
||||||
|
customerId: proforma.customerId ?? "",
|
||||||
|
|
||||||
|
description: proforma.description ?? "",
|
||||||
|
reference: proforma.reference ?? "",
|
||||||
|
notes: proforma.notes ?? "",
|
||||||
|
|
||||||
|
languageCode: proforma.languageCode ?? "es",
|
||||||
|
currencyCode: proforma.currencyCode ?? "EUR",
|
||||||
|
|
||||||
|
globalDiscountPercentage: proforma.globalDiscountPercentage ?? 0,
|
||||||
|
|
||||||
|
taxMode,
|
||||||
|
defaultTaxPercentage,
|
||||||
|
hasEquivalenceSurcharge: hasPositivePercentage(defaultEquivalenceSurchargePercentage),
|
||||||
|
hasRetention: hasPositivePercentage(defaultRetentionPercentage),
|
||||||
|
retentionPercentage: defaultRetentionPercentage,
|
||||||
|
|
||||||
|
paymentMethod: proforma.paymentMethod ?? "",
|
||||||
|
|
||||||
|
items: proforma.items.map(mapProformaItemsToProformaItemsUpdateForm),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstTaxableItem = (items: ProformaItem[]): ProformaItem | undefined => {
|
||||||
|
return items.find((item) => item.isValued) ?? items[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferProformaTaxMode = (items: ProformaItem[]): ProformaTaxMode => {
|
||||||
|
const comparableItems = items.filter((item) => item.isValued);
|
||||||
|
|
||||||
|
const sourceItems = comparableItems.length > 0 ? comparableItems : items;
|
||||||
|
|
||||||
|
const ivaPercentages = uniqueNumbers(sourceItems.map((item) => item.ivaPercentage));
|
||||||
|
const recPercentages = uniqueNumbers(sourceItems.map((item) => item.recPercentage));
|
||||||
|
const retentionPercentages = uniqueNumbers(sourceItems.map((item) => item.retentionPercentage));
|
||||||
|
|
||||||
|
const hasSingleTaxSetup =
|
||||||
|
ivaPercentages.length <= 1 && recPercentages.length <= 1 && retentionPercentages.length <= 1;
|
||||||
|
|
||||||
|
return hasSingleTaxSetup ? "single" : "perLine";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueNumbers = (values: Array<number | null | undefined>): number[] => {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.filter((value): value is number => value !== null && value !== undefined)
|
||||||
|
.map((value) => normalizePercentage(value))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePercentage = (value: number): number => {
|
||||||
|
return Math.round(value * 10000) / 10000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPositivePercentage = (value: number | null | undefined): boolean => {
|
||||||
|
return value !== null && value !== undefined && value > 0;
|
||||||
|
};
|
||||||
@ -17,11 +17,24 @@ export const mapProformaToSelectedCustomer = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: "active",
|
||||||
|
|
||||||
id: proforma.customerId,
|
id: proforma.customerId,
|
||||||
name: proforma.recipient.name ?? "",
|
name: proforma.recipient.name ?? "",
|
||||||
|
tradeName: "",
|
||||||
tin: proforma.recipient.tin ?? "",
|
tin: proforma.recipient.tin ?? "",
|
||||||
|
|
||||||
languageCode: proforma.languageCode ?? "",
|
street: proforma.recipient.street ?? "",
|
||||||
currencyCode: proforma.currencyCode ?? "",
|
street2: proforma.recipient.street2 ?? "",
|
||||||
|
city: proforma.recipient.city ?? "",
|
||||||
|
province: proforma.recipient.province ?? "",
|
||||||
|
postalCode: proforma.recipient.postalCode ?? "",
|
||||||
|
country: proforma.recipient.country ?? "",
|
||||||
|
|
||||||
|
primaryEmail: "",
|
||||||
|
primaryMobile: "",
|
||||||
|
primaryPhone: "",
|
||||||
|
|
||||||
|
isCompany: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import type { Proforma } from "../../shared";
|
|
||||||
import type { ProformaUpdateForm } from "../entities";
|
|
||||||
|
|
||||||
import { mapProformaItemsToProformaItemsUpdateForm } from "./proforma-items-to-proforma-items-update-form.adapter";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapea una proforma a un formulario de actualización de proforma.
|
|
||||||
*
|
|
||||||
* @param proforma
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const mapProformaToProformaUpdateForm = (proforma: Proforma): ProformaUpdateForm => {
|
|
||||||
return {
|
|
||||||
series: proforma.series ?? "",
|
|
||||||
|
|
||||||
invoiceDate: proforma.invoiceDate ?? "",
|
|
||||||
operationDate: proforma.operationDate ?? "",
|
|
||||||
|
|
||||||
customerId: proforma.customerId ?? "",
|
|
||||||
|
|
||||||
description: proforma.description ?? "",
|
|
||||||
reference: proforma.reference ?? "",
|
|
||||||
notes: proforma.notes ?? "",
|
|
||||||
|
|
||||||
languageCode: proforma.languageCode ?? "es",
|
|
||||||
currencyCode: proforma.currencyCode ?? "EUR",
|
|
||||||
|
|
||||||
globalDiscountPercentage: proforma.globalDiscountPercentage ?? 0,
|
|
||||||
|
|
||||||
paymentMethod: proforma.paymentMethod ?? "",
|
|
||||||
|
|
||||||
items: proforma.items.map(mapProformaItemsToProformaItemsUpdateForm),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export * from "./use-update-proforma-controller";
|
export * from "./use-update-proforma-controller";
|
||||||
export * from "./use-update-proforma-items-controller";
|
export * from "./use-update-proforma-items-controller";
|
||||||
export * from "./use-update-proforma-page-controller";
|
export * from "./use-update-proforma-page-controller";
|
||||||
|
export * from "./use-update-proforma-tax-controller";
|
||||||
|
export * from "./use-update-proforma-totals-controller";
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import {
|
|||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller";
|
import { useUpdateProformaItemsController } from "./use-update-proforma-items-controller";
|
||||||
|
import { useUpdateProformaTaxController } from "./use-update-proforma-tax-controller";
|
||||||
|
import { useUpdateProformaTotalsController } from "./use-update-proforma-totals-controller";
|
||||||
|
|
||||||
export interface UseUpdateProformaControllerOptions {
|
export interface UseUpdateProformaControllerOptions {
|
||||||
onUpdated?(updated: Proforma): void;
|
onUpdated?(updated: Proforma): void;
|
||||||
@ -238,16 +240,33 @@ export const useUpdateProformaController = (
|
|||||||
submitHandler(event);
|
submitHandler(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currencyCode = form.watch("currencyCode");
|
||||||
|
const languageCode = form.watch("languageCode");
|
||||||
|
|
||||||
|
const taxCtrl = useUpdateProformaTaxController({
|
||||||
|
form,
|
||||||
|
});
|
||||||
|
|
||||||
const itemsCtrl = useUpdateProformaItemsController({
|
const itemsCtrl = useUpdateProformaItemsController({
|
||||||
form,
|
form,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalsCtrl = useUpdateProformaTotalsController({
|
||||||
|
form,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// form
|
// form
|
||||||
formId,
|
formId,
|
||||||
form,
|
form,
|
||||||
|
|
||||||
itemsCtrl,
|
itemsCtrl,
|
||||||
|
taxCtrl,
|
||||||
|
totalsCtrl,
|
||||||
|
|
||||||
|
//
|
||||||
|
currencyCode,
|
||||||
|
languageCode,
|
||||||
|
|
||||||
// handlers del form
|
// handlers del form
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { NumberHelper } from "@repo/rdx-utils";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
type FieldArrayWithId,
|
type FieldArrayWithId,
|
||||||
@ -10,6 +9,8 @@ import {
|
|||||||
|
|
||||||
import type { ProformaItemUpdateForm, ProformaUpdateForm } from "../entities";
|
import type { ProformaItemUpdateForm, ProformaUpdateForm } from "../entities";
|
||||||
import { buildProformaItemUpdateDefault } from "../utils";
|
import { buildProformaItemUpdateDefault } from "../utils";
|
||||||
|
import { calculateCommercialDocumentLineAmounts } from "../utils/calculations/calculate-commercial-document-line-amounts";
|
||||||
|
import { calculateCommercialDocumentLinesTotals } from "../utils/calculations/calculate-commercial-document-lines-totals";
|
||||||
|
|
||||||
export interface ProformaItemAmounts {
|
export interface ProformaItemAmounts {
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
@ -66,51 +67,6 @@ const normalizeItemPositions = (items: ProformaItemUpdateForm[]): ProformaItemUp
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateItemAmounts = (
|
|
||||||
item?: Pick<ProformaItemUpdateForm, "quantity" | "unitAmount" | "itemDiscountPercentage">
|
|
||||||
): ProformaItemAmounts => {
|
|
||||||
if (!item) {
|
|
||||||
return {
|
|
||||||
subtotal: 0,
|
|
||||||
itemDiscountAmount: 0,
|
|
||||||
total: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const quantity = NumberHelper.toSafeNumber(item.quantity);
|
|
||||||
const unitAmount = NumberHelper.toSafeNumber(item.unitAmount);
|
|
||||||
const itemDiscountPercentage = NumberHelper.toSafeNumber(item.itemDiscountPercentage);
|
|
||||||
|
|
||||||
const subtotal = roundCurrency(quantity * unitAmount);
|
|
||||||
const itemDiscountAmount = roundCurrency(subtotal * (itemDiscountPercentage / 100));
|
|
||||||
const total = roundCurrency(subtotal - itemDiscountAmount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subtotal,
|
|
||||||
itemDiscountAmount,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateItemsTotals = (items: ProformaItemUpdateForm[]): ProformaItemsTotals => {
|
|
||||||
return items.reduce<ProformaItemsTotals>(
|
|
||||||
(acc, item) => {
|
|
||||||
const amounts = calculateItemAmounts(item);
|
|
||||||
|
|
||||||
return {
|
|
||||||
subtotal: roundCurrency(acc.subtotal + amounts.subtotal),
|
|
||||||
discountAmount: roundCurrency(acc.discountAmount + amounts.itemDiscountAmount),
|
|
||||||
total: roundCurrency(acc.total + amounts.total),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subtotal: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateProformaItemsController = ({
|
export const useUpdateProformaItemsController = ({
|
||||||
form,
|
form,
|
||||||
}: UseUpdateProformaItemsControllerParams): UseUpdateProformaItemsControllerResult => {
|
}: UseUpdateProformaItemsControllerParams): UseUpdateProformaItemsControllerResult => {
|
||||||
@ -223,11 +179,16 @@ export const useUpdateProformaItemsController = ({
|
|||||||
|
|
||||||
const getItemAmounts = React.useCallback(
|
const getItemAmounts = React.useCallback(
|
||||||
(index: number): ProformaItemAmounts => {
|
(index: number): ProformaItemAmounts => {
|
||||||
return calculateItemAmounts(items[index]);
|
const amounts = calculateCommercialDocumentLineAmounts(items[index]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotal: amounts.grossAmount,
|
||||||
|
itemDiscountAmount: amounts.itemDiscountAmount,
|
||||||
|
total: amounts.taxableBaseBeforeGlobalDiscount,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
[items]
|
[items]
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertItemAt = React.useCallback(
|
const insertItemAt = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const currentItems = getValues("items") ?? [];
|
const currentItems = getValues("items") ?? [];
|
||||||
@ -259,7 +220,15 @@ export const useUpdateProformaItemsController = ({
|
|||||||
[insertItemAt]
|
[insertItemAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
const totals = React.useMemo(() => calculateItemsTotals(items), [items]);
|
const totals = React.useMemo<ProformaItemsTotals>(() => {
|
||||||
|
const lineTotals = calculateCommercialDocumentLinesTotals(items);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotal: lineTotals.subtotalBeforeDiscounts,
|
||||||
|
discountAmount: lineTotals.lineDiscountTotal,
|
||||||
|
total: lineTotals.taxableBaseBeforeGlobalDiscount,
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const itemErrors = React.useMemo<ProformaItemError[]>(() => {
|
const itemErrors = React.useMemo<ProformaItemError[]>(() => {
|
||||||
if (!Array.isArray(errors.items)) {
|
if (!Array.isArray(errors.items)) {
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { ProformaTaxMode, ProformaUpdateForm } from "../entities";
|
||||||
|
|
||||||
|
interface UseUpdateProformaTaxControllerParams {
|
||||||
|
form: UseFormReturn<ProformaUpdateForm>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateProformaTaxControllerResult {
|
||||||
|
taxMode: ProformaTaxMode;
|
||||||
|
|
||||||
|
defaultTaxPercentage: number | null;
|
||||||
|
hasEquivalenceSurcharge: boolean;
|
||||||
|
hasRetention: boolean;
|
||||||
|
retentionPercentage: number | null;
|
||||||
|
|
||||||
|
usesSingleTax: boolean;
|
||||||
|
usesPerLineTax: boolean;
|
||||||
|
|
||||||
|
enablePerLineTaxes: () => void;
|
||||||
|
disablePerLineTaxes: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEquivalenceSurchargePercentage = (
|
||||||
|
taxPercentage: number | null | undefined
|
||||||
|
): number | null => {
|
||||||
|
if (taxPercentage === 21) return 5.2;
|
||||||
|
if (taxPercentage === 10) return 1.4;
|
||||||
|
if (taxPercentage === 4) return 0.5;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateProformaTaxController = ({
|
||||||
|
form,
|
||||||
|
}: UseUpdateProformaTaxControllerParams): UseUpdateProformaTaxControllerResult => {
|
||||||
|
const { control, getValues, setValue } = form;
|
||||||
|
|
||||||
|
const taxMode = useWatch({ control, name: "taxMode" });
|
||||||
|
const hasEquivalenceSurcharge = useWatch({ control, name: "hasEquivalenceSurcharge" }) ?? false;
|
||||||
|
const hasRetention = useWatch({ control, name: "hasRetention" }) ?? false;
|
||||||
|
const retentionPercentage = useWatch({ control, name: "retentionPercentage" }) ?? null;
|
||||||
|
const defaultTaxPercentage = useWatch({ control, name: "defaultTaxPercentage" }) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taxMode !== "single") return;
|
||||||
|
|
||||||
|
const currentItems = getValues("items") ?? [];
|
||||||
|
const equivalenceSurchargePercentage = hasEquivalenceSurcharge
|
||||||
|
? getEquivalenceSurchargePercentage(defaultTaxPercentage)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
currentItems.forEach((_, index) => {
|
||||||
|
setValue(`items.${index}.taxPercentage`, defaultTaxPercentage ?? null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setValue(`items.${index}.equivalenceSurchargePercentage`, equivalenceSurchargePercentage, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [defaultTaxPercentage, getValues, hasEquivalenceSurcharge, setValue, taxMode]);
|
||||||
|
|
||||||
|
const enablePerLineTaxes = useCallback(() => {
|
||||||
|
setValue("taxMode", "perLine", {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
|
const disablePerLineTaxes = useCallback(() => {
|
||||||
|
setValue("taxMode", "single", {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}, [setValue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
taxMode,
|
||||||
|
|
||||||
|
defaultTaxPercentage,
|
||||||
|
hasEquivalenceSurcharge,
|
||||||
|
hasRetention,
|
||||||
|
retentionPercentage,
|
||||||
|
|
||||||
|
usesSingleTax: taxMode === "single",
|
||||||
|
usesPerLineTax: taxMode === "perLine",
|
||||||
|
|
||||||
|
enablePerLineTaxes,
|
||||||
|
disablePerLineTaxes,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { type UseFormReturn, useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { ProformaTotals, ProformaUpdateForm } from "../entities";
|
||||||
|
import { calculateProformaTotals } from "../utils";
|
||||||
|
|
||||||
|
interface UseUpdateProformaTotalsControllerParams {
|
||||||
|
form: UseFormReturn<ProformaUpdateForm>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseUpdateProformaTotalsControllerResult {
|
||||||
|
totals: ProformaTotals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateProformaTotalsController = ({
|
||||||
|
form,
|
||||||
|
}: UseUpdateProformaTotalsControllerParams): UseUpdateProformaTotalsControllerResult => {
|
||||||
|
const { control, getValues } = form;
|
||||||
|
|
||||||
|
const items = useWatch({ control, name: "items" });
|
||||||
|
const globalDiscountPercentage = useWatch({ control, name: "globalDiscountPercentage" });
|
||||||
|
const hasRetention = useWatch({ control, name: "hasRetention" });
|
||||||
|
const retentionPercentage = useWatch({ control, name: "retentionPercentage" });
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return calculateProformaTotals({
|
||||||
|
...getValues(),
|
||||||
|
items,
|
||||||
|
globalDiscountPercentage,
|
||||||
|
hasRetention,
|
||||||
|
retentionPercentage,
|
||||||
|
});
|
||||||
|
}, [items, globalDiscountPercentage, hasRetention, retentionPercentage]);
|
||||||
|
|
||||||
|
return { totals };
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
export interface CommercialDocumentLineInput {
|
||||||
|
quantity: number | null;
|
||||||
|
unitAmount: number | null;
|
||||||
|
itemDiscountPercentage: number | null;
|
||||||
|
taxPercentage: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommercialDocumentLineAmounts {
|
||||||
|
grossAmount: number;
|
||||||
|
itemDiscountAmount: number;
|
||||||
|
taxableBaseBeforeGlobalDiscount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommercialDocumentTaxBreakdownLine {
|
||||||
|
taxPercentage: number;
|
||||||
|
taxableBase: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommercialDocumentTotals {
|
||||||
|
subtotalBeforeDiscounts: number;
|
||||||
|
|
||||||
|
lineDiscountTotal: number;
|
||||||
|
globalDiscountPercentage: number;
|
||||||
|
globalDiscountAmount: number;
|
||||||
|
|
||||||
|
taxableBase: number;
|
||||||
|
|
||||||
|
taxBreakdown: CommercialDocumentTaxBreakdownLine[];
|
||||||
|
taxTotal: number;
|
||||||
|
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
export * from "./commercial-document-calculation.entity";
|
||||||
export * from "./proforma-item-update-form.entity";
|
export * from "./proforma-item-update-form.entity";
|
||||||
export * from "./proforma-item-update-form.schema";
|
export * from "./proforma-item-update-form.schema";
|
||||||
export * from "./proforma-item-update-patch.entity";
|
export * from "./proforma-item-update-patch.entity";
|
||||||
export * from "./proforma-update-form.entity";
|
export * from "./proforma-update-form.entity";
|
||||||
export * from "./proforma-update-form.schema";
|
export * from "./proforma-update-form.schema";
|
||||||
export * from "./proforma-update-patch.entity";
|
export * from "./proforma-update-patch.entity";
|
||||||
|
export * from "./proforma-update-totals.entity";
|
||||||
|
|||||||
@ -9,4 +9,7 @@ export interface ProformaItemUpdateForm {
|
|||||||
unitAmount: number | null;
|
unitAmount: number | null;
|
||||||
|
|
||||||
itemDiscountPercentage: number | null;
|
itemDiscountPercentage: number | null;
|
||||||
|
|
||||||
|
taxPercentage: number | null;
|
||||||
|
equivalenceSurchargePercentage: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,16 +15,19 @@ import { z } from "zod/v4";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const ProformaItemUpdateFormSchema = z.object({
|
export const ProformaItemUpdateFormSchema = z.object({
|
||||||
id: z.uuid(),
|
id: z.string(),
|
||||||
position: z.number().int().nonnegative(),
|
position: z.number(),
|
||||||
isValued: z.boolean(),
|
isValued: z.boolean(),
|
||||||
|
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
|
|
||||||
quantity: z.number().nullable(),
|
quantity: z.number().nullable(),
|
||||||
unitAmount: z.number().nonnegative().nullable(),
|
unitAmount: z.number().nullable(),
|
||||||
|
|
||||||
itemDiscountPercentage: z.number().min(0).max(100).nullable(),
|
itemDiscountPercentage: z.number().nullable(),
|
||||||
|
|
||||||
|
taxPercentage: z.number().nullable(),
|
||||||
|
equivalenceSurchargePercentage: z.number().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;
|
export type ProformaItemUpdateFormSchemaType = z.infer<typeof ProformaItemUpdateFormSchema>;
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
import type { ProformaItemUpdateForm } from "./proforma-item-update-form.entity";
|
import type { ProformaItemUpdateForm } from "./proforma-item-update-form.entity";
|
||||||
|
|
||||||
|
export type ProformaTaxMode = "single" | "perLine";
|
||||||
|
|
||||||
export interface ProformaUpdateForm {
|
export interface ProformaUpdateForm {
|
||||||
series: string;
|
series: string;
|
||||||
|
|
||||||
@ -32,6 +34,13 @@ export interface ProformaUpdateForm {
|
|||||||
|
|
||||||
globalDiscountPercentage: number;
|
globalDiscountPercentage: number;
|
||||||
|
|
||||||
|
taxMode: ProformaTaxMode;
|
||||||
|
defaultTaxPercentage: number | null;
|
||||||
|
|
||||||
|
hasEquivalenceSurcharge: boolean;
|
||||||
|
hasRetention: boolean;
|
||||||
|
retentionPercentage: number | null;
|
||||||
|
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
|
|
||||||
items: ProformaItemUpdateForm[];
|
items: ProformaItemUpdateForm[];
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { CurrencyCodeSchema, LanguageCodeSchema } from "@erp/core";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema";
|
import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema";
|
||||||
@ -18,25 +17,32 @@ import { ProformaItemUpdateFormSchema } from "./proforma-item-update-form.schema
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const ProformaUpdateFormSchema = z.object({
|
export const ProformaUpdateFormSchema = z.object({
|
||||||
series: z.string().or(z.literal("")),
|
series: z.string(),
|
||||||
|
|
||||||
invoiceDate: z.string(),
|
invoiceDate: z.string().min(1),
|
||||||
operationDate: z.string().or(z.literal("")),
|
operationDate: z.string(),
|
||||||
|
|
||||||
customerId: z.string(),
|
customerId: z.string().min(1),
|
||||||
|
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
reference: z.string().or(z.literal("")),
|
reference: z.string(),
|
||||||
notes: z.string().or(z.literal("")),
|
notes: z.string(),
|
||||||
|
|
||||||
languageCode: LanguageCodeSchema,
|
languageCode: z.string().min(1),
|
||||||
currencyCode: CurrencyCodeSchema,
|
currencyCode: z.string().min(1),
|
||||||
|
|
||||||
globalDiscountPercentage: z.number(),
|
globalDiscountPercentage: z.number().min(0).max(100),
|
||||||
|
|
||||||
paymentMethod: z.string().or(z.literal("")),
|
taxMode: z.enum(["single", "perLine"]),
|
||||||
|
defaultTaxPercentage: z.number().nullable(),
|
||||||
|
|
||||||
items: z.array(ProformaItemUpdateFormSchema),
|
hasEquivalenceSurcharge: z.boolean(),
|
||||||
|
hasRetention: z.boolean(),
|
||||||
|
retentionPercentage: z.number().nullable(),
|
||||||
|
|
||||||
|
paymentMethod: z.string(),
|
||||||
|
|
||||||
|
items: z.array(ProformaItemUpdateFormSchema).min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProformaUpdateFormSchemaType = z.infer<typeof ProformaUpdateFormSchema>;
|
export type ProformaUpdateFormSchemaType = z.infer<typeof ProformaUpdateFormSchema>;
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
export interface ProformaTaxBreakdownLine {
|
||||||
|
taxPercentage: number;
|
||||||
|
taxableBase: number;
|
||||||
|
taxAmount: number;
|
||||||
|
equivalenceSurchargePercentage: number | null;
|
||||||
|
equivalenceSurchargeAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProformaTotals {
|
||||||
|
subtotalBeforeDiscounts: number;
|
||||||
|
|
||||||
|
lineDiscountTotal: number;
|
||||||
|
globalDiscountPercentage: number;
|
||||||
|
globalDiscountAmount: number;
|
||||||
|
|
||||||
|
taxableBase: number;
|
||||||
|
|
||||||
|
taxBreakdown: ProformaTaxBreakdownLine[];
|
||||||
|
taxTotal: number;
|
||||||
|
|
||||||
|
equivalenceSurchargeTotal: number;
|
||||||
|
|
||||||
|
retentionPercentage: number | null;
|
||||||
|
retentionAmount: number;
|
||||||
|
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
export * from "./proforma-basic-info-fields";
|
|
||||||
export * from "./proforma-form-field-shell";
|
|
||||||
export * from "./proforma-header-fields-card";
|
|
||||||
export * from "./proforma-line-editor";
|
export * from "./proforma-line-editor";
|
||||||
|
export * from "./proforma-totals-summary";
|
||||||
export * from "./selected-recipient";
|
export * from "./selected-recipient";
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from "./items-editor";
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import type { ProformaFormData, ProformaItemFormData } from "../../../../../types";
|
|
||||||
|
|
||||||
export function ItemRowEditor({
|
|
||||||
row,
|
|
||||||
index,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
row: ProformaItemFormData;
|
|
||||||
index: number;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
// Editor simple reutilizando el mismo RHF
|
|
||||||
const { register } = useFormContext<ProformaFormData>();
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<h3 className="text-base font-semibold">Edit line #{index + 1}</h3>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`desc-${index}`}>Description</Label>
|
|
||||||
<Textarea id={`desc-${index}`} rows={4} {...register(`items.${index}.description`)} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`qty-${index}`}>Qty</Label>
|
|
||||||
<Input
|
|
||||||
id={`qty-${index}`}
|
|
||||||
step="1"
|
|
||||||
type="number"
|
|
||||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`unit-${index}`}>Unit</Label>
|
|
||||||
<Input
|
|
||||||
id={`unit-${index}`}
|
|
||||||
step="0.01"
|
|
||||||
type="number"
|
|
||||||
{...register(`items.${index}.unit_amount`, { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`disc-${index}`}>Discount %</Label>
|
|
||||||
<Input
|
|
||||||
id={`disc-${index}`}
|
|
||||||
step="0.01"
|
|
||||||
type="number"
|
|
||||||
{...register(`items.${index}.discount_percentage`, { valueAsNumber: true })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button onClick={onClose}>OK</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
const createEmptyItem = () => defaultProformaItemFormData;
|
|
||||||
|
|
||||||
export const ItemsEditor = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const context = useProformaContext();
|
|
||||||
const form = useFormContext<ProformaFormData>();
|
|
||||||
const { control } = form;
|
|
||||||
|
|
||||||
useProformaAutoRecalc(form, context);
|
|
||||||
|
|
||||||
const { fields, append, remove, move, insert, update } = useFieldArray({
|
|
||||||
control,
|
|
||||||
name: "items",
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseColumns = useWithRowSelection(useProformaGridColumns(), true);
|
|
||||||
const columns = useMemo(() => baseColumns, [baseColumns]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns as any}
|
|
||||||
data={fields}
|
|
||||||
EditorComponent={ItemRowEditor}
|
|
||||||
enablePagination={false}
|
|
||||||
enableRowSelection
|
|
||||||
meta={{
|
|
||||||
tableOps: {
|
|
||||||
onAdd: () => append({ ...createEmptyItem() }),
|
|
||||||
//appendItem: (item: any) => append(item),
|
|
||||||
},
|
|
||||||
rowOps: {
|
|
||||||
remove: (i: number) => remove(i),
|
|
||||||
move: (from: number, to: number) => move(from, to),
|
|
||||||
//insertItem: (index: number, item: any) => insert(index, item),
|
|
||||||
/*duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
|
|
||||||
const items = getValues("items") || [];
|
|
||||||
// duplicate in descending order to keep indexes stable
|
|
||||||
[...indexes].sort((a, b) => b - a).forEach(i => {
|
|
||||||
const curr = items[i] as any;
|
|
||||||
if (curr) {
|
|
||||||
const { id, ...rest } = curr;
|
|
||||||
append({ ...rest });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},*/
|
|
||||||
/*deleteItems: (indexes: number[]) => {
|
|
||||||
// remove in descending order to avoid shifting issues
|
|
||||||
[...indexes].sort((a, b) => b - a).forEach(i => remove(i));
|
|
||||||
},*/
|
|
||||||
//updateItem: (index: number, item: any) => update(index, item),
|
|
||||||
},
|
|
||||||
bulkOps: {
|
|
||||||
duplicateSelected: (indexes, table) => {
|
|
||||||
const originalData = indexes.map((i) => {
|
|
||||||
const { id, ...original } = table.getRowModel().rows[i].original;
|
|
||||||
return original;
|
|
||||||
});
|
|
||||||
|
|
||||||
insert(indexes[indexes.length - 1] + 1, originalData, { shouldFocus: true });
|
|
||||||
table.resetRowSelection();
|
|
||||||
},
|
|
||||||
removeSelected: (indexes) => indexes.sort((a, b) => b - a).forEach(remove),
|
|
||||||
moveSelectedUp: (indexes) => indexes.forEach((i) => move(i, i - 1)),
|
|
||||||
moveSelectedDown: (indexes) => [...indexes].reverse().forEach((i) => move(i, i + 1)),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
pageSize={999}
|
|
||||||
readOnly={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
|
|
||||||
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
|
||||||
|
|
||||||
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { control } = useFormContext<ProformaFormData>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FieldSet {...props}>
|
|
||||||
<FieldLegend className="hidden text-foreground" variant="label">
|
|
||||||
{t("form_groups.basic_info.title")}
|
|
||||||
</FieldLegend>
|
|
||||||
<FieldDescription className="hidden">
|
|
||||||
{t("form_groups.basic_info.description")}
|
|
||||||
</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
|
|
||||||
<DatePickerInputField
|
|
||||||
className="min-w-44 flex-1 sm:max-w-44"
|
|
||||||
control={control}
|
|
||||||
description={t("form_fields.invoice_date.description")}
|
|
||||||
label={t("form_fields.invoice_date.label")}
|
|
||||||
name="invoice_date"
|
|
||||||
numberOfMonths={2}
|
|
||||||
placeholder={t("form_fields.invoice_date.placeholder")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DatePickerInputField
|
|
||||||
className="min-w-44 flex-1 sm:max-w-44"
|
|
||||||
control={control}
|
|
||||||
description={t("form_fields.operation_date.description")}
|
|
||||||
label={t("form_fields.operation_date.label")}
|
|
||||||
name="operation_date"
|
|
||||||
numberOfMonths={2}
|
|
||||||
placeholder={t("form_fields.operation_date.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
className="min-w-16 flex-1 sm:max-w-16"
|
|
||||||
control={control}
|
|
||||||
description={t("form_fields.series.description")}
|
|
||||||
label={t("form_fields.series.label")}
|
|
||||||
name="series"
|
|
||||||
placeholder={t("form_fields.series.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
className="min-w-32 flex-1 sm:max-w-44"
|
|
||||||
control={control}
|
|
||||||
description={t("form_fields.reference.description")}
|
|
||||||
label={t("form_fields.reference.label")}
|
|
||||||
maxLength={256}
|
|
||||||
name="reference"
|
|
||||||
placeholder={t("form_fields.reference.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
className="min-w-32 flex-1 xs:max-w-full"
|
|
||||||
control={control}
|
|
||||||
description={t("form_fields.description.description")}
|
|
||||||
label={t("form_fields.description.label")}
|
|
||||||
maxLength={256}
|
|
||||||
name="description"
|
|
||||||
placeholder={t("form_fields.description.placeholder")}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
// update/ui/blocks/proforma-form-field-shell.tsx
|
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type ProformaFieldSpan = "xs" | "sm" | "md" | "lg" | "full";
|
|
||||||
|
|
||||||
const fieldSpanClasses: Record<ProformaFieldSpan, string> = {
|
|
||||||
xs: "md:col-span-2",
|
|
||||||
sm: "md:col-span-3",
|
|
||||||
md: "md:col-span-4",
|
|
||||||
lg: "md:col-span-6",
|
|
||||||
full: "md:col-span-12",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProformaFormFieldShellProps {
|
|
||||||
label: ReactNode;
|
|
||||||
htmlFor: string;
|
|
||||||
span?: ProformaFieldSpan;
|
|
||||||
required?: boolean;
|
|
||||||
description?: ReactNode;
|
|
||||||
error?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProformaFormFieldShell = ({
|
|
||||||
label,
|
|
||||||
htmlFor,
|
|
||||||
span = "md",
|
|
||||||
required = false,
|
|
||||||
description,
|
|
||||||
error,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: ProformaFormFieldShellProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn(fieldSpanClasses[span], "space-y-1.5", className)}>
|
|
||||||
<label className="inline-flex items-center gap-1 text-sm font-medium" htmlFor={htmlFor}>
|
|
||||||
<span>{label}</span>
|
|
||||||
{required ? <span className="text-destructive">*</span> : null}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="text-xs text-destructive" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
import { DatePickerField, TextField } from "@repo/rdx-ui/components";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSet,
|
|
||||||
Input,
|
|
||||||
Textarea,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
|
||||||
import type { ProformaUpdateForm } from "../../entities";
|
|
||||||
|
|
||||||
export const ProformaHeaderFieldsCard = (props: ComponentProps<"fieldset">) => {
|
|
||||||
const { className, ...rest } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
//const { register, formState } = useFormContext<ProformaUpdateForm>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FieldSet {...rest}>
|
|
||||||
<FieldLegend className="text-foreground" variant="label">
|
|
||||||
{t("form_groups.basic_info.title")}
|
|
||||||
</FieldLegend>
|
|
||||||
<FieldDescription className="">{t("form_groups.basic_info.description")}</FieldDescription>
|
|
||||||
|
|
||||||
<FieldGroup className={className}>
|
|
||||||
<TextField
|
|
||||||
className="md:col-span-2"
|
|
||||||
description={t("form_fields.series.description")}
|
|
||||||
label={t("form_fields.series.label")}
|
|
||||||
name="series"
|
|
||||||
placeholder={t("form_fields.series.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DatePickerField
|
|
||||||
className="md:col-span-2"
|
|
||||||
description={t("form_fields.invoice_date.description")}
|
|
||||||
label={t("form_fields.invoice_date.label")}
|
|
||||||
name="invoice_date"
|
|
||||||
placeholder={t("form_fields.invoice_date.placeholder")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DatePickerField
|
|
||||||
className="md:col-span-2"
|
|
||||||
description={t("form_fields.operation_date.description")}
|
|
||||||
label={t("form_fields.operation_date.label")}
|
|
||||||
name="operation_date"
|
|
||||||
placeholder={t("form_fields.operation_date.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
className="md:col-span-7"
|
|
||||||
description={t("form_fields.reference.description")}
|
|
||||||
label={t("form_fields.reference.label")}
|
|
||||||
maxLength={256}
|
|
||||||
name="reference"
|
|
||||||
placeholder={t("form_fields.reference.placeholder")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
className="md:col-span-12"
|
|
||||||
description={t("form_fields.description.description")}
|
|
||||||
label={t("form_fields.description.label")}
|
|
||||||
maxLength={256}
|
|
||||||
name="description"
|
|
||||||
placeholder={t("form_fields.description.placeholder")}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
</FieldSet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProformaHeaderFieldsCard2 = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { register, formState } = useFormContext<ProformaUpdateForm>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("proformas.update.header.title", "Cabecera")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="series">
|
|
||||||
{t("proformas.fields.series", "Serie")}
|
|
||||||
</label>
|
|
||||||
<Input id="series" {...register("series")} />
|
|
||||||
<FieldError message={formState.errors.series?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="description">
|
|
||||||
{t("proformas.fields.description", "Descripción")}
|
|
||||||
</label>
|
|
||||||
<Input id="description" {...register("description")} />
|
|
||||||
<FieldError message={formState.errors.series?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="customerId">
|
|
||||||
{t("proformas.fields.customer", "Cliente")}
|
|
||||||
</label>
|
|
||||||
<Input id="customerId" {...register("customerId")} />
|
|
||||||
<FieldError message={formState.errors.customerId?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="invoiceDate">
|
|
||||||
{t("proformas.fields.invoice_date", "Fecha")}
|
|
||||||
</label>
|
|
||||||
<Input id="invoiceDate" type="date" {...register("invoiceDate")} />
|
|
||||||
<FieldError message={formState.errors.invoiceDate?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="operationDate">
|
|
||||||
{t("proformas.fields.operation_date", "Fecha operación")}
|
|
||||||
</label>
|
|
||||||
<Input id="operationDate" type="date" {...register("operationDate")} />
|
|
||||||
<FieldError message={formState.errors.operationDate?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="languageCode">
|
|
||||||
{t("proformas.fields.language", "Idioma")}
|
|
||||||
</label>
|
|
||||||
<Input id="languageCode" {...register("languageCode")} />
|
|
||||||
<FieldError message={formState.errors.languageCode?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="currencyCode">
|
|
||||||
{t("proformas.fields.currency", "Moneda")}
|
|
||||||
</label>
|
|
||||||
<Input id="currencyCode" {...register("currencyCode")} />
|
|
||||||
<FieldError message={formState.errors.currencyCode?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="reference">
|
|
||||||
{t("proformas.fields.reference", "Referencia")}
|
|
||||||
</label>
|
|
||||||
<Input id="reference" {...register("reference")} />
|
|
||||||
<FieldError message={formState.errors.reference?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="paymentMethod">
|
|
||||||
{t("proformas.fields.payment_method", "Forma de pago")}
|
|
||||||
</label>
|
|
||||||
<Input id="paymentMethod" {...register("paymentMethod")} />
|
|
||||||
<FieldError message={formState.errors.paymentMethod?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor="notes">
|
|
||||||
{t("proformas.fields.notes", "Notas")}
|
|
||||||
</label>
|
|
||||||
<Textarea id="notes" rows={5} {...register("notes")} />
|
|
||||||
<FieldError message={formState.errors.notes?.message} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FieldErrorProps = {
|
|
||||||
message?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FieldError = ({ message }: FieldErrorProps) => {
|
|
||||||
if (!message) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <p className="text-sm text-destructive">{message}</p>;
|
|
||||||
};
|
|
||||||
@ -18,6 +18,8 @@ import { LineEditor, type LineEditorColumn } from "./line-editor";
|
|||||||
interface ProformaLineEditorProps {
|
interface ProformaLineEditorProps {
|
||||||
fields: ProformaItemField[];
|
fields: ProformaItemField[];
|
||||||
|
|
||||||
|
showLineTaxes: boolean;
|
||||||
|
|
||||||
getItemAmounts: (index: number) => ProformaItemAmounts;
|
getItemAmounts: (index: number) => ProformaItemAmounts;
|
||||||
getItemErrorMessage: (index: number) => string | undefined;
|
getItemErrorMessage: (index: number) => string | undefined;
|
||||||
|
|
||||||
@ -39,6 +41,8 @@ interface ProformaLineEditorProps {
|
|||||||
export const ProformaLineEditor = ({
|
export const ProformaLineEditor = ({
|
||||||
fields,
|
fields,
|
||||||
|
|
||||||
|
showLineTaxes,
|
||||||
|
|
||||||
getItemAmounts,
|
getItemAmounts,
|
||||||
getItemErrorMessage,
|
getItemErrorMessage,
|
||||||
|
|
||||||
@ -111,6 +115,25 @@ export const ProformaLineEditor = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...(showLineTaxes
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "taxPercentage",
|
||||||
|
header: t("form_fields.items.tax_percentage.label", "IVA"),
|
||||||
|
headClassName: "w-[110px] text-right",
|
||||||
|
cell: ({ index }) => (
|
||||||
|
<PercentageField
|
||||||
|
inputClassName="border-none"
|
||||||
|
maxFractionDigits={2}
|
||||||
|
minFractionDigits={0}
|
||||||
|
name={`items.${index}.taxPercentage`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
} satisfies LineEditorColumn<ProformaItemField>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "total",
|
id: "total",
|
||||||
header: t("form_fields.items.total.label", "Total"),
|
header: t("form_fields.items.total.label", "Total"),
|
||||||
|
|||||||
@ -0,0 +1,202 @@
|
|||||||
|
import { FormSectionCard, FormSectionGrid, PercentageField } from "@repo/rdx-ui/components";
|
||||||
|
import { MoneyHelper, PercentageHelper } from "@repo/rdx-utils";
|
||||||
|
import { Separator } from "@repo/shadcn-ui/components";
|
||||||
|
import { CurrencyIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { ProformaTotals } from "../../entities";
|
||||||
|
|
||||||
|
interface ProformaTotalsSummaryProps {
|
||||||
|
totals: ProformaTotals;
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
showEquivalenceSurcharge?: boolean;
|
||||||
|
showRetention?: boolean;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformaTotalsSummary = ({
|
||||||
|
totals,
|
||||||
|
currency = "EUR",
|
||||||
|
|
||||||
|
showEquivalenceSurcharge = false,
|
||||||
|
showRetention = false,
|
||||||
|
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
|
||||||
|
className,
|
||||||
|
}: ProformaTotalsSummaryProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const formatMoney = (value: number): string => {
|
||||||
|
return MoneyHelper.formatCurrency(value, 2, currency);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number): string => {
|
||||||
|
return PercentageHelper.formatPercent(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSectionCard
|
||||||
|
className={className}
|
||||||
|
description={t("proformas.update.totals.description")}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={<CurrencyIcon className="size-5" />}
|
||||||
|
title={t("proformas.update.totals.title", "Totales")}
|
||||||
|
>
|
||||||
|
<FormSectionGrid className="md:grid-cols-1 space-y-5">
|
||||||
|
<TotalsRow
|
||||||
|
label={t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal sin descuentos")}
|
||||||
|
value={formatMoney(totals.subtotalBeforeDiscounts)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="space-y-3 rounded-lg border bg-background p-4">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("proformas.update.totals.discounts", "Descuentos")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<TotalsRow
|
||||||
|
label={t("proformas.update.totals.lineDiscountTotal", "Descuento en líneas")}
|
||||||
|
value={`-${formatMoney(totals.lineDiscountTotal)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_10rem] items-end gap-3">
|
||||||
|
<PercentageField
|
||||||
|
disabled={disabled}
|
||||||
|
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
|
||||||
|
name="globalDiscountPercentage"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pb-1 text-right">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("proformas.update.totals.globalDiscountAmount", "Importe descuento global")}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-sm tabular-nums">
|
||||||
|
-{formatMoney(totals.globalDiscountAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TotalsRow
|
||||||
|
label={t("proformas.update.totals.taxableBase", "Base imponible")}
|
||||||
|
strong
|
||||||
|
value={formatMoney(totals.taxableBase)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="space-y-3 rounded-lg border bg-background p-4">
|
||||||
|
<h3 className="text-sm font-medium">{t("proformas.update.totals.taxes", "Impuestos")}</h3>
|
||||||
|
|
||||||
|
{totals.taxBreakdown.length > 0 ? (
|
||||||
|
totals.taxBreakdown.map((tax) => (
|
||||||
|
<div className="space-y-2" key={tax.taxPercentage}>
|
||||||
|
<TotalsRow
|
||||||
|
description={formatMoney(tax.taxableBase)}
|
||||||
|
label={`${t("proformas.update.totals.taxPercentage", "IVA")} ${formatPercent(
|
||||||
|
tax.taxPercentage
|
||||||
|
)}`}
|
||||||
|
value={formatMoney(tax.taxAmount)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showEquivalenceSurcharge && tax.equivalenceSurchargePercentage !== null ? (
|
||||||
|
<TotalsRow
|
||||||
|
description={formatMoney(tax.taxableBase)}
|
||||||
|
label={`${t(
|
||||||
|
"proformas.update.totals.equivalenceSurcharge",
|
||||||
|
"Recargo equivalencia"
|
||||||
|
)} ${formatPercent(tax.equivalenceSurchargePercentage)}`}
|
||||||
|
value={formatMoney(tax.equivalenceSurchargeAmount)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("proformas.update.totals.noTaxes", "Sin impuestos aplicados")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<TotalsRow
|
||||||
|
label={t("proformas.update.totals.taxTotal", "Total IVA")}
|
||||||
|
strong
|
||||||
|
value={formatMoney(totals.taxTotal)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showEquivalenceSurcharge ? (
|
||||||
|
<TotalsRow
|
||||||
|
label={t(
|
||||||
|
"proformas.update.totals.equivalenceSurchargeTotal",
|
||||||
|
"Total recargo equivalencia"
|
||||||
|
)}
|
||||||
|
strong
|
||||||
|
value={formatMoney(totals.equivalenceSurchargeTotal)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{showRetention ? (
|
||||||
|
<section className="space-y-3 rounded-lg border bg-background p-4">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
{t("proformas.update.totals.retention", "Retención")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<TotalsRow
|
||||||
|
label={`${t("proformas.update.totals.retentionPercentage", "IRPF")} ${formatPercent(
|
||||||
|
totals.retentionPercentage ?? 0
|
||||||
|
)}`}
|
||||||
|
value={`-${formatMoney(totals.retentionAmount)}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-primary px-4 py-3 text-primary-foreground">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{t("proformas.update.totals.total", "Total factura")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xl font-bold tabular-nums">
|
||||||
|
{formatMoney(totals.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormSectionGrid>
|
||||||
|
</FormSectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TotalsRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
strong?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TotalsRow = ({ label, value, description, strong = false }: TotalsRowProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className={strong ? "text-sm font-semibold" : "text-sm text-muted-foreground"}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? <div className="text-xs text-muted-foreground">{description}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
strong ? "font-mono text-sm font-semibold tabular-nums" : "font-mono text-sm tabular-nums"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,47 +1,42 @@
|
|||||||
import type { CustomerSelectionOption } from "@erp/customers";
|
import type { CustomerSelectionOption } from "@erp/customers";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import {
|
import { MailIcon, MapPinIcon, PhoneIcon } from "lucide-react";
|
||||||
Building2Icon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
MailIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../../../../i18n";
|
import { useTranslation } from "../../../../../i18n";
|
||||||
|
|
||||||
type SelectedRecipientSummaryProps = {
|
type SelectedRecipientSummaryProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
|
||||||
|
|
||||||
recipient?: CustomerSelectionOption | null;
|
recipient?: CustomerSelectionOption | null;
|
||||||
|
|
||||||
onChangeClick: () => void;
|
|
||||||
onCreateClick?: () => void;
|
|
||||||
onViewClick?: (recipient: CustomerSelectionOption) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildCustomerAddress = (recipient: CustomerSelectionOption): string => {
|
type CustomerAddress = {
|
||||||
return [
|
has: boolean;
|
||||||
recipient.street,
|
full: string;
|
||||||
recipient.street2,
|
};
|
||||||
[recipient.postalCode, recipient.city].filter(Boolean).join(" "),
|
|
||||||
recipient.province,
|
const buildCustomerAddress = (recipient: CustomerSelectionOption): CustomerAddress => {
|
||||||
recipient.country,
|
const line1 = [recipient.street, recipient.street2].filter(Boolean).join(", ");
|
||||||
]
|
|
||||||
.filter(Boolean)
|
const line2 =
|
||||||
.join(", ");
|
recipient.postalCode && recipient.city
|
||||||
|
? `${recipient.postalCode}\u00A0${recipient.city}`
|
||||||
|
: [recipient.postalCode, recipient.city].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const line3 = [recipient.province, recipient.country].filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
const stack = [line1, line2, line3].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
has: stack.length > 0,
|
||||||
|
full: stack.join(", "),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCustomerStatusBadgeClassName = (status: string): string => {
|
const getCustomerStatusBadgeClassName = (status: string): string => {
|
||||||
@ -64,182 +59,88 @@ const getCustomerStatusBadgeClassName = (status: string): string => {
|
|||||||
|
|
||||||
export const SelectedRecipientSummary = ({
|
export const SelectedRecipientSummary = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
|
||||||
|
|
||||||
recipient,
|
recipient,
|
||||||
onChangeClick,
|
|
||||||
onCreateClick,
|
|
||||||
onViewClick,
|
|
||||||
}: SelectedRecipientSummaryProps) => {
|
}: SelectedRecipientSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const showActions = !(readOnly || disabled);
|
const address = recipient ? buildCustomerAddress(recipient) : null;
|
||||||
const address = recipient ? buildCustomerAddress(recipient) : "";
|
|
||||||
const phone = recipient?.primaryPhone ?? recipient?.primaryMobile;
|
const phone = recipient?.primaryPhone ?? recipient?.primaryMobile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("min-w-0", disabled && "opacity-70")}>
|
||||||
className={cn(
|
{recipient ? (
|
||||||
"rounded-lg border p-4",
|
<div className="min-w-0 space-y-3">
|
||||||
recipient ? "border-primary/20 bg-primary/[0.03]" : "bg-muted/20",
|
<div className="min-w-0 space-y-1">
|
||||||
disabled && "opacity-70"
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
)}
|
<p className="min-w-0 wrap-break-word text-base font-semibold">{recipient.name}</p>
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:justify-between">
|
{recipient.status ? (
|
||||||
<div className="flex min-w-0 flex-1 gap-4">
|
<Badge
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
"shrink-0 border",
|
||||||
"flex size-14 shrink-0 items-center justify-center rounded-full",
|
getCustomerStatusBadgeClassName(recipient.status)
|
||||||
recipient ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
)}
|
||||||
)}
|
variant="outline"
|
||||||
>
|
>
|
||||||
{recipient?.isCompany ? (
|
{recipient.status}
|
||||||
<Building2Icon className="size-6" />
|
</Badge>
|
||||||
) : (
|
) : null}
|
||||||
<UserIcon className="size-6" />
|
</div>
|
||||||
)}
|
|
||||||
|
{recipient.tradeName && recipient.tradeName !== recipient.name ? (
|
||||||
|
<p className="wrap-break-word text-muted-foreground">{recipient.tradeName}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{recipient.tin ? (
|
||||||
|
<p className="wrap-break-word text-muted-foreground">{recipient.tin}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="grid gap-1 text-sm text-muted-foreground">
|
||||||
{recipient ? (
|
{recipient.primaryEmail ? (
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.8fr)]">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<div className="min-w-0 space-y-2">
|
<MailIcon className="size-3.5 shrink-0" />
|
||||||
<div className="min-w-0 space-y-1">
|
<span className="truncate">{recipient.primaryEmail}</span>
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<p className="truncate text-base font-semibold">{recipient.name}</p>
|
|
||||||
|
|
||||||
{recipient.status ? (
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"border",
|
|
||||||
getCustomerStatusBadgeClassName(recipient.status)
|
|
||||||
)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{recipient.status}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{recipient.tradeName && recipient.tradeName !== recipient.name ? (
|
|
||||||
<p className="truncate text-sm text-muted-foreground">
|
|
||||||
{recipient.tradeName}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{recipient.tin ? (
|
|
||||||
<p className="text-xs text-muted-foreground">{recipient.tin}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
{recipient.primaryEmail ? (
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<MailIcon className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{recipient.primaryEmail}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{phone ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<PhoneIcon className="size-3.5 shrink-0" />
|
|
||||||
{phone}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 space-y-2 lg:border-l lg:pl-4">
|
|
||||||
{address ? (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<div className="flex items-start gap-1 text-sm text-muted-foreground">
|
|
||||||
<MapPinIcon className="mt-0.5 size-3.5 shrink-0" />
|
|
||||||
<span className="line-clamp-3">{address}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TooltipContent align="start" className="max-w-md">
|
|
||||||
{address}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("customers.selected_customer.no_address", "Sin dirección registrada")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
{phone ? (
|
||||||
{t("customers.selected_customer.empty_title", "Cliente no seleccionado")}
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
</p>
|
<PhoneIcon className="size-3.5 shrink-0" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<span className="truncate">{phone}</span>
|
||||||
{t("customers.selected_customer.empty", "Selecciona o crea un cliente")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
{address?.has ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<div className="flex min-w-0 items-start gap-1">
|
||||||
|
<MapPinIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span className="line-clamp-2">{address.full}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TooltipContent align="start" className="max-w-md">
|
||||||
|
{address.full}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{showActions ? (
|
<div>
|
||||||
<div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch">
|
<p className="font-medium">
|
||||||
{recipient && onViewClick ? (
|
{t("customers.selected_customer.empty_title", "Cliente no seleccionado")}
|
||||||
<Button onClick={() => onViewClick(recipient)} type="button" variant="ghost">
|
</p>
|
||||||
<ExternalLinkIcon className="mr-2 size-4" />
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("customers.selected_customer.view", "Ver cliente")}
|
{t("customers.selected_customer.empty", "Selecciona o crea un cliente")}
|
||||||
</Button>
|
</p>
|
||||||
) : null}
|
</div>
|
||||||
|
)}
|
||||||
{onCreateClick ? (
|
|
||||||
<Button onClick={onCreateClick} type="button" variant="outline">
|
|
||||||
<PlusIcon className="mr-2 size-4" />
|
|
||||||
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={onChangeClick}
|
|
||||||
type="button"
|
|
||||||
variant={recipient ? "secondary" : "default"}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="mr-2 size-4" />
|
|
||||||
{recipient
|
|
||||||
? t("customers.selected_customer.change", "Cambiar cliente")
|
|
||||||
: t("customers.selected_customer.select", "Seleccionar cliente")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildAddress = (recipient: CustomerSelectionOption): string => {
|
|
||||||
// Línea 1: calle(s)
|
|
||||||
const line1 = [recipient.street, recipient.street2].filter(Boolean).join(", ");
|
|
||||||
|
|
||||||
// Línea 2: CP + ciudad con espacio no rompible entre CP y ciudad
|
|
||||||
const line2Parts: string[] = [];
|
|
||||||
if (recipient.postalCode && recipient.city) {
|
|
||||||
line2Parts.push(`${recipient.postalCode}\u00A0${recipient.city}`); // CP Ciudad
|
|
||||||
} else {
|
|
||||||
if (recipient.postalCode) line2Parts.push(recipient.postalCode);
|
|
||||||
if (recipient.city) line2Parts.push(recipient.city);
|
|
||||||
}
|
|
||||||
const line2 = line2Parts.join(" ");
|
|
||||||
|
|
||||||
// Línea 3: provincia + país
|
|
||||||
const line3 = [recipient.province, recipient.country].filter(Boolean).join(", ");
|
|
||||||
|
|
||||||
const stack = [line1, line2, line3].filter(Boolean);
|
|
||||||
const inline = stack.join(" · "); // separador compacto
|
|
||||||
const full = stack.join(", ");
|
|
||||||
|
|
||||||
return { has: stack.length > 0, stack, inline, full };
|
|
||||||
};
|
|
||||||
|
|||||||
@ -7,10 +7,16 @@ import { Button } from "@repo/shadcn-ui/components";
|
|||||||
import { ProformaUpdateRecipientEditor } from ".";
|
import { ProformaUpdateRecipientEditor } from ".";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import type { UseUpdateProformaItemsControllerResult } from "../../controllers";
|
import type {
|
||||||
|
UseUpdateProformaItemsControllerResult,
|
||||||
|
UseUpdateProformaTaxControllerResult,
|
||||||
|
UseUpdateProformaTotalsControllerResult,
|
||||||
|
} from "../../controllers";
|
||||||
|
import { ProformaTotalsSummary } from "../blocks";
|
||||||
|
|
||||||
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
|
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
|
||||||
import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
|
import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
|
||||||
|
import { ProformaUpdateTaxEditor } from "./proforma-update-tax-editor";
|
||||||
|
|
||||||
type ProformaUpdateEditorProps = {
|
type ProformaUpdateEditorProps = {
|
||||||
formId: string;
|
formId: string;
|
||||||
@ -23,6 +29,11 @@ type ProformaUpdateEditorProps = {
|
|||||||
onCreateCustomerClick: () => void;
|
onCreateCustomerClick: () => void;
|
||||||
|
|
||||||
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
||||||
|
taxCtrl: UseUpdateProformaTaxControllerResult;
|
||||||
|
totalsCtrl: UseUpdateProformaTotalsControllerResult;
|
||||||
|
|
||||||
|
currencyCode?: string;
|
||||||
|
languageCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProformaUpdateEditorForm = ({
|
export const ProformaUpdateEditorForm = ({
|
||||||
@ -34,6 +45,10 @@ export const ProformaUpdateEditorForm = ({
|
|||||||
onChangeCustomerClick,
|
onChangeCustomerClick,
|
||||||
onCreateCustomerClick,
|
onCreateCustomerClick,
|
||||||
itemsCtrl,
|
itemsCtrl,
|
||||||
|
taxCtrl,
|
||||||
|
totalsCtrl,
|
||||||
|
currencyCode,
|
||||||
|
languageCode,
|
||||||
}: ProformaUpdateEditorProps) => {
|
}: ProformaUpdateEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -45,17 +60,31 @@ export const ProformaUpdateEditorForm = ({
|
|||||||
onKeyDown={preventEnterKeySubmitForm}
|
onKeyDown={preventEnterKeySubmitForm}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
>
|
>
|
||||||
<ProformaUpdateHeaderEditor disabled={isSubmitting} />
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||||
|
<ProformaUpdateHeaderEditor className="md:col-span-8" disabled={isSubmitting} />
|
||||||
|
|
||||||
<ProformaUpdateRecipientEditor
|
<ProformaUpdateRecipientEditor
|
||||||
disabled={isSubmitting}
|
className="md:col-span-4"
|
||||||
onChangeCustomerClick={onChangeCustomerClick}
|
disabled={isSubmitting}
|
||||||
onCreateCustomerClick={onCreateCustomerClick}
|
onChangeCustomerClick={onChangeCustomerClick}
|
||||||
selectedCustomer={selectedCustomer}
|
onCreateCustomerClick={onCreateCustomerClick}
|
||||||
/>
|
selectedCustomer={selectedCustomer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} />
|
<ProformaUpdateItemsEditor disabled={isSubmitting} itemsCtrl={itemsCtrl} taxCtrl={taxCtrl} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
|
||||||
|
<ProformaUpdateTaxEditor className="md:col-span-6" taxCtrl={taxCtrl} />
|
||||||
|
<ProformaTotalsSummary
|
||||||
|
className="md:col-span-6"
|
||||||
|
currency={currencyCode}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
showEquivalenceSurcharge={taxCtrl.hasEquivalenceSurcharge}
|
||||||
|
showRetention={taxCtrl.hasRetention}
|
||||||
|
totals={totalsCtrl.totals}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
|
<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">
|
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
|
||||||
{t("common.reset", "Restablecer")}
|
{t("common.reset", "Restablecer")}
|
||||||
|
|||||||
@ -13,16 +13,20 @@ import { useTranslation } from "../../../../i18n";
|
|||||||
interface ProformaUpdateHeaderEditorProps {
|
interface ProformaUpdateHeaderEditorProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaUpdateHeaderEditor = ({
|
export const ProformaUpdateHeaderEditor = ({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
className,
|
||||||
}: ProformaUpdateHeaderEditorProps) => {
|
}: ProformaUpdateHeaderEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<FormSectionCard
|
||||||
|
className={className}
|
||||||
description={t("form_groups.proformas.basic_info.description")}
|
description={t("form_groups.proformas.basic_info.description")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
icon={<FileTextIcon className="size-5" />}
|
icon={<FileTextIcon className="size-5" />}
|
||||||
|
|||||||
@ -3,15 +3,18 @@ import { ListIcon } from "lucide-react";
|
|||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { UseUpdateProformaTaxControllerResult } from "../../controllers";
|
||||||
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller";
|
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller";
|
||||||
import { ProformaLineEditor } from "../blocks";
|
import { ProformaLineEditor } from "../blocks";
|
||||||
|
|
||||||
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
|
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
|
||||||
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
itemsCtrl: UseUpdateProformaItemsControllerResult;
|
||||||
|
taxCtrl: UseUpdateProformaTaxControllerResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaUpdateItemsEditor = ({
|
export const ProformaUpdateItemsEditor = ({
|
||||||
itemsCtrl,
|
itemsCtrl,
|
||||||
|
taxCtrl,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: ProformaUpdateItemsEditorProps) => {
|
}: ProformaUpdateItemsEditorProps) => {
|
||||||
@ -36,6 +39,7 @@ export const ProformaUpdateItemsEditor = ({
|
|||||||
moveItemDown={itemsCtrl.moveItemDown}
|
moveItemDown={itemsCtrl.moveItemDown}
|
||||||
moveItemUp={itemsCtrl.moveItemUp}
|
moveItemUp={itemsCtrl.moveItemUp}
|
||||||
removeItem={itemsCtrl.removeItem}
|
removeItem={itemsCtrl.removeItem}
|
||||||
|
showLineTaxes={taxCtrl.usesPerLineTax}
|
||||||
totals={itemsCtrl.totals}
|
totals={itemsCtrl.totals}
|
||||||
/>
|
/>
|
||||||
</FormSectionCard>
|
</FormSectionCard>
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import type { CustomerSelectionOption } from "@erp/customers";
|
import type { CustomerSelectionOption } from "@erp/customers";
|
||||||
import { FormSectionCard } from "@repo/rdx-ui/components";
|
import {
|
||||||
import { UserIcon } from "lucide-react";
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import { PlusIcon, RefreshCwIcon, UserIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../../i18n";
|
import { useTranslation } from "../../../../i18n";
|
||||||
import { SelectedRecipientSummary } from "../blocks";
|
import { SelectedRecipientSummary } from "../blocks";
|
||||||
@ -12,6 +21,8 @@ interface ProformaUpdateRecipientEditorProps {
|
|||||||
selectedCustomer?: CustomerSelectionOption | null;
|
selectedCustomer?: CustomerSelectionOption | null;
|
||||||
onChangeCustomerClick: () => void;
|
onChangeCustomerClick: () => void;
|
||||||
onCreateCustomerClick: () => void;
|
onCreateCustomerClick: () => void;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProformaUpdateRecipientEditor = ({
|
export const ProformaUpdateRecipientEditor = ({
|
||||||
@ -21,26 +32,64 @@ export const ProformaUpdateRecipientEditor = ({
|
|||||||
selectedCustomer,
|
selectedCustomer,
|
||||||
onChangeCustomerClick,
|
onChangeCustomerClick,
|
||||||
onCreateCustomerClick,
|
onCreateCustomerClick,
|
||||||
|
|
||||||
|
className,
|
||||||
}: ProformaUpdateRecipientEditorProps) => {
|
}: ProformaUpdateRecipientEditorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const showActions = !(readOnly || disabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSectionCard
|
<Card className={className}>
|
||||||
description={t(
|
<CardHeader className={"flex flex-row items-start gap-4"}>
|
||||||
"form_groups.proformas.customer.description",
|
<div className="flex items-start gap-3">
|
||||||
"Cliente destinatario de la proforma"
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
|
||||||
|
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{t("form_groups.proformas.customer.title", "Cliente")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("form_groups.proformas.customer.description", "Cliente destinatario de la proforma")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
<SelectedRecipientSummary disabled={disabled} recipient={selectedCustomer} />
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{showActions && (
|
||||||
|
<CardFooter className="flex flex-col space-y-4">
|
||||||
|
<Button
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={onCreateCustomerClick}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2 size-4" />
|
||||||
|
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full justify-center"
|
||||||
|
onClick={onChangeCustomerClick}
|
||||||
|
type="button"
|
||||||
|
variant={selectedCustomer ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="mr-2 size-4" />
|
||||||
|
{selectedCustomer
|
||||||
|
? t("customers.selected_customer.change", "Cambiar cliente")
|
||||||
|
: t("customers.selected_customer.select", "Seleccionar cliente")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
</Card>
|
||||||
icon={<UserIcon className="size-5" />}
|
|
||||||
title={t("form_groups.proformas.customer.title", "Cliente")}
|
|
||||||
>
|
|
||||||
<SelectedRecipientSummary
|
|
||||||
disabled={disabled}
|
|
||||||
onChangeClick={onChangeCustomerClick}
|
|
||||||
onCreateClick={onCreateCustomerClick}
|
|
||||||
readOnly={readOnly}
|
|
||||||
recipient={selectedCustomer}
|
|
||||||
/>
|
|
||||||
</FormSectionCard>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
CheckboxField,
|
||||||
|
FormSectionCard,
|
||||||
|
FormSectionGrid,
|
||||||
|
PercentageField,
|
||||||
|
SelectField,
|
||||||
|
} from "@repo/rdx-ui/components";
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
Label,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { ReceiptTextIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../../i18n";
|
||||||
|
import type { UseUpdateProformaTaxControllerResult } from "../../controllers";
|
||||||
|
|
||||||
|
interface ProformaUpdateTaxEditorProps {
|
||||||
|
taxCtrl: UseUpdateProformaTaxControllerResult;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProformaUpdateTaxEditor = ({
|
||||||
|
taxCtrl,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
}: ProformaUpdateTaxEditorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
console.log(taxCtrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSectionCard
|
||||||
|
className={className}
|
||||||
|
description={t(
|
||||||
|
"form_groups.proformas.taxes.description",
|
||||||
|
"Configuración fiscal de la proforma"
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={<ReceiptTextIcon className="size-5" />}
|
||||||
|
title={t("form_groups.proformas.taxes.title", "Impuestos")}
|
||||||
|
>
|
||||||
|
<FormSectionGrid>
|
||||||
|
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
|
||||||
|
<SelectField
|
||||||
|
className="md:col-span-4 md:col-start-1"
|
||||||
|
disabled={disabled}
|
||||||
|
items={[
|
||||||
|
{ value: "1", label: "01: Operación de régimen general." },
|
||||||
|
{ value: "2", label: "02: Exportación." },
|
||||||
|
{
|
||||||
|
value: "3",
|
||||||
|
label:
|
||||||
|
"03: Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección.",
|
||||||
|
},
|
||||||
|
{ value: "4", label: "04: Régimen especial del oro de inversión." },
|
||||||
|
{ value: "5", label: "05: Régimen especial de las agencias de viajes." },
|
||||||
|
{
|
||||||
|
value: "6",
|
||||||
|
label: "06: Régimen especial grupo de entidades en IVA o IGIC (Nivel Avanzado)",
|
||||||
|
},
|
||||||
|
{ value: "7", label: "07: Régimen especial del criterio de caja." },
|
||||||
|
{ value: "8", label: "08: Operaciones sujetas al IPSI/IVA o IGIC." },
|
||||||
|
{
|
||||||
|
value: "9",
|
||||||
|
label:
|
||||||
|
"09: Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena (D.A.4ª RD1619/2012)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "10",
|
||||||
|
label:
|
||||||
|
"10: Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial, de autor u otros por cuenta de sus socios, asociados o colegiados efectuados por sociedades, asociaciones, colegios profesionales u otras entidades que realicen estas funciones de cobro.",
|
||||||
|
},
|
||||||
|
{ value: "11", label: "11: Operaciones de arrendamiento de local de negocio." },
|
||||||
|
{
|
||||||
|
value: "14",
|
||||||
|
label:
|
||||||
|
"14: Factura con IVA o IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "15",
|
||||||
|
label:
|
||||||
|
"15: Factura con IVA o IGIC pendiente de devengo en operaciones de tracto sucesivo.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "17",
|
||||||
|
label:
|
||||||
|
"17: Operación acogida a alguno de los regímenes previstos en el Capítulo XI del Título IX (OSS e IOSS) o régimen especial de comerciante minorista",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "18",
|
||||||
|
label:
|
||||||
|
"18: Recargo de equivalencia o régimen especial del pequeño empresario o profesional.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "19",
|
||||||
|
label:
|
||||||
|
"19: Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP) u operaciones interiores exentas por aplicación artículo 25 Ley 19/1994",
|
||||||
|
},
|
||||||
|
{ value: "20", label: "20: Régimen simplificado" },
|
||||||
|
]}
|
||||||
|
label={t("form_fields.proformas.tax_regime_code.label", "Régimen de IVA")}
|
||||||
|
name="taxRegimeCode"
|
||||||
|
placeholder={t(
|
||||||
|
"form_fields.proformas.tax_regime_code.placeholder",
|
||||||
|
"Selecciona el régimen de IVA de esta proforma"
|
||||||
|
)}
|
||||||
|
readOnly={readOnly || taxCtrl.usesPerLineTax}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormSectionGrid>
|
||||||
|
<FieldSeparator className="my-4" />
|
||||||
|
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend>Billing Address</FieldLegend>
|
||||||
|
<FieldDescription>The billing address associated with your payment method</FieldDescription>
|
||||||
|
|
||||||
|
<FormSectionGrid>
|
||||||
|
<Field className="md:col-span-12 md:col-start-1" orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
checked={taxCtrl.usesSingleTax}
|
||||||
|
disabled={disabled || readOnly}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
checked ? taxCtrl.disablePerLineTaxes() : taxCtrl.enablePerLineTaxes()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="terms-checkbox">
|
||||||
|
{t(
|
||||||
|
"proformas.update.taxes.disable_per_line",
|
||||||
|
"Usar el mismo impuesto en toda la proforma"
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<SelectField
|
||||||
|
className="md:col-span-4 md:col-start-1"
|
||||||
|
disabled={disabled}
|
||||||
|
items={[
|
||||||
|
{ value: "0", label: "0%" },
|
||||||
|
{ value: "4", label: "4%" },
|
||||||
|
{ value: "10", label: "10%" },
|
||||||
|
{ value: "21", label: "21%" },
|
||||||
|
]}
|
||||||
|
label={t("form_fields.proformas.default_tax_percentage.label", "IVA por defecto")}
|
||||||
|
name="defaultTaxPercentage"
|
||||||
|
placeholder={t(
|
||||||
|
"form_fields.proformas.default_tax_percentage.placeholder",
|
||||||
|
"Selecciona IVA"
|
||||||
|
)}
|
||||||
|
readOnly={readOnly || taxCtrl.usesPerLineTax}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
className="md:col-span-12 md:col-start-1"
|
||||||
|
disabled={disabled}
|
||||||
|
label={t(
|
||||||
|
"form_fields.proformas.has_equivalence_surcharge.label",
|
||||||
|
"Recargo de equivalencia"
|
||||||
|
)}
|
||||||
|
name="hasEquivalenceSurcharge"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
className="md:col-span-12 md:col-start-1"
|
||||||
|
disabled={disabled}
|
||||||
|
label={t("form_fields.proformas.has_retention.label", "Incluir retención/IRPF")}
|
||||||
|
name="hasRetention"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PercentageField
|
||||||
|
className="md:col-span-4 md:col-start-1"
|
||||||
|
disabled={disabled}
|
||||||
|
label={t("form_fields.proformas.retention_percentage.label", "Retención")}
|
||||||
|
name="retentionPercentage"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</FormSectionGrid>
|
||||||
|
</FieldSet>
|
||||||
|
</FormSectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -97,15 +97,19 @@ export const ProformaUpdatePage = () => {
|
|||||||
<>
|
<>
|
||||||
<FormProvider {...updateCtrl.form}>
|
<FormProvider {...updateCtrl.form}>
|
||||||
<ProformaUpdateEditorForm
|
<ProformaUpdateEditorForm
|
||||||
|
currencyCode={updateCtrl.currencyCode}
|
||||||
formId={updateCtrl.formId}
|
formId={updateCtrl.formId}
|
||||||
isSubmitting={updateCtrl.isUpdating}
|
isSubmitting={updateCtrl.isUpdating}
|
||||||
itemsCtrl={updateCtrl.itemsCtrl}
|
itemsCtrl={updateCtrl.itemsCtrl}
|
||||||
|
languageCode={updateCtrl.languageCode}
|
||||||
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
|
||||||
//onCreateCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
|
//onCreateCustomerClick={selectCustomerCtrl.createCtrl.openDialog}
|
||||||
onCreateCustomerClick={() => null}
|
onCreateCustomerClick={() => null}
|
||||||
onReset={updateCtrl.resetForm}
|
onReset={updateCtrl.resetForm}
|
||||||
onSubmit={updateCtrl.onSubmit}
|
onSubmit={updateCtrl.onSubmit}
|
||||||
selectedCustomer={updateCtrl.selectedCustomer}
|
selectedCustomer={updateCtrl.selectedCustomer}
|
||||||
|
taxCtrl={updateCtrl.taxCtrl}
|
||||||
|
totalsCtrl={updateCtrl.totalsCtrl}
|
||||||
/>
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
<SelectCustomerDialog
|
<SelectCustomerDialog
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
// update/utils/calculate-proforma-totals.ts
|
||||||
|
|
||||||
|
import { mapProformaFormToCommercialDocumentLines } from "../adapters";
|
||||||
|
import type { ProformaTotals, ProformaUpdateForm } from "../entities";
|
||||||
|
|
||||||
|
import { calculateCommercialDocumentTotals } from "./calculations";
|
||||||
|
|
||||||
|
export const calculateProformaTotals = (form: ProformaUpdateForm): ProformaTotals => {
|
||||||
|
return calculateCommercialDocumentTotals({
|
||||||
|
lines: mapProformaFormToCommercialDocumentLines(form),
|
||||||
|
globalDiscountPercentage: form.globalDiscountPercentage,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import type { CommercialDocumentLineAmounts, CommercialDocumentLineInput } from "../../entities";
|
||||||
|
|
||||||
|
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
|
||||||
|
|
||||||
|
export const calculateCommercialDocumentLineAmounts = (
|
||||||
|
line?: CommercialDocumentLineInput
|
||||||
|
): CommercialDocumentLineAmounts => {
|
||||||
|
if (!line) {
|
||||||
|
return {
|
||||||
|
grossAmount: 0,
|
||||||
|
itemDiscountAmount: 0,
|
||||||
|
taxableBaseBeforeGlobalDiscount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = toCalculationNumber(line.quantity);
|
||||||
|
const unitAmount = toCalculationNumber(line.unitAmount);
|
||||||
|
const itemDiscountPercentage = toCalculationNumber(line.itemDiscountPercentage);
|
||||||
|
|
||||||
|
const grossAmount = quantity * unitAmount;
|
||||||
|
const itemDiscountAmount = percentageAmount(grossAmount, itemDiscountPercentage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossAmount: roundMoney(grossAmount),
|
||||||
|
itemDiscountAmount: roundMoney(itemDiscountAmount),
|
||||||
|
taxableBaseBeforeGlobalDiscount: roundMoney(grossAmount - itemDiscountAmount),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import type { CommercialDocumentLineInput } from "../../entities";
|
||||||
|
|
||||||
|
import { calculateCommercialDocumentLineAmounts } from "./calculate-commercial-document-line-amounts";
|
||||||
|
import { roundMoney } from "./money-calculation";
|
||||||
|
|
||||||
|
export interface CommercialDocumentLinesTotals {
|
||||||
|
subtotalBeforeDiscounts: number;
|
||||||
|
lineDiscountTotal: number;
|
||||||
|
taxableBaseBeforeGlobalDiscount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateCommercialDocumentLinesTotals = (
|
||||||
|
lines: CommercialDocumentLineInput[]
|
||||||
|
): CommercialDocumentLinesTotals => {
|
||||||
|
return lines.reduce<CommercialDocumentLinesTotals>(
|
||||||
|
(acc, line) => {
|
||||||
|
const amounts = calculateCommercialDocumentLineAmounts(line);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotalBeforeDiscounts: roundMoney(acc.subtotalBeforeDiscounts + amounts.grossAmount),
|
||||||
|
lineDiscountTotal: roundMoney(acc.lineDiscountTotal + amounts.itemDiscountAmount),
|
||||||
|
taxableBaseBeforeGlobalDiscount: roundMoney(
|
||||||
|
acc.taxableBaseBeforeGlobalDiscount + amounts.taxableBaseBeforeGlobalDiscount
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subtotalBeforeDiscounts: 0,
|
||||||
|
lineDiscountTotal: 0,
|
||||||
|
taxableBaseBeforeGlobalDiscount: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
CommercialDocumentLineInput,
|
||||||
|
CommercialDocumentTaxBreakdownLine,
|
||||||
|
} from "../../entities";
|
||||||
|
|
||||||
|
import { calculateCommercialDocumentLineAmounts } from "./calculate-commercial-document-line-amounts";
|
||||||
|
import { calculateProportionalDiscount } from "./calculate-proportional-discount";
|
||||||
|
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
|
||||||
|
|
||||||
|
interface CalculateCommercialDocumentTaxBreakdownParams {
|
||||||
|
lines: CommercialDocumentLineInput[];
|
||||||
|
taxableBaseBeforeGlobalDiscount: number;
|
||||||
|
globalDiscountAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateCommercialDocumentTaxBreakdown = ({
|
||||||
|
lines,
|
||||||
|
taxableBaseBeforeGlobalDiscount,
|
||||||
|
globalDiscountAmount,
|
||||||
|
}: CalculateCommercialDocumentTaxBreakdownParams): CommercialDocumentTaxBreakdownLine[] => {
|
||||||
|
const taxMap = new Map<number, { taxableBase: number; taxAmount: number }>();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineAmounts = calculateCommercialDocumentLineAmounts(line);
|
||||||
|
|
||||||
|
const proportionalGlobalDiscount = calculateProportionalDiscount(
|
||||||
|
lineAmounts.taxableBaseBeforeGlobalDiscount,
|
||||||
|
taxableBaseBeforeGlobalDiscount,
|
||||||
|
globalDiscountAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineTaxableBase =
|
||||||
|
lineAmounts.taxableBaseBeforeGlobalDiscount - proportionalGlobalDiscount;
|
||||||
|
|
||||||
|
const taxPercentage = toCalculationNumber(line.taxPercentage);
|
||||||
|
const taxAmount = percentageAmount(lineTaxableBase, taxPercentage);
|
||||||
|
|
||||||
|
const current = taxMap.get(taxPercentage) ?? {
|
||||||
|
taxableBase: 0,
|
||||||
|
taxAmount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
taxMap.set(taxPercentage, {
|
||||||
|
taxableBase: current.taxableBase + lineTaxableBase,
|
||||||
|
taxAmount: current.taxAmount + taxAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(taxMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([taxPercentage, value]) => ({
|
||||||
|
taxPercentage,
|
||||||
|
taxableBase: roundMoney(value.taxableBase),
|
||||||
|
taxAmount: roundMoney(value.taxAmount),
|
||||||
|
}));
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import type { CommercialDocumentLineInput, CommercialDocumentTotals } from "../../entities";
|
||||||
|
|
||||||
|
import { calculateCommercialDocumentLinesTotals } from "./calculate-commercial-document-lines-totals";
|
||||||
|
import { calculateCommercialDocumentTaxBreakdown } from "./calculate-commercial-document-tax-breakdown";
|
||||||
|
import { percentageAmount, roundMoney, toCalculationNumber } from "./money-calculation";
|
||||||
|
|
||||||
|
interface CalculateCommercialDocumentTotalsParams {
|
||||||
|
lines: CommercialDocumentLineInput[];
|
||||||
|
globalDiscountPercentage: number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateCommercialDocumentTotals = ({
|
||||||
|
lines,
|
||||||
|
globalDiscountPercentage,
|
||||||
|
}: CalculateCommercialDocumentTotalsParams): CommercialDocumentTotals => {
|
||||||
|
const normalizedGlobalDiscountPercentage = toCalculationNumber(globalDiscountPercentage);
|
||||||
|
|
||||||
|
const linesTotals = calculateCommercialDocumentLinesTotals(lines);
|
||||||
|
|
||||||
|
const globalDiscountAmount = percentageAmount(
|
||||||
|
linesTotals.taxableBaseBeforeGlobalDiscount,
|
||||||
|
normalizedGlobalDiscountPercentage
|
||||||
|
);
|
||||||
|
|
||||||
|
const taxableBase = linesTotals.taxableBaseBeforeGlobalDiscount - globalDiscountAmount;
|
||||||
|
|
||||||
|
const taxBreakdown = calculateCommercialDocumentTaxBreakdown({
|
||||||
|
lines,
|
||||||
|
taxableBaseBeforeGlobalDiscount: linesTotals.taxableBaseBeforeGlobalDiscount,
|
||||||
|
globalDiscountAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taxTotal = taxBreakdown.reduce((total, tax) => total + tax.taxAmount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtotalBeforeDiscounts: roundMoney(linesTotals.subtotalBeforeDiscounts),
|
||||||
|
|
||||||
|
lineDiscountTotal: roundMoney(linesTotals.lineDiscountTotal),
|
||||||
|
globalDiscountPercentage: normalizedGlobalDiscountPercentage,
|
||||||
|
globalDiscountAmount: roundMoney(globalDiscountAmount),
|
||||||
|
|
||||||
|
taxableBase: roundMoney(taxableBase),
|
||||||
|
|
||||||
|
taxBreakdown,
|
||||||
|
taxTotal: roundMoney(taxTotal),
|
||||||
|
|
||||||
|
total: roundMoney(taxableBase + taxTotal),
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export const calculateProportionalDiscount = (
|
||||||
|
lineBase: number,
|
||||||
|
totalBase: number,
|
||||||
|
discountAmount: number
|
||||||
|
): number => {
|
||||||
|
if (totalBase <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return discountAmount * (lineBase / totalBase);
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./calculate-commercial-document-totals";
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
// update/utils/calculations/money-calculation.ts
|
||||||
|
|
||||||
|
import { NumberHelper } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
export const toCalculationNumber = (value: number | null | undefined): number => {
|
||||||
|
return NumberHelper.toSafeNumber(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roundMoney = (value: number): number => {
|
||||||
|
return NumberHelper.roundToScale(value, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const percentageAmount = (baseAmount: number, percentage: number): number => {
|
||||||
|
return baseAmount * (percentage / 100);
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
export * from "./build-proforma-item-update-default";
|
export * from "./build-proforma-item-update-default";
|
||||||
export * from "./build-proforma-update-default";
|
export * from "./build-proforma-update-default";
|
||||||
export * from "./build-proforma-update-default";
|
|
||||||
export * from "./build-proforma-update-patch";
|
export * from "./build-proforma-update-patch";
|
||||||
export * from "./build-update-proforma-by-id-params";
|
export * from "./build-update-proforma-by-id-params";
|
||||||
|
export * from "./calculate-proforma-totals";
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import type { Table } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
ChevronsLeftIcon,
|
|
||||||
ChevronsRightIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
|
||||||
|
|
||||||
import type { DataTableMeta } from "./data-table.tsx";
|
|
||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
|
||||||
table: Table<TData>;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
|
|
||||||
const meta = table.options.meta as DataTableMeta<TData>;
|
|
||||||
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
|
|
||||||
|
|
||||||
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
|
||||||
const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 10;
|
|
||||||
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
|
|
||||||
const hasSelected = table.getFilteredSelectedRowModel().rows.length > 0;
|
|
||||||
|
|
||||||
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
|
|
||||||
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
|
|
||||||
|
|
||||||
const gotoPage = (index: number) => {
|
|
||||||
const nextIndex = Math.max(0, Math.min(index, pageCount - 1));
|
|
||||||
table.setPageIndex(nextIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageSizeChange = (size: string | null) => {
|
|
||||||
if (!size) return;
|
|
||||||
table.setPageSize(Number(size));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center justify-between text-muted-foreground", className)}>
|
|
||||||
<div className="flex flex-1 flex-col items-center gap-4 sm:flex-row">
|
|
||||||
<span aria-live="polite">
|
|
||||||
{t("components.datatable.pagination.showing_range", { start, end, total: totalRows })}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{hasSelected && (
|
|
||||||
<span aria-live="polite">
|
|
||||||
{t("components.datatable.pagination.rows_selected", {
|
|
||||||
count: table.getFilteredSelectedRowModel().rows.length,
|
|
||||||
total: table.getFilteredRowModel().rows.length,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{t("components.datatable.pagination.rows_per_page")}</span>
|
|
||||||
<Select onValueChange={handlePageSizeChange} value={String(pageSize)}>
|
|
||||||
<SelectTrigger className="h-8 w-20 border-gray-200 bg-white">
|
|
||||||
<SelectValue placeholder={String(pageSize)} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[5, 10, 20, 25, 30, 40, 50].map((size) => (
|
|
||||||
<SelectItem key={size} value={String(size)}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination>
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
aria-label={t("components.datatable.pagination.goto_first_page")}
|
|
||||||
className="cursor-pointer px-2.5"
|
|
||||||
isActive={pageIndex > 0}
|
|
||||||
onClick={() => pageIndex > 0 && gotoPage(0)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<ChevronsLeftIcon className="size-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
aria-label={t("components.datatable.pagination.goto_previous_page")}
|
|
||||||
className="cursor-pointer px-2.5"
|
|
||||||
isActive={pageIndex > 0}
|
|
||||||
onClick={() => pageIndex > 0 && gotoPage(pageIndex - 1)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="size-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
<span aria-live="polite" className="px-2 text-sm text-muted-foreground">
|
|
||||||
{t("components.datatable.pagination.page_of", {
|
|
||||||
page: pageIndex + 1,
|
|
||||||
of: pageCount,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
aria-label={t("components.datatable.pagination.goto_next_page")}
|
|
||||||
className="cursor-pointer px-2.5"
|
|
||||||
isActive={pageIndex < pageCount - 1}
|
|
||||||
onClick={() => pageIndex < pageCount - 1 && gotoPage(pageIndex + 1)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="size-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink
|
|
||||||
aria-label={t("components.datatable.pagination.goto_last_page")}
|
|
||||||
className="cursor-pointer px-2.5"
|
|
||||||
isActive={pageIndex < pageCount - 1}
|
|
||||||
onClick={() => pageIndex < pageCount - 1 && gotoPage(pageCount - 1)}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<ChevronsRightIcon className="size-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 20, 25, 30, 40, 50] as const;
|
||||||
|
|
||||||
|
export interface DataTablePageSizeSelectProps {
|
||||||
|
pageSize: number;
|
||||||
|
onPageSizeChange: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTablePageSizeSelect = ({
|
||||||
|
pageSize,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: DataTablePageSizeSelectProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-start gap-2 whitespace-nowrap xs:justify-start">
|
||||||
|
<span>{t("components.datatable.pagination.rows_per_page")}</span>
|
||||||
|
|
||||||
|
<Select onValueChange={onPageSizeChange} value={String(pageSize)}>
|
||||||
|
<SelectTrigger className="h-8 w-20 border-gray-200 bg-white">
|
||||||
|
<SelectValue placeholder={String(pageSize)} />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { PaginationItem, PaginationLink } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
|
||||||
|
export interface DataTablePaginationButtonProps {
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTablePaginationButton = ({
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: DataTablePaginationButtonProps) => (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
aria-disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(
|
||||||
|
"h-8 min-w-8 cursor-pointer px-2.5",
|
||||||
|
"focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) onClick();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import { Pagination, PaginationContent } from "@repo/shadcn-ui/components";
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
|
||||||
|
import { DataTablePaginationButton } from "./data-table-pagination-button.tsx";
|
||||||
|
|
||||||
|
export interface DataTablePaginationControlsProps {
|
||||||
|
pageIndex: number;
|
||||||
|
pageCount: number;
|
||||||
|
onGotoPage: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTablePaginationControls = ({
|
||||||
|
pageIndex,
|
||||||
|
pageCount,
|
||||||
|
onGotoPage,
|
||||||
|
}: DataTablePaginationControlsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const canGoPrevious = pageIndex > 0;
|
||||||
|
const canGoNext = pageIndex < pageCount - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination className="mx-0 w-auto justify-start lg:justify-end">
|
||||||
|
<PaginationContent className="gap-1">
|
||||||
|
<DataTablePaginationButton
|
||||||
|
ariaLabel={t("components.datatable.pagination.goto_first_page")}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
|
disabled={!canGoPrevious}
|
||||||
|
onClick={() => onGotoPage(0)}
|
||||||
|
>
|
||||||
|
<ChevronsLeftIcon className="size-4" />
|
||||||
|
</DataTablePaginationButton>
|
||||||
|
|
||||||
|
<DataTablePaginationButton
|
||||||
|
ariaLabel={t("components.datatable.pagination.goto_previous_page")}
|
||||||
|
disabled={!canGoPrevious}
|
||||||
|
onClick={() => onGotoPage(pageIndex - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</DataTablePaginationButton>
|
||||||
|
|
||||||
|
<span
|
||||||
|
aria-live="polite"
|
||||||
|
className="min-w-20 px-2 text-center text-sm tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("components.datatable.pagination.page_of", {
|
||||||
|
page: pageIndex + 1,
|
||||||
|
of: pageCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<DataTablePaginationButton
|
||||||
|
ariaLabel={t("components.datatable.pagination.goto_next_page")}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
onClick={() => onGotoPage(pageIndex + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</DataTablePaginationButton>
|
||||||
|
|
||||||
|
<DataTablePaginationButton
|
||||||
|
ariaLabel={t("components.datatable.pagination.goto_last_page")}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
|
disabled={!canGoNext}
|
||||||
|
onClick={() => onGotoPage(pageCount - 1)}
|
||||||
|
>
|
||||||
|
<ChevronsRightIcon className="size-4" />
|
||||||
|
</DataTablePaginationButton>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
|
||||||
|
export interface DataTablePaginationSummaryProps {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
totalRows: number;
|
||||||
|
selectedRows: number;
|
||||||
|
filteredRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTablePaginationSummary = ({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
totalRows,
|
||||||
|
selectedRows,
|
||||||
|
filteredRows,
|
||||||
|
}: DataTablePaginationSummaryProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4">
|
||||||
|
<span aria-live="polite" className="tabular-nums">
|
||||||
|
{t("components.datatable.pagination.showing_range", {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
total: totalRows,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{selectedRows > 0 && (
|
||||||
|
<span aria-live="polite" className="tabular-nums">
|
||||||
|
{t("components.datatable.pagination.rows_selected", {
|
||||||
|
count: selectedRows,
|
||||||
|
total: filteredRows,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { Separator } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import type { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import type { DataTableMeta } from "../data-table.tsx";
|
||||||
|
|
||||||
|
import { DataTablePageSizeSelect } from "./data-table-pagesize-select.tsx";
|
||||||
|
import { DataTablePaginationControls } from "./data-table-pagination-controls.tsx";
|
||||||
|
import { DataTablePaginationSummary } from "./data-table-pagination-summary.tsx";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps<TData> {
|
||||||
|
table: Table<TData>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
|
||||||
|
const { pageIndex, pageSize, pageCount, totalRows, start, end } = getPaginationState(table);
|
||||||
|
|
||||||
|
const selectedRows = table.getFilteredSelectedRowModel().rows.length;
|
||||||
|
const filteredRows = table.getFilteredRowModel().rows.length;
|
||||||
|
|
||||||
|
const gotoPage = (index: number) => {
|
||||||
|
const nextIndex = Math.max(0, Math.min(index, pageCount - 1));
|
||||||
|
table.setPageIndex(nextIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size: string | null) => {
|
||||||
|
if (!size) return;
|
||||||
|
|
||||||
|
table.setPageSize(Number(size));
|
||||||
|
table.setPageIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 text-sm text-muted-foreground",
|
||||||
|
"lg:flex-row lg:items-center lg:justify-between",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DataTablePaginationSummary
|
||||||
|
end={end}
|
||||||
|
filteredRows={filteredRows}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
start={start}
|
||||||
|
totalRows={totalRows}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={cn("flex flex-col gap-2", "lg:flex-row lg:items-center lg:justify-between")}>
|
||||||
|
<DataTablePageSizeSelect onPageSizeChange={handlePageSizeChange} pageSize={pageSize} />
|
||||||
|
<Separator className="hidden lg:visible lg:ml-4" orientation="vertical" />
|
||||||
|
|
||||||
|
<DataTablePaginationControls
|
||||||
|
onGotoPage={gotoPage}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPaginationState = <TData,>(table: Table<TData>) => {
|
||||||
|
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
|
||||||
|
const meta = table.options.meta as DataTableMeta<TData> | undefined;
|
||||||
|
|
||||||
|
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
|
||||||
|
|
||||||
|
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
||||||
|
const pageSize = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 10;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||||
|
|
||||||
|
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
|
||||||
|
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalRows,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./data-table-pagination.tsx";
|
||||||
@ -7,7 +7,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
@ -30,10 +29,12 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
import { useTranslation } from "../../locales/i18n.ts";
|
||||||
|
|
||||||
import { DataTablePagination } from "./data-table-pagination.tsx";
|
import { DataTablePagination } from "./data-table-pagination/data-table-pagination.tsx";
|
||||||
import { DataTableToolbar } from "./data-table-toolbar.tsx";
|
import { DataTableToolbar } from "./data-table-toolbar.tsx";
|
||||||
|
|
||||||
import "./types.ts";
|
import "./data-table-meta.ts";
|
||||||
|
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
|
||||||
export type DataTableOps<TData> = {
|
export type DataTableOps<TData> = {
|
||||||
onAdd?: (table: Table<TData>) => void;
|
onAdd?: (table: Table<TData>) => void;
|
||||||
@ -64,8 +65,6 @@ export type DataTableMeta<TData> = TableMeta<TData> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface DataTableProps<TData, TValue> {
|
export interface DataTableProps<TData, TValue> {
|
||||||
className?: string;
|
|
||||||
|
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
meta?: DataTableMeta<TData>;
|
meta?: DataTableMeta<TData>;
|
||||||
@ -91,8 +90,6 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
className,
|
|
||||||
|
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
meta,
|
meta,
|
||||||
@ -178,171 +175,111 @@ export function DataTable<TData, TValue>({
|
|||||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
|
||||||
const [canScrollRight, setCanScrollRight] = React.useState(true);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const check = () => {
|
|
||||||
setIsScrolled(el.scrollLeft > 0);
|
|
||||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
check();
|
|
||||||
el.addEventListener("scroll", check);
|
|
||||||
window.addEventListener("resize", check);
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener("scroll", check);
|
|
||||||
window.removeEventListener("resize", check);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="transition-[max-height] duration-300 ease-in-out">
|
||||||
className={cn(
|
|
||||||
"transition-[max-height] duration-300 ease-in-out rounded-xl border border-border bg-card shadow-sm ",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<DataTableToolbar showViewOptions={!readOnly} table={table} />
|
<DataTableToolbar showViewOptions={!readOnly} table={table} />
|
||||||
|
|
||||||
{/* Contenedor con fade + tabla */}
|
<div className="overflow-hidden rounded-md border">
|
||||||
<div className="relative overflow-hidden">
|
<TableComp className="min-w-full sm:min-w-[820px] w-full">
|
||||||
{/*
|
{/* CABECERA */}
|
||||||
* ─── CAPA DE FADE ──────────────────────────────────────────────────────
|
<TableHeader>
|
||||||
* Div absolutamente posicionado sobre el área scrollable.
|
{table.getHeaderGroups().map((hg) => (
|
||||||
* - pointer-events: none → no bloquea interacciones.
|
<TableRow className="bg-muted/50 hover:bg-muted/50 text-xs sm:text-sm" key={hg.id}>
|
||||||
* - linear-gradient → de transparente a bg-card (blanco/oscuro).
|
{hg.headers.map((h) => {
|
||||||
* - z-10 → queda por DEBAJO de la columna sticky (z-20).
|
/*
|
||||||
* - Solo visible cuando aún hay contenido a la derecha.
|
const w = h.getSize();
|
||||||
* ────────────────────────────────────────────────────────────────────────
|
const minW = h.column.columnDef.minSize;
|
||||||
*/}
|
const maxW = h.column.columnDef.maxSize;
|
||||||
{canScrollRight && (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute right-10 top-0 h-full w-32 z-10"
|
|
||||||
style={{
|
|
||||||
background: "linear-gradient(to right, transparent, hsl(var(--card, 0 0% 100%)))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scroll container */}
|
|
||||||
<div className="overflow-x-auto max-w-full -mx-px" ref={scrollRef}>
|
style={{
|
||||||
<TableComp>
|
width: w ? `${w}px` : undefined,
|
||||||
{/* CABECERA */}
|
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
||||||
<TableHeader>
|
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
||||||
{table.getHeaderGroups().map((hg) => (
|
}}
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50" key={hg.id}>
|
|
||||||
{hg.headers.map((h) => {
|
*/
|
||||||
const w = h.getSize();
|
|
||||||
const minW = h.column.columnDef.minSize;
|
const headerClassName = h.column.columnDef.meta?.headerClassName;
|
||||||
const maxW = h.column.columnDef.maxSize;
|
|
||||||
const isActionsColumn = h.column.columnDef.meta?.isActionsColumn;
|
return (
|
||||||
const headerClassName = h.column.columnDef.meta?.headerClassName;
|
<TableHead
|
||||||
return (
|
className={cn("whitespace-nowrap", headerClassName)}
|
||||||
<TableHead
|
colSpan={h.colSpan}
|
||||||
className={cn(
|
key={h.id}
|
||||||
"whitespace-nowrap",
|
>
|
||||||
isActionsColumn &&
|
{h.isPlaceholder
|
||||||
"sticky right-0 z-20 w-10 bg-muted/50 transition-shadow",
|
? null
|
||||||
isActionsColumn &&
|
: flexRender(h.column.columnDef.header, h.getContext())}
|
||||||
isScrolled &&
|
</TableHead>
|
||||||
"shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]",
|
);
|
||||||
headerClassName
|
})}
|
||||||
)}
|
</TableRow>
|
||||||
colSpan={h.colSpan}
|
))}
|
||||||
key={h.id}
|
</TableHeader>
|
||||||
style={{
|
|
||||||
|
{/* CUERPO */}
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row, rowIndex) => (
|
||||||
|
<TableRow
|
||||||
|
className={"group bg-background text-xs sm:text-sm"}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
key={row.id}
|
||||||
|
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ")
|
||||||
|
onRowClick?.(row.original, rowIndex, e as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
/*
|
||||||
|
const w = cell.column.getSize();
|
||||||
|
const minW = cell.column.columnDef.minSize;
|
||||||
|
const maxW = cell.column.columnDef.maxSize;
|
||||||
|
style={{
|
||||||
width: w ? `${w}px` : undefined,
|
width: w ? `${w}px` : undefined,
|
||||||
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
||||||
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{h.isPlaceholder
|
*/
|
||||||
? null
|
|
||||||
: flexRender(h.column.columnDef.header, h.getContext())}
|
const cellClassName = cell.column.columnDef.meta?.cellClassName;
|
||||||
</TableHead>
|
|
||||||
|
return (
|
||||||
|
<TableCell className={cn("whitespace-nowrap", cellClassName)} key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
</TableHeader>
|
) : (
|
||||||
|
<TableRow>
|
||||||
{/* CUERPO */}
|
<TableCell
|
||||||
<TableBody>
|
className="h-24 text-center text-muted-foreground"
|
||||||
{table.getRowModel().rows.length ? (
|
colSpan={columns.length}
|
||||||
table.getRowModel().rows.map((row, rowIndex) => (
|
>
|
||||||
<TableRow
|
{t("components.datatable.empty")}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
</TableCell>
|
||||||
key={row.id}
|
</TableRow>
|
||||||
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ")
|
|
||||||
onRowClick?.(row.original, rowIndex, e as any);
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => {
|
|
||||||
const w = cell.column.getSize();
|
|
||||||
const minW = cell.column.columnDef.minSize;
|
|
||||||
const maxW = cell.column.columnDef.maxSize;
|
|
||||||
const isActionsColumn = cell.column.columnDef.meta?.isActionsColumn;
|
|
||||||
const cellClassName = cell.column.columnDef.meta?.cellClassName;
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
className={cn(
|
|
||||||
"align-top whitespace-nowrap font-medium truncate",
|
|
||||||
isActionsColumn &&
|
|
||||||
"sticky right-0 z-20 w-10 bg-card transition-shadow",
|
|
||||||
isActionsColumn &&
|
|
||||||
isScrolled &&
|
|
||||||
"shadow-[-8px_0_12px_-4px_rgba(0,0,0,0.08)]",
|
|
||||||
cellClassName
|
|
||||||
)}
|
|
||||||
key={cell.id}
|
|
||||||
style={{
|
|
||||||
width: w ? `${w}px` : undefined,
|
|
||||||
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
|
||||||
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
className="h-24 text-center text-muted-foreground"
|
|
||||||
colSpan={columns.length}
|
|
||||||
>
|
|
||||||
{t("components.datatable.empty")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
|
|
||||||
{/* Paginación */}
|
|
||||||
{enablePagination && (
|
|
||||||
<TableFooter className="bg-background">
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={100}>
|
|
||||||
<DataTablePagination table={table} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
)}
|
)}
|
||||||
</TableComp>
|
</TableBody>
|
||||||
</div>
|
|
||||||
|
{/* Paginación */}
|
||||||
|
{enablePagination && (
|
||||||
|
<TableFooter className="bg-background">
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={100}>
|
||||||
|
<DataTablePagination table={table} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
)}
|
||||||
|
</TableComp>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
FieldDescription,
|
||||||
FieldLegend,
|
FieldLegend,
|
||||||
FieldSet,
|
FieldSet,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
@ -15,9 +14,7 @@ interface FormSectionCardProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
@ -28,50 +25,38 @@ export const FormSectionCard = ({
|
|||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
||||||
children,
|
children,
|
||||||
|
|
||||||
className,
|
className,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}: FormSectionCardProps) => {
|
}: FormSectionCardProps) => {
|
||||||
const hasHeader = Boolean(title || description);
|
const hasHeader = Boolean(title || description || icon);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn(className)}>
|
<Card className={className}>
|
||||||
<FieldSet disabled={disabled}>
|
<FieldSet disabled={disabled}>
|
||||||
{hasHeader ? (
|
<CardHeader className={headerClassName}>
|
||||||
<CardHeader className={cn("flex flex-row items-start gap-4", headerClassName)}>
|
<div className="flex items-start gap-3">
|
||||||
<FieldLegend className="w-full">
|
{icon ? (
|
||||||
<div className="flex items-start gap-3">
|
<div
|
||||||
{icon ? (
|
className={cn(
|
||||||
<div
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
|
||||||
className={cn(
|
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
||||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
|
)}
|
||||||
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
>
|
||||||
)}
|
{" "}
|
||||||
>
|
{icon}{" "}
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{title ? (
|
|
||||||
<CardTitle className="text-base font-semibold tracking-tight">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</FieldLegend>
|
) : null}
|
||||||
</CardHeader>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<CardContent className={cn(hasHeader ? "pt-0" : undefined, contentClassName)}>
|
<div className="space-y-1">
|
||||||
{children}
|
{title ? <FieldLegend>{title}</FieldLegend> : null}
|
||||||
</CardContent>
|
{description ? <FieldDescription>{description}</FieldDescription> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className={contentClassName}>{children}</CardContent>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export * from "./money-helper";
|
|||||||
export * from "./number-helper";
|
export * from "./number-helper";
|
||||||
export * from "./object-helper";
|
export * from "./object-helper";
|
||||||
export * from "./patch-field";
|
export * from "./patch-field";
|
||||||
|
export * from "./percentage-helper";
|
||||||
export * from "./result";
|
export * from "./result";
|
||||||
export * from "./result-collection";
|
export * from "./result-collection";
|
||||||
export * from "./rule-validator";
|
export * from "./rule-validator";
|
||||||
|
|||||||
10
packages/rdx-utils/src/helpers/percentage-helper.ts
Normal file
10
packages/rdx-utils/src/helpers/percentage-helper.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const formatPercent = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "percent",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value / 100);
|
||||||
|
};
|
||||||
|
export const PercentageHelper = {
|
||||||
|
formatPercent,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user