Facturas de cliente y clientes

This commit is contained in:
David Arranz 2025-08-24 12:14:20 +02:00
parent c260f64007
commit 5b7ee437ff
20 changed files with 330 additions and 64 deletions

View File

@ -38,6 +38,7 @@
"i18next-browser-languagedetector": "^8.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.4",
"react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0",

View File

@ -3,16 +3,19 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { I18nextProvider } from "react-i18next";
import { UnsavedWarnProvider } from "@/lib/hooks";
import { i18n } from "@/locales";
import { AuthProvider, createAuthService } from "@erp/auth/client";
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider } from "@erp/core/hooks";
import { DataSourceProvider, UnsavedWarnProvider } from "@erp/core/hooks";
import DineroFactory from "dinero.js";
import "./app.css";
import { RouterProvider } from "react-router-dom";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
import { AppRoutes } from "./routes";
import { getAppRouter } from "./routes";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import { Suspense } from "react";
import "./app.css";
export const App = () => {
DineroFactory.globalLocale = "es-ES";
@ -37,6 +40,7 @@ export const App = () => {
});
const dataSource = createAxiosDataSource(axiosInstance);
const appRouter = getAppRouter();
return (
<I18nextProvider i18n={i18n}>
@ -52,7 +56,10 @@ export const App = () => {
>
<TooltipProvider delayDuration={0}>
<UnsavedWarnProvider>
<AppRoutes />
{/* Fallback Route */}
<Suspense fallback={<LoadingOverlay />}>
<RouterProvider router={appRouter} />
</Suspense>
</UnsavedWarnProvider>
</TooltipProvider>
<Toaster />

View File

@ -0,0 +1,214 @@
import { Button } from "@repo/shadcn-ui/components";
import * as React from "react";
import { FallbackProps } from "react-error-boundary";
/**
* 1) Fallback simple
*/
export function SimpleFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role='alert' className='p-4 rounded-md border bg-red-50 text-red-700'>
<p className='font-medium'> Algo salió mal</p>
<pre className='mt-2 text-sm whitespace-pre-wrap'>{error?.message}</pre>
<Button
onClick={resetErrorBoundary}
className='mt-3 px-3 py-1.5 rounded-md bg-red-600 text-white'
>
Reintentar
</Button>
</div>
);
}
/**
* 2) Fallback de sección (tarjeta compacta)
*/
export function SectionCardFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role='alert' className='rounded-2xl border shadow-sm p-5 bg-white'>
<div className='flex items-start gap-3'>
<div className='shrink-0 rounded-full p-2 bg-red-100'></div>
<div className='grow'>
<h3 className='font-semibold text-gray-900'>No se pudo cargar esta sección</h3>
<p className='mt-1 text-sm text-gray-600'>{error?.message}</p>
<div className='mt-3 flex gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-gray-900 text-white'
>
Reintentar
</Button>
<Button
onClick={() => window.location.reload()}
className='px-3 py-1.5 rounded-md border'
>
Refrescar página
</Button>
</div>
</div>
</div>
</div>
);
}
/**
* 3) Fallback pantalla completa
*/
export function FullPageFallback({ error }: FallbackProps) {
return (
<div className='min-h-screen flex flex-col items-center justify-center px-6 bg-gray-50 text-center'>
<div className='text-5xl'>😵</div>
<h1 className='mt-4 text-2xl font-bold text-gray-900'>Ocurrió un error inesperado</h1>
<p className='mt-2 text-gray-600'>{error?.message}</p>
<div className='mt-6 flex flex-wrap items-center justify-center gap-3'>
<a href='/' className='px-4 py-2 rounded-md bg-blue-600 text-white'>
Volver al inicio
</a>
<Button onClick={() => window.location.reload()} className='px-4 py-2 rounded-md border'>
Recargar
</Button>
</div>
</div>
);
}
/**
* 4) Fallback para listas
*/
export function ListFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role='alert' className='p-4 rounded-md border bg-amber-50'>
<div className='flex items-start gap-3'>
<span className='text-xl'>🗂</span>
<div>
<p className='font-medium text-amber-900'>No pudimos cargar la lista.</p>
<p className='text-sm text-amber-800 mt-1'>{error?.message}</p>
<div className='mt-3 flex gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-amber-700 text-white'
>
Reintentar
</Button>
<Button
onClick={() => window.location.reload()}
className='px-3 py-1.5 rounded-md border'
>
Recargar
</Button>
</div>
</div>
</div>
</div>
);
}
/**
* 5) Fallback para formularios
*/
export function FormFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role='alert' className='rounded-md border p-4 bg-red-50'>
<h4 className='font-semibold text-red-800'>No se pudo mostrar el formulario</h4>
<p className='mt-1 text-sm text-red-700'>{error?.message}</p>
<div className='mt-3 flex gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-red-600 text-white'
>
Reintentar
</Button>
<Button onClick={() => history.back()} className='px-3 py-1.5 rounded-md border'>
Volver atrás
</Button>
</div>
</div>
);
}
/**
* 6) Fallback para problemas de red
*/
export function NetworkFallback({ error, resetErrorBoundary }: FallbackProps) {
const note = navigator.onLine
? "La conexión parece estar activa."
: "Estás sin conexión. Revisa tu red y reintenta.";
return (
<div role='alert' className='rounded-md border p-4 bg-blue-50 text-blue-900'>
<div className='font-medium'>No pudimos obtener los datos</div>
<p className='mt-1 text-sm'>{error?.message}</p>
<p className='mt-1 text-xs opacity-80'>{note}</p>
<div className='mt-3 flex gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-blue-700 text-white'
>
Reintentar
</Button>
<Button onClick={() => window.location.reload()} className='px-3 py-1.5 rounded-md border'>
Recargar
</Button>
</div>
</div>
);
}
/**
* 7) Fallback modo desarrollador (detalle de stack)
*/
export function DevDetailsFallback({ error, resetErrorBoundary }: FallbackProps) {
const [open, setOpen] = React.useState(false);
return (
<div role='alert' className='rounded-md border p-4 bg-gray-50'>
<div className='flex items-center justify-between'>
<p className='font-medium text-gray-900'>Algo falló</p>
<div className='flex gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-gray-900 text-white'
>
Reintentar
</Button>
<Button onClick={() => setOpen((v) => !v)} className='px-3 py-1.5 rounded-md border'>
{open ? "Ocultar detalles" : "Ver detalles"}
</Button>
</div>
</div>
{open && (
<pre className='mt-3 text-xs whitespace-pre-wrap bg-white p-3 rounded-md border overflow-auto'>
{error?.stack || error?.message}
</pre>
)}
</div>
);
}
/**
* 8) Fallback con soporte (link a ayuda)
*/
export function SupportFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role='alert' className='rounded-md border p-4 bg-white'>
<h4 className='font-semibold text-gray-900'>No pudimos completar la acción</h4>
<p className='mt-1 text-gray-700'>{error?.message}</p>
<div className='mt-3 flex flex-wrap gap-2'>
<Button
onClick={resetErrorBoundary}
className='px-3 py-1.5 rounded-md bg-gray-900 text-white'
>
Intentar de nuevo
</Button>
<a href='/ayuda' className='px-3 py-1.5 rounded-md border'>
Ir a Ayuda
</a>
<a
href='mailto:soporte@tuapp.com?subject=Error%20en%20la%20aplicaci%C3%B3n'
className='px-3 py-1.5 rounded-md border'
>
Contactar soporte
</a>
</div>
</div>
);
}

View File

@ -1 +1,2 @@
export * from "./error-fallbacks";
export * from "./slider-demo";

View File

@ -1,2 +1 @@
export * from "./use-theme";
export * from "./use-unsaved-changes-notifier";

View File

@ -1 +0,0 @@
export * from "./warn-about-change-provider";

View File

@ -1,8 +1,7 @@
import { ModuleRoutes } from "@/components/module-routes";
import { IModuleClient } from "@erp/core/client";
import { AppLayout, LoadingOverlay, ScrollToTop } from "@repo/rdx-ui/components";
import { JSX, Suspense } from "react";
import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom";
import { AppLayout } from "@repo/rdx-ui/components";
import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import { ErrorPage } from "../pages";
import { modules } from "../register-modules"; // Aquí ca
@ -25,41 +24,36 @@ function groupModulesByLayout(modules: IModuleClient[]) {
return groups;
}
export const AppRoutes = (): JSX.Element => {
export const getAppRouter = () => {
const params = {
...import.meta.env,
};
const grouped = groupModulesByLayout(modules);
console.log(grouped);
console.debug(grouped);
return (
<Router>
<ScrollToTop />
return createBrowserRouter(
createRoutesFromElements(
<Route path='/'>
{/* Auth Layout */}
<Route path='/auth'>
<Route index element={<Navigate to='login' />} />
<Route path='*' element={<ModuleRoutes modules={grouped.auth} params={params} />} />
</Route>
{/* Fallback Route */}
<Suspense fallback={<LoadingOverlay />}>
<Routes>
{/* Auth Layout */}
<Route path='/auth'>
<Route index element={<Navigate to='login' />} />
<Route path='*' element={<ModuleRoutes modules={grouped.auth} params={params} />} />
</Route>
{/* App Layout */}
<Route element={<AppLayout />}>
{/* Dynamic Module Routes */}
<Route path='*' element={<ModuleRoutes modules={grouped.app} params={params} />} />
{/* App Layout */}
<Route element={<AppLayout />}>
{/* Dynamic Module Routes */}
<Route path='*' element={<ModuleRoutes modules={grouped.app} params={params} />} />
{/* Main Layout */}
<Route path='/dashboard' element={<ErrorPage />} />
<Route path='/settings' element={<ErrorPage />} />
<Route path='/catalog' element={<ErrorPage />} />
<Route path='/quotes' element={<ErrorPage />} />
</Route>
</Routes>
</Suspense>
</Router>
{/* Main Layout */}
<Route path='/dashboard' element={<ErrorPage />} />
<Route path='/settings' element={<ErrorPage />} />
<Route path='/catalog' element={<ErrorPage />} />
<Route path='/quotes' element={<ErrorPage />} />
</Route>
</Route>
)
);
};

View File

@ -27,6 +27,6 @@
"noUncheckedSideEffectImports": true,
"allowUnreachableCode": true
},
"include": ["src"],
"include": ["src", "../../modules/core/src/web/hooks/use-unsaved-changes-notifier"],
"exclude": ["node_modules"]
}

View File

@ -1,5 +1,7 @@
{
"common": {},
"common": {
"required": "required"
},
"components": {
"taxes_multi_select": {
"label": "Taxes",
@ -7,5 +9,13 @@
"description": "Select the taxes to apply to the invoice items",
"invalid_tax_selection": "Invalid tax selection. Please select a valid tax."
}
},
"hooks": {
"use_unsaved_changes_notifier": {
"unsaved_changes": "You have unsaved changes",
"unsaved_changes_explanation": "If you leave, your changes will be lost.",
"discard_changes": "Discard changes",
"stay_on_page": "Stay on page"
}
}
}

View File

@ -1,5 +1,7 @@
{
"common": {},
"common": {
"required": "requerido"
},
"components": {
"taxes_multi_select": {
"label": "Impuestos",
@ -7,5 +9,13 @@
"description": "Seleccionar los impuestos a aplicar a los artículos de la factura",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido."
}
},
"hooks": {
"use_unsaved_changes_notifier": {
"unsaved_changes": "Tienes cambios no guardados",
"unsaved_changes_explanation": "Si sales, tus cambios se perderán.",
"discard_changes": "Descartar cambios",
"stay_on_page": "Permanecer en la página"
}
}
}

View File

@ -2,3 +2,4 @@ export * from "./use-datasource";
export * from "./use-pagination";
export * from "./use-query-key";
export * from "./use-toggle";
export * from "./use-unsaved-changes-notifier";

View File

@ -0,0 +1,2 @@
export * from "./use-unsaved-changes-notifier";
export * from "./warn-about-change-provider";

View File

@ -1,9 +1,10 @@
import { t } from "i18next";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useBlocker } from "react-router-dom";
import { useTranslation } from "../../i18n";
import { useWarnAboutChange } from "./use-warn-about-change";
export type UnsavedChangesNotifierProps = {
isDirty?: boolean;
title?: string;
subtitle?: string;
confirmText?: string;
@ -15,26 +16,37 @@ export type UnsavedChangesNotifierProps = {
export const useUnsavedChangesNotifier = ({
isDirty = false,
title = t("hooks.use_unsaved_changes_notifier.title"),
subtitle = t("hooks.use_unsaved_changes_notifier.subtitle"),
confirmText = t("hooks.use_unsaved_changes_notifier.confirm_text"),
cancelText = t("hooks.use_unsaved_changes_notifier.cancel_text"),
title,
subtitle,
confirmText,
cancelText,
onConfirm,
onCancel,
type = "warning",
}: UnsavedChangesNotifierProps & { isDirty?: boolean }) => {
}: UnsavedChangesNotifierProps = {}) => {
const { t } = useTranslation();
const blocker = useBlocker(isDirty);
const { show } = useWarnAboutChange();
const texts = useMemo(
() => ({
title: title ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes"),
subtitle: subtitle ?? t("hooks.use_unsaved_changes_notifier.unsaved_changes_explanation"),
confirmText: confirmText ?? t("hooks.use_unsaved_changes_notifier.discard_changes"),
cancelText: cancelText ?? t("hooks.use_unsaved_changes_notifier.stay_on_page"),
}),
[t, title, subtitle, confirmText, cancelText]
);
const confirm = useCallback(() => {
if (!isDirty) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
show({
title,
subtitle,
confirmText,
cancelText,
title: texts.title,
subtitle: texts.subtitle,
confirmText: texts.confirmText,
cancelText: texts.cancelText,
type,
onConfirm: () => {
resolve(true);
@ -46,7 +58,7 @@ export const useUnsavedChangesNotifier = ({
},
});
});
}, [cancelText, confirmText, isDirty, onCancel, onConfirm, show, subtitle, title, type]);
}, [isDirty, show, texts, type, onConfirm, onCancel]);
useEffect(() => {
if (blocker.state === "blocked") {
@ -59,13 +71,13 @@ export const useUnsavedChangesNotifier = ({
useEffect(() => {
if (isDirty) {
window.onbeforeunload = () => subtitle;
window.onbeforeunload = () => texts.subtitle;
}
return () => {
window.onbeforeunload = null;
};
}, [isDirty, subtitle]);
}, [isDirty, texts.subtitle]);
return {
confirm,

View File

@ -1,5 +1,5 @@
import { NullOr } from "@repo/rdx-utils";
import { createContext } from "react";
import type { NullOr } from "../../types";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
export interface IUnsavedWarnContextState {

View File

@ -1,6 +1,6 @@
import { CustomDialog } from "@repo/rdx-ui/components";
import { NullOr } from "@repo/rdx-utils";
import { type PropsWithChildren, useCallback, useMemo, useState } from "react";
import type { NullOr } from "../../types";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
import { UnsavedWarnContext } from "./warn-about-change-context";

View File

@ -40,12 +40,8 @@ export const createAxiosInstance = ({
getAccessToken,
onAuthError,
}: AxiosFactoryConfig): AxiosInstance => {
console.log({ baseURL, getAccessToken, onAuthError });
const instance = axios.create(defaultAxiosRequestConfig);
instance.defaults.baseURL = baseURL;
setupInterceptors(instance, getAccessToken, onAuthError);
return instance;

View File

@ -1,6 +1,6 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useBlocker, useNavigate } from "react-router-dom";
import { useCreateCustomerMutation } from "../../hooks/use-create-customer-mutation";
import { useTranslation } from "../../i18n";
@ -9,6 +9,7 @@ import { CustomerEditForm } from "./customer-edit-form";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { block, unblock } = useBlocker(1);
const { mutate, isPending, isError, error } = useCreateCustomerMutation();

View File

@ -14,6 +14,8 @@ import {
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { useTranslation } from "../../i18n";
import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
@ -45,6 +47,11 @@ export const CustomerEditForm = ({
const form = useForm<CustomerData>({
resolver: zodResolver(CustomerDataFormSchema),
defaultValues: initialData,
disabled: isPending,
});
useUnsavedChangesNotifier({
isDirty: form.formState.isDirty,
});
const handleSubmit = (data: CustomerData) => {

View File

@ -170,7 +170,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -228,6 +228,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
react-error-boundary:
specifier: ^6.0.0
version: 6.0.0(react@19.1.0)
react-hook-form:
specifier: ^7.56.4
version: 7.58.1(react@19.1.0)
@ -5335,6 +5338,11 @@ packages:
peerDependencies:
react: ^19.1.0
react-error-boundary@6.0.0:
resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==}
peerDependencies:
react: '>=16.13.1'
react-hook-form@7.58.1:
resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==}
engines: {node: '>=18.0.0'}
@ -11044,6 +11052,11 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
react-error-boundary@6.0.0(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.6
react: 19.1.0
react-hook-form@7.58.1(react@19.1.0):
dependencies:
react: 19.1.0
@ -11655,7 +11668,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -11673,7 +11686,6 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):