Facturas de cliente y clientes

This commit is contained in:
David Arranz 2025-08-23 13:57:48 +02:00
parent 335c2764b7
commit c260f64007
70 changed files with 1447 additions and 681 deletions

View File

@ -14,17 +14,12 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@hookform/devtools": "^4.4.0",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-query-devtools": "^5.74.11",
"@types/dinero.js": "^1.9.4",
"@types/node": "^22.15.12",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"vite": "^6.3.5"
},
@ -33,7 +28,6 @@
"@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tailwindcss/vite": "^4.1.11",
@ -45,12 +39,10 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-hook-form-persist": "^3.0.0",
"react-i18next": "^15.0.1",
"react-router-dom": "^6.26.0",
"react-secure-storage": "^1.3.2",
"sequelize": "^6.37.5",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.2.9",
"vite-plugin-html": "^3.2.2"

View File

@ -7,8 +7,8 @@ import { UnsavedWarnProvider } from "@/lib/hooks";
import { i18n } from "@/locales";
import { AuthProvider, createAuthService } from "@erp/auth/client";
import { DataSourceProvider, createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { createAxiosDataSource, createAxiosInstance } from "@erp/core/client";
import { DataSourceProvider } from "@erp/core/hooks";
import DineroFactory from "dinero.js";
import "./app.css";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib";
@ -30,8 +30,9 @@ export const App = () => {
baseURL: import.meta.env.VITE_API_SERVER_URL,
getAccessToken,
onAuthError: () => {
console.error("Error de autenticación");
clearAccessToken();
window.location.href = "/login"; // o usar navegación programática
//window.location.href = "/login"; // o usar navegación programática
},
});

View File

@ -1,5 +1,11 @@
import { AuthModuleManifiest } from "@erp/auth/client";
import { IModuleClient } from "@erp/core/client";
import CoreModuleManifiest, { IModuleClient } from "@erp/core/client";
import { CustomerInvoicesModuleManifiest } from "@erp/customer-invoices/client";
import { CustomersModuleManifiest } from "@erp/customers/client";
export const modules: IModuleClient[] = [AuthModuleManifiest, CustomerInvoicesModuleManifiest];
export const modules: IModuleClient[] = [
AuthModuleManifiest,
CoreModuleManifiest,
CustomersModuleManifiest,
CustomerInvoicesModuleManifiest,
];

View File

@ -1,4 +1,4 @@
import { useDataSource } from "@erp/core/client";
import { useDataSource } from "@erp/core/hooks";
import { ILoginRequestDTO, ILoginResponseDTO } from "../../common";
export interface IAuthService {

View File

@ -1,4 +1,4 @@
import { useDataSource } from "@erp/core/client";
import { useDataSource } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks";
import { User } from "./types";

View File

@ -1,4 +1,4 @@
import { useDataSource } from "@erp/core/client";
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "./types";

View File

@ -4,32 +4,34 @@
"exports": {
".": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./client": "./src/web/index.ts"
"./client": "./src/web/manifest.ts",
"./components": "./src/web/components/index.ts",
"./hooks": "./src/web/hooks/index.ts"
},
"peerDependencies": {
"dinero.js": "^1.9.1"
"dinero.js": "^1.9.1",
"react": "^19.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/axios": "^0.14.4",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/jest": "29.5.14",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"ts-to-zod": "^3.15.0",
"typescript": "^5.8.3",
"zod-to-ts": "^1.2.0"
"typescript": "^5.8.3"
},
"dependencies": {
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0",
"express": "^4.18.2",
"http-status": "^2.1.0",
"joi": "^17.13.3",
"libphonenumber-js": "^1.11.20",
"i18next": "^25.1.1",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"zod": "^3.25.67"

View File

@ -1,7 +1,7 @@
// src/common/middlewares/validate-dto.ts
import { ExpressController, InternalApiError, ValidationApiError } from "@erp/core/api";
import { RequestHandler } from "express";
import { ZodSchema } from "zod/v4";
import { InternalApiError, ValidationApiError } from "../../../errors";
import { ExpressController } from "../express-controller";
/**
* Middleware genérico para validar un objeto de Express

View File

@ -0,0 +1,11 @@
{
"common": {},
"components": {
"taxes_multi_select": {
"label": "Taxes",
"placeholder": "Select taxes",
"description": "Select the taxes to apply to the invoice items",
"invalid_tax_selection": "Invalid tax selection. Please select a valid tax."
}
}
}

View File

@ -0,0 +1,11 @@
{
"common": {},
"components": {
"taxes_multi_select": {
"label": "Impuestos",
"placeholder": "Seleccionar impuestos",
"description": "Seleccionar los impuestos a aplicar a los artículos de la factura",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido."
}
}
}

View File

@ -0,0 +1 @@
export * from "./taxes-multi-select-field";

View File

@ -0,0 +1,69 @@
// DatePickerField.tsx
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../i18n.ts";
import { TaxesMultiSelect } from "../taxes-multi-select.tsx";
type TaxesMultiSelectFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
placeholder?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
};
export function TaxesMultiSelectField<TFormValues extends FieldValues>({
control,
name,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
className,
}: TaxesMultiSelectFieldProps<TFormValues>) {
const { t } = useTranslation();
const isDisabled = disabled || readOnly;
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("space-y-0", className)}>
{label && (
<div className='flex justify-between items-center'>
<FormLabel className='m-0'>{label}</FormLabel>
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
</div>
)}
<FormControl>
<TaxesMultiSelect disabled={isDisabled} placeholder={placeholder} {...field} />
</FormControl>
<FormDescription
className={cn("text-xs text-muted-foreground", !description && "invisible")}
>
{description || "\u00A0"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -0,0 +1,2 @@
export * from "./form";
export * from "./taxes-multi-select";

View File

@ -1 +0,0 @@
//

View File

@ -33,13 +33,13 @@ const taxesList = [
{ label: "REC 0%", value: "rec_0", group: "Recargo de equivalencia" },
];
interface CustomerTaxesMultiSelect {
interface TaxesMultiSelect {
value: string[];
onChange: (selectedValues: string[]) => void;
[key: string]: any; // Allow other props to be passed
}
export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
export const TaxesMultiSelect = (props: TaxesMultiSelect) => {
const { value, onChange, ...otherProps } = props;
const { t } = useTranslation();
@ -50,7 +50,7 @@ export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
const handleValidateOption = (candidateValue: string) => {
const exists = (value || []).some((item) => item.startsWith(candidateValue.substring(0, 3)));
if (exists) {
alert(t("components.customer_invoice_taxes_multi_select.invalid_tax_selection"));
alert(t("components.taxes_multi_select.invalid_tax_selection"));
}
return exists === false;
};
@ -62,7 +62,7 @@ export const CustomerTaxesMultiSelect = (props: CustomerTaxesMultiSelect) => {
onValueChange={handleOnChange}
onValidateOption={handleValidateOption}
defaultValue={value}
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
placeholder={t("components.taxes_multi_select.placeholder")}
variant='inverted'
animation={0}
maxCount={3}

View File

@ -1,3 +1,4 @@
export * from "./use-datasource";
export * from "./use-pagination";
export * from "./use-query-key";
export * from "./use-toggle";

View File

@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import { IDataSource } from "./datasource.interface";
import { IDataSource } from "../../lib/data-source/datasource.interface";
const DataSourceContext = createContext<IDataSource | null>(null);

View File

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

View File

@ -7,7 +7,7 @@ import {
useQuery,
} from "@tanstack/react-query";
import { isResponseAListDTO } from "@erp/core/common/dto";
import { isResponseAListDTO } from "../../../common/dto";
import {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,

View File

@ -0,0 +1,25 @@
import { i18n } from "i18next";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: i18n) => {
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME);
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME);
if (needsEn) {
i18n.addResourceBundle("en", MODULE_NAME, enResources, true, true);
}
if (needsEs) {
i18n.addResourceBundle("es", MODULE_NAME, esResources, true, true);
}
};
export const useTranslation = () => {
const { i18n } = useI18NextTranslation();
addMissingBundles(i18n);
return useI18NextTranslation(MODULE_NAME);
};

View File

@ -1,2 +0,0 @@
export * from "./hooks";
export * from "./lib";

View File

@ -1,4 +1,3 @@
export * from "./axios";
export * from "./build-text-filters";
export * from "./datasource-context";
export * from "./datasource.interface";

View File

@ -0,0 +1,20 @@
import { IModuleClient, ModuleClientParams } from "./lib";
export const MODULE_NAME = "Core";
const MODULE_VERSION = "1.0.0";
export const CoreModuleManifiest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["core"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => {
return [];
},
};
export default CoreModuleManifiest;
export * from "./lib";

View File

@ -10,15 +10,17 @@
"./globals.css": "./src/web/globals.css"
},
"peerDependencies": {
"@tanstack/react-query": "^5.74.11",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"i18next": "^25.1.1",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"sequelize": "^6.37.5",
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
@ -39,21 +41,14 @@
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"@tanstack/react-table": "^8.21.3",
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
"date-fns": "^4.1.0",
"i18next": "^25.1.1",
"libphonenumber-js": "^1.12.7",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"slugify": "^1.6.6",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.4"
"react-router-dom": "^6.26.0"
}
}

View File

@ -1,7 +1,7 @@
import { CustomerInvoice } from "@erp/customer-invoices/api/domain";
import { CustomerInvoiceListResponseDTO } from "@erp/customer-invoices/common/dto";
import { Criteria } from "@repo/rdx-criteria/server";
import { Collection } from "@repo/rdx-utils";
import { CustomerInvoiceListResponseDTO } from "../../../../common/dto";
import { CustomerInvoice } from "../../../domain";
export class ListCustomerInvoicesAssembler {
toDTO(

View File

@ -25,6 +25,7 @@ export class ListCustomerInvoicesUseCase {
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
//const { rows, total, limit, offset } = await this.repo.searchInCompany(criteria, tenantId);
const result = await this.customerInvoiceService.findByCriteria(criteria, transaction);
if (result.isFailure) {

View File

@ -0,0 +1,167 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import { Op, OrderItem, WhereOptions, literal } from "sequelize";
// Campos físicos (DB) que permitimos filtrar/ordenar
const ALLOWED_FILTERS = {
id: "id",
customerId: "customer_id",
invoiceSeries: "invoice_series",
invoiceNumber: "invoice_number",
issueDate: "issue_date",
status: "status",
currencyCode: "currency_code",
// Rango por total (en unidades menores)
totalAmountValue: "total_amount_value",
} as const;
const ALLOWED_SORT: Record<string, string | string[]> = {
// Sort "issueDate" realmente ordena por (issue_date DESC, invoice_series ASC, invoice_number DESC, id DESC)
issueDate: ["issue_date"],
invoiceNumber: ["invoice_number"],
invoiceSeries: ["invoice_series"],
status: ["status"],
createdAt: ["created_at"],
updatedAt: ["updated_at"],
};
// Proyección mínima para el listado (evita N+1 y payloads grandes)
export const DEFAULT_LIST_ATTRIBUTES = [
"id",
"company_id",
"customer_id",
"invoice_series",
"invoice_number",
"issue_date",
"status",
"total_amount_value",
"total_amount_scale",
"currency_code",
// Agregamos itemsCount por subconsulta (no es columna real)
] as const;
type Sanitized = {
where: WhereOptions;
order: OrderItem[];
limit: number;
offset: number;
attributes: (string | any)[];
// keyset opcional
keyset?: {
after?: { issueDate: string; invoiceSeries: string; invoiceNumber: number; id: string };
};
};
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 25;
export function sanitizeListCriteria(criteria: Criteria): Sanitized {
const { filters = {}, sort = [], pagination = {}, search } = (criteria ?? {}) as any;
// LIMIT/OFFSET
const rawLimit = Number(pagination?.limit ?? DEFAULT_LIMIT);
const rawOffset = Number(pagination?.offset ?? 0);
const limit = Number.isFinite(rawLimit)
? Math.max(1, Math.min(MAX_LIMIT, rawLimit))
: DEFAULT_LIMIT;
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
// WHERE base siempre por seguridad a nivel superior (company en repo)
const where: WhereOptions = {};
// Búsqueda libre (q) → aplicar sobre campos concretos (series/number/status)
if (typeof search?.q === "string" && search.q.trim() !== "") {
const q = `%${search.q.trim()}%`;
Object.assign(where, {
[Op.or]: [
{ invoice_series: { [Op.like]: q } },
{ status: { [Op.like]: q } },
// Buscar por número como texto
literal(`CAST(invoice_number AS CHAR) LIKE ${escapeLikeLiteral(q)}`),
],
});
}
// Filtros permitidos
for (const [key, value] of Object.entries(filters)) {
const column = (ALLOWED_FILTERS as any)[key];
if (!column) {
throw forbiddenFilter(key);
}
if (value == null) continue;
// Operadores sencillos permitidos
if (typeof value === "object" && !Array.isArray(value)) {
const v = value as any;
const ops: any = {};
if (v.eq !== undefined) ops[Op.eq] = v.eq;
if (v.ne !== undefined) ops[Op.ne] = v.ne;
if (v.in !== undefined && Array.isArray(v.in)) ops[Op.in] = v.in;
if (v.like !== undefined) ops[Op.like] = `%${String(v.like)}%`;
if (v.gte !== undefined) ops[Op.gte] = v.gte;
if (v.lte !== undefined) ops[Op.lte] = v.lte;
if (Object.keys(ops).length === 0) continue;
(where as any)[column] = ops;
} else {
(where as any)[column] = value;
}
}
// ORDER (determinista)
const order: OrderItem[] = [];
const sortArray = Array.isArray(sort)
? sort
: String(sort || "")
.split(",")
.filter(Boolean);
if (sortArray.length === 0) {
// orden por defecto: issue_date desc, invoice_series asc, invoice_number desc, id desc
order.push(
["issue_date", "DESC"],
["invoice_series", "ASC"],
["invoice_number", "DESC"],
["id", "DESC"]
);
} else {
for (const part of sortArray) {
const desc = String(part).startsWith("-");
const key = desc ? String(part).slice(1) : String(part);
const allowed = ALLOWED_SORT[key];
if (!allowed) throw forbiddenSort(key);
const cols = Array.isArray(allowed) ? allowed : [allowed];
for (const c of cols) {
order.push([c, desc ? "DESC" : "ASC"]);
}
}
// tiebreaker final
order.push(["id", "DESC"]);
}
// Atributos (proyección)
const attributes: any[] = [...DEFAULT_LIST_ATTRIBUTES];
// itemsCount por subconsulta (evitamos include)
attributes.push([
literal(
"(SELECT COUNT(1) FROM customer_invoice_items it WHERE it.invoice_id = customer_invoices.id AND it.deleted_at IS NULL)"
),
"itemsCount",
]);
return { where, order, limit, offset, attributes };
}
// Helpers
function forbiddenFilter(field: string) {
const e = new Error(`Filter "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_FILTER";
return e;
}
function forbiddenSort(field: string) {
const e = new Error(`Sort "${field}" is not allowed`);
(e as any).code = "FORBIDDEN_SORT";
return e;
}
function escapeLikeLiteral(v: string) {
// Simple escapado para usar en literal; asume conexión confiable. Para máxima seguridad usa replacements.
return `'${v.replace(/'/g, "''")}'`;
}

View File

@ -45,9 +45,9 @@ export class CustomerInvoiceItemModel extends Model<
declare invoice: NonAttribute<CustomerInvoiceModel>;
static associate(database: Sequelize) {
const { Invoice_Model, CustomerInvoiceItem_Model } = connection.models;
const { CustomerInvoiceModel, CustomerInvoiceItemModel } = database.models;
CustomerInvoiceItem_Model.belongsTo(Invoice_Model, {
CustomerInvoiceItemModel.belongsTo(CustomerInvoiceModel, {
as: "customerInvoice",
targetKey: "id",
foreignKey: "invoice_id",

View File

@ -19,6 +19,39 @@ export class CustomerInvoiceRepository
this.mapper = mapper;
}
// 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 CustomerInvoiceModel.findAndCountAll(options);
return {
rows: rows as unknown as InvoiceListRow[],
total: typeof count === "number" ? count : (count as any[]).length,
limit,
offset,
};
} */
async existsById(id: UniqueID, transaction?: Transaction): Promise<Result<boolean, Error>> {
try {
const result = await this._exists(CustomerInvoiceModel, "id", id.toString(), transaction);

View File

@ -1,6 +1,6 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ICreateCustomerInvoiceRequestDTO } from "../../common/dto";
import { CreateCustomerInvoiceRequestDTO } from "../../common/dto";
export const useCreateCustomerInvoiceMutation = () => {
const queryClient = useQueryClient();
@ -8,9 +8,9 @@ export const useCreateCustomerInvoiceMutation = () => {
const keys = useQueryKey();
return useMutation<
ICreateCustomerInvoiceRequestDTO,
CreateCustomerInvoiceRequestDTO,
Error,
Partial<ICreateCustomerInvoiceRequestDTO>
Partial<CreateCustomerInvoiceRequestDTO>
>({
mutationFn: (data) => {
console.log(data);

View File

@ -1,4 +1,4 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query";
import { CustomerInvoiceListResponseDTO } from "../../common/dto";

View File

@ -1,4 +1,4 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { IListCustomerInvoicesResponseDTO } from "@erp/customerInvoices/common/dto";
export type UseCustomerInvoicesListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {

View File

@ -1,10 +1,10 @@
import { useEffect } from "react";
import { i18n } from "i18next";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: any) => {
const addMissingBundles = (i18n: i18n) => {
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME);
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME);
@ -19,10 +19,7 @@ const addMissingBundles = (i18n: any) => {
export const useTranslation = () => {
const { i18n } = useI18NextTranslation();
useEffect(() => {
addMissingBundles(i18n);
}, [i18n]);
addMissingBundles(i18n);
return useI18NextTranslation(MODULE_NAME);
};

View File

@ -7,13 +7,11 @@ const MODULE_VERSION = "1.0.0";
export const CustomerInvoicesModuleManifiest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["auth", "Customers"],
dependencies: ["auth", "Core", "Customers"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => {
// i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
// i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
return CustomerInvoiceRoutes(params);
},
};

View File

@ -44,7 +44,7 @@ import { useTranslation } from "../../i18n";
import { CustomerInvoiceData } from "./customer-invoice.schema";
import { formatCurrency } from "./utils";
const invoiceSchema = z.object({
const invoiceFormSchema = z.object({
id: z.string(),
invoice_status: z.string(),
invoice_number: z.string().min(1, "Número de factura requerido"),
@ -237,7 +237,7 @@ export const CustomerInvoiceEditForm = ({
const { t } = useTranslation();
const form = useForm<CustomerInvoiceData>({
resolver: zodResolver(invoiceSchema),
resolver: zodResolver(invoiceFormSchema),
defaultValues: initialData,
});

View File

@ -1,7 +1,7 @@
import { CreateCustomerInvoiceCommandSchema } from "@erp/customer-invoices/common/dto";
import * as z from "zod/v4";
import { CreateCustomerInvoiceRequestSchema } from "../../../common/dto";
export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSchema.extend({
export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceRequestSchema.extend({
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),

View File

@ -11,50 +11,38 @@
"./components": "./src/web/components/index.ts"
},
"peerDependencies": {
"@erp/core": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"dinero.js": "^1.9.1",
"express": "^4.18.2",
"i18next": "^25.1.1",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"sequelize": "^6.37.5",
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@hookform/devtools": "^4.4.0",
"@types/dinero.js": "^1.9.4",
"@types/express": "^4.17.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/react-i18next": "^8.1.0",
"typescript": "^5.8.3"
},
"dependencies": {
"@ag-grid-community/locale": "34.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@erp/auth": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/core": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"@tanstack/react-table": "^8.21.3",
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
"date-fns": "^4.1.0",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-data-table-component": "^7.7.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"slugify": "^1.6.6",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.4"
"use-debounce": "^10.0.5"
}
}

View File

@ -1,21 +1,21 @@
import { GetCustomerByIdResultDTO } from "../../../../common/dto";
import { GetCustomerByIdResponseDTO } from "../../../../common/dto";
import { Customer } from "../../../domain";
export class GetCustomerAssembler {
toDTO(customer: Customer): GetCustomerByIdResultDTO {
toDTO(customer: Customer): GetCustomerByIdResponseDTO {
return {
id: customer.id.toPrimitive(),
reference: customer.reference,
customer_status: customer.status.toString(),
customer_number: customer.customerNumber.toString(),
customer_series: customer.customerSeries.toString(),
issue_date: customer.issueDate.toDateString(),
operation_date: customer.operationDate.toDateString(),
language_code: "ES",
currency: customer.currency,
is_freelancer: customer.isFreelancer,
name: customer.name,
trade_name: customer.tradeName.getOrUndefined(),
tin: customer.tin.toPrimitive(),
metadata: {
entity: "customers",
entity: "customer",
//updated_at: customer.updatedAt.toDateString(),
//created_at: customer.createdAt.toDateString(),
},
//subtotal: customer.calculateSubtotal().toPrimitive(),

View File

@ -14,7 +14,7 @@ export class ListCustomersAssembler {
is_freelancer: customer.isFreelancer,
name: customer.name,
trade_name: customer.tradeName.getOrUndefined(),
trade_name: customer.tradeName.getOrUndefined() || "",
tin: customer.tin.toString(),
street: address.street,
@ -25,32 +25,28 @@ export class ListCustomersAssembler {
email: customer.email.getValue(),
phone: customer.phone.getValue(),
fax: customer.fax.getOrUndefined(),
website: customer.website.getOrUndefined(),
fax: customer.fax.isSome() ? customer.fax.getOrUndefined()!.getValue() : "",
website: customer.website.getOrUndefined() || "",
legal_record: customer.legalRecord,
default_tax: customer.defaultTax,
status: customer.isActive ? 'active' : 'inactive',
status: customer.isActive ? "active" : "inactive",
lang_code: customer.langCode,
currency_code: customer.currencyCode,
metadata: {
entity: "customer",
id: customer.id.toPrimitive(),
//created_at: customer.createdAt.toPrimitive(),
//updated_at: customer.updatedAt.toPrimitive()
}
metadata: {
entity: "customer",
id: customer.id.toPrimitive(),
//created_at: customer.createdAt.toPrimitive(),
//updated_at: customer.updatedAt.toPrimitive()
},
};
});
const totalItems = customers.total();
const totalItems = customers.total();
return {
return {
page: criteria.pageNumber,
per_page: criteria.pageSize,
total_pages: Math.ceil(totalItems / criteria.pageSize),
@ -66,5 +62,5 @@ return {
//},
},
};
},
}
}

View File

@ -1,5 +1,135 @@
{
"common": {},
"pages": {
"title": "Customers",
"description": "Manage your customers",
"list": {
"title": "Customer list",
"description": "List all customers",
"grid_columns": {
"name": "Name",
"trade_name": "Trade name",
"status": "Status",
"email": "Email"
}
},
"create": {
"title": "New customer",
"description": "Create a new customer",
"back_to_list": "Back to the list"
}
},
"form_fields": {
"customer_type": {
"label": "Customer type",
"description": "Select the type of customer",
"company": "Company",
"individual": "Individual"
},
"name": {
"label": "Name",
"placeholder": "Enter customer name",
"description": "The full name of the customer"
},
"trade_name": {
"label": "Trade name",
"placeholder": "Enter trade name",
"description": "The trade name of the customer"
},
"tin": {
"label": "Tax Identification Number",
"placeholder": "Enter TIN",
"description": "The tax identification number of the customer"
},
"reference": {
"label": "Reference",
"placeholder": "Enter reference",
"description": "A reference for the customer"
},
"street": {
"label": "Street",
"placeholder": "Enter street",
"description": "The street address of the customer"
},
"city": {
"label": "City",
"placeholder": "Enter city",
"description": "The city of the customer"
},
"postal_code": {
"label": "Postal code",
"placeholder": "Enter postal code",
"description": "The postal code of the customer"
},
"state": {
"label": "State",
"placeholder": "Enter state",
"description": "The state of the customer"
},
"country": {
"label": "Country",
"placeholder": "Select country",
"description": "The country of the customer"
},
"email": {
"label": "Email",
"placeholder": "Enter email",
"description": "The email address of the customer"
},
"phone": {
"label": "Phone",
"placeholder": "Enter phone number",
"description": "The phone number of the customer"
},
"fax": {
"label": "Fax",
"placeholder": "Enter fax number",
"description": "The fax number of the customer"
},
"website": {
"label": "Website",
"placeholder": "Enter website URL",
"description": "The website of the customer"
},
"default_tax": {
"label": "Default tax",
"placeholder": "Select default tax",
"description": "The default tax rate for the customer"
},
"lang_code": {
"label": "Language",
"placeholder": "Select language",
"description": "The preferred language of the customer"
},
"currency_code": {
"label": "Currency",
"placeholder": "Select currency",
"description": "The preferred currency of the customer"
},
"legal_record": {
"label": "Legal record",
"placeholder": "Enter legal record",
"description": "The legal record of the customer"
}
},
"form_groups": {
"basic_info": {
"title": "Basic information",
"description": "General customer details"
},
"address": {
"title": "Address",
"description": "Customer location"
},
"contact_info": {
"title": "Contact information",
"description": "Customer contact details"
},
"additional_config": {
"title": "Additional settings",
"description": "Additional customer configurations"
}
},
"components": {
"entity_selector": {
"close": "Close",

View File

@ -1,5 +1,135 @@
{
"common": {},
"pages": {
"title": "Clientes",
"description": "Gestiona tus clientes",
"list": {
"title": "Lista de clientes",
"description": "Lista todos los clientes",
"grid_columns": {
"name": "Nombre",
"trade_name": "Nombre comercial",
"status": "Estado",
"email": "Correo electrónico"
}
},
"create": {
"title": "Nuevo cliente",
"description": "Crear un nuevo cliente",
"back_to_list": "Volver a la lista"
}
},
"form_fields": {
"customer_type": {
"label": "Tipo de cliente",
"description": "Seleccione el tipo de cliente",
"company": "Empresa",
"individual": "Persona física"
},
"name": {
"label": "Nombre",
"placeholder": "Ingrese el nombre del cliente",
"description": "El nombre completo del cliente"
},
"trade_name": {
"label": "Nombre comercial",
"placeholder": "Ingrese el nombre comercial",
"description": "El nombre comercial del cliente"
},
"tin": {
"label": "Número de Identificación Fiscal",
"placeholder": "Ingrese el NIF",
"description": "El número de identificación fiscal del cliente"
},
"reference": {
"label": "Referencia",
"placeholder": "Ingrese la referencia",
"description": "Una referencia interna para el cliente"
},
"street": {
"label": "Calle",
"placeholder": "Ingrese la calle",
"description": "La dirección de la calle del cliente"
},
"city": {
"label": "Ciudad",
"placeholder": "Ingrese la ciudad",
"description": "La ciudad del cliente"
},
"postal_code": {
"label": "Código postal",
"placeholder": "Ingrese el código postal",
"description": "El código postal del cliente"
},
"state": {
"label": "Estado",
"placeholder": "Ingrese el estado",
"description": "El estado del cliente"
},
"country": {
"label": "País",
"placeholder": "Seleccione el país",
"description": "El país del cliente"
},
"email": {
"label": "Correo electrónico",
"placeholder": "Ingrese el correo electrónico",
"description": "La dirección de correo electrónico del cliente"
},
"phone": {
"label": "Teléfono",
"placeholder": "Ingrese el número de teléfono",
"description": "El número de teléfono del cliente"
},
"fax": {
"label": "Fax",
"placeholder": "Ingrese el número de fax",
"description": "El número de fax del cliente"
},
"website": {
"label": "Sitio web",
"placeholder": "Ingrese la URL del sitio web",
"description": "El sitio web del cliente"
},
"default_tax": {
"label": "Impuesto por defecto",
"placeholder": "Seleccione el impuesto por defecto",
"description": "La tasa de impuesto por defecto para el cliente"
},
"lang_code": {
"label": "Idioma",
"placeholder": "Seleccione el idioma",
"description": "El idioma preferido del cliente"
},
"currency_code": {
"label": "Moneda",
"placeholder": "Seleccione la moneda",
"description": "La moneda preferida del cliente"
},
"legal_record": {
"label": "Registro legal",
"placeholder": "Ingrese el registro legal",
"description": "El registro legal del cliente"
}
},
"form_groups": {
"basic_info": {
"title": "Información básica",
"description": "Detalles generales del cliente"
},
"address": {
"title": "Dirección",
"description": "Ubicación del cliente"
},
"contact_info": {
"title": "Información de contacto",
"description": "Detalles de contacto del cliente"
},
"additional_config": {
"title": "Configuración adicional",
"description": "Configuraciones adicionales del cliente"
}
},
"components": {
"entity_selector": {
"close": "Cerrar",

View File

@ -3,7 +3,6 @@ import DataTable, { TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import { buildTextFilters } from "@erp/core/client";
import { ListCustomersResultDTO } from "@erp/customers/common/dto";
import {
Badge,
Button,
@ -19,9 +18,10 @@ import {
} from "@repo/shadcn-ui/components";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react";
import { CustomerListResponsetDTO } from "../../common";
import { useCustomersQuery } from "../hooks";
type Customer = ListCustomersResultDTO["items"][number];
type Customer = CustomerListResponsetDTO["items"][number];
const columns: TableColumn<Customer>[] = [
{

View File

@ -1,88 +0,0 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../i18n";
import { formatCurrency } from "../pages/create/utils";
export const CustomerPricesCard = () => {
const { t } = useTranslation();
const { register, formState, control, watch } = useFormContext();
/*const pricesWatch = useWatch({ control, name: ["subtotal_price", "discount", "tax"] });
const totals = calculateQuoteTotals(pricesWatch);
const subtotal_price = formatNumber(totals.subtotalPrice);
const discount_price = formatNumber(totals.discountPrice);
const tax_price = formatNumber(totals.taxesPrice);
const total_price = formatNumber(totals.totalPrice);*/
const currency_symbol = watch("currency");
return (
<Card>
<CardHeader>
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className='flex flex-row items-end gap-2 p-4'>
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
<CardDescription className='text-sm'>
{t("form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.discount_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
{t("form_fields.tax_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-2xl tabular-nums'>
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>{" "}
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-0'>
<CardDescription className='text-sm font-semibold text-right text-foreground'>
{t("form_fields.total_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-3xl tabular-nums'>
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -3,7 +3,7 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../i18n";
export type CustomerStatus = "draft" | "emitted" | "sent" | "received" | "rejected";
export type CustomerStatus = "active" | "inactive";
export type CustomerStatusBadgeProps = {
status: string; // permitir cualquier valor
@ -11,30 +11,16 @@ export type CustomerStatusBadgeProps = {
};
const statusColorConfig: Record<CustomerStatus, { badge: string; dot: string }> = {
draft: {
inactive: {
badge:
"bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60",
dot: "bg-gray-500",
},
emitted: {
badge:
"bg-amber-600/10 dark:bg-amber-600/20 hover:bg-amber-600/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
sent: {
badge:
"bg-cyan-600/10 dark:bg-cyan-600/20 hover:bg-cyan-600/10 text-cyan-500 border-cyan-600/60 shadow-none rounded-full",
dot: "bg-cyan-500",
},
received: {
active: {
badge:
"bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-600/10 dark:bg-red-600/20 hover:bg-red-600/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
};
export const CustomerStatusBadge = forwardRef<HTMLDivElement, CustomerStatusBadgeProps>(

View File

@ -7,8 +7,6 @@ import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
ModuleRegistry.registerModules([AllCommunityModule]);
import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { AgGridReact } from "ag-grid-react";
import { useCustomersQuery } from "../hooks";
@ -23,31 +21,20 @@ export const CustomersListGrid = () => {
// Column Definitions: Defines & controls grid columns.
const [colDefs] = useState<ColDef[]>([
{
field: "invoice_status",
filter: true,
headerName: t("pages.list.grid_columns.invoice_status"),
field: "status",
headerName: t("pages.list.grid_columns.status"),
cellRenderer: (params: ValueFormatterParams) => {
return <CustomerStatusBadge status={params.value} />;
},
},
{ field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") },
{ field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") },
{ field: "name", headerName: t("pages.list.grid_columns.name") },
{ field: "trade_name", headerName: t("pages.list.grid_columns.trade_name") },
{
field: "issue_date",
headerName: t("pages.list.grid_columns.issue_date"),
valueFormatter: (params: ValueFormatterParams) => {
return formatDate(params.value);
},
},
{
field: "total_price",
headerName: t("pages.list.grid_columns.total_price"),
valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value;
return formatMoney(rawValue);
},
field: "email",
headerName: t("pages.list.grid_columns.email"),
},
]);

View File

@ -1 +1,3 @@
export * from "./client-selector";
export * from "./customers-layout";
export * from "./customers-list-grid";

View File

@ -2,7 +2,7 @@ import { PropsWithChildren, createContext } from "react";
/**
*
* 💡 Posibles usos del InvoicingContext
* 💡 Posibles usos del Context
*
* Este contexto se diseña para encapsular estado y lógica compartida dentro del
* bounded context de facturación (facturas), proporcionando acceso global a datos

View File

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

View File

@ -1,19 +1,2 @@
@source "./components";
@source "./pages";
.custom-dialog-lg {
max-width: 1024px !important;
width: 100% !important;
}
.custom-dialog-xl {
max-width: 1280px !important;
width: 100% !important;
}
.custom-dialog-2xl {
max-width: 1536px !important;
width: 100% !important;
}
.custom-dialog-3xl {
max-width: 1920px !important;
width: 100% !important;
}

View File

@ -1,13 +1,13 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ICreateCustomerRequestDTO } from "../../common/dto";
import { CreateCustomerRequestDTO } from "../../common/dto";
export const useCreateCustomerMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const keys = useQueryKey();
return useMutation<ICreateCustomerRequestDTO, Error, Partial<ICreateCustomerRequestDTO>>({
return useMutation<CreateCustomerRequestDTO, Error, Partial<CreateCustomerRequestDTO>>({
mutationFn: (data) => {
console.log(data);
return dataSource.createOne("customers", data);

View File

@ -1,4 +1,4 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query";
import { CustomerListResponsetDTO } from "../../common/dto";

View File

@ -1,4 +1,4 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { useDataSource, useQueryKey } from "@erp/core/hooks";
import { IListCustomersResponseDTO } from "@erp/customers/common/dto";
export type UseCustomersListParams = Omit<IGetListDataProviderParams, "filters" | "resource"> & {

View File

@ -1,10 +1,10 @@
import { useEffect } from "react";
import { i18n } from "i18next";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
const addMissingBundles = (i18n: any) => {
const addMissingBundles = (i18n: i18n) => {
const needsEn = !i18n.hasResourceBundle("en", MODULE_NAME);
const needsEs = !i18n.hasResourceBundle("es", MODULE_NAME);
@ -19,10 +19,7 @@ const addMissingBundles = (i18n: any) => {
export const useTranslation = () => {
const { i18n } = useI18NextTranslation();
useEffect(() => {
addMissingBundles(i18n);
}, [i18n]);
addMissingBundles(i18n);
return useI18NextTranslation(MODULE_NAME);
};

View File

@ -1,6 +1,5 @@
import { IModuleClient, ModuleClientParams } from "@erp/core/client";
//import enResources from "../common/locales/en.json";
//import esResources from "../common/locales/es.json";
import { CustomerRoutes } from "./customer-routes";
export const MODULE_NAME = "Customers";
const MODULE_VERSION = "1.0.0";
@ -8,15 +7,12 @@ const MODULE_VERSION = "1.0.0";
export const CustomersModuleManifiest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["auth"],
dependencies: ["auth", "Core"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => {
//i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
//i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
//return CustomerRoutes(params);
return [];
return CustomerRoutes(params);
},
};

View File

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

View File

@ -0,0 +1,319 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { TaxesMultiSelectField } from "@erp/core/components";
import { SelectField, TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Form,
Label,
RadioGroup,
RadioGroupItem,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
import { CustomerData, CustomerDataFormSchema } from "./customer.schema";
const defaultCustomerData = {
id: "5e4dc5b3-96b9-4968-9490-14bd032fec5f",
status: "active",
name: "1",
language_code: "ES",
currency: "EUR",
};
interface CustomerFormProps {
initialData?: CustomerData;
isPending?: boolean;
/**
* Callback function to handle form submission.
* @param data - The customer data submitted by the form.
*/
onSubmit?: (data: CustomerData) => void;
}
export const CustomerEditForm = ({
initialData = defaultCustomerData,
onSubmit,
isPending,
}: CustomerFormProps) => {
const { t } = useTranslation();
const form = useForm<CustomerData>({
resolver: zodResolver(CustomerDataFormSchema),
defaultValues: initialData,
});
const handleSubmit = (data: CustomerData) => {
console.log("Datos del formulario:", data);
onSubmit?.(data);
};
const handleError = (errors: any) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleCancel = () => {
form.reset(initialData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)}>
<div className='grid grid-cols-1 space-y-6'>
{/* Información básica */}
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.basic_info.title")}</CardTitle>
<CardDescription>{t("form_groups.basic_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<div className='space-y-3 xl:col-span-2'>
<Label className='text-sm font-medium'>
{t("form_fields.customer_type.label")}
</Label>
<RadioGroup
value={"customer_type"}
onValueChange={(value: "company" | "individual") => {
// Usar setValue del form
form.setValue("customer_type", value);
}}
className='flex gap-6'
>
<div className='flex items-center space-x-2'>
<RadioGroupItem
value='company'
id='company'
{...form.register("customer_type")}
/>
<Label htmlFor='company'>{t("form_fields.customer_type.company")}</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem
value='individual'
id='individual'
{...form.register("customer_type")}
/>
<Label htmlFor='individual'>{t("form_fields.customer_type.individual")}</Label>
</div>
</RadioGroup>
</div>
<TextField
control={form.control}
name='name'
required
label={t("form_fields.name.label")}
placeholder={t("form_fields.name.placeholder")}
description={t("form_fields.name.description")}
/>
<TextField
control={form.control}
name='trade_name'
required
label={t("form_fields.trade_name.label")}
placeholder={t("form_fields.trade_name.placeholder")}
description={t("form_fields.trade_name.description")}
/>
<TextField
control={form.control}
name='tin'
required
label={t("form_fields.tin.label")}
placeholder={t("form_fields.tin.placeholder")}
description={t("form_fields.tin.description")}
/>
<TextField
control={form.control}
name='reference'
required
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
/>
</CardContent>
</Card>
{/* Dirección */}
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.address.title")}</CardTitle>
<CardDescription>{t("form_groups.address.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
className='xl:col-span-2'
control={form.control}
name='street'
required
label={t("form_fields.street.label")}
placeholder={t("form_fields.street.placeholder")}
description={t("form_fields.street.description")}
/>
<TextField
control={form.control}
name='city'
required
label={t("form_fields.city.label")}
placeholder={t("form_fields.city.placeholder")}
description={t("form_fields.city.description")}
/>
<TextField
control={form.control}
name='postal_code'
required
label={t("form_fields.postal_code.label")}
placeholder={t("form_fields.postal_code.placeholder")}
description={t("form_fields.postal_code.description")}
/>
<TextField
control={form.control}
name='state'
required
label={t("form_fields.state.label")}
placeholder={t("form_fields.state.placeholder")}
description={t("form_fields.state.description")}
/>
<SelectField
control={form.control}
name='country'
required
label={t("form_fields.country.label")}
placeholder={t("form_fields.country.placeholder")}
description={t("form_fields.country.description")}
items={[
{ value: "ES", label: "España" },
{ value: "FR", label: "Francia" },
{ value: "DE", label: "Alemania" },
{ value: "IT", label: "Italia" },
{ value: "PT", label: "Portugal" },
{ value: "US", label: "Estados Unidos" },
{ value: "MX", label: "México" },
{ value: "AR", label: "Argentina" },
]}
/>
</CardContent>
</Card>
{/* Contacto */}
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.contact_info.title")}</CardTitle>
<CardDescription>{t("form_groups.contact_info.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TextField
control={form.control}
name='email'
required
label={t("form_fields.email.label")}
placeholder={t("form_fields.email.placeholder")}
description={t("form_fields.email.description")}
/>
<TextField
control={form.control}
name='phone'
required
label={t("form_fields.phone.label")}
placeholder={t("form_fields.phone.placeholder")}
description={t("form_fields.phone.description")}
/>
<TextField
control={form.control}
name='fax'
required
label={t("form_fields.fax.label")}
placeholder={t("form_fields.fax.placeholder")}
description={t("form_fields.fax.description")}
/>
<TextField
className='xl:col-span-2'
control={form.control}
name='website'
required
label={t("form_fields.website.label")}
placeholder={t("form_fields.website.placeholder")}
description={t("form_fields.website.description")}
/>
</CardContent>
</Card>
{/* Configuraciones Adicionales */}
<Card className='border-0 shadow-none'>
<CardHeader>
<CardTitle>{t("form_groups.additional_config.title")}</CardTitle>
<CardDescription>{t("form_groups.additional_config.description")}</CardDescription>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-y-8 gap-x-6 @xl:grid-cols-2'>
<TaxesMultiSelectField
control={form.control}
name='default_tax'
required
label={t("form_fields.default_tax.label")}
placeholder={t("form_fields.default_tax.placeholder")}
description={t("form_fields.default_tax.description")}
/>
<SelectField
control={form.control}
name='lang_code'
required
label={t("form_fields.lang_code.label")}
placeholder={t("form_fields.lang_code.placeholder")}
description={t("form_fields.lang_code.description")}
items={[
{ value: "es", label: "Español" },
{ value: "en", label: "Inglés" },
{ value: "fr", label: "Francés" },
{ value: "de", label: "Alemán" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Portugués" },
]}
/>
<SelectField
control={form.control}
name='currency_code'
required
label={t("form_fields.currency_code.label")}
placeholder={t("form_fields.currency_code.placeholder")}
description={t("form_fields.currency_code.description")}
items={[
{ value: "EUR", label: "Euro" },
{ value: "USD", label: "Dólar estadounidense" },
{ value: "GBP", label: "Libra esterlina" },
{ value: "ARS", label: "Peso argentino" },
{ value: "MXN", label: "Peso mexicano" },
{ value: "JPY", label: "Yen japonés" },
]}
/>
<TextAreaField
className=''
control={form.control}
name='legal_record'
required
label={t("form_fields.legal_record.label")}
placeholder={t("form_fields.legal_record.placeholder")}
description={t("form_fields.legal_record.description")}
/>
</CardContent>
</Card>
</div>
</form>
</Form>
);
};

View File

@ -0,0 +1,6 @@
import * as z from "zod/v4";
import { CreateCustomerRequestSchema } from "../../../common";
export const CustomerDataFormSchema = CreateCustomerRequestSchema;
export type CustomerData = z.infer<typeof CustomerDataFormSchema>;

View File

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

View File

@ -0,0 +1,41 @@
import type { InvoiceItem } from "@/types/invoice";
export function calculateItemTotal(quantity: number, unitPrice: number, discount = 0): number {
const subtotal = quantity * unitPrice;
const discountAmount = (subtotal * discount) / 100;
return subtotal - discountAmount;
}
export function calculateInvoiceTotals(items: InvoiceItem[], taxRate = 21) {
const subtotal = items.reduce((sum, item) => {
return (
sum + (item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale)
);
}, 0);
const totalDiscount = items.reduce((sum, item) => {
const itemSubtotal =
(item.quantity.amount * item.unit_price.amount) / Math.pow(10, item.unit_price.scale);
return sum + (itemSubtotal * item.discount.amount) / Math.pow(10, item.discount.scale) / 100;
}, 0);
const beforeTax = subtotal - totalDiscount;
const taxAmount = (beforeTax * taxRate) / 100;
const total = beforeTax + taxAmount;
return {
subtotal: Math.round(subtotal * 100),
totalDiscount: Math.round(totalDiscount * 100),
beforeTax: Math.round(beforeTax * 100),
taxAmount: Math.round(taxAmount * 100),
total: Math.round(total * 100),
};
}
export function formatCurrency(amount: number, scale = 2, currency = "EUR"): string {
const value = amount / Math.pow(10, scale);
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency,
}).format(value);
}

View File

@ -0,0 +1,2 @@
export * from "./create";
export * from "./list";

View File

@ -0,0 +1,34 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CustomersListGrid } from "../components";
import { useTranslation } from "../i18n";
export const CustomersList = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.list.title")}</h2>
<p className='text-muted-foreground'>{t("pages.list.description")}</p>
</div>
<div className='flex items-center space-x-2'>
<Button onClick={() => navigate("/customers/create")} className='cursor-pointer'>
<PlusIcon className='w-4 h-4 mr-2' />
{t("pages.create.title")}
</Button>
</div>
</div>
<div className='flex flex-col w-full h-full py-4'>
<CustomersListGrid />
</div>
</AppContent>
</>
);
};

View File

@ -1,11 +1,7 @@
{
"name": "uecko-erp-2025",
"private": true,
"workspaces": [
"apps/*",
"modules/*",
"packages/*"
],
"workspaces": ["apps/*", "modules/*", "packages/*"],
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
@ -21,9 +17,7 @@
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@repo/typescript-config": "workspace:*",
"fs": "0.0.1-security",
"inquirer": "^12.5.2",
"path": "^0.12.7",
"ts-node": "^10.9.2",
"turbo": "^2.5.1",
"typescript": "5.8.3"

View File

@ -0,0 +1,84 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Control, FieldPath, FieldValues } from "react-hook-form";
import { useTranslation } from "../../locales/i18n.ts";
type SelectFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
items: Array<{ value: string; label: string }>;
label?: string;
placeholder?: string;
description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
};
export function SelectField<TFormValues extends FieldValues>({
control,
name,
items,
label,
placeholder,
description,
disabled = false,
required = false,
readOnly = false,
className,
}: SelectFieldProps<TFormValues>) {
const { t } = useTranslation();
const isDisabled = disabled || readOnly;
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("space-y-0", className)}>
{label && (
<div className='flex justify-between items-center'>
<FormLabel className='m-0'>{label}</FormLabel>
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
</div>
)}
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isDisabled}>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{items.map((item) => (
<SelectItem key={`key-${item.value}`} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription
className={cn("text-xs text-muted-foreground", !description && "invisible")}
>
{description || "\u00A0"}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@ -1,5 +1,3 @@
// DatePickerField.tsx
import {
FormControl,
FormField,

View File

@ -1,4 +1,5 @@
export * from "./DatePickerField.tsx";
export * from "./DatePickerInputField.tsx";
export * from "./SelectField.tsx";
export * from "./TextAreaField.tsx";
export * from "./TextField.tsx";

View File

@ -1,5 +1,5 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { t } from "i18next";
import { useTranslation } from "../../locales/i18n.ts";
import { LoadingSpinIcon } from "./loading-spin-icon.tsx";
export type LoadingIndicatorProps = {
@ -13,9 +13,10 @@ export type LoadingIndicatorProps = {
export const LoadingIndicator = ({
active = true,
look = "dark",
title = t("components.loading_indicator.title"),
title,
subtitle = "",
}: LoadingIndicatorProps) => {
const { t } = useTranslation();
const isDark = look === "dark";
const loadingSpinClassName = isDark ? "text-brand" : "text-white";
@ -24,11 +25,7 @@ export const LoadingIndicator = ({
}
return (
<div
className={
"flex flex-col items-center justify-center max-w-xs justify-center w-full h-full mx-auto"
}
>
<div className={"flex flex-col items-center max-w-xs justify-center w-full h-full mx-auto"}>
<LoadingSpinIcon size={12} className={loadingSpinClassName} />
{/*<Spinner {...spinnerProps} />*/}
{title ? (
@ -38,12 +35,12 @@ export const LoadingIndicator = ({
isDark ? "text-slate-600" : "text-white"
)}
>
{title}
{title || t("components.loading_indicator.title")}
</h2>
) : null}
{subtitle ? (
<p className={cn("text-center text-white", isDark ? "text-slate-600" : "text-white")}>
{subtitle}
{subtitle || t("components.loading_indicator.subtitle")}
</p>
) : null}
</div>

View File

@ -7,7 +7,8 @@
},
"components": {
"loading_indicator": {
"title": "Loading..."
"title": "Loading...",
"subtitle": "This may take a few seconds. Please do not close this page."
},
"loading_overlay": {
"title": "Loading...",

View File

@ -6,8 +6,9 @@
"search": "Buscar"
},
"components": {
"LoadingIndicator": {
"title": "Cargando..."
"loading_indicator": {
"title": "Cargando...",
"subtitle": "Esto puede tardar unos segundos. Por favor, no cierre esta página."
},
"loading_overlay": {
"title": "Cargando...",

File diff suppressed because it is too large Load Diff