Facturas de cliente y clientes
This commit is contained in:
parent
335c2764b7
commit
c260f64007
@ -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"
|
||||
|
||||
@ -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
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useDataSource } from "@erp/core/client";
|
||||
import { useDataSource } from "@erp/core/hooks";
|
||||
import { ILoginRequestDTO, ILoginResponseDTO } from "../../common";
|
||||
|
||||
export interface IAuthService {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
11
modules/core/src/common/locales/en.json
Normal file
11
modules/core/src/common/locales/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
11
modules/core/src/common/locales/es.json
Normal file
11
modules/core/src/common/locales/es.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
1
modules/core/src/web/components/form/index.ts
Normal file
1
modules/core/src/web/components/form/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./taxes-multi-select-field";
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
modules/core/src/web/components/index.ts
Normal file
2
modules/core/src/web/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./form";
|
||||
export * from "./taxes-multi-select";
|
||||
@ -1 +0,0 @@
|
||||
//
|
||||
@ -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}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./use-datasource";
|
||||
export * from "./use-pagination";
|
||||
export * from "./use-query-key";
|
||||
export * from "./use-toggle";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./datasource-context";
|
||||
@ -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,
|
||||
|
||||
25
modules/core/src/web/i18n.ts
Normal file
25
modules/core/src/web/i18n.ts
Normal 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);
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./hooks";
|
||||
export * from "./lib";
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./axios";
|
||||
export * from "./build-text-filters";
|
||||
export * from "./datasource-context";
|
||||
export * from "./datasource.interface";
|
||||
|
||||
20
modules/core/src/web/manifest.ts
Normal file
20
modules/core/src/web/manifest.ts
Normal 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";
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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, "''")}'`;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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"> & {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
@ -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 {
|
||||
//},
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>[] = [
|
||||
{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>(
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@ -1 +1,3 @@
|
||||
export * from "./client-selector";
|
||||
export * from "./customers-layout";
|
||||
export * from "./customers-list-grid";
|
||||
|
||||
@ -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
|
||||
|
||||
1
modules/customers/src/web/context/index.ts
Normal file
1
modules/customers/src/web/context/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customers-context";
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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"> & {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
73
modules/customers/src/web/pages/create/create.tsx
Normal file
73
modules/customers/src/web/pages/create/create.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
319
modules/customers/src/web/pages/create/customer-edit-form.tsx
Normal file
319
modules/customers/src/web/pages/create/customer-edit-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
1
modules/customers/src/web/pages/create/index.ts
Normal file
1
modules/customers/src/web/pages/create/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./create";
|
||||
41
modules/customers/src/web/pages/create/utils.ts
Normal file
41
modules/customers/src/web/pages/create/utils.ts
Normal 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);
|
||||
}
|
||||
2
modules/customers/src/web/pages/index.ts
Normal file
2
modules/customers/src/web/pages/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./create";
|
||||
export * from "./list";
|
||||
34
modules/customers/src/web/pages/list.tsx
Normal file
34
modules/customers/src/web/pages/list.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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"
|
||||
|
||||
84
packages/rdx-ui/src/components/form/SelectField.tsx
Normal file
84
packages/rdx-ui/src/components/form/SelectField.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
// DatePickerField.tsx
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./DatePickerField.tsx";
|
||||
export * from "./DatePickerInputField.tsx";
|
||||
export * from "./SelectField.tsx";
|
||||
export * from "./TextAreaField.tsx";
|
||||
export * from "./TextField.tsx";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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...",
|
||||
|
||||
535
pnpm-lock.yaml
535
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user