diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts index 793f2902..09c92f8e 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts @@ -42,13 +42,9 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => { getBaseUrl: () => (client as AxiosInstance).getUri(), getList: async (resource: string, params?: Record): Promise => { - const { pagination } = params as any; + const { signal, ...rest } = params as any; // en 'rest' puede venir el "criteria". - const res = await (client as AxiosInstance).get(resource, { - params: { - ...pagination, - }, - }); + const res = await (client as AxiosInstance).get(resource, { signal, params: rest }); return res.data; }, diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts index 4bcf5a6d..8a9c9bb9 100644 --- a/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts @@ -143,7 +143,7 @@ export default (database: Sequelize) => { is_proforma: { type: DataTypes.BOOLEAN, allowNull: false, - defaultValue: false, + defaultValue: true, }, status: { diff --git a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx index dbe4299f..44d5a139 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx @@ -34,7 +34,6 @@ export const InvoiceBasicInfoFields = () => { ; +} + +export const CustomerCard = ({ customer }: CustomerCardProps) => { + return ( +
+
+ +
+
+
+

{customer.name}

+ +
+
+
+ + {customer.email_primary} +
+ {customer.mobile_primary && ( +
+ + {customer.mobile_primary} +
+ )} + {customer.trade_name && ( +
+ + {customer.trade_name} +
+ )} + {customer.tin && ( +
+ + {customer.tin} +
+ )} +
+
+
+ ); +}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-empty-card.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-empty-card.tsx new file mode 100644 index 00000000..fcfb39f7 --- /dev/null +++ b/modules/customers/src/web/components/customer-modal-selector/customer-empty-card.tsx @@ -0,0 +1,20 @@ +import { SearchIcon, UserPlusIcon } from "lucide-react"; + +export const CustomerEmptyCard = () => { + return ( +
+
+ +
+
+

+ Seleccionar Cliente +

+

+ Haz clic para buscar un cliente existente o crear uno nuevo +

+
+ +
+ ); +}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-form-dialog.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-form-dialog.tsx new file mode 100644 index 00000000..137b2bf7 --- /dev/null +++ b/modules/customers/src/web/components/customer-modal-selector/customer-form-dialog.tsx @@ -0,0 +1,100 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@repo/shadcn-ui/components"; +import { Plus } from "lucide-react"; + +interface CustomerFormDialogProps { + open: boolean; + onOpenChange: (o: boolean) => void; + client: Omit; + onChange: (c: Omit) => void; + onSubmit: () => void; +} + +export const CreateCustomerFormDialog = ({ + open, + onOpenChange, + client, + onChange, + onSubmit, +}: CustomerFormDialogProps) => { + return ( + + + + + Agregar Nuevo Cliente + + + Complete la información del cliente. Los campos marcados con * son obligatorios. + + +
+ {/* Nombre */} +
+ + onChange({ ...client, name: e.target.value })} + /> +
+ {/* Email */} +
+ + onChange({ ...client, email: e.target.value })} + /> +
+ {/* Teléfono / NIF */} +
+
+ + onChange({ ...client, phone: e.target.value })} + /> +
+
+ + onChange({ ...client, taxId: e.target.value })} + /> +
+
+ {/* Empresa */} +
+ + onChange({ ...client, company: e.target.value })} + /> +
+
+ + + + +
+
+ ); +}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx index 340ba216..ba8dec2a 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx @@ -1,436 +1,122 @@ -"use client"; +import { useEffect, useMemo, useState } from "react"; +import { useCustomersSearchQuery } from "../../hooks"; +import { CustomerSummary } from "../../schemas"; +import { CustomerCard } from "./customer-card"; +import { CustomerEmptyCard } from "./customer-empty-card"; +import { CreateCustomerFormDialog } from "./customer-form-dialog"; +import { CustomerSearchDialog } from "./customer-search-dialog"; -import { - Badge, - Button, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, -} from "@repo/shadcn-ui/components"; -import { cn } from "@repo/shadcn-ui/lib/utils"; -import { - Building2Icon, - Check, - FileTextIcon, - Mail, - Phone, - Plus, - PlusIcon, - Search, - SearchIcon, - User, - XIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; - -interface Client { - id: string; - name: string; - email: string; - phone?: string; - company?: string; - taxId?: string; +// Debounce pequeño y tipado +function useDebouncedValue(value: T, delay = 300) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + return debounced; } -interface ClientSelectorProps { +interface CustomerModalSelectorProps { value?: string; - onValueChange?: (clientId: string) => void; - placeholder?: string; + onValueChange?: (id: string) => void; } -// Datos de ejemplo - en una app real vendrían de tu base de datos -const mockClients: Client[] = [ - { - id: "1", - name: "María García", - email: "maria.garcia@empresa.com", - phone: "+34 666 123 456", - company: "Empresa ABC S.L.", - taxId: "B12345678", - }, - { - id: "2", - name: "Juan Pérez", - email: "juan.perez@gmail.com", - phone: "+34 677 987 654", - company: "Autónomo", - taxId: "12345678Z", - }, - { - id: "3", - name: "Ana Martínez", - email: "ana@startup.io", - phone: "+34 688 555 777", - company: "StartUp Innovadora", - taxId: "B87654321", - }, - { - id: "4", - name: "Carlos López", - email: "carlos.lopez@corporacion.es", - phone: "+34 699 111 222", - company: "Corporación XYZ", - taxId: "A11111111", - }, -]; - -export function CustomerModalSelector({ - value, - onValueChange, - placeholder = "Seleccionar cliente...", -}: ClientSelectorProps) { - const [showClientSelector, setShowClientSelector] = useState(false); - const [clients, setClients] = useState(mockClients); - const [selectedClient, setSelectedClient] = useState(null); - const [showNewClientDialog, setShowNewClientDialog] = useState(false); +export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSelectorProps) => { + // UI state + const [showSearch, setShowSearch] = useState(false); + const [showForm, setShowForm] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); - const [newClient, setNewClient] = useState({ - name: "", - email: "", - phone: "", - company: "", - taxId: "", + // Cliente seleccionado y creación local optimista + const [selected, setSelected] = useState(null); + const [newClient, setNewClient] = useState>({ name: "", email: "" }); + const [localCreated, setLocalCreated] = useState([]); + + const { + data: remoteCustomers = [], + isLoading, + isError, + error, + } = useCustomersSearchQuery({ + q: debouncedQuery, + pageSize: 5, + orderBy: "updated_at", + order: "asc", }); + // Combinar locales optimistas + remotos + const customers: CustomerSummary[] = useMemo(() => { + const byId = new Map(); + [...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary)); + return Array.from(byId.values()); + }, [localCreated, remoteCustomers]); + + // Sync con `value` useEffect(() => { - if (value) { - const client = clients.find((c) => c.id === value); - setSelectedClient(client || null); - } - }, [value, clients]); + if (!value) return; + const found = customers.find((c) => c.id === value) ?? null; + setSelected(found); + }, [value, customers]); - const filteredClients = clients.filter( - (client) => - client.name.toLowerCase().includes(searchQuery.toLowerCase()) || - client.email.toLowerCase().includes(searchQuery.toLowerCase()) || - client.company?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleSelectClient = (client: Client) => { - setSelectedClient(client); - onValueChange?.(client.id); - setShowClientSelector(false); - }; - - const handleCreateClient = () => { + const handleCreate = () => { if (!newClient.name || !newClient.email) return; - - const client: Client = { - id: Date.now().toString(), - name: newClient.name, - email: newClient.email, - phone: newClient.phone || undefined, - company: newClient.company || undefined, - taxId: newClient.taxId || undefined, + const client: CustomerSummary = { + id: crypto.randomUUID?.() ?? Date.now().toString(), + ...newClient, }; - - setClients((prev) => [...prev, client]); - setSelectedClient(client); + setLocalCreated((prev) => [client, ...prev]); + setSelected(client); onValueChange?.(client.id); - setShowNewClientDialog(false); - setShowClientSelector(false); - - setNewClient({ - name: "", - email: "", - phone: "", - company: "", - taxId: "", - }); + setShowForm(false); + setShowSearch(false); }; - const openNewClientDialog = () => { - setNewClient({ ...newClient, name: searchQuery }); - setShowNewClientDialog(true); - }; + console.log(customers); return ( <> -
setShowClientSelector(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setShowClientSelector(true); - } - }} - className='group w-full cursor-pointer rounded-lg border border-border bg-card p-4 transition-all hover:bg-accent/50 hover:border-primary' + type='button' + onClick={() => setShowSearch(true)} + onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)} + className='group w-full cursor-pointer rounded-lg border border-border bg-card p-4 transition hover:bg-accent/50 hover:border-primary' + aria-label='Seleccionar cliente' > - {selectedClient ? ( -
-
- -
-
-
-

{selectedClient.name}

- -
-
-
- - {selectedClient.email} -
- {selectedClient.phone && ( -
- - {selectedClient.phone} -
- )} - {selectedClient.company && ( -
- - {selectedClient.company} -
- )} - {selectedClient.taxId && ( -
- - {selectedClient.taxId} -
- )} -
-
-
- ) : ( -
-
- -
-
-

- Seleccionar Cliente -

-

- Haz clic para buscar un cliente existente o crear uno nuevo -

-
- -
- )} -
+ {selected ? : } + - - - - -
- - Seleccionar Cliente -
- -
- - Busca un cliente existente o crea uno nuevo. - -
+ { + setSelected(c); + onValueChange?.(c.id); + setShowSearch(false); + }} + onCreateClient={(name) => { + /*setNewClient({ name: name ?? "", email: "" }); + setShowForm(true);*/ + }} + isLoading={isLoading} + isError={isError} + errorMessage={ + isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined + } + /> -
- -
- - -
- - -
- -

No se encontraron clientes

- {searchQuery && ( - - )} -
-
- - {filteredClients.map((client) => ( - handleSelectClient(client)} - className='flex items-center gap-3 p-3 cursor-pointer hover:bg-accent' - > -
- -
-
-
- {client.name} - {client.company && ( - - {client.company} - - )} -
-
-
- - {client.email} -
- {client.phone && ( -
- - {client.phone} -
- )} -
-
- -
- ))} -
- {filteredClients.length > 0 && ( - - -
- -
- Agregar nuevo cliente -
-
- )} -
-
-
-
-
- - - - - - - Agregar Nuevo Cliente - - - Complete la información del cliente. Los campos marcados con * son obligatorios. - - -
-
- - setNewClient({ ...newClient, name: e.target.value })} - placeholder='Ej: María García' - className='bg-input border-border text-foreground' - /> -
-
- - setNewClient({ ...newClient, email: e.target.value })} - placeholder='Ej: maria@empresa.com' - className='bg-input border-border text-foreground' - /> -
-
-
- - setNewClient({ ...newClient, phone: e.target.value })} - placeholder='Ej: +34 666 123 456' - className='bg-input border-border text-foreground' - /> -
-
- - setNewClient({ ...newClient, taxId: e.target.value })} - placeholder='Ej: 12345678Z' - className='bg-input border-border text-foreground' - /> -
-
-
- - setNewClient({ ...newClient, company: e.target.value })} - placeholder='Ej: Empresa ABC S.L.' - className='bg-input border-border text-foreground' - /> -
-
- - - - -
-
+ ); -} +}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx new file mode 100644 index 00000000..7b56866a --- /dev/null +++ b/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx @@ -0,0 +1,141 @@ +import { + Badge, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { AsteriskIcon, Check, MailIcon, Plus, SmartphoneIcon, User, UserIcon } from "lucide-react"; +import { CustomerSummary } from "../../schemas"; + +interface CustomerSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + searchQuery: string; + onSearchQueryChange: (q: string) => void; + customers: CustomerSummary[]; + selectedClient: CustomerSummary | null; + onSelectClient: (c: CustomerSummary) => void; + onCreateClient: (name?: string) => void; + isLoading?: boolean; + isError?: boolean; + errorMessage?: string; +} + +export const CustomerSearchDialog = ({ + open, + onOpenChange, + searchQuery, + onSearchQueryChange, + customers, + selectedClient, + onSelectClient, + onCreateClient, +}: CustomerSearchDialogProps) => { + return ( + + + + + + + Seleccionar Cliente + + + Busca un cliente existente o crea uno nuevo. + + +
+ + + + +
+ +

No se encontraron clientes

+ {searchQuery && ( + + )} +
+
+ + {customers.map((customer) => ( + onSelectClient(customer)} + className='flex items-center gap-x-4 py-5 cursor-pointer' + > +
+ +
+
+
+ {customer.name} + {customer.trade_name && ( + + {customer.trade_name} + + )} +
+
+ {customer.tin && ( + + {customer.tin} + + )} + {customer.email_primary && ( + + {customer.email_primary} + + )} + {customer.mobile_primary && ( + + {customer.mobile_primary} + + )} +
+
+ +
+ ))} +
+ + onCreateClient()} + className='flex items-center gap-3 p-3 border-t' + > +
+ +
+ Agregar nuevo cliente +
+
+
+
+
+
+
+ ); +}; diff --git a/modules/customers/src/web/hooks/index.ts b/modules/customers/src/web/hooks/index.ts index e7126993..53bb397c 100644 --- a/modules/customers/src/web/hooks/index.ts +++ b/modules/customers/src/web/hooks/index.ts @@ -2,4 +2,5 @@ export * from "./use-create-customer-mutation"; export * from "./use-customer-query"; export * from "./use-customers-context"; export * from "./use-customers-query"; +export * from "./use-customers-search-query"; export * from "./use-update-customer-mutation"; diff --git a/modules/customers/src/web/hooks/use-create-customer-mutation.ts b/modules/customers/src/web/hooks/use-create-customer-mutation.ts index 38d5cefc..3468fe87 100644 --- a/modules/customers/src/web/hooks/use-create-customer-mutation.ts +++ b/modules/customers/src/web/hooks/use-create-customer-mutation.ts @@ -2,7 +2,7 @@ import { useDataSource } from "@erp/core/hooks"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { CreateCustomerRequestSchema } from "../../common"; -import { CustomerData, CustomerFormData } from "../schemas"; +import { Customer, CustomerFormData } from "../schemas"; import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation"; type CreateCustomerPayload = { @@ -14,7 +14,7 @@ export function useCreateCustomer() { const dataSource = useDataSource(); const schema = CreateCustomerRequestSchema; - return useMutation({ + return useMutation({ mutationKey: ["customer:create"], mutationFn: async (payload) => { @@ -38,7 +38,7 @@ export function useCreateCustomer() { } const created = await dataSource.createOne("customers", newCustomerData); - return created as CustomerData; + return created as Customer; }, onSuccess: () => { diff --git a/modules/customers/src/web/hooks/use-customer-query.ts b/modules/customers/src/web/hooks/use-customer-query.ts index 553f66ca..4bfb8105 100644 --- a/modules/customers/src/web/hooks/use-customer-query.ts +++ b/modules/customers/src/web/hooks/use-customer-query.ts @@ -1,6 +1,6 @@ import { useDataSource } from "@erp/core/hooks"; import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; -import { CustomerData } from "../schemas"; +import { Customer } from "../schemas"; export const CUSTOMER_QUERY_KEY = (id: string): QueryKey => ["customer", id] as const; @@ -12,14 +12,14 @@ export function useCustomerQuery(customerId?: string, options?: CustomerQueryOpt const dataSource = useDataSource(); const enabled = (options?.enabled ?? true) && !!customerId; - return useQuery({ + return useQuery({ queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"), queryFn: async (context) => { const { signal } = context; if (!customerId) { if (!customerId) throw new Error("customerId is required"); } - return await dataSource.getOne("customers", customerId, { + return await dataSource.getOne("customers", customerId, { signal, }); }, diff --git a/modules/customers/src/web/hooks/use-customers-search-query.tsx b/modules/customers/src/web/hooks/use-customers-search-query.tsx new file mode 100644 index 00000000..81a8d782 --- /dev/null +++ b/modules/customers/src/web/hooks/use-customers-search-query.tsx @@ -0,0 +1,31 @@ +import { useDataSource } from "@erp/core/hooks"; +import { useQuery } from "@tanstack/react-query"; +import { CustomerSummary, CustomersPage } from "../schemas"; + +export interface CustomersCriteria { + q?: string; + orderBy?: string; + order?: string; + pageSize?: number; + pageNumber?: number; +} + +// Obtener todos los clientes +export const useCustomersSearchQuery = (criteria: CustomersCriteria) => { + const dataSource = useDataSource(); + + return useQuery({ + queryKey: ["customer", criteria], + queryFn: async (context) => { + const { signal } = context; + + const customers = await dataSource.getList("customers", { + signal, + ...criteria, + }); + + return customers as CustomersPage; + }, + select: (data) => data.items as CustomerSummary[], + }); +}; diff --git a/modules/customers/src/web/schemas/customer.api.schema.ts b/modules/customers/src/web/schemas/customer.api.schema.ts index 5e6507a5..bcb7b34d 100644 --- a/modules/customers/src/web/schemas/customer.api.schema.ts +++ b/modules/customers/src/web/schemas/customer.api.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; +import { ArrayElement } from "@repo/rdx-utils"; import { CreateCustomerRequestSchema, GetCustomerByIdResponseSchema, @@ -7,12 +8,18 @@ import { UpdateCustomerByIdRequestSchema, } from "../../common"; +// Esquemas (Zod) provenientes del servidor +export const CustomerSchema = GetCustomerByIdResponseSchema.omit({ metadata: true }); export const CustomerCreateSchema = CreateCustomerRequestSchema; export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema; -export const CustomerSchema = GetCustomerByIdResponseSchema.omit({ - metadata: true, -}); -export type CustomerData = z.infer; +// Tipos (derivados de Zod o DTOs del backend) +export type Customer = z.infer; // Entidad completa (GET/POST/PUT result) +export type CustomerCreateInput = z.infer; // Cuerpo para crear +export type CustomerUpdateInput = z.infer; // Cuerpo para actualizar -export type CustomersListData = ListCustomersResponseDTO; +// Resultado de consulta con criteria (paginado, etc.) +export type CustomersPage = ListCustomersResponseDTO; + +// Ítem simplificado dentro del listado (no toda la entidad) +export type CustomerSummary = ArrayElement; diff --git a/packages/rdx-ui/src/components/form/DatePickerInputField.tsx b/packages/rdx-ui/src/components/form/DatePickerInputField.tsx index b1d724bc..f6add0c4 100644 --- a/packages/rdx-ui/src/components/form/DatePickerInputField.tsx +++ b/packages/rdx-ui/src/components/form/DatePickerInputField.tsx @@ -106,7 +106,7 @@ export function DatePickerInputField({ readOnly={isReadOnly} disabled={isDisabled} className={cn( - "w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring", + "w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:font-normal placeholder:italic", isDisabled && "bg-muted text-muted-foreground cursor-not-allowed", isReadOnly && "bg-muted text-foreground cursor-default", !isDisabled && !isReadOnly && "bg-white text-foreground", diff --git a/packages/rdx-ui/src/components/form/SelectField.tsx b/packages/rdx-ui/src/components/form/SelectField.tsx index 7ec0169d..aa6fd35c 100644 --- a/packages/rdx-ui/src/components/form/SelectField.tsx +++ b/packages/rdx-ui/src/components/form/SelectField.tsx @@ -73,7 +73,10 @@ export function SelectField({