Clientes y facturas de cliente

This commit is contained in:
David Arranz 2025-09-24 12:34:04 +02:00
parent 9683ee5102
commit 8d0c0b88de
50 changed files with 1200 additions and 362 deletions

View File

@ -1,5 +1,6 @@
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import verifactuAPIModule from "@erp/verifactu/api";
import { registerModule } from "./lib";
@ -7,4 +8,5 @@ export const registerModules = () => {
//registerModule(authAPIModule);
registerModule(customersAPIModule);
registerModule(customerInvoicesAPIModule);
registerModule(verifactuAPIModule);
};

View File

@ -52,8 +52,8 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
return <R>res.data;
},
getOne: async <T>(resource: string, id: string | number) => {
const res = await (client as AxiosInstance).get<T>(`${resource}/${id}`);
getOne: async <T>(resource: string, id: string | number, params?: Record<string, unknown>) => {
const res = await (client as AxiosInstance).get<T>(`${resource}/${id}`, params);
return res.data;
},
@ -62,18 +62,31 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
return <R>res.data;
},
createOne: async <T>(resource: string, data: Partial<T>) => {
const res = await (client as AxiosInstance).post<T>(resource, data);
createOne: async <T>(
resource: string,
data: Partial<T>,
params?: Record<string, unknown>
): Promise<T> => {
const res = await (client as AxiosInstance).post<T>(resource, data, params);
return res.data;
},
updateOne: async <T>(resource: string, id: string | number, data: Partial<T>) => {
const res = await (client as AxiosInstance).put<T>(`${resource}/${id}`, data);
updateOne: async <T>(
resource: string,
id: string | number,
data: Partial<T>,
params?: Record<string, unknown>
) => {
const res = await (client as AxiosInstance).put<T>(`${resource}/${id}`, data, params);
return res.data;
},
deleteOne: async <T>(resource: string, id: string | number) => {
await (client as AxiosInstance).delete(`${resource}/${id}`);
deleteOne: async <T>(
resource: string,
id: string | number,
params?: Record<string, unknown>
) => {
await (client as AxiosInstance).delete(`${resource}/${id}`, params);
},
custom: async <T>(customParams: ICustomParams) => {

View File

@ -15,11 +15,20 @@ export interface ICustomParams {
export interface IDataSource {
getBaseUrl(): string;
getList<T, R>(resource: string, params?: Record<string, unknown>): Promise<R>;
getOne<T>(resource: string, id: string | number): Promise<T>;
getOne<T>(resource: string, id: string | number, params?: Record<string, unknown>): Promise<T>;
getMany<T, R>(resource: string, ids: Array<string | number>): Promise<R>;
createOne<T, R>(resource: string, data: Partial<T>): Promise<R>;
updateOne<T, R>(resource: string, id: string | number, data: Partial<T>): Promise<R>;
deleteOne<T>(resource: string, id: string | number): Promise<void>;
createOne<T>(resource: string, data: Partial<T>, params?: Record<string, unknown>): Promise<T>;
updateOne<T>(
resource: string,
id: string | number,
data: Partial<T>,
params?: Record<string, unknown>
): Promise<T>;
deleteOne<T>(
resource: string,
id: string | number,
params?: Record<string, unknown>
): Promise<void>;
custom: <R>(customParams: ICustomParams) => Promise<R>;
}

View File

@ -0,0 +1,44 @@
/**
* Extrae solo los valores marcados como "dirty" por react-hook-form,
* respetando la estructura anidada de dirtyFields.
*/
export function pickFormDirtyValues<T extends Record<string, any>>(
values: T,
dirtyFields: Partial<Record<keyof T, any>>
): Partial<T> {
const result: Partial<T> = {};
for (const key in dirtyFields) {
if (!Object.prototype.hasOwnProperty.call(dirtyFields, key)) continue;
const isDirty = dirtyFields[key];
const value = values[key];
if (isDirty === true) {
// 🔹 Campo "leaf": se ha tocado → copiar valor
result[key] = value;
} else if (typeof isDirty === "object" && isDirty !== null) {
// 🔹 Campo anidado: recursión
const nested = pickFormDirtyValues(value, isDirty);
if (Object.keys(nested).length > 0) {
result[key] = nested as any;
}
}
}
return result;
}
/**
* Devuelve true si hay al menos un campo dirty en cualquier nivel.
*/
export function formHasAnyDirty(dirtyFields: Partial<Record<string, any>> | boolean): boolean {
if (dirtyFields === true) return true;
if (dirtyFields === false || dirtyFields == null) return false;
if (typeof dirtyFields === "object") {
return Object.values(dirtyFields).some((v) => formHasAnyDirty(v));
}
return false;
}

View File

@ -1,2 +1,3 @@
export * from "./date-func";
export * from "./form-utils";
export * from "./money-funcs";

View File

@ -22,7 +22,7 @@ export const CustomerAdditionalConfigFields = () => {
<CardDescription>{t("form_groups.preferences.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<div className='grid grid-cols-1 gap-8 lg:grid-cols-4 mb-12 '>
<SelectField
className='lg:col-span-2'
control={control}

View File

@ -1,4 +1,11 @@
import { SelectField, TextField } from "@repo/rdx-ui/components";
import {
Description,
FieldGroup,
Fieldset,
Legend,
SelectField,
TextField,
} from "@repo/rdx-ui/components";
import {
Card,
CardContent,
@ -15,6 +22,65 @@ export const CustomerAddressFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
return (
<Fieldset>
<Legend>{t("form_groups.address.title")}</Legend>
<Description>{t("form_groups.address.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-8 lg:grid-cols-4'>
<TextField
className='lg:col-span-2'
control={control}
name='street'
label={t("form_fields.street.label")}
placeholder={t("form_fields.street.placeholder")}
description={t("form_fields.street.description")}
/>
<TextField
className='lg:col-span-2'
control={control}
name='street2'
label={t("form_fields.street2.label")}
placeholder={t("form_fields.street2.placeholder")}
description={t("form_fields.street2.description")}
/>
<TextField
className='lg:col-span-2'
control={control}
name='city'
label={t("form_fields.city.label")}
placeholder={t("form_fields.city.placeholder")}
description={t("form_fields.city.description")}
/>
<TextField
control={control}
name='postal_code'
label={t("form_fields.postal_code.label")}
placeholder={t("form_fields.postal_code.placeholder")}
description={t("form_fields.postal_code.description")}
/>
<TextField
className='lg:col-span-2 lg:col-start-1'
control={control}
name='province'
label={t("form_fields.province.label")}
placeholder={t("form_fields.province.placeholder")}
description={t("form_fields.province.description")}
/>
<SelectField
control={control}
name='country'
required
label={t("form_fields.country.label")}
placeholder={t("form_fields.country.placeholder")}
description={t("form_fields.country.description")}
items={[...COUNTRY_OPTIONS]}
/>
</FieldGroup>
</Fieldset>
);
return (
<Card className='border-0 shadow-none'>
<CardHeader>
@ -22,7 +88,7 @@ export const CustomerAddressFields = () => {
<CardDescription>{t("form_groups.address.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 '>
<div className='grid grid-cols-1 gap-8 lg:grid-cols-4 mb-6 '>
<TextField
className='lg:col-span-2'
control={control}
@ -56,7 +122,7 @@ export const CustomerAddressFields = () => {
description={t("form_fields.postal_code.description")}
/>
</div>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-0 '>
<div className='grid grid-cols-1 gap-8 lg:grid-cols-4 mb-0 '>
<TextField
className='lg:col-span-2'
control={control}

View File

@ -1,10 +1,14 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Description,
Field,
FieldGroup,
Fieldset,
Legend,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import {
FormControl,
FormField,
FormItem,
@ -28,107 +32,99 @@ export const CustomerBasicInfoFields = () => {
});
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>
<Fieldset>
<Legend>Identificación</Legend>
<Description>descripción</Description>
<FieldGroup className='grid grid-cols-1 gap-8 lg:grid-cols-4'>
<Field 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")}
/>
</Field>
<Field 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={(value: string) => {
field.onChange(value === "false" ? "false" : "true");
}}
defaultValue={field.value ? "true" : "false"}
className='flex items-center gap-8'
>
<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>
)}
/>
</Field>
<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={(value: string) => {
field.onChange(value === "false" ? "false" : "true");
}}
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
{isCompany === "false" ? (
<TextField
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")}
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>
</CardContent>
</Card>
) : (
<></>
)}
<TextField
className='lg:col-span-2 lg:col-start-1'
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
<TaxesMultiSelectField
className='lg:col-span-2'
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")}
/>
<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")}
/>
</FieldGroup>
</Fieldset>
);
};

View File

@ -1,15 +1,6 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@repo/shadcn-ui/components";
import { Description, FieldGroup, Fieldset, Legend, TextField } from "@repo/rdx-ui/components";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/shadcn-ui/components";
import { TextField } from "@repo/rdx-ui/components";
import { ChevronDown, MailIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
@ -21,91 +12,83 @@ export const CustomerContactFields = () => {
const { control } = useFormContext();
return (
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<TextField
className='lg:col-span-2'
control={control}
name='email_primary'
label={t("form_fields.email_primary.label")}
placeholder={t("form_fields.email_primary.placeholder")}
description={t("form_fields.email_primary.description")}
icon={
<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
typePreset='email'
required
/>
<TextField
className='lg:col-span-2'
control={control}
name='mobile_primary'
label={t("form_fields.mobile_primary.label")}
placeholder={t("form_fields.mobile_primary.placeholder")}
description={t("form_fields.mobile_primary.description")}
icon={
<SmartphoneIcon
className='h-[18px] w-[18px] text-muted-foreground'
strokeWidth={1.5}
/>
}
/>
<Fieldset>
<Legend>{t("form_groups.contact_info.title")}</Legend>
<Description>{t("form_groups.contact_info.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-8 lg:grid-cols-4'>
<TextField
className='lg:col-span-2'
control={control}
name='email_primary'
label={t("form_fields.email_primary.label")}
placeholder={t("form_fields.email_primary.placeholder")}
description={t("form_fields.email_primary.description")}
icon={<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
typePreset='email'
required
/>
<TextField
className='lg:col-span-2'
control={control}
name='mobile_primary'
label={t("form_fields.mobile_primary.label")}
placeholder={t("form_fields.mobile_primary.placeholder")}
description={t("form_fields.mobile_primary.description")}
icon={
<SmartphoneIcon
className='h-[18px] w-[18px] text-muted-foreground'
strokeWidth={1.5}
/>
}
/>
<TextField
className='lg:col-span-2'
control={control}
name='phone_primary'
label={t("form_fields.phone_primary.label")}
placeholder={t("form_fields.phone_primary.placeholder")}
description={t("form_fields.phone_primary.description")}
icon={
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
</div>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-12 '>
<TextField
className='lg:col-span-2'
control={control}
name='email_secondary'
label={t("form_fields.email_secondary.label")}
placeholder={t("form_fields.email_secondary.placeholder")}
description={t("form_fields.email_secondary.description")}
icon={
<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
<TextField
className='lg:col-span-2'
control={control}
name='mobile_secondary'
label={t("form_fields.mobile_secondary.label")}
placeholder={t("form_fields.mobile_secondary.placeholder")}
description={t("form_fields.mobile_secondary.description")}
icon={
<SmartphoneIcon
className='h-[18px] w-[18px] text-muted-foreground'
strokeWidth={1.5}
/>
}
/>
<TextField
className='lg:col-span-2'
control={control}
name='phone_secondary'
label={t("form_fields.phone_secondary.label")}
placeholder={t("form_fields.phone_secondary.placeholder")}
description={t("form_fields.phone_secondary.description")}
icon={
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
</div>
<TextField
className='lg:col-span-2'
control={control}
name='phone_primary'
label={t("form_fields.phone_primary.label")}
placeholder={t("form_fields.phone_primary.placeholder")}
description={t("form_fields.phone_primary.description")}
icon={
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
<TextField
className='lg:col-span-2 lg:col-start-1'
control={control}
name='email_secondary'
label={t("form_fields.email_secondary.label")}
placeholder={t("form_fields.email_secondary.placeholder")}
description={t("form_fields.email_secondary.description")}
icon={<MailIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />}
/>
<TextField
className='lg:col-span-2'
control={control}
name='mobile_secondary'
label={t("form_fields.mobile_secondary.label")}
placeholder={t("form_fields.mobile_secondary.placeholder")}
description={t("form_fields.mobile_secondary.description")}
icon={
<SmartphoneIcon
className='h-[18px] w-[18px] text-muted-foreground'
strokeWidth={1.5}
/>
}
/>
<TextField
className='lg:col-span-2'
control={control}
name='phone_secondary'
label={t("form_fields.phone_secondary.label")}
placeholder={t("form_fields.phone_secondary.placeholder")}
description={t("form_fields.phone_secondary.description")}
icon={
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
<Collapsible open={open} onOpenChange={setOpen} className='space-y-4'>
<CollapsibleTrigger className='inline-flex items-center gap-1 text-sm text-primary hover:underline'>
@ -113,30 +96,27 @@ export const CustomerContactFields = () => {
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</CollapsibleTrigger>
<CollapsibleContent>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<div className='sm:col-span-2'>
<TextField
className='xl:col-span-2'
control={control}
name='website'
label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")}
/>
</div>
<div className='sm:col-span-2'>
<TextField
control={control}
name='fax'
label={t("form_fields.fax.label")}
placeholder={t("form_fields.fax.placeholder")}
description={t("form_fields.fax.description")}
/>
</div>
</div>
<FieldGroup className='grid grid-cols-1 gap-8 lg:grid-cols-4'>
<TextField
className='lg:col-span-2'
control={control}
name='website'
label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")}
/>
<TextField
className='lg:col-span-2'
control={control}
name='fax'
label={t("form_fields.fax.label")}
placeholder={t("form_fields.fax.placeholder")}
description={t("form_fields.fax.description")}
/>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</FieldGroup>
</Fieldset>
);
};

View File

@ -1,8 +1,8 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema, CustomerCreationResponseDTO } from "../../common";
import { CustomerFormData } from "../schemas";
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema } from "../../common";
import { CustomerData, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
type CreateCustomerPayload = {
@ -14,7 +14,7 @@ export function useCreateCustomerMutation() {
const dataSource = useDataSource();
const schema = CreateCustomerRequestSchema;
return useMutation<CustomerCreationResponseDTO, Error, CreateCustomerPayload>({
return useMutation<CustomerData, DefaultError, CreateCustomerPayload>({
mutationKey: ["customer:create"],
mutationFn: async (payload) => {
@ -38,7 +38,7 @@ export function useCreateCustomerMutation() {
}
const created = await dataSource.createOne("customers", newCustomerData);
return created as CustomerCreationResponseDTO;
return created as CustomerData;
},
onSuccess: () => {

View File

@ -10,7 +10,7 @@ type UseCustomerFormProps = {
};
export function useCustomerForm({ initialValues, disabled, onDirtyChange }: UseCustomerFormProps) {
const form = useForm<CustomerFormData>({
const form = useForm({
resolver: zodResolver(CustomerFormSchema),
defaultValues: initialValues,
disabled,

View File

@ -1,40 +1,51 @@
import { useDataSource } from "@erp/core/hooks";
import { GetCustomerByIdResponseDTO } from "@erp/customer-invoices/common";
import { type QueryKey, type UseQueryOptions, useQuery } from "@tanstack/react-query";
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import { CustomerData } from "../schemas";
export const CUSTOMER_QUERY_KEY = (id: string): QueryKey => ["customer", id] as const;
type Options = Omit<
UseQueryOptions<
GetCustomerByIdResponseDTO,
Error,
GetCustomerByIdResponseDTO,
ReturnType<typeof CUSTOMER_QUERY_KEY>
>,
"queryKey" | "queryFn" | "enabled"
> & {
type CustomerQueryOptions = {
enabled?: boolean;
};
export function useCustomerQuery(customerId?: string, options?: Options) {
export function useCustomerQuery(customerId?: string, options?: CustomerQueryOptions) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!customerId;
return useQuery<
GetCustomerByIdResponseDTO,
Error,
GetCustomerByIdResponseDTO,
ReturnType<typeof CUSTOMER_QUERY_KEY>
>({
const queryResult = useQuery<CustomerData, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"),
enabled,
queryFn: async (context) => {
if (!customerId) throw new Error("customerId is required");
const { signal } = context;
const customer = await dataSource.getOne("customers", customerId);
return customer as GetCustomerByIdResponseDTO;
if (!customerId) {
if (!customerId) throw new Error("customerId is required");
}
const customer = await dataSource.getOne<CustomerData>("customers", customerId, {
signal,
});
return customer;
},
...options,
enabled,
});
return queryResult;
}
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/

View File

@ -7,9 +7,11 @@ import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
type UpdateCustomerContext = {};
type UpdateCustomerPayload = {
id: string;
data: CustomerFormData;
data: Partial<CustomerFormData>;
};
export function useUpdateCustomerMutation() {
@ -17,7 +19,7 @@ export function useUpdateCustomerMutation() {
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<UpdateCustomerByIdRequestDTO, Error, UpdateCustomerPayload>({
return useMutation<CustomerFormData, Error, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: ["customer:update"], //, customerId],
mutationFn: async (payload) => {
@ -38,9 +40,9 @@ export function useUpdateCustomerMutation() {
}
const updated = await dataSource.updateOne("customers", customerId, data);
return updated as UpdateCustomerByIdRequestDTO;
return updated as CustomerFormData;
},
onSuccess: (updated, variables) => {
onSuccess: (updated: CustomerFormData, variables) => {
const { id: customerId } = variables;
// Refresca inmediatamente el detalle

View File

@ -2,7 +2,7 @@ import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form";
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCreateCustomerMutation, useCustomerForm } from "../../hooks";
@ -34,7 +34,7 @@ export const CustomerCreate = () => {
onSuccess(data) {
showSuccessToast(t("pages.create.successTitle"), t("pages.create.successMsg"));
// 🔹 reset limpia el form e isDirty pasa a false
// 🔹 limpiar el form e isDirty pasa a false
form.reset(defaultCustomerFormData);
navigate("/customers/list", {

View File

@ -1,9 +1,10 @@
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { FormCommitButtonGroup, UnsavedChangesProvider, useUrlParamId } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/shadcn-ui/lib/utils";
import { FieldErrors } from "react-hook-form";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form";
import {
CustomerEditForm,
CustomerEditorSkeleton,
@ -12,7 +13,7 @@ import {
} from "../../components";
import { useCustomerForm, useCustomerQuery, useUpdateCustomerMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
import { CustomerFormData, defaultCustomerFormData } from "../../schemas";
export const CustomerUpdate = () => {
const customerId = useUrlParamId();
@ -37,26 +38,27 @@ export const CustomerUpdate = () => {
// 3) Form hook
const form = useCustomerForm({
initialValues: customerData,
initialValues: customerData ?? defaultCustomerFormData,
});
// 3) Submit con navegación condicionada por éxito
// 4) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => {
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast("No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
mutate(
{ id: customerId!, data: formData },
{ id: customerId!, data: patchData },
{
onSuccess(data) {
setIsDirty(false);
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
// El timeout es para que a React le dé tiempo a procesar
// el cambio de estado de isDirty / setIsDirty.
setTimeout(() => {
navigate("/customers/list", {
state: { customerId: data.id, isNew: true },
replace: true,
});
}, 0);
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
@ -112,7 +114,7 @@ export const CustomerUpdate = () => {
<>
<AppBreadcrumb />
<AppContent>
<UnsavedChangesProvider isDirty={isDirty}>
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<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'>
@ -127,7 +129,7 @@ export const CustomerUpdate = () => {
to: "/customers/list",
}}
submit={{
formId: "customer-create-form",
formId: "customer-update-form",
disabled: isUpdating,
isLoading: isUpdating,
}}
@ -145,13 +147,13 @@ export const CustomerUpdate = () => {
)}
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerEditForm
formId={"customer-update-form"} // para que el botón del header pueda hacer submit
initialValues={customerData}
onSubmit={handleSubmit}
onError={handleError}
onDirtyChange={setIsDirty}
/>
<FormProvider {...form}>
<CustomerEditForm
formId={"customer-update-form"} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit}
onError={handleError}
/>
</FormProvider>
</div>
</UnsavedChangesProvider>
</AppContent>

View File

@ -1,4 +1,15 @@
import { CreateCustomerRequestSchema, UpdateCustomerByIdRequestSchema } from "@erp/customers";
import * as z from "zod/v4";
import {
CreateCustomerRequestSchema,
GetCustomerByIdResponseSchema,
UpdateCustomerByIdRequestSchema,
} from "@erp/customers";
export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
export const CustomerSchema = GetCustomerByIdResponseSchema.omit({
metadata: true,
});
export type CustomerData = z.infer<typeof CustomerSchema>;

View File

@ -3,7 +3,7 @@ import * as z from "zod/v4";
export const CustomerFormSchema = z.object({
reference: z.string().optional(),
is_company: z.enum(["true", "false"]),
is_company: z.string().default("true"),
name: z
.string({
error: "El nombre es obligatorio",

View File

@ -11,6 +11,7 @@
"dependencies": {
"@erp/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*"
"@repo/rdx-utils": "workspace:*",
"@repo/rdx-logger": "workspace:*"
}
}

View File

@ -0,0 +1,2 @@
//export * from "./presenters";
export * from "./use-cases";

View File

@ -1 +1 @@
export * from "./send-invoice-verifactu.use-case";
export * from "./send-invoice.use-case";

View File

@ -4,18 +4,18 @@ import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { VerifactuRecordService } from "../../../domain";
type SendInvoiceVerifactuUseCaseInput = {
type SendInvoiceUseCaseInput = {
invoice_id: string;
};
export class SendInvoiceVerifactuUseCase {
export class SendInvoiceUseCase {
constructor(
private readonly service: VerifactuRecordService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public async execute(params: SendInvoiceVerifactuUseCaseInput) {
public async execute(params: SendInvoiceUseCaseInput) {
const { invoice_id } = params;
const idOrError = UniqueID.create(invoice_id);

View File

@ -0,0 +1,2 @@
export * from "./verifactu-record-estado";
export * from "./verifactu-record-url";

View File

@ -0,0 +1,68 @@
import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
interface IVerifactuRecordEstadoProps {
value: string;
}
export enum VERIFACTU_RECORD_STATUS {
PENDIENTE = "Pendiente", // <- Registro encolado y no procesado aún
CORRECTO = "Correcto", // <- Registro procesado correctamente por la AEAT
ACEPTADO_CON_ERROR = "Aceptado con errores", // <- Registro aceptado con errores por la AEAT. Se requiere enviar un registro de subsanación o emitir una rectificativa
INCORRECTO = "Incorrecto", // <- Registro considerado incorrecto por la AEAT. Se requiere enviar un registro de subsanación con rechazo_previo=S o rechazo_previo=X o emitir una rectificativa
DUPLICADO = "Duplicado", // <- Registro no aceptado por la AEAT por existir un registro con el mismo (serie, numero, fecha_expedicion)
ANULADO = "Anulado", // <- Registro de anulación procesado correctamente por la AEAT
FACTURA_INEXISTENTE = "Factura inexistente", // <- Registro de anulación no aceptado por la AEAT por no existir la factura.
RECHAZADO = "No registrado", // <- Registro rechazado por la AEAT
ERROR = "Error servidor AEAT", // <- Error en el servidor de la AEAT. Se intentará reenviar el registro de facturación de nuevo
}
export class VerifactuRecordEstado extends ValueObject<IVerifactuRecordEstadoProps> {
private static readonly ALLOWED_STATUSES = [
"Pendiente",
"Correcto",
"Aceptado con errores",
"Incorrecto",
"Duplicado",
"Anulado",
"Factura inexistente",
"No registrado",
"Error servidor AEAT",
];
private static readonly FIELD = "estado";
private static readonly ERROR_CODE = "INVALID_RECORD_STATUS";
/*
private static readonly TRANSITIONS: Record<string, string[]> = {
draft: [INVOICE_STATUS.SENT],
sent: [INVOICE_STATUS.APPROVED, INVOICE_STATUS.REJECTED],
approved: [INVOICE_STATUS.EMITTED],
rejected: [INVOICE_STATUS.DRAFT],
};
*/
static create(value: string): Result<VerifactuRecordEstado, Error> {
if (!VerifactuRecordEstado.ALLOWED_STATUSES.includes(value)) {
const detail = `Estado de la factura no válido: ${value}`;
return Result.fail(
new DomainValidationError(
VerifactuRecordEstado.ERROR_CODE,
VerifactuRecordEstado.FIELD,
detail
)
);
}
return Result.ok(new VerifactuRecordEstado({ value }));
}
getProps(): string {
return this.props.value;
}
toPrimitive() {
return this.getProps();
}
toString() {
return String(this.props.value);
}
}

View File

@ -0,0 +1,56 @@
import { DomainValidationError } from "@erp/core/api";
import { ValueObject } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
interface VerifactuRecordUrlProps {
value: string;
}
export class VerifactuRecordUrl extends ValueObject<VerifactuRecordUrlProps> {
private static readonly MAX_LENGTH = 255;
private static readonly FIELD = "verifactuRecordUrl";
private static readonly ERROR_CODE = "INVALID_URL";
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(VerifactuRecordUrl.MAX_LENGTH, {
message: `Description must be at most ${VerifactuRecordUrl.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = VerifactuRecordUrl.validate(value);
if (!valueIsValid.success) {
const detail = valueIsValid.error.message;
return Result.fail(
new DomainValidationError(VerifactuRecordUrl.ERROR_CODE, VerifactuRecordUrl.FIELD, detail)
);
}
return Result.ok(new VerifactuRecordUrl({ value }));
}
static createNullable(value?: string): Result<Maybe<VerifactuRecordUrl>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.none<VerifactuRecordUrl>());
}
return VerifactuRecordUrl.create(value).map((value) => Maybe.some(value));
}
getProps(): string {
return this.props.value;
}
toString() {
return String(this.props.value);
}
toPrimitive() {
return this.getProps();
}
}

View File

@ -1,3 +1,7 @@
export * from "./aggregates";
export * from "./repositories";
export * from "./services";
//export * from "./entities";
//export * from "./errors";
//export * from "./value-objects";

View File

@ -0,0 +1 @@
export * from "./logger";

View File

@ -0,0 +1,3 @@
import { loggerSingleton } from "@repo/rdx-logger";
export const logger = loggerSingleton();

View File

@ -0,0 +1,30 @@
import { IModuleServer, ModuleParams } from "@erp/core/api";
import { models, verifactuRouter } from "./infrastructure";
export const verifactuAPIModule: IModuleServer = {
name: "verifactu",
version: "1.0.0",
dependencies: ["customers-invoices"],
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
verifactuRouter(params);
logger.info("🚀 Verifactu module initialized", { label: this.name });
},
async registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 Verifactu module dependencies registered", {
label: this.name,
});
return {
models,
services: {
sendInvoiceToVerifactu: () => {},
/*...*/
},
};
},
};
export default verifactuAPIModule;

View File

@ -2,27 +2,13 @@
import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api";
import { JsonTaxCatalogProvider } from "@erp/core";
import {
InMemoryMapperRegistry,
InMemoryPresenterRegistry,
SequelizeTransactionManager,
} from "@erp/core/api";
import {
CreateCustomerInvoiceUseCase,
CustomerInvoiceFullPresenter,
CustomerInvoiceItemsFullPresenter,
CustomerInvoiceReportHTMLPresenter,
CustomerInvoiceReportPDFPresenter,
CustomerInvoiceReportPresenter,
GetCustomerInvoiceUseCase,
ListCustomerInvoicesPresenter,
ListCustomerInvoicesUseCase,
RecipientInvoiceFullPresenter,
ReportCustomerInvoiceUseCase,
} from "../application";
import { JsonTaxCatalogProvider, spainTaxCatalogProvider } from "@erp/core";
import { SendCustomerInvoiceUseCase } from "../application";
import { CustomerInvoiceItemsReportPersenter } from "../application/presenters/queries/customer-invoice-items.report.presenter";
import { CustomerInvoiceService } from "../domain";
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
@ -38,19 +24,13 @@ export type CustomerInvoiceDeps = {
taxes: JsonTaxCatalogProvider;
};
build: {
list: () => ListCustomerInvoicesUseCase;
get: () => GetCustomerInvoiceUseCase;
create: () => CreateCustomerInvoiceUseCase;
//update: () => UpdateCustomerInvoiceUseCase;
//delete: () => DeleteCustomerInvoiceUseCase;
report: () => ReportCustomerInvoiceUseCase;
send: () => SendCustomerInvoiceUseCase;
};
};
export function buildCustomerInvoiceDependencies(params: ModuleParams): CustomerInvoiceDeps {
export function buildVerifactuDependencies(params: ModuleParams): CustomerInvoiceDeps {
const { database } = params;
const transactionManager = new SequelizeTransactionManager(database);
const catalogs = { taxes: spainTaxCatalogProvider };
// Mapper Registry
const mapperRegistry = new InMemoryMapperRegistry();

View File

@ -1,8 +1,8 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { SendInvoiceVerifactuUseCase } from "../../../application/use-cases/send";
import { SendInvoiceUseCase } from "@erp/customer-invoices/api/application";
export class SendInvoiceVerifactuController extends ExpressController {
public constructor(private readonly useCase: SendInvoiceVerifactuUseCase) {
public constructor(private readonly useCase: SendInvoiceUseCase) {
super();
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
@ -15,10 +15,12 @@ export class SendInvoiceVerifactuController extends ExpressController {
}
const { invoice_id } = this.req.params;
const result = await this.useCase.execute({ invoice_id, companyId });
console.log("CONTROLLER -----ESTO ES UNA PRUEBA>>>>>>");
const result = await this.useCase.execute({ invoice_id });
return result.match(
({ data, filename }) => this.downloadPDF(data, filename),
(data) => this.ok(data, {}),
(err) => this.handleError(err)
);
}

View File

@ -0,0 +1 @@
export * from "./verifactu.routes";

View File

@ -2,7 +2,8 @@ import { RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth
import { ILogger, ModuleParams, validateRequest } from "@erp/core/api";
import { Application, NextFunction, Request, Response, Router } from "express";
import { Sequelize } from "sequelize";
import { ReportCustomerInvoiceByIdRequestSchema } from "../../../common/dto";
import { SendCustomerInvoiceByIdRequestSchema } from "../../../common/dto";
import { buildVerifactuDependencies } from "../dependencies";
import { SendInvoiceVerifactuController } from "./controllers";
export const verifactuRouter = (params: ModuleParams) => {
@ -13,7 +14,7 @@ export const verifactuRouter = (params: ModuleParams) => {
logger: ILogger;
};
//const deps = buildCustomerInvoiceDependencies(params);
const deps = buildVerifactuDependencies(params);
const router: Router = Router({ mergeParams: true });
@ -38,7 +39,7 @@ export const verifactuRouter = (params: ModuleParams) => {
router.get(
"/:invoice_id/sendVerifactu",
//checkTabContext,
validateRequest(ReportCustomerInvoiceByIdRequestSchema, "params"),
validateRequest(SendCustomerInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.report();
const controller = new SendInvoiceVerifactuController(useCase);

View File

@ -1,3 +1,3 @@
export * from "./mappers";
export * from "./sequelize";
//export * from "./mappers";
//export * from "./sequelize";
export * from "./express";

View File

@ -0,0 +1,120 @@
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { VerifactuRecordEstado } from "@erp/customer-invoices/api/domain/aggregates/value-objects";
import {
UniqueID,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InferCreationAttributes } from "sequelize";
import { VerifactuRecord, VerifactuRecordProps } from "../../../domain";
import { VerifactuRecordCreationAttributes, VerifactuRecordModel } from "../../sequelize";
export interface IVerifactuRecordDomainMapper
extends ISequelizeDomainMapper<
VerifactuRecordModel,
VerifactuRecordCreationAttributes,
VerifactuRecord
> {}
export class VerifactuRecordDomainMapper
extends SequelizeDomainMapper<
VerifactuRecordModel,
VerifactuRecordCreationAttributes,
VerifactuRecord
>
implements IVerifactuRecordDomainMapper
{
constructor(params: MapperParamsType) {
super();
}
private mapAttributesToDomain(source: VerifactuRecordModel, params?: MapperParamsType) {
const { errors, index, attributes } = params as {
index: number;
errors: ValidationErrorDetail[];
attributes: Partial<VerifactuRecordProps>;
};
const Id = extractOrPushError(UniqueID.create(source.id), `items[${index}].id`, errors);
const estado = extractOrPushError(
maybeFromNullableVO(source.estado, (value) => VerifactuRecordEstado.create(value)),
`items[${index}].estado`,
errors
);
/*
const quantity = extractOrPushError(
maybeFromNullableVO(source.quantity_value, (value) => ItemQuantity.create({ value })),
`items[${index}].discount_percentage`,
errors
);
const unitAmount = extractOrPushError(
maybeFromNullableVO(source.unit_amount_value, (value) =>
ItemAmount.create({ value, currency_code: attributes.currencyCode!.code })
),
`items[${index}].unit_amount`,
errors
);
*/
return {
Id,
estado,
};
}
public mapToDomain(
source: VerifactuRecordModel,
params?: MapperParamsType
): Result<VerifactuRecord, Error> {
const { errors, index, requireIncludes } = params as {
index: number;
requireIncludes: boolean;
errors: ValidationErrorDetail[];
attributes: Partial<VerifactuRecordProps>;
};
// 1) Valores escalares (atributos generales)
const attributes = this.mapAttributesToDomain(source, params);
// 2) Comprobar relaciones
/* if (requireIncludes) {
if (!source.taxes) {
errors.push({
path: `items[${index}].taxes`,
message: "Taxes not included in query (requireIncludes=true)",
});
}
}
*/
// 5) Construcción del elemento de dominio
const createResult = VerifactuRecord.create({
id: attributes.Id,
//invoiceId: attributes.invoiveID
estado: attributes.estado!,
url: attributes.url!,
qr1: attributes.qr1,
});
if (createResult.isFailure) {
return Result.fail(
new ValidationErrorCollection("Verifactu record entity creation failed", [
{ path: `items[${index}]`, message: createResult.error.message },
])
);
}
return createResult;
}
public mapToPersistence(
source: VerifactuRecord,
params?: MapperParamsType
): Result<InferCreationAttributes<VerifactuRecordModel, {}>, Error> {
throw new Error("not implemented");
}
}

View File

@ -0,0 +1,7 @@
import verifactuRecordModelInit from "./models/verifactu-record.model";
export * from "./models";
export * from "./verifactu-record.repository";
// Array de inicializadores para que registerModels() lo use
export const models = [verifactuRecordModelInit];

View File

@ -0,0 +1 @@
export * from "./verifactu-record.model";

View File

@ -0,0 +1,91 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
/*import {
CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel,
} from "./customer-invoice-item-tax.model";
*/
export type VerifactuRecordCreationAttributes = InferCreationAttributes<VerifactuRecordModel>;
export class VerifactuRecordModel extends Model<
InferAttributes<VerifactuRecordModel>,
InferCreationAttributes<VerifactuRecordModel>
> {
declare id: string;
declare invoice_id: string;
declare estado: string;
declare url: string;
declare qr1: JSON;
declare qr2: Blob;
static associate(database: Sequelize) {
const { VerifactuRecordModel } = database.models;
VerifactuRecordModel.belongsTo(VerifactuRecordModel, {
as: "verifactu-record",
targetKey: "id",
foreignKey: "invoice_id",
onDelete: "CASCADE",
onUpdate: "CASCADE",
});
}
}
export default (database: Sequelize) => {
VerifactuRecordModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
invoice_id: {
type: DataTypes.UUID,
allowNull: false,
},
estado: {
type: new DataTypes.TEXT(),
allowNull: true,
defaultValue: null,
},
url: {
type: new DataTypes.TEXT(),
allowNull: false,
defaultValue: null,
},
qr1: {
type: new DataTypes.JSON(),
allowNull: false,
defaultValue: null,
},
qr2: {
type: new DataTypes.BLOB(),
allowNull: false,
defaultValue: null,
},
},
{
sequelize: database,
tableName: "verifactu_records",
underscored: true,
indexes: [],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {
order: [["position", "ASC"]],
},
scopes: {},
}
);
return VerifactuRecordModel;
};

View File

@ -0,0 +1,251 @@
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize";
import { IVerifactuRecordRepository, VerifactuRecord } from "../../domain";
import {
// VerifactuRecordListDTO,
IVerifactuRecordDomainMapper,
} from "../mappers";
import { VerifactuRecordModel } from "./models/verifactu-record.model";
export class VerifactuRecordRepository
extends SequelizeRepository<VerifactuRecord>
implements IVerifactuRecordRepository
{
getById(id: UniqueID, transaction?: any): Promise<Result<VerifactuRecord, Error>> {
throw new Error("Method not implemented.");
}
// Listado por tenant con criteria saneada
/* async searchInCompany(criteria: any, companyId: string): Promise<{
rows: InvoiceListRow[];
total: number;
limit: number;
offset: number;
}> {
const { where, order, limit, offset, attributes } = sanitizeListCriteria(criteria);
// WHERE con scope de company
const scopedWhere = { ...where, company_id: companyId };
const options: FindAndCountOptions = {
where: scopedWhere,
order,
limit,
offset,
attributes,
raw: true, // devolvemos objetos planos -> más rápido
nest: false,
distinct: true // por si en el futuro añadimos includes no duplicar count
};
const { rows, count } = await VerifactuRecordModel.findAndCountAll(options);
return {
rows: rows as unknown as InvoiceListRow[],
total: typeof count === "number" ? count : (count as any[]).length,
limit,
offset,
};
} */
/**
*
* Persiste una nueva factura o actualiza una existente.
*
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<VerifactuRecord, Error>
*/
async save(
invoice: VerifactuRecord,
transaction: Transaction
): Promise<Result<VerifactuRecord, Error>> {
try {
const mapper: IVerifactuRecordDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const mapperData = mapper.mapToPersistence(invoice);
if (mapperData.isFailure) {
return Result.fail(mapperData.error);
}
const { data } = mapperData;
const [instance] = await VerifactuRecordModel.upsert(data, { transaction, returning: true });
const savedInvoice = mapper.mapToDomain(instance);
return savedInvoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Comprueba si existe una factura con un `id` dentro de una `company`.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
async existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: Transaction
): Promise<Result<boolean, Error>> {
try {
const count = await VerifactuRecordModel.count({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
*
* Busca una factura por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @returns Result<VerifactuRecord, Error>
*/
async getByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<VerifactuRecord, Error>> {
try {
const mapper: IVerifactuRecordDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const { CustomerModel } = this._database.models;
const row = await VerifactuRecordModel.findOne({
where: { id: id.toString(), company_id: companyId.toString() },
order: [[{ model: VerifactuRecordItemModel, as: "items" }, "position", "ASC"]],
include: [
{
model: CustomerModel,
as: "current_customer",
required: false, // false => LEFT JOIN
},
{
model: VerifactuRecordItemModel,
as: "items",
required: false,
include: [
{
model: VerifactuRecordItemTaxModel,
as: "taxes",
required: false,
},
],
},
{
model: VerifactuRecordTaxModel,
as: "taxes",
required: false,
},
],
transaction,
});
if (!row) {
return Result.fail(new EntityNotFoundError("VerifactuRecord", "id", id.toString()));
}
const customer = mapper.mapToDomain(row);
return customer;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<VerifactuRecord[], Error>
*
* @see Criteria
*/
public async findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction
): Promise<Result<Collection<VerifactuRecordListDTO>, Error>> {
try {
const mapper: IVerifactuRecordListMapper = this._registry.getQueryMapper({
resource: "customer-invoice",
query: "LIST",
});
const { CustomerModel } = this._database.models;
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
query.where = {
...query.where,
company_id: companyId.toString(),
};
query.include = [
{
model: CustomerModel,
as: "current_customer",
required: false, // false => LEFT JOIN
},
{
model: VerifactuRecordTaxModel,
as: "taxes",
required: false,
},
];
const { rows, count } = await VerifactuRecordModel.findAndCountAll({
...query,
transaction,
});
return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Elimina o marca como eliminada una factura.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: any
): Promise<Result<void, Error>> {
try {
const deleted = await VerifactuRecordModel.destroy({
where: { id: id.toString(), company_id: companyId.toString() },
transaction,
});
return Result.ok<void>();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./request";
//export * from "./response";

View File

@ -0,0 +1 @@
export * from "./send-customer-invoice-by-id.request.dto";

View File

@ -0,0 +1,9 @@
import * as z from "zod/v4";
export const SendCustomerInvoiceByIdRequestSchema = z.object({
invoice_id: z.string(),
});
export type SendCustomerInvoiceByIdRequestDTO = z.infer<
typeof SendCustomerInvoiceByIdRequestSchema
>;

View File

@ -0,0 +1 @@
export * from "./dto";

View File

@ -7,15 +7,13 @@
"ui:lint": "biome lint --fix"
},
"exports": {
"./helpers": "./src/helpers/index.ts",
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./components": "./src/components/index.tsx",
"./components/*": "./src/components/*.tsx",
"./locales/*": "./src/locales/*",
"./hooks/*": [
"./src/hooks/*.tsx",
"./src/hooks/*.ts"
]
"./hooks/*": ["./src/hooks/*.tsx", "./src/hooks/*.ts"]
},
"peerDependencies": {
"date-fns": "^4.1.0",

View File

@ -0,0 +1,46 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
export const Fieldset = ({ className, children, ...props }: React.ComponentProps<"fieldset">) => (
<fieldset
data-slot='fieldset'
className={cn("*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6", className)}
{...props}
>
{children}
</fieldset>
);
export const FieldGroup = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div data-slot='control' className={cn("space-y-8", className)} {...props}>
{children}
</div>
);
export const Field = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div
className={cn(
"[&>[data-slot=label]+[data-slot=control]]:mt-3 [&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=description]+[data-slot=control]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3 [&>[data-slot=control]+[data-slot=error]]:mt-3 *:data-[slot=label]:font-medium",
className
)}
{...props}
>
{children}
</div>
);
export const Legend = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div
data-slot='legend'
className={cn("text-base/6 font-semibold data-disabled:opacity-50 sm:text-sm/6", className)}
{...props}
>
{children}
</div>
);
export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => (
<p data-slot='text' className={cn("text-base/6 sm:text-sm/6", className)} {...props}>
{children}
</p>
);

View File

@ -1,5 +1,6 @@
export * from "./DatePickerField.tsx";
export * from "./DatePickerInputField.tsx";
export * from "./fieldset.tsx";
export * from "./form-content.tsx";
export * from "./multi-select-field.tsx";
export * from "./SelectField.tsx";

View File

@ -0,0 +1 @@
export * from "./toast-utils.ts";

View File

@ -0,0 +1,37 @@
import { toast } from "@repo/shadcn-ui/components";
/**
* Muestra un toast de aviso
*/
export function showInfoToast(title: string, description?: string) {
toast.info(title, {
description,
});
}
/**
* Muestra un toast de aviso
*/
export function showWarningToast(title: string, description?: string) {
toast.warning(title, {
description,
});
}
/**
* Muestra un toast de éxito
*/
export function showSuccessToast(title: string, description?: string) {
toast.success(title, {
description,
});
}
/**
* Muestra un toast de error
*/
export function showErrorToast(title: string, description?: string) {
toast.error(title, {
description,
});
}

View File

@ -1,4 +1,4 @@
"use client";
export const PACKAGE_NAME = "rdx-ui";
export * from "./components/index.tsx";
export * from "./helpers/index.ts";

View File

@ -1,25 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { toast } from "../components/sonner.tsx";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Muestra un toast de éxito
*/
export function showSuccessToast(title: string, description?: string) {
toast.success(title, {
description,
});
}
/**
* Muestra un toast de error
*/
export function showErrorToast(title: string, description?: string) {
toast.error(title, {
description,
});
}

View File

@ -665,6 +665,9 @@ importers:
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-logger':
specifier: workspace:*
version: link:../../packages/rdx-logger
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils