Clientes y Facturas de cliente

This commit is contained in:
David Arranz 2025-10-21 20:05:12 +02:00
parent ab12f5815e
commit 50a19381ce
20 changed files with 205 additions and 108 deletions

View File

@ -1,5 +1,6 @@
@import "@repo/shadcn-ui/globals.css";
@import "@repo/rdx-ui/globals.css";
@import "@erp/core/globals.css";
@import "@erp/customers/globals.css";
@import "@erp/customer-invoices/globals.css";

View File

@ -5,6 +5,7 @@
".": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css",
"./components": "./src/web/components/index.ts",
"./hooks": "./src/web/hooks/index.ts"
},
@ -13,6 +14,7 @@
"react": "^19.1.0"
},
"devDependencies": {
"@hookform/devtools": "^4.4.0",
"@types/axios": "^0.14.4",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",

View File

@ -1,3 +1,4 @@
import { DevTool } from '@hookform/devtools';
import { useState } from "react";
import { useFormContext } from "react-hook-form";
@ -43,12 +44,15 @@ function DebugField({ label, oldValue, newValue }: { label?: string; oldValue: a
}
export const FormDebug = () => {
const { watch, formState } = useFormContext();
const { isDirty, dirtyFields, defaultValues } = formState;
const currentValues = watch();
const { control } = useFormContext();
//const { watch, formState } = useFormContext();
//const { isDirty, dirtyFields, defaultValues } = formState;
//const currentValues = watch();
return (
<div className="p-4 border rounded bg-red-50 mb-6">
return <DevTool control={control} placement="top-right" />
/*return (
<div className="absolute right-4 bottom-4 z-50 p-4 border rounded bg-red-50">
<p>
<strong>¿Formulario modificado?</strong> {isDirty ? "Sí" : "No"}
</p>
@ -70,5 +74,5 @@ export const FormDebug = () => {
)}
</div>
</div>
);
);*/
};

View File

@ -15,30 +15,35 @@ interface PageHeaderProps {
className?: string;
}
export function PageHeader({ backIcon, title, description, rightSlot, className }: PageHeaderProps) {
return (
<div className={cn("pt-4 pb-6 bg-background flex items-center justify-between", className)}>
<div className={cn("pt-6 pb-6 lg:flex lg:items-center lg:justify-between", className)}>
{/* Lado izquierdo */}
<div className='flex items-center gap-4'>
{backIcon && (
<Button
variant='ghost'
size='icon'
className='cursor-pointer'
onClick={() => window.history.back()}
>
<ChevronLeftIcon className='size-5' />
</Button>
)}
<div className='min-w-0 flex-1'>
<div className='flex items-start gap-4'>
{backIcon && (
<Button
variant='ghost'
size='icon'
className='cursor-pointer'
onClick={() => window.history.back()}
>
<ChevronLeftIcon className='size-5' />
</Button>
)}
<div>
<h2 className='text-2xl font-semibold text-foreground'>{title}</h2>
{description && <p className='text-base text-muted-foreground'>{description}</p>}
<div>
<h2 className='h-8 text-xl font-semibold text-foreground sm:truncate sm:tracking-tight'>{title}</h2>
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
</div>
</div>
</div>
{/* Lado derecho parametrizable */}
{rightSlot && <>{rightSlot}</>}
<div className="mt-4 flex lg:mt-0 lg:ml-4">
{rightSlot}
</div>
</div>
);
}

View File

@ -0,0 +1 @@
@source "./components";

View File

@ -31,10 +31,11 @@ export const InvoiceUpdateComp = ({
}: InvoiceUpdateCompProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { invoice_id } = useInvoiceContext(); // ahora disponible desde el inicio
const context = useInvoiceContext();
const formId = useId();
const context = useInvoiceContext();
const { invoice_id } = context;
const isPending = !invoiceData;
const {
@ -54,7 +55,7 @@ export const InvoiceUpdateComp = ({
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
initialValues,
disabled: !invoiceData || isUpdating
disabled: !invoiceData || isUpdating,
});
const handleSubmit = (formData: InvoiceFormData) => {
@ -89,7 +90,11 @@ export const InvoiceUpdateComp = ({
backIcon
title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`}
description={t("pages.edit.description")}
rightSlot={
rightSlot={<>
<button type="submit" form={formId} onClick={(e) => {
e.preventDefault(); const submit = form.handleSubmit(handleSubmit, handleError);
void submit(e)
}}>Enviar</button>
<UpdateCommitButtonGroup
isLoading={isPending}
@ -97,7 +102,7 @@ export const InvoiceUpdateComp = ({
cancel={{ formId, to: "/customer-invoices/list" }}
onBack={() => navigate(-1)}
/>
}
</>}
/>
</AppHeader>

View File

@ -21,21 +21,27 @@ export const InvoiceUpdateForm = ({
const form = useFormContext<InvoiceFormData>();
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={cn("p-6 space-y-6", className)}>
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
<form noValidate id={formId} 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">
<InvoiceRecipient className="flex flex-col" />
<InvoiceBasicInfoFields className="flex flex-col lg:col-span-2" />
</div>
<div className='w-full gap-6 px-6'>
<div className='w-full'>
<InvoiceItems />
</div>
<div className="w-full p-6 grid grid-cols-1 lg:grid-cols-2">
<div className="w-full grid grid-cols-1 lg:grid-cols-2">
<InvoiceTotals className='lg:col-start-2' />
</div>
<div className="w-full p-6">
<FormDebug />
<div className="w-full">
</div>
</section>
</form>

View File

@ -10,7 +10,7 @@ import {
DialogTitle,
} from "@repo/shadcn-ui/components";
import { Plus } from "lucide-react";
import { useId } from 'react';
import { useCallback, useId } from 'react';
import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from '../../pages/create/use-customer-create-controller';
import { CustomerFormData } from "../../schemas";
@ -32,6 +32,8 @@ export function CustomerCreateModal({
const { t } = useTranslation();
const formId = useId();
const { requestConfirm } = useUnsavedChangesContext();
const {
form, isCreating, isCreateError, createError,
handleSubmit, handleError, FormProvider
@ -39,31 +41,40 @@ export function CustomerCreateModal({
const { isDirty } = form.formState;
const guardClose = async (nextOpen: boolean) => {
const guardClose = useCallback(async (nextOpen: boolean) => {
if (nextOpen) return onOpenChange(true);
if (isCreating) return;
const { requestConfirm } = useUnsavedChangesContext();
const ok = await requestConfirm();
if (ok) onOpenChange(false);
};
if (!isDirty) {
return onOpenChange(false);
}
if (await requestConfirm()) {
return onOpenChange(false);
}
}, [requestConfirm, isCreating, onOpenChange, isDirty]);
const handleFormSubmit = (data: CustomerFormData) => handleSubmit(data /*, () => onOpenChange(false)*/);
return (
<UnsavedChangesProvider isDirty={isDirty}>
<FormProvider {...form}>
<Dialog open={open} onOpenChange={guardClose}>
<DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:max-w-[min(100vw-3rem,1280px)] h-[calc(100dvh-2rem)]">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" /> {t("pages.create.title")}
</DialogTitle>
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription>
</DialogHeader>
<div className="px-6 py-4 overflow-y-auto h:[calc(100%-8rem)]">
<Dialog open={open} onOpenChange={guardClose}>
<DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:max-w-[min(100vw-3rem,1280px)] h-[calc(100dvh-2rem)]">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" /> {t("pages.create.title")}
</DialogTitle>
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription>
</DialogHeader>
<div className="px-6 py-4 overflow-y-auto h:[calc(100%-8rem)]">
<FormProvider {...form}>
<CustomerEditForm
formId={formId}
onSubmit={(data: CustomerFormData) => handleSubmit(data, () => onOpenChange(false))}
onSubmit={handleFormSubmit}
onError={handleError}
className="max-w-none"
/>
@ -73,19 +84,20 @@ export function CustomerCreateModal({
{(createError as Error)?.message}
</p>
)}
</div>
</FormProvider>
</div>
<DialogFooter className="px-6 py-4 border-t bg-card">
<Button type="button" form={formId} variant="outline" className='cursor-pointer' onClick={() => guardClose(false)} disabled={isCreating}>
{t('common.cancel', "Cancelar")}
</Button>
<Button type="submit" form={formId} disabled={isCreating} className='cursor-pointer'>
{isCreating ? <span aria-live="polite">{t('common.saving', "Guardando")}</span> : <span>{t('common.save', "Guardar")}</span>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter className="px-6 py-4 border-t bg-card">
<Button type="button" form={formId} variant="outline" className='cursor-pointer' onClick={() => guardClose(false)} disabled={isCreating}>
{t('common.cancel', "Cancelar")}
</Button>
<Button type="submit" form={formId} disabled={isCreating} className='cursor-pointer'>
{isCreating ? <span aria-live="polite">{t('common.saving', "Guardando")}</span> : <span>{t('common.save', "Guardar")}</span>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</FormProvider>
</UnsavedChangesProvider>
);
}

View File

@ -1,43 +1,73 @@
import { FormField, FormItem } from "@repo/shadcn-ui/components";
import { Field, FieldLabel } from "@repo/shadcn-ui/components";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
import { CustomerSummary } from '../../schemas';
import { CustomerModalSelector } from "./customer-modal-selector";
type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive",
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
initiaCustomer?: unknown;
};
export function CustomerModalSelectorField<TFormValues extends FieldValues>({
control,
name,
disabled = false, // Solo lectura y sin botones
readOnly = false, // Solo se puede ver la ficha del cliente
label,
description,
orientation = 'vertical',
disabled = false,
required = false,
readOnly = false,
className,
initiaCustomer = {},
}: CustomerModalSelectorFieldProps<TFormValues>) {
const isDisabled = disabled;
const isReadOnly = readOnly && !disabled;
return (
<FormField
<Controller
control={control}
name={name}
render={({ field }) => {
render={({ field, fieldState }) => {
const { name, value, onChange, onBlur, ref } = field;
return (
<FormItem className={className}>
<Field
data-invalid={fieldState.invalid}
orientation={orientation}
className={cn("gap-1", className)}
>
{label && (
<FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>
{label}
</FieldLabel>
)}
<CustomerModalSelector
value={value as string | undefined}
value={value}
onValueChange={onChange}
disabled={isDisabled}
readOnly={isReadOnly}
initialCustomer={initiaCustomer as CustomerSummary}
/>
</FormItem>
</Field>
);
}}
/>
);
}
}

View File

@ -6,12 +6,19 @@ import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
export const CustomerAdditionalConfigFields = () => {
interface CustomerAdditionalConfigFieldsProps {
className?: string;
}
export const CustomerAdditionalConfigFields = ({
className, ...props
}: CustomerAdditionalConfigFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
return (
<FieldSet>
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.preferences.title")}</FieldLegend>
<FieldDescription>{t("form_groups.preferences.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>

View File

@ -8,12 +8,16 @@ import { COUNTRY_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
export const CustomerAddressFields = () => {
interface CustomerAddressFieldsProps {
className?: string;
}
export const CustomerAddressFields = ({ className, ...props }: CustomerAddressFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
return (
<FieldSet>
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.address.title")}</FieldLegend>
<FieldDescription>{t("form_groups.address.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>

View File

@ -19,9 +19,10 @@ import { CustomerFormData } from "../../schemas";
interface CustomerBasicInfoFieldsProps {
focusRef?: React.RefObject<HTMLInputElement>;
className?: string;
}
export const CustomerBasicInfoFields = ({ focusRef }: CustomerBasicInfoFieldsProps) => {
export const CustomerBasicInfoFields = ({ focusRef, className, ...props }: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
@ -32,7 +33,7 @@ export const CustomerBasicInfoFields = ({ focusRef }: CustomerBasicInfoFieldsPro
return (
<FieldSet>
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>

View File

@ -12,13 +12,17 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
export const CustomerContactFields = () => {
interface CustomerContactFieldsProps {
className?: string;
}
export const CustomerContactFields = ({ className, ...props }: CustomerContactFieldsProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const { control } = useFormContext();
return (
<FieldSet>
<FieldSet className={className} {...props}>
<FieldLegend>{t("form_groups.contact_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.contact_info.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>

View File

@ -20,19 +20,16 @@ export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRe
const form = useFormContext<CustomerFormData>();
return (
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={cn("p-6", className)}>
<div className='xl:flex xl:flex-row-reverse xl:items-start'>
<div className='w-full xl:w-6/12'>
<FormDebug />
</div>
<div className='w-full xl:grow space-y-6'>
<CustomerBasicInfoFields focusRef={focusRef} />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerAdditionalConfigFields />
</div>
</div>
<form noValidate id={formId} onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
event.stopPropagation();
form.handleSubmit(onSubmit, onError)(event)
}}>
<FormDebug />
<section className={cn("space-y-6 p-6 xl:grid-cols-2 xl:grid xl:gap-6", className)}>
<CustomerBasicInfoFields focusRef={focusRef} />
<CustomerAddressFields />
<CustomerContactFields />
<CustomerAdditionalConfigFields />
</section>
</form>
);

View File

@ -29,7 +29,7 @@ export function useCreateCustomer() {
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
mutationKey: CUSTOMER_CREATE_KEY,
mutationFn: async (data) => {
mutationFn: async ({ data }, context) => {
const id = UniqueID.generateNewID().toString();
const payload = { ...data, id };

View File

@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { PageHeader } from '@erp/core/components';
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { useId } from 'react';
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from './use-customer-create-controller';
@ -10,6 +11,7 @@ import { useCustomerCreateController } from './use-customer-create-controller';
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const formId = useId();
const {
form, isCreating, isCreateError, createError,
@ -37,7 +39,7 @@ export const CustomerCreatePage = () => {
disabled: isCreating,
}}
submit={{
formId: "customer-create-form",
formId: formId,
disabled: isCreating,
}}
onBack={() => handleBack()}
@ -59,14 +61,14 @@ export const CustomerCreatePage = () => {
<FormProvider {...form}>
<CustomerEditForm
formId='customer-create-form'
formId={formId}
onSubmit={(data) =>
handleSubmit(data, ({ id }) =>
navigate("/customers/list", { state: { customerId: id, isNew: true }, replace: true })
)
}
onError={handleError}
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
/>
</FormProvider>

View File

@ -1,12 +1,19 @@
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { useId } from "react";
import { FieldErrors, FormProvider } from "react-hook-form";
import { useCreateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
export const useCustomerCreateController = () => {
export interface UseCustomerCreateControllerOptions {
onCreated?(created: CustomerFormData): void; // navegación, cierre modal, etc.
successToasts?: boolean; // permite desactivar toasts si el host los gestiona
}
export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => {
const { t } = useTranslation();
const formId = useId(); // id único por instancia
// 1) Estado de creación (mutación)
const {
@ -23,19 +30,24 @@ export const useCustomerCreateController = () => {
disabled: isCreating,
});
const handleSubmit = (formData: CustomerFormData, onSuccess?: (data: { id: string }) => void) => {
const handleSubmit = (formData: CustomerFormData) => {
console.log(formData);
mutate(
{ data: formData },
{
data: formData,
},
{
onSuccess(data) {
form.reset(defaultCustomerFormData);
showSuccessToast(
t("pages.create.successTitle", "Cliente creado"),
t("pages.create.successMsg", "Se ha creado correctamente.")
);
onSuccess?.({ id: data.id });
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.create.successTitle", "Cliente creado"),
t("pages.create.successMsg", "Se ha creado correctamente.")
);
}
options?.onCreated?.(data);
},
onError(err: unknown) {
console.log("No se pudo crear el cliente.");
const msg =
@ -63,6 +75,7 @@ export const useCustomerCreateController = () => {
return {
form,
formId,
isCreating,
isCreateError,
createError,

View File

@ -9,7 +9,7 @@ export const AppContent = ({
return (
<div
className={cn(
"app-content flex flex-1 flex-col gap-4 p-6 pt-8 bg-primary/5 min-h-screen",
"app-content flex flex-1 flex-col gap-4 p-4 pt-6 bg-primary/5 min-h-screen",
className
)}
{...props}

View File

@ -7,7 +7,7 @@ export const AppHeader = ({
...props
}: PropsWithChildren<{ className?: string }>) => {
return (
<div className={cn("app-header gap-4 px-6 pt-0 border-b bg-background", className)} {...props}>
<div className={cn("app-header gap-4 px-4 pt-0 border-b bg-background", className)} {...props}>
{children}
</div>
);

View File

@ -429,6 +429,9 @@ importers:
specifier: ^4.1.11
version: 4.1.12
devDependencies:
'@hookform/devtools':
specifier: ^4.4.0
version: 4.4.0(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@types/axios':
specifier: ^0.14.4
version: 0.14.4