Clientes y facturas de cliente

This commit is contained in:
David Arranz 2025-09-22 19:43:55 +02:00
parent 0367c51a97
commit 633f7cf6bd
37 changed files with 634 additions and 906 deletions

View File

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

View File

@ -51,6 +51,7 @@ export const useUnsavedChangesNotifier = ({
onConfirm: () => {
resolve(true);
onConfirm?.();
window.onbeforeunload = null; // limpirar de cambios
},
onCancel: () => {
resolve(false);
@ -71,7 +72,12 @@ export const useUnsavedChangesNotifier = ({
useEffect(() => {
if (isDirty) {
window.onbeforeunload = () => texts.subtitle;
window.onbeforeunload = (e) => {
e.preventDefault();
return texts.subtitle;
};
} else {
window.onbeforeunload = null;
}
return () => {
@ -79,7 +85,5 @@ export const useUnsavedChangesNotifier = ({
};
}, [isDirty, texts.subtitle]);
return {
confirm,
};
return { confirm };
};

View File

@ -3,8 +3,8 @@ import { UnsavedWarnContext } from "./warn-about-change-context";
export const useWarnAboutChange = () => {
const context = useContext(UnsavedWarnContext);
if (context === null)
throw new Error("useWarnAboutChange must be used within a UnsavedWarnProvider");
if (context === null) {
throw new Error("useWarnAboutChange must be used within an UnsavedWarnProvider");
}
return context;
};

View File

@ -4,6 +4,7 @@ import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier
export interface IUnsavedWarnContextState {
show: (options: NullOr<UnsavedChangesNotifierProps>) => void;
hide: () => void;
}
export const UnsavedWarnContext = createContext<NullOr<IUnsavedWarnContextState>>(null);

View File

@ -4,38 +4,43 @@ import { type PropsWithChildren, useCallback, useMemo, useState } from "react";
import type { UnsavedChangesNotifierProps } from "./use-unsaved-changes-notifier";
import { UnsavedWarnContext } from "./warn-about-change-context";
export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => {
const [confirm, setConfirm] = useState<NullOr<UnsavedChangesNotifierProps>>(null);
// Aseguramos que las props mínimas para el diálogo siempre estén presentes
type ConfirmOptions = Required<
Pick<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">
> &
Omit<UnsavedChangesNotifierProps, "title" | "subtitle" | "confirmText" | "cancelText">;
const [open, toggle] = useState(false);
export const UnsavedWarnProvider = ({ children }: PropsWithChildren) => {
const [confirm, setConfirm] = useState<NullOr<ConfirmOptions>>(null);
const [open, setOpen] = useState(false);
const show = useCallback((confirmOptions: NullOr<UnsavedChangesNotifierProps>) => {
setConfirm(confirmOptions);
toggle(true);
if (confirmOptions) {
setConfirm(confirmOptions as ConfirmOptions);
setOpen(true);
}
}, []);
const onConfirm = () => {
const hide = useCallback(() => setOpen(false), []);
const onConfirm = useCallback(() => {
confirm?.onConfirm?.();
toggle(false);
};
hide();
}, [confirm, hide]);
const onCancel = () => {
const onCancel = useCallback(() => {
confirm?.onCancel?.();
toggle(false);
};
hide();
}, [confirm, hide]);
const value = useMemo(() => ({ show }), [show]);
const value = useMemo(() => ({ show, hide }), [show, hide]);
return (
<UnsavedWarnContext.Provider value={value}>
{children}
<CustomDialog
//type='warning'
onCancel={() => {
//console.log("onCancel");
onCancel();
}}
onConfirm={() => onConfirm()}
onCancel={onCancel}
onConfirm={onConfirm}
title={confirm?.title}
description={confirm?.subtitle}
confirmLabel={confirm?.confirmText}

View File

@ -32,6 +32,13 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
taxes: z.string(),
payment_method: z
.object({
payment_id: z.string(),
payment_description: z.string(),
})
.optional(),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,

View File

@ -100,7 +100,7 @@ export const CustomerInvoicesListGrid = () => {
size='icon'
className='size-8'
onClick={() => {
navigate(`${data.id}/edit`);
navigate(`/customer-invoices/${data.id}/edit`);
}}
>
<ChevronRightIcon />

View File

@ -1,38 +1,35 @@
import * as z from "zod/v4";
export const CreateCustomerRequestSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
reference: z.string().default(""),
reference: z.string().optional(),
is_company: z.string().toLowerCase().default("false"),
name: z.string().default(""),
trade_name: z.string().default(""),
tin: z.string().default(""),
is_company: z.string().toLowerCase().default("false").optional(),
name: z.string(),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.array(z.string()).default([]).optional(),
street: z.string().default(""),
street2: z.string().default(""),
city: z.string().default(""),
province: z.string().default(""),
postal_code: z.string().default(""),
country: z.string().default("es"),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().default("es").optional(),
email_primary: z.string().default(""),
email_secondary: z.string().default(""),
phone_primary: z.string().default(""),
phone_secondary: z.string().default(""),
mobile_primary: z.string().default(""),
mobile_secondary: z.string().default(""),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
fax: z.string().default(""),
website: z.string().default(""),
fax: z.string().optional(),
website: z.string().optional(),
legal_record: z.string().default(""),
legal_record: z.string().optional(),
default_taxes: z.array(z.string()).default([]),
status: z.string().toLowerCase().default("active"),
language_code: z.string().toLowerCase().default("es"),
currency_code: z.string().toUpperCase().default("EUR"),
language_code: z.string().toLowerCase().default("es").optional(),
currency_code: z.string().toUpperCase().default("EUR").optional(),
});
export type CreateCustomerRequestDTO = z.infer<typeof CreateCustomerRequestSchema>;

View File

@ -33,10 +33,10 @@
},
"form_fields": {
"customer_type": {
"label": "Customer type",
"label": "This contact is...",
"description": "Select the type of customer",
"company": "Company",
"individual": "Individual"
"company": "a company",
"individual": "a person"
},
"name": {
"label": "Name",

View File

@ -35,10 +35,10 @@
},
"form_fields": {
"customer_type": {
"label": "Tipo de cliente",
"label": "Este cliente es...",
"description": "Seleccione el tipo de cliente",
"company": "Empresa",
"individual": "Persona física"
"company": "una empresa",
"individual": "una persona"
},
"name": {
"label": "Nombre",

View File

@ -79,7 +79,7 @@ export const CustomersListGrid = () => {
size='icon'
className='size-8'
onClick={() => {
navigate(`${data.id}/edit`, { relative: "route" });
navigate(`/customers/${data.id}/edit`);
}}
>
<ChevronRightIcon />

View File

@ -6,15 +6,14 @@ import {
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { Control } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { CURRENCY_OPTIONS, LANGUAGE_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerAdditionalConfigFields = ({
control,
}: { control: Control<CustomerUpdateData> }) => {
export const CustomerAdditionalConfigFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
return (
<Card className='border-0 shadow-none'>

View File

@ -6,13 +6,14 @@ import {
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import { Control } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { COUNTRY_OPTIONS } from "../../constants";
import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
import { CustomerFormData } from "../../schemas";
export const CustomerAddressFields = ({ control }: { control: Control<CustomerUpdateData> }) => {
export const CustomerAddressFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
return (
<Card className='border-0 shadow-none'>

View File

@ -0,0 +1,131 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Card,
CardContent,
CardHeader,
CardTitle,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
export const CustomerBasicInfoFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
const isCompany = useWatch({
name: "is_company",
defaultValue: "true",
});
return (
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>Identificación</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<div className='lg:col-span-2'>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
</div>
<div className='lg:col-span-2'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "true" : "false"}
className='flex items-center gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem id='rgi-company' value='true' />
</FormControl>
<FormLabel className='cursor-pointer' htmlFor='rgi-company'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem id='rgi-individual' value='false' />
</FormControl>
<FormLabel className='cursor-pointer' htmlFor='rgi-individual'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{isCompany === "false" ? (
<div className='lg:col-span-full'>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
</div>
) : (
<></>
)}
<div className='lg:col-span-2 lg:col-start-1'>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</div>
<div className='lg:col-span-2'>
<TaxesMultiSelectField
control={control}
name='default_taxes'
required
label={t("form_fields.default_taxes.label")}
placeholder={t("form_fields.default_taxes.placeholder")}
description={t("form_fields.default_taxes.description")}
/>
</div>
<TextAreaField
className='lg:col-span-full'
control={control}
name='legal_record'
label={t("form_fields.legal_record.label")}
placeholder={t("form_fields.legal_record.placeholder")}
description={t("form_fields.legal_record.description")}
/>
</div>
</CardContent>
</Card>
);
};

View File

@ -12,13 +12,13 @@ import {
import { TextField } from "@repo/rdx-ui/components";
import { ChevronDown, MailIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
import { useState } from "react";
import { Control } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
export const CustomerContactFields = ({ control }: { control: Control<CustomerUpdateData> }) => {
export const CustomerContactFields = () => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const { control } = useFormContext();
return (
<Card className='border-0 shadow-none'>
@ -124,7 +124,7 @@ export const CustomerContactFields = ({ control }: { control: Control<CustomerUp
description={t("form_fields.website.description")}
/>
</div>
<div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='fax'

View File

@ -0,0 +1,63 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, FormProvider, useForm } from "react-hook-form";
import { useEffect } from "react";
import { CustomerFormData, CustomerFormSchema } from "../../schemas";
import { FormDebug } from "../form-debug";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps {
formId: string;
initialValues: CustomerFormData;
onSubmit: (data: CustomerFormData) => void;
onError: (errors: FieldErrors<CustomerFormData>) => void;
disabled?: boolean;
onDirtyChange: (isDirty: boolean) => void;
}
export function CustomerEditForm({
formId,
initialValues,
onSubmit,
onError,
disabled,
onDirtyChange,
}: CustomerFormProps) {
const form = useForm<CustomerFormData>({
resolver: zodResolver(CustomerFormSchema),
defaultValues: initialValues,
disabled,
});
const {
formState: { isDirty },
} = form;
useEffect(() => {
if (onDirtyChange) {
onDirtyChange(isDirty);
}
}, [isDirty, onDirtyChange]);
// Resetear el form si cambian los valores iniciales
useEffect(() => {
form.reset(initialValues);
}, [initialValues, form]);
return (
<FormProvider {...form}>
<FormDebug />
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<div className='w-full xl:w-2/3'>
<CustomerBasicInfoFields />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerAdditionalConfigFields />
</div>
</form>
</FormProvider>
);
}

View File

@ -0,0 +1 @@
export * from "./customer-edit-form";

View File

@ -1,6 +1,8 @@
import { UseFormReturn } from "react-hook-form";
import { useFormContext } from "react-hook-form";
export const FormDebug = () => {
const form = useFormContext(); // ✅ mantiene el tipo de T
export const FormDebug = ({ form }: { form: UseFormReturn }) => {
const {
watch,
formState: { isDirty, dirtyFields, defaultValues },

View File

@ -2,6 +2,7 @@ export * from "./client-selector";
export * from "./customer-editor-skeleton";
export * from "./customers-layout";
export * from "./customers-list-grid";
export * from "./editor";
export * from "./error-alert";
export * from "./form-debug";
export * from "./not-found-card";

View File

@ -1,20 +1,33 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CustomerCreateData, CustomerData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
type CreateCustomerPayload = {
data: CustomerCreateData;
};
export function useCreateCustomerMutation() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<CustomerData, Error, CustomerCreateData>({
return useMutation<CustomerData, Error, CreateCustomerPayload>({
mutationKey: ["customer:create"],
mutationFn: async (data: CustomerCreateData) => {
const created = await dataSource.createOne("customers", data);
mutationFn: async (payload) => {
const { data } = payload;
const customerId = UniqueID.generateNewID();
const created = await dataSource.createOne("customers", {
...data,
id: customerId.toString(),
});
return created as CustomerData;
},
onSuccess: () => {
// Invalida el listado de clientes para incluir el nuevo
// Invalida el listado para refrescar desde servidor
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
},
});

View File

@ -1,81 +0,0 @@
import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerMutation } from "../../hooks/use-create-customer-mutation";
import { useTranslation } from "../../i18n";
import { CustomerEditForm } from "./customer-edit-form";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mutate, isPending, isError, error } = useCreateCustomerMutation();
const handleSubmit = (data: any) => {
// Handle form submission logic here
console.log("Form submitted with data:", data);
mutate(data);
// Navigate to the list page after submission
navigate("/customers/list");
};
if (isError) {
console.error("Error creating customer:", error);
// Optionally, you can show an error message to the user
}
// Render the component
// You can also handle loading state if needed
// For example, you can disable the submit button while the mutation is in progress
// const isLoading = useCreateCustomerMutation().isLoading;
// Return the JSX for the component
// You can customize the form and its fields as needed
// For example, you can use a form library like react-hook-form or Formik to handle form state and validation
// Here, we are using a simple form with a submit button
// Note: Make sure to replace the form fields with your actual invoice fields
// and handle validation as needed.
// This is just a basic example to demonstrate the structure of the component.
// If you are using a form library, you can pass the handleSubmit function to the form's onSubmit prop
// and use the form library's methods to handle form state and validation.
// Example of a simple form submission handler
// You can replace this with your actual form handling logic
// const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
// event.preventDefault();
// const formData = new FormData(event.currentTarget);
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-4'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.create.description")}
</p>
</div>
<ButtonGroup>
<Button className='cursor-pointer' onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button type='submit' className='cursor-pointer'>
{t("common.save")}
</Button>
</ButtonGroup>
</div>
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm onSubmit={handleSubmit} isPending={isPending} />
</div>
</AppContent>
</>
);
};

View File

@ -0,0 +1,124 @@
import { AppBreadcrumb, AppContent, ButtonGroup } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { useState } from "react";
import { FieldErrors } from "react-hook-form";
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCreateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData, defaultCustomerFormData } from "../../schemas";
export const CustomerCreate = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isDirty, setIsDirty] = useState(false);
// 2) Estado de creación (mutación)
const {
mutateAsync,
isPending: isCreating,
isError: isCreateError,
error: createError,
} = useCreateCustomerMutation();
const { confirm: confirmCancel } = useUnsavedChangesNotifier({
isDirty,
});
// 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerFormData) => {
/*const changedData: Record<string, string> = {};
Object.keys(dirtyFields).forEach((field) => {
const value = String(currentValues[field as keyof CustomerFormData]);
changedData[field] = value;
});*/
try {
const result = await mutateAsync({ data: formData });
console.log(result);
if (result) {
showSuccessToast(t("pages.create.successTitle"), t("pages.create.successMsg"));
navigate("/customers/list");
}
} catch (e) {
showErrorToast(t("pages.create.errorTitle"), (e as Error).message);
} finally {
}
};
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-4 px-6'>
<div className='space-y-2'>
<h2 className='text-2xl font-bold tracking-tight text-balance scroll-m-2'>
{t("pages.create.title")}
</h2>
<p className='text-muted-foreground scroll-m-20 tracking-tight text-balance'>
{t("pages.create.description")}
</p>
</div>
<ButtonGroup>
<Button
variant={"outline"}
className='cursor-pointer'
onClick={async (e) => {
e.preventDefault();
const ok = await confirmCancel();
if (ok) {
console.log("Cambios descartados");
navigate("/customers/list");
}
}}
>
{t("common.cancel")}
</Button>
<Button
type='submit'
form='customer-create-form'
className='cursor-pointer'
disabled={isCreating}
aria-busy={isCreating}
aria-disabled={isCreating}
data-state={isCreating ? "loading" : "idle"}
>
{t("common.save")}
</Button>
</ButtonGroup>
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isCreateError && (
<ErrorAlert
title={t("pages.create.errorTitle", "No se pudo guardar los cambios")}
message={
(createError as Error)?.message ??
t("pages.create.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-create-form"} // para que el botón del header pueda hacer submit
initialValues={defaultCustomerFormData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
</div>
</AppContent>
</>
);
};

View File

@ -1,350 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { TaxesMultiSelectField } from "@erp/core/components";
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { useTranslation } from "../../i18n";
import { CustomerData, CustomerUpdateSchema } from "../../schemas";
const defaultCustomerData = {
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
is_company: true,
status: "active",
tin: "B12345678",
name: "Pepe",
trade_name: "Pepe's Shop",
email: "pepe@example.com",
phone: "+34 123 456 789",
website: "https://pepe.com",
fax: "+34 123 456 789",
street: "Calle Falsa 123",
city: "Madrid",
country: "ES",
postal_code: "28080",
province: "Madrid",
language_code: "es",
currency_code: "EUR",
legal_record: "Registro Mercantil de Madrid, Tomo 12345, Folio 67, Hoja M-123456",
default_taxes: ["iva_21", "rec_5_2"],
};
interface CustomerFormProps {
initialData?: CustomerData;
isPending?: boolean;
/**
* Callback function to handle form submission.
* @param data - The customer data submitted by the form.
*/
onSubmit?: (data: CustomerData) => void;
}
export const CustomerEditForm = ({
initialData = defaultCustomerData,
onSubmit,
isPending,
}: CustomerFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerData>({
resolver: zodResolver(CustomerUpdateSchema),
defaultValues: initialData,
disabled: isPending,
});
useUnsavedChangesNotifier({
isDirty: form.formState.isDirty,
});
const handleSubmit = (data: CustomerData) => {
console.log("Datos del formulario:", data);
onSubmit?.(data);
};
const handleError = (errors: any) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(initialData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='w-full grid grid-cols-1 space-y-8 space-x-8 xl:grid-cols-2'>
{/* Información básica */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<FormField
control={form.control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"1"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value={"0"} />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TextField
control={form.control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
<TextField
control={form.control}
name='trade_name'
required
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
<TextField
control={form.control}
name='tin'
required
label={t("form_fields.tin.label")}
placeholder={t("form_fields.tin.placeholder")}
description={t("form_fields.tin.description")}
/>
<TextField
control={form.control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</CardContent>
</Card>
{/* Dirección */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.address.title")}</CardTitle>
<CardDescription>{t("form_groups.address.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
className='xl:col-span-2'
control={form.control}
name='street'
required
label={t("form_fields.street.label")}
placeholder={t("form_fields.street.placeholder")}
description={t("form_fields.street.description")}
/>
<TextField
control={form.control}
name='city'
required
label={t("form_fields.city.label")}
placeholder={t("form_fields.city.placeholder")}
description={t("form_fields.city.description")}
/>
<TextField
control={form.control}
name='postal_code'
required
label={t("form_fields.postal_code.label")}
placeholder={t("form_fields.postal_code.placeholder")}
description={t("form_fields.postal_code.description")}
/>
<TextField
control={form.control}
name='province'
required
label={t("form_fields.province.label")}
placeholder={t("form_fields.province.placeholder")}
description={t("form_fields.province.description")}
/>
<SelectField
control={form.control}
name='country'
required
label={t("form_fields.country.label")}
placeholder={t("form_fields.country.placeholder")}
description={t("form_fields.country.description")}
items={[
{ value: "ES", label: "España" },
{ value: "FR", label: "Francia" },
{ value: "DE", label: "Alemania" },
{ value: "IT", label: "Italia" },
{ value: "PT", label: "Portugal" },
{ value: "US", label: "Estados Unidos" },
{ value: "MX", label: "México" },
{ value: "AR", label: "Argentina" },
]}
/>
</CardContent>
</Card>
{/* Contacto */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
control={form.control}
name='email'
required
label={t("form_fields.email.label")}
placeholder={t("form_fields.email.placeholder")}
description={t("form_fields.email.description")}
/>
<TextField
control={form.control}
name='phone'
required
label={t("form_fields.phone.label")}
placeholder={t("form_fields.phone.placeholder")}
description={t("form_fields.phone.description")}
/>
<TextField
control={form.control}
name='fax'
required
label={t("form_fields.fax.label")}
placeholder={t("form_fields.fax.placeholder")}
description={t("form_fields.fax.description")}
/>
<TextField
className='xl:col-span-2'
control={form.control}
name='website'
required
label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")}
/>
</CardContent>
</Card>
{/* Configuraciones Adicionales */}
<Card className='shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TaxesMultiSelectField
control={form.control}
name='default_taxes'
required
label={t("form_fields.default_taxes.label")}
placeholder={t("form_fields.default_taxes.placeholder")}
description={t("form_fields.default_taxes.description")}
/>
<SelectField
control={form.control}
name='language_code'
required
label={t("form_fields.language_code.label")}
placeholder={t("form_fields.language_code.placeholder")}
description={t("form_fields.language_code.description")}
items={[
{ value: "es", label: "Español" },
{ value: "en", label: "Inglés" },
{ value: "fr", label: "Francés" },
{ value: "de", label: "Alemán" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Portugués" },
]}
/>
<SelectField
control={form.control}
name='currency_code'
required
label={t("form_fields.currency_code.label")}
placeholder={t("form_fields.currency_code.placeholder")}
description={t("form_fields.currency_code.description")}
items={[
{ value: "EUR", label: "Euro" },
{ value: "USD", label: "Dólar estadounidense" },
{ value: "GBP", label: "Libra esterlina" },
{ value: "ARS", label: "Peso argentino" },
{ value: "MXN", label: "Peso mexicano" },
{ value: "JPY", label: "Yen japonés" },
]}
/>
<TextAreaField
className=''
control={form.control}
name='legal_record'
required
label={t("form_fields.legal_record.label")}
placeholder={t("form_fields.legal_record.placeholder")}
description={t("form_fields.legal_record.description")}
/>
</CardContent>
</Card>
</div>
<Button type='submit'>Submit</Button>
</form>
</Form>
);
};

View File

@ -1 +1 @@
export * from "./create";
export * from "./customer-create";

View File

@ -1,272 +0,0 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { Control } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
export const CustomerBasicInfoFields = ({ control }: { control: Control<CustomerUpdateData> }) => {
const { t } = useTranslation();
return (
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>Identificación</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<div className='lg:col-span-full'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "true" : "false"}
className='flex items-center gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem id='rgi-company' value='true' />
</FormControl>
<FormLabel className='font-normal' htmlFor='rgi-company'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem id='rgi-individual' value='false' />
</FormControl>
<FormLabel className='font-normal' htmlFor='rgi-individual'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<div className='lg:col-span-2'>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
</div>
<div className='lg:col-span-2'>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
</div>
<div className='lg:col-span-2'>
<TaxesMultiSelectField
control={control}
name='default_taxes'
required
label={t("form_fields.default_taxes.label")}
placeholder={t("form_fields.default_taxes.placeholder")}
description={t("form_fields.default_taxes.description")}
/>
</div>
<div className='lg:col-span-2'>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</div>
<TextAreaField
className='lg:col-span-full'
control={control}
name='legal_record'
label={t("form_fields.legal_record.label")}
placeholder={t("form_fields.legal_record.placeholder")}
description={t("form_fields.legal_record.description")}
/>
</div>
</CardContent>
</Card>
);
return (
<div className='space-y-12'>
<div className='border-b border-gray-900/10 pb-12 dark:border-white/10'>
<h2 className='text-base/7 font-semibold text-gray-900 dark:text-white'>
{t("form_groups.basic_info.title")}
</h2>
<p className='mt-1 text-sm/6 text-gray-600 dark:text-gray-400'>
{t("form_groups.basic_info.description")}
</p>
<div className='mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6'>
<div className='sm:col-span-6'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='0' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
</div>
<div className='col-span-full'>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</div>
</div>
</div>
</div>
);
return (
<Card className='shadow-sm bg-gray-50/50'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<FormField
control={control}
name='is_company'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>{t("form_fields.customer_type.label")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value ? "1" : "0"}
className='flex gap-6'
>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.company")}
</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-2'>
<FormControl>
<RadioGroupItem value='0' />
</FormControl>
<FormLabel className='font-normal'>
{t("form_fields.customer_type.individual")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TextField
control={control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
<TextField
control={control}
name='trade_name'
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
<TextField
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</CardContent>
</Card>
);
};

View File

@ -1,80 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { FieldErrors, useForm } from "react-hook-form";
import { Form } from "@repo/shadcn-ui/components";
import { useUnsavedChangesNotifier } from "@erp/core/hooks";
import { FormDebug } from "../../components/form-debug";
import { useTranslation } from "../../i18n";
import { CustomerData, CustomerUpdateData, CustomerUpdateSchema } from "../../schemas";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from "./customer-contact-fields";
interface CustomerFormProps {
defaultValues: CustomerData; // ✅ ya no recibe DTO
isPending?: boolean;
onSubmit: (data: CustomerUpdateData) => void;
onError: (errors: FieldErrors<CustomerUpdateData>) => void;
}
export const CustomerEditForm = ({ defaultValues, onSubmit, isPending }: CustomerFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerUpdateData>({
resolver: zodResolver(CustomerUpdateSchema),
defaultValues,
disabled: isPending,
});
const {
watch,
formState: { isDirty, dirtyFields },
} = form;
useUnsavedChangesNotifier({
isDirty,
});
const currentValues = watch();
const handleSubmit = (data: CustomerUpdateData) => {
console.log("Datos del formulario:", data);
const changedData: Record<string, string> = {};
Object.keys(dirtyFields).forEach((field) => {
const value = String(currentValues[field as keyof CustomerUpdateData]);
changedData[field] = value;
});
console.log(changedData);
onSubmit(changedData);
};
const handleError = (errors: FieldErrors<CustomerUpdateData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(defaultValues);
};
return (
<Form {...form}>
<FormDebug form={form} />
<form id='customer-edit-form' onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='flex gap-6'>
<div className='w-full xl:w-2/3 space-y-12'>
<CustomerBasicInfoFields control={form.control} />
<CustomerContactFields control={form.control} />
<CustomerAddressFields control={form.control} />
<CustomerAdditionalConfigFields control={form.control} />
</div>
</div>
</form>
</Form>
);
};

View File

@ -5,15 +5,19 @@ import { useNavigate } from "react-router-dom";
import { useUrlParamId } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { FieldErrors } from "react-hook-form";
import { CustomerEditorSkeleton, ErrorAlert, NotFoundCard } from "../../components";
import {
CustomerEditForm,
CustomerEditorSkeleton,
ErrorAlert,
NotFoundCard,
} from "../../components";
import { useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerUpdateData } from "../../schemas";
import { CustomerEditForm } from "./customer-edit-form";
import { CustomerFormData } from "../../schemas";
export const CustomerUpdate = () => {
const { t } = useTranslation();
const customerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
@ -33,7 +37,8 @@ export const CustomerUpdate = () => {
} = useUpdateCustomerMutation();
// 3) Submit con navegación condicionada por éxito
const handleSubmit = async (formData: CustomerUpdateData) => {
const handleSubmit = async (formData: CustomerFormData) => {
console.log(formData);
try {
const result = await mutateAsync({ id: customerId!, data: formData });
console.log(result);
@ -48,7 +53,7 @@ export const CustomerUpdate = () => {
}
};
const handleError = (errors: FieldErrors<CustomerUpdateData>) => {
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
@ -110,6 +115,7 @@ export const CustomerUpdate = () => {
className='cursor-pointer'
onClick={(e) => {
e.preventDefault();
navigate("/customers/list");
}}
>
{t("common.cancel")}
@ -117,7 +123,7 @@ export const CustomerUpdate = () => {
<Button
type='submit'
form='customer-edit-form'
form='customer-update-form'
className='cursor-pointer'
disabled={isUpdating || isLoadingCustomer}
aria-busy={isUpdating}
@ -140,12 +146,12 @@ export const CustomerUpdate = () => {
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
{/* Importante: proveemos un formId para que el botón del header pueda hacer submit */}
<CustomerEditForm
defaultValues={customerData}
formId={"customer-update-form"} // para que el botón del header pueda hacer submit
initialValues={customerData}
onSubmit={handleSubmit}
onError={handleError}
isPending={isUpdating}
disabled={isLoadingCustomer}
/>
</div>
</AppContent>

View File

@ -1 +1 @@
export * from "./update";
export * from "./customer-update";

View File

@ -0,0 +1,4 @@
import { CreateCustomerRequestSchema, UpdateCustomerByIdRequestSchema } from "@erp/customers";
export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;

View File

@ -0,0 +1,66 @@
import * as z from "zod/v4";
export const CustomerFormSchema = z.object({
reference: z.string().optional(),
is_company: z.string().optional(),
name: z.string().optional(),
trade_name: z.string().optional(),
tin: z.string().optional(),
default_taxes: z.array(z.string()).optional(), // completo (sustituye), o null => vaciar
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
fax: z.string().optional(),
website: z.string().optional(),
legal_record: z.string().optional(),
language_code: z.string().optional(),
currency_code: z.string().optional(),
});
export type CustomerFormData = z.infer<typeof CustomerFormSchema>;
export const defaultCustomerFormData: CustomerFormData = {
reference: "",
is_company: "false",
name: "",
trade_name: "",
tin: "",
default_taxes: [],
street: "",
street2: "",
city: "",
province: "",
postal_code: "",
country: "es",
email_primary: "",
email_secondary: "",
phone_primary: "",
phone_secondary: "",
mobile_primary: "",
mobile_secondary: "",
fax: "",
website: "",
legal_record: "",
language_code: "es",
currency_code: "EUR",
};

View File

@ -1,15 +0,0 @@
import {
CreateCustomerRequestDTO,
CreateCustomerRequestSchema,
GetCustomerByIdResponseDTO,
UpdateCustomerByIdRequestDTO,
UpdateCustomerByIdRequestSchema,
} from "@erp/customers";
export type CustomerData = GetCustomerByIdResponseDTO;
export const CustomerCreateSchema = CreateCustomerRequestSchema;
export type CustomerCreateData = CreateCustomerRequestDTO;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
export type CustomerUpdateData = UpdateCustomerByIdRequestDTO;

View File

@ -1 +1,2 @@
export * from "./customer.schema";
export * from "./customer.api.schema";
export * from "./customer.form.schema";

View File

@ -47,6 +47,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-tabs": "^1.1.12",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",

View File

@ -8,13 +8,11 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@repo/shadcn-ui/components";
import { SyntheticEvent } from "react";
import { Link } from "react-router-dom";
interface CustomDialogProps {
isOpen: boolean;
onCancel: (event: SyntheticEvent) => void;
onConfirm: (event: SyntheticEvent) => void;
onCancel: () => void;
onConfirm: () => void;
title: React.ReactNode;
description: React.ReactNode;
cancelLabel: React.ReactNode;
@ -31,23 +29,15 @@ export const CustomDialog = ({
confirmLabel,
}: CustomDialogProps) => {
return (
<AlertDialog open={isOpen}>
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Link to='#' onClick={onCancel}>
{cancelLabel}
</Link>
</AlertDialogCancel>
<AlertDialogAction>
<Link to='#' onClick={onConfirm}>
{confirmLabel}
</Link>
</AlertDialogAction>
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -0,0 +1,104 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { cva } from "class-variance-authority";
const DynamicTabsListVariants = cva("inline-flex items-center justify-center", {
variants: {
variant: {
default: "rounded-lg bg-muted p-1 text-muted-foreground",
underline: "border-b rounded-none bg-background gap-2 p-0 justify-start",
},
width: {
full: "w-full",
fit: "w-fit",
},
},
defaultVariants: {
variant: "default",
},
});
const DynamicTabsTriggerVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap text-sm font-normal transition-all disabled:pointer-events-none data-[state=active]:text-foreground px-3",
{
variants: {
variant: {
default:
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
underline:
"bg-background border-b-2 border-background focus:border-primary ring-0 outline-none shadow-none data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary disabled:opacity-100 data-[state=active]:shadow-none rounded-none m-0 pt-1.5 pb-2 hover:bg-background-muted",
},
width: {
full: "w-full",
fit: "w-fit",
},
},
defaultVariants: {
variant: "default",
},
}
);
function DynamicTabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot='tabs'
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DynamicTabsList({
className,
variant,
width,
asChild = false,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot='tabs-list'
className={cn(DynamicTabsListVariants({ variant, width, className }))}
{...props}
/>
);
}
function DynamicTabsTrigger({
className,
variant,
width,
asChild = false,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot='tabs-trigger'
className={cn(DynamicTabsTriggerVariants({ variant, width, className }))}
{...props}
/>
);
}
function DynamicTabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot='tabs-content'
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
);
}
export { DynamicTabs, DynamicTabsContent, DynamicTabsList, DynamicTabsTrigger };

View File

@ -1,6 +1,7 @@
export * from "./buttons/index.tsx";
export * from "./custom-dialog.tsx";
export * from "./datatable/index.tsx";
export * from "./dynamics-tabs.tsx";
export * from "./error-overlay.tsx";
export * from "./form/index.tsx";
export * from "./grid/index.ts";

View File

@ -179,7 +179,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
@ -703,6 +703,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.1.0)
'@radix-ui/react-tabs':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../shadcn-ui
@ -12292,7 +12295,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
@ -12310,7 +12313,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):