Pantalla update fullscreen

This commit is contained in:
David Arranz 2026-06-02 18:40:23 +02:00
parent 823e5cebe1
commit 66623cbe78
17 changed files with 460 additions and 174 deletions

View File

@ -1,57 +1,146 @@
import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; import type {
import type { JSX } from "react"; IModuleClient,
import { type RouteObject, useRoutes } from "react-router-dom"; ModuleClientParams,
ModuleRouteLayout,
ModuleRouteObject,
} from "@erp/core/client";
import type { ReactNode } from "react";
import type { RouteObject } from "react-router-dom";
interface ModuleRoutesProps { import { RequireAuthRouteGuard } from "@/routes/require-auth-guard";
interface BuildModuleRoutesParams {
modules: IModuleClient[]; modules: IModuleClient[];
params: ModuleClientParams; params: ModuleClientParams;
layout: ModuleRouteLayout;
} }
interface WarpIfProtectedProps { interface NormalizeRouteParams {
component: JSX.Element; route: ModuleRouteObject;
isProtected: boolean; module: IModuleClient;
targetLayout: ModuleRouteLayout;
inheritedLayout?: ModuleRouteLayout;
inheritedProtected?: boolean;
} }
// biome-ignore lint/correctness/noUnusedVariables: <posible uso futuro> const DEFAULT_LAYOUT: ModuleRouteLayout = "app-sidebar";
const WarpIfProtected = ({ component, isProtected }: WarpIfProtectedProps) => {
return isProtected ? <>{component}</> : component; const resolveRouteLayout = (
route: ModuleRouteObject,
module: IModuleClient,
inheritedLayout?: ModuleRouteLayout
): ModuleRouteLayout => {
return route.handle?.layout ?? inheritedLayout ?? module.layout ?? DEFAULT_LAYOUT;
}; };
export const ModuleRoutes = ({ modules, params }: ModuleRoutesProps) => { const resolveRouteProtected = (
route: ModuleRouteObject,
module: IModuleClient,
inheritedProtected?: boolean
): boolean => {
return route.handle?.protected ?? inheritedProtected ?? module.protected ?? false;
};
const wrapProtectedElement = (element: ReactNode, isProtected: boolean): ReactNode => {
if (!isProtected) {
return element;
}
return <RequireAuthRouteGuard>{element}</RequireAuthRouteGuard>;
};
const normalizeRoute = ({
route,
module,
targetLayout,
inheritedLayout,
inheritedProtected,
}: NormalizeRouteParams): RouteObject | null => {
const routeLayout = resolveRouteLayout(route, module, inheritedLayout);
const routeProtected = resolveRouteProtected(route, module, inheritedProtected);
const normalizedChildren = route.children
?.map((child) =>
normalizeRoute({
route: child,
module,
targetLayout,
inheritedLayout: routeLayout,
inheritedProtected: routeProtected,
})
)
.filter((child): child is RouteObject => child !== null);
const hasMatchingLayout = routeLayout === targetLayout;
const hasChildren = Boolean(normalizedChildren?.length);
const hasElement = route.element !== undefined || route.Component !== undefined;
if (!(hasMatchingLayout || hasChildren)) {
return null;
}
if (!hasMatchingLayout && hasChildren) {
return {
path: route.path,
index: route.index,
children: normalizedChildren,
} as RouteObject;
}
const { children: _children, handle, element, ...rest } = route;
const normalizedRoute = {
...rest,
handle,
element: element ? wrapProtectedElement(element, routeProtected) : undefined,
children: normalizedChildren,
} as RouteObject;
if (!(hasElement || hasChildren)) {
console.warn(
`[ModuleRoutes] Ruta sin element ni children válidos: "${String(route.path ?? "index")}"`
);
return null;
}
return normalizedRoute;
};
export const buildModuleRoutes = ({
modules,
params,
layout,
}: BuildModuleRoutesParams): RouteObject[] => {
const routes: RouteObject[] = []; const routes: RouteObject[] = [];
if (modules) { for (const module of modules) {
for (const module of modules) { if (typeof module.routes !== "function") {
if (typeof module.routes !== "function") { console.warn(`[ModuleRoutes] El módulo "${module.name}" no define una función routes().`);
console.warn(`[ModuleRoutes] El módulo "${module.name}" no define una función 'routes()'`); continue;
continue; }
}
const moduleRoutes = module.routes(params); const moduleRoutes = module.routes(params);
if (!Array.isArray(moduleRoutes)) { if (!Array.isArray(moduleRoutes)) {
console.error( console.error(
`[ModuleRoutes] El módulo "${module.name}" debe devolver un RouteObject[], pero devolvió:`, `[ModuleRoutes] El módulo "${module.name}" debe devolver un ModuleRouteObject[], pero devolvió:`,
moduleRoutes moduleRoutes
);
continue;
}
const allAreRouteObjects = moduleRoutes.every(
(r) => typeof r === "object" && r.element !== undefined
); );
continue;
}
if (!allAreRouteObjects) { for (const route of moduleRoutes) {
console.error( const normalizedRoute = normalizeRoute({
`[ModuleRoutes] El módulo "${module.name}" contiene elementos inválidos en su RouteObject[]`, route,
moduleRoutes module,
); targetLayout: layout,
continue; });
if (normalizedRoute) {
routes.push(normalizedRoute);
} }
routes.push(...moduleRoutes);
} }
} }
return useRoutes(routes); return routes;
}; };

View File

@ -0,0 +1,9 @@
import { Outlet } from "react-router-dom";
export const AppFullscreenLayout = () => {
return (
<main className="h-dvh w-dvw overflow-hidden bg-background">
<Outlet />
</main>
);
};

View File

@ -6,7 +6,7 @@ import { AppMain } from "./app-main";
import { AppSidebar } from "./app-sidebar"; import { AppSidebar } from "./app-sidebar";
import { AppTopbar } from "./app-topbar"; import { AppTopbar } from "./app-topbar";
export const AppLayout = () => { export const AppSidebarLayout = () => {
return ( return (
<SidebarProvider <SidebarProvider
style={ style={

View File

@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
export const AuthLayout = () => {
return (
<main className="grid min-h-dvh bg-muted px-4 py-8">
<section className="m-auto w-full max-w-md">
<Outlet />
</section>
</main>
);
};

View File

@ -1,4 +1,6 @@
export * from './app-layout'; export * from "./app-fullscreen-layout";
export * from './app-main'; export * from "./app-main";
export * from './app-sidebar'; export * from "./app-sidebar";
export * from './app-topbar'; export * from "./app-sidebar-layout";
export * from "./app-topbar";
export * from "./auth-layout";

View File

@ -1,66 +1,103 @@
// apps/web/src/routes/app-routes.tsx // apps/web/src/routes/app-routes.tsx
import type { IModuleClient } from "@erp/core/client";
import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import { ModuleRoutes } from "@/components/module-routes"; import { Navigate, createBrowserRouter } from "react-router-dom";
import { AppLayout } from "@/layout";
import { buildModuleRoutes } from "@/components/module-routes";
import { AppFullscreenLayout, AppSidebarLayout, AuthLayout } from "@/layout";
import ShadcnShowcasePage from "@/pages/shadcn-ui-page"; import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page"; import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
import { ErrorPage, LoginForm } from "../pages"; import { ErrorPage } from "../pages";
import { modules } from "../register-modules"; import { modules } from "../register-modules";
function groupModulesByLayout(modules: IModuleClient[]) {
const groups: Record<string, IModuleClient[]> = {
auth: [],
app: [],
};
for (const module of modules) {
const layout = typeof module.layout === "string" ? module.layout : "app";
groups[layout] = groups[layout] ?? [];
groups[layout].push(module);
}
return groups;
}
export const getAppRouter = () => { export const getAppRouter = () => {
const params = { const params = {
...import.meta.env, ...import.meta.env,
}; };
const grouped = groupModulesByLayout(modules); const authRoutes = buildModuleRoutes({
modules,
params,
layout: "auth",
});
return createBrowserRouter( const appSidebarRoutes = buildModuleRoutes({
createRoutesFromElements( modules,
<Route path="/"> params,
<Route element={<Navigate replace to="/proformas" />} index /> layout: "app-sidebar",
});
{/* Auth Layout */} const appFullscreenRoutes = buildModuleRoutes({
<Route path="auth"> modules,
<Route element={<Navigate replace to="login" />} index /> params,
<Route element={<LoginForm />} path="login" /> layout: "app-fullscreen",
<Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" /> });
</Route>
{/* App Layout */} const appRoutes = createBrowserRouter([
<Route element={<AppLayout />}> {
{/* Test */} path: "/",
<Route element={<ShadcnShowcasePage />} path="shadcnui" /> children: [
<Route element={<TailwindV4ShowcasePage />} path="tailwindcss4" /> {
index: true,
element: <Navigate replace to="/proformas" />,
},
{/* Static / provisional routes */} {
<Route element={<ErrorPage />} path="dashboard" /> path: "auth",
<Route element={<ErrorPage />} path="settings" /> element: <AuthLayout />,
<Route element={<ErrorPage />} path="catalog" /> children: [
<Route element={<ErrorPage />} path="quotes" /> {
index: true,
element: <Navigate replace to="login" />,
},
...authRoutes,
],
},
{/* Dynamic module routes. Keep this last. */} {
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" /> element: <AppFullscreenLayout />,
</Route> children: appFullscreenRoutes,
</Route> },
)
); {
element: <AppSidebarLayout />,
children: [
{
path: "shadcnui",
element: <ShadcnShowcasePage />,
},
{
path: "tailwindcss4",
element: <TailwindV4ShowcasePage />,
},
{
path: "dashboard",
element: <ErrorPage />,
},
{
path: "settings",
element: <ErrorPage />,
},
{
path: "catalog",
element: <ErrorPage />,
},
{
path: "quotes",
element: <ErrorPage />,
},
...appSidebarRoutes,
],
},
{
path: "*",
element: <ErrorPage />,
},
],
},
]);
return appRoutes;
}; };

View File

@ -0,0 +1,18 @@
// apps/web/src/routes/guards/public-only-route.tsx
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
interface PublicOnlyRouteProps {
children: ReactNode;
}
export const PublicOnlyRouteGuard = ({ children }: PublicOnlyRouteProps) => {
// TODO: sustituir por tu estado real de auth.
const isAuthenticated = false;
if (isAuthenticated) {
return <Navigate replace to="/proformas" />;
}
return <>{children}</>;
};

View File

@ -0,0 +1,20 @@
// apps/web/src/routes/guards/require-auth.tsx
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
interface RequireAuthRouteProps {
children: ReactNode;
}
export const RequireAuthRouteGuard = ({ children }: RequireAuthRouteProps) => {
const location = useLocation();
// TODO: sustituir por tu estado real de auth.
const isAuthenticated = true;
if (!isAuthenticated) {
return <Navigate replace state={{ from: location }} to="/auth/login" />;
}
return <>{children}</>;
};

View File

@ -1,27 +1,33 @@
import { ModuleClientParams } from "@erp/core/client"; import type { ModuleClientParams } from "@erp/core/client";
import { Outlet, RouteObject } from "react-router-dom"; import type { RouteObject } from "react-router-dom";
import { AuthLayout } from "./components";
import { LoginPage } from "./pages"; import { LoginPage } from "./pages";
export const AuthRoutes = (params: ModuleClientParams): RouteObject[] => { export const AuthRoutes = (params: ModuleClientParams): RouteObject[] => {
return [ return [
{ {
path: "*", path: "login",
element: ( handle: {
<AuthLayout> layout: "auth",
<Outlet context={params} /> protected: false,
</AuthLayout> },
), element: <LoginPage />,
children: [ },
{ {
path: "login", path: "register",
element: <LoginPage />, handle: {
}, layout: "auth",
{ protected: false,
path: "register", },
element: <div>Register</div>, //element: <RegisterPage />,
}, },
], {
path: "forgot-password",
handle: {
layout: "auth",
protected: false,
},
//element: <ForgotPasswordPage />,
}, },
]; ];
}; };

View File

@ -1,10 +1,7 @@
import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; import type { IModuleClient } from "@erp/core/client";
import { AuthRoutes } from "./auth-routes"; import { AuthRoutes } from "./auth-routes";
//import enResources from "../common/locales/en.json";
//import esResources from "../common/locales/es.json";
const MODULE_NAME = "auth"; const MODULE_NAME = "auth";
const MODULE_VERSION = "1.0.0"; const MODULE_VERSION = "1.0.0";
@ -13,13 +10,10 @@ export const AuthModuleManifest: IModuleClient = {
version: MODULE_VERSION, version: MODULE_VERSION,
dependencies: ["core"], dependencies: ["core"],
protected: false, protected: false,
layout: "auth", layout: "auth",
routes: (params: ModuleClientParams) => { routes: (params) => AuthRoutes(params),
//i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
//i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
return AuthRoutes(params);
},
}; };
export default AuthModuleManifest; export default AuthModuleManifest;

View File

@ -3,11 +3,34 @@ import type { RouteObject } from "react-router-dom";
import type { ModuleMetadata } from "../../../common"; import type { ModuleMetadata } from "../../../common";
export type ModuleClientParams = { [key: string]: any }; export type ModuleClientParams = Record<string, unknown>;
export type ModuleRouteLayout = "app-sidebar" | "auth" | "app-fullscreen";
export interface ModuleRouteHandle {
layout?: ModuleRouteLayout;
protected?: boolean;
}
export type ModuleRouteObject = Omit<RouteObject, "children" | "handle"> & {
children?: ModuleRouteObject[];
handle?: RouteObject["handle"] & ModuleRouteHandle;
};
export interface IModuleClient extends ModuleMetadata { export interface IModuleClient extends ModuleMetadata {
protected?: boolean; // determina si las rutas deben ser protegidas /**
* Default de protección para las rutas del módulo.
* Puede sobrescribirse por ruta con handle.protected.
*/
protected?: boolean;
/**
* Default de layout para las rutas del módulo.
* Puede sobrescribirse por ruta con handle.layout.
*/
layout?: ModuleRouteLayout;
icon?: ReactNode; icon?: ReactNode;
routes?: (params: ModuleClientParams) => RouteObject[];
layout?: "app" | "auth"; routes?: (params: ModuleClientParams) => ModuleRouteObject[];
} }

View File

@ -30,20 +30,42 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
return [ return [
{ {
path: "proformas", path: "proformas",
handle: {
layout: "app-sidebar",
protected: true,
},
element: ( element: (
<ProformaLayout> <ProformaLayout>
<Outlet context={params} /> <Outlet context={params} />
</ProformaLayout> </ProformaLayout>
), ),
children: [ children: [
{ index: true, element: <ProformasListPage /> }, // index {
{ path: "list", element: <ProformasListPage /> }, index: true,
//{ path: "create", element: <ProformaCreatePage /> }, element: <ProformasListPage />,
{ path: ":id/edit", element: <ProformaUpdatePage /> }, },
{
path: "list",
element: <ProformasListPage />,
},
], ],
}, },
{
path: "proformas/:id/edit",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: <ProformaUpdatePage />,
},
{ {
path: "customer-invoices", path: "customer-invoices",
handle: {
layout: "app-sidebar",
protected: true,
},
element: ( element: (
<IssuedInvoicesLayout> <IssuedInvoicesLayout>
<Outlet context={params} /> <Outlet context={params} />

View File

@ -1,4 +1,4 @@
import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; import type { IModuleClient } from "@erp/core/client";
import { CustomerInvoiceRoutes } from "./customer-invoice-routes"; import { CustomerInvoiceRoutes } from "./customer-invoice-routes";
@ -9,12 +9,11 @@ export const CustomerInvoicesModuleManifest: IModuleClient = {
name: MODULE_NAME, name: MODULE_NAME,
version: MODULE_VERSION, version: MODULE_VERSION,
dependencies: ["auth", "Core", "Catalogs", "Customers"], dependencies: ["auth", "Core", "Catalogs", "Customers"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => { protected: true, // protegido por defecto
return CustomerInvoiceRoutes(params); layout: "app-sidebar", // layout por defecto
},
routes: (params) => CustomerInvoiceRoutes(params),
}; };
export default CustomerInvoicesModuleManifest; export default CustomerInvoicesModuleManifest;

View File

@ -1,5 +1,6 @@
import { import {
AmountField, AmountField,
FormFieldLabel,
LineDescriptionField, LineDescriptionField,
PercentageField, PercentageField,
QuantityField, QuantityField,
@ -14,6 +15,7 @@ import type {
ProformaItemField, ProformaItemField,
ProformaItemsTotals, ProformaItemsTotals,
} from "../../controllers"; } from "../../controllers";
import type { ProformaTotals } from "../../entities";
import { LineEditor, type LineEditorColumn } from "./line-editor"; import { LineEditor, type LineEditorColumn } from "./line-editor";
@ -25,7 +27,8 @@ interface ProformaLineEditorProps {
getItemAmounts: (index: number) => ProformaItemAmounts; getItemAmounts: (index: number) => ProformaItemAmounts;
getItemErrorMessage: (index: number) => string | undefined; getItemErrorMessage: (index: number) => string | undefined;
totals: ProformaItemsTotals; itemsTotals: ProformaItemsTotals;
totals: ProformaTotals;
addItemAtStart: () => void; addItemAtStart: () => void;
appendItem: () => void; appendItem: () => void;
@ -39,6 +42,7 @@ interface ProformaLineEditorProps {
currency?: string; currency?: string;
className?: string; className?: string;
} }
2;
export const ProformaLineEditor = ({ export const ProformaLineEditor = ({
fields, fields,
@ -48,6 +52,7 @@ export const ProformaLineEditor = ({
getItemAmounts, getItemAmounts,
getItemErrorMessage, getItemErrorMessage,
itemsTotals,
totals, totals,
addItemAtStart, addItemAtStart,
@ -94,7 +99,7 @@ export const ProformaLineEditor = ({
{ {
id: "unitAmount", id: "unitAmount",
header: t("form_fields.items.unit_amount.label", "Importe unitario"), header: t("form_fields.items.unit_amount.label", "Importe unitario"),
headClassName: "text-right", headClassName: "w-[200px] text-right",
cell: ({ index }) => ( cell: ({ index }) => (
<AmountField <AmountField
inputClassName="border-none" inputClassName="border-none"
@ -140,7 +145,7 @@ export const ProformaLineEditor = ({
{ {
id: "total", id: "total",
header: t("form_fields.items.total.label", "Total"), header: t("form_fields.items.total.label", "Total"),
headClassName: "w-[120px] text-right", headClassName: "w-[200px] text-right",
className: "text-right font-semibold tabular-nums pt-4", className: "text-right font-semibold tabular-nums pt-4",
cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency), cell: ({ index }) => MoneyHelper.formatCurrency(getItemAmounts(index).total, 2, currency),
}, },
@ -174,6 +179,79 @@ export const ProformaLineEditor = ({
onRemove={removeItem} onRemove={removeItem}
removeLabel={t("common.remove", "Eliminar")} removeLabel={t("common.remove", "Eliminar")}
renderFooter={() => ( renderFooter={() => (
<ProformaLineFooterEditor currency={currency} itemsTotals={itemsTotals} totals={totals} />
)}
title={t("form_fields.items.title", "Líneas de detalle")}
/>
);
};
type ProformaLineFooterEditorProps = Pick<
ProformaLineEditorProps,
"totals" | "itemsTotals" | "currency"
>;
export const ProformaLineFooterEditor = ({
totals,
itemsTotals,
currency,
}: ProformaLineFooterEditorProps) => {
const { t } = useTranslation();
return (
<div className="flex items-end justify-between mt-4 pt-4 border-t border-gray-200">
{/* Global discount */}
<div className="flex items-center gap-3">
<FormFieldLabel>Descuento global (%):</FormFieldLabel>
<div className="flex items-center gap-2 w-24">
<PercentageField
maxFractionDigits={2}
minFractionDigits={0}
name={"globalDiscountPercentage"}
/>
</div>
<div className="px-6 py-3">
<div className="flex items-baseline gap-4">
{t("form_fields.items.total.label", "Total dto. global")}:
<span className=" font-medium text-amber-600 tabular-nums">
{MoneyHelper.formatCurrency(totals.globalDiscountAmount, 2, currency)}
</span>
</div>
</div>
{/*totals.descuentoGlobal > 0 && (
<span className="text-sm text-amber-600 font-medium">
-{MoneyHelper.formatCurrency(descuentoGlobal)}
</span>
)*/}
</div>
{/* Totals summary */}
<div className="flex items-center gap-6">
<div className="px-6 py-3">
<div className="flex items-baseline gap-4">
{t("form_fields.items.total.label", "Total dto. líneas")}:
<span className=" font-medium text-amber-600 tabular-nums">
{MoneyHelper.formatCurrency(itemsTotals.discountAmount, 2, currency)}
</span>
</div>
</div>
<div className="rounded-md border px-6 py-3">
<div className="flex items-baseline gap-4">
{t("form_fields.items.total.label", "Total")}:
<span className=" font-bold tabular-nums">
{MoneyHelper.formatCurrency(itemsTotals.total, 2, currency)}
</span>
</div>
</div>
</div>
</div>
);
};
/*
(
<div className="flex justify-end"> <div className="flex justify-end">
<div className="rounded-md border bg-muted/50 px-6 py-3"> <div className="rounded-md border bg-muted/50 px-6 py-3">
<div className="flex items-baseline gap-4"> <div className="flex items-baseline gap-4">
@ -186,8 +264,5 @@ export const ProformaLineEditor = ({
</div> </div>
</div> </div>
</div> </div>
)} )
title={t("form_fields.items.title", "Líneas de detalle")} */
/>
);
};

View File

@ -18,7 +18,6 @@ import type {
UseUpdateProformaTaxControllerResult, UseUpdateProformaTaxControllerResult,
UseUpdateProformaTotalsControllerResult, UseUpdateProformaTotalsControllerResult,
} from "../../controllers"; } from "../../controllers";
import { EditorSidebar } from "../blocks";
import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary"; import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor"; import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
@ -72,35 +71,6 @@ export const ProformaUpdateEditorForm = ({
onKeyDown={preventEnterKeySubmitForm} onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{/* Contenido principal */}
<div className="flex flex-1 overflow-hidden hidden">
{/* Área principal */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto space-y-6 p-4 pb-24 lg:p-6">
{/* Formulario de datos básicos */}
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
{/* Tabla de líneas */}
<ProformaUpdateItemsEditor
disabled={isSubmitting}
itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl}
/>
</div>
</main>
{/* Sidebar - desktop */}
<aside className="w-96 shrink-0 border-none bg-transparent lg:block">
<EditorSidebar
className="2xl:col-span-1"
disabled={isSubmitting}
onChangeCustomerClick={onChangeCustomerClick}
onCreateCustomerClick={onCreateCustomerClick}
selectedCustomer={selectedCustomer}
/>
</aside>
</div>
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-4 2xl:gap-6"> <div className="grid grid-cols-1 2xl:grid-cols-4 gap-4 2xl:gap-6">
<div className="2xl:col-span-3 space-y-4 gap-4 2xl:gap-6 2xl:space-y-6"> <div className="2xl:col-span-3 space-y-4 gap-4 2xl:gap-6 2xl:space-y-6">
<div className="grid 2xl:grid-cols-3 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6"> <div className="grid 2xl:grid-cols-3 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6">
@ -111,6 +81,7 @@ export const ProformaUpdateEditorForm = ({
disabled={isSubmitting} disabled={isSubmitting}
itemsCtrl={itemsCtrl} itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl} taxCtrl={taxCtrl}
totalsCtrl={totalsCtrl}
/> />
</div> </div>
<div className="2xl:col-span-1 space-y-4 2xl:space-y-6"> <div className="2xl:col-span-1 space-y-4 2xl:space-y-6">

View File

@ -3,22 +3,28 @@ 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 {
import type { UseUpdateProformaItemsControllerResult } from "../../controllers/use-update-proforma-items-controller"; UseUpdateProformaItemsControllerResult,
UseUpdateProformaTaxControllerResult,
UseUpdateProformaTotalsControllerResult,
} from "../../controllers";
import { ProformaLineEditor } from "../blocks"; import { ProformaLineEditor } from "../blocks";
interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> { interface ProformaUpdateItemsEditorProps extends ComponentProps<"fieldset"> {
totalsCtrl: UseUpdateProformaTotalsControllerResult;
itemsCtrl: UseUpdateProformaItemsControllerResult; itemsCtrl: UseUpdateProformaItemsControllerResult;
taxCtrl: UseUpdateProformaTaxControllerResult; taxCtrl: UseUpdateProformaTaxControllerResult;
} }
export const ProformaUpdateItemsEditor = ({ export const ProformaUpdateItemsEditor = ({
totalsCtrl,
itemsCtrl, itemsCtrl,
taxCtrl, taxCtrl,
disabled, disabled,
...props ...props
}: ProformaUpdateItemsEditorProps) => { }: ProformaUpdateItemsEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { totals } = totalsCtrl;
return ( return (
<FormSectionCard <FormSectionCard
@ -35,11 +41,12 @@ export const ProformaUpdateItemsEditor = ({
getItemErrorMessage={itemsCtrl.getItemErrorMessage} getItemErrorMessage={itemsCtrl.getItemErrorMessage}
insertItemAfter={itemsCtrl.insertItemAfter} insertItemAfter={itemsCtrl.insertItemAfter}
insertItemBefore={itemsCtrl.insertItemBefore} insertItemBefore={itemsCtrl.insertItemBefore}
itemsTotals={itemsCtrl.totals}
moveItemDown={itemsCtrl.moveItemDown} moveItemDown={itemsCtrl.moveItemDown}
moveItemUp={itemsCtrl.moveItemUp} moveItemUp={itemsCtrl.moveItemUp}
removeItem={itemsCtrl.removeItem} removeItem={itemsCtrl.removeItem}
showLineTaxes={taxCtrl.usesPerLineTax} showLineTaxes={taxCtrl.usesPerLineTax}
totals={itemsCtrl.totals} totals={totals}
/> />
</FormSectionCard> </FormSectionCard>
); );

View File

@ -5,6 +5,7 @@ import {
SwitchField, SwitchField,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { PercentageHelper } from "@repo/rdx-utils"; import { PercentageHelper } from "@repo/rdx-utils";
import { Separator } from "@repo/shadcn-ui/components";
import { ReceiptTextIcon } from "lucide-react"; import { ReceiptTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -42,7 +43,7 @@ export const ProformaUpdateTaxEditor = ({
)} )}
disabled={disabled} disabled={disabled}
icon={<ReceiptTextIcon className="size-5" />} icon={<ReceiptTextIcon className="size-5" />}
title={t("form_groups.proformas.taxes.title", "Impuestos")} title={t("form_groups.proformas.taxes.title", "Impuestos y retenciones")}
> >
<FormSectionGrid> <FormSectionGrid>
<SelectField <SelectField
@ -134,6 +135,8 @@ export const ProformaUpdateTaxEditor = ({
serialize={(value) => (typeof value === "number" ? String(value) : "")} serialize={(value) => (typeof value === "number" ? String(value) : "")}
/> />
<Separator className="w-full col-start-1 col-span-full" />
<SwitchField <SwitchField
checked={taxCtrl.usesSingleTax} checked={taxCtrl.usesSingleTax}
className="md:col-span-12 md:col-start-1 not-disabled:cursor-pointer" className="md:col-span-12 md:col-start-1 not-disabled:cursor-pointer"