Clientes y facturas de cliente
This commit is contained in:
parent
9683ee5102
commit
8d0c0b88de
@ -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);
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
44
modules/core/src/web/lib/helpers/form-utils.ts
Normal file
44
modules/core/src/web/lib/helpers/form-utils.ts
Normal 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;
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./date-func";
|
||||
export * from "./form-utils";
|
||||
export * from "./money-funcs";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@erp/core": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*"
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
2
modules/verifactu/src/api/application/index.ts
Normal file
2
modules/verifactu/src/api/application/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
//export * from "./presenters";
|
||||
export * from "./use-cases";
|
||||
@ -1 +1 @@
|
||||
export * from "./send-invoice-verifactu.use-case";
|
||||
export * from "./send-invoice.use-case";
|
||||
|
||||
@ -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);
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./verifactu-record-estado";
|
||||
export * from "./verifactu-record-url";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
export * from "./aggregates";
|
||||
export * from "./repositories";
|
||||
export * from "./services";
|
||||
|
||||
//export * from "./entities";
|
||||
//export * from "./errors";
|
||||
//export * from "./value-objects";
|
||||
|
||||
1
modules/verifactu/src/api/helpers/index.ts
Normal file
1
modules/verifactu/src/api/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./logger";
|
||||
3
modules/verifactu/src/api/helpers/logger.ts
Normal file
3
modules/verifactu/src/api/helpers/logger.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { loggerSingleton } from "@repo/rdx-logger";
|
||||
|
||||
export const logger = loggerSingleton();
|
||||
30
modules/verifactu/src/api/index.ts
Normal file
30
modules/verifactu/src/api/index.ts
Normal 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;
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./verifactu.routes";
|
||||
@ -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);
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export * from "./mappers";
|
||||
export * from "./sequelize";
|
||||
//export * from "./mappers";
|
||||
//export * from "./sequelize";
|
||||
export * from "./express";
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
@ -0,0 +1 @@
|
||||
export * from "./verifactu-record.model";
|
||||
@ -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;
|
||||
};
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
modules/verifactu/src/common/dto/index.ts
Normal file
2
modules/verifactu/src/common/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./request";
|
||||
//export * from "./response";
|
||||
1
modules/verifactu/src/common/dto/request/index.ts
Normal file
1
modules/verifactu/src/common/dto/request/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./send-customer-invoice-by-id.request.dto";
|
||||
@ -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
|
||||
>;
|
||||
1
modules/verifactu/src/common/index.ts
Normal file
1
modules/verifactu/src/common/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./dto";
|
||||
@ -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",
|
||||
|
||||
46
packages/rdx-ui/src/components/form/fieldset.tsx
Normal file
46
packages/rdx-ui/src/components/form/fieldset.tsx
Normal 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>
|
||||
);
|
||||
@ -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";
|
||||
|
||||
1
packages/rdx-ui/src/helpers/index.ts
Normal file
1
packages/rdx-ui/src/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./toast-utils.ts";
|
||||
37
packages/rdx-ui/src/helpers/toast-utils.ts
Normal file
37
packages/rdx-ui/src/helpers/toast-utils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export const PACKAGE_NAME = "rdx-ui";
|
||||
|
||||
export * from "./components/index.tsx";
|
||||
export * from "./helpers/index.ts";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user