Facturas de cliente

This commit is contained in:
David Arranz 2025-07-17 20:04:00 +02:00
parent 75738dadde
commit 0e59a18cbb
22 changed files with 670 additions and 505 deletions

View File

@ -25,6 +25,7 @@
"complexity": {
"noForEach": "off",
"noBannedTypes": "info",
"noUselessFragments": "off",
"useOptionalChain": "off"
},
"suspicious": {

View File

@ -1,150 +1,5 @@
{
"common": {
"append_empty_row": "Añadir fila",
"append_empty_row_tooltip": "Añadir una fila vacía",
"duplicate_row": "Duplicar fila",
"insert_row_above": "Insertar fila encima",
"insert_row_below": "Insertar fila debajo",
"remove_row": "Eliminar"
},
"pages": {
"title": "Facturas",
"description": "Gestiona tus facturas",
"list": {
"title": "Lista de facturas",
"description": "Lista todas las facturas",
"grid_columns": {
"invoice_number": "Num. factura",
"invoice_series": "Serie",
"invoice_status": "Estado",
"issue_date": "Fecha",
"total_price": "Imp. total"
}
},
"create": {
"title": "Crear factura",
"description": "Crear una nueva factura",
"back_to_list": "Volver a la lista"
},
"edit": {
"title": "Editar factura",
"description": "Editar la factura seleccionada"
},
"delete": {
"title": "Eliminar factura",
"description": "Eliminar la factura seleccionada"
},
"view": {
"title": "Ver factura",
"description": "Ver los detalles de la factura seleccionada"
}
},
"status": {
"draft": "Borrador",
"emitted": "Emitida",
"sent": "Enviada",
"received": "Recibida",
"rejected": "Rechazada"
},
"form_fields": {
"invoice_number": {
"label": "Num. factura",
"placeholder": "",
"description": ""
},
"issue_date": {
"label": "Fecha",
"placeholder": "Seleccionar una fecha",
"description": "Fecha de emisión de la factura"
},
"invoice_series": {
"label": "Serie",
"placeholder": "",
"description": ""
},
"operation_date": {
"label": "Intervención",
"placeholder": "Seleccionar una fecha",
"description": "Fecha de intervención de los trabajos"
},
"description": {
"label": "Descripción",
"placeholder": "Descripción de la factura",
"description": "Descripción general de la factura"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Porcentaje de descuento"
},
"discount_price": {
"label": "Imp. descuento",
"placeholder": "",
"desc": "Importe del descuento"
},
"total_price": {
"label": "Imp. total",
"placeholder": "",
"description": "Importe total con el descuento ya aplicado"
},
"notes": {
"label": "Notas",
"placeholder": "Notas adicionales sobre la factura",
"description": "Notas adicionales que se pueden incluir en la factura"
},
"items": {
"quantity": {
"label": "Cantidad",
"placeholder": "",
"description": ""
},
"description": {
"label": "Descripción",
"placeholder": "",
"description": ""
},
"unit_price": {
"label": "Imp. unitario",
"placeholder": "",
"description": "Importe unitario del artículo"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Porcentaje de descuento"
},
"discount_price": {
"label": "Imp. descuento",
"placeholder": "",
"desc": "Importe del descuento"
},
"taxes": {
"label": "Impuestos",
"placeholder": "",
"desc": "Lista de impuestos aplicables"
},
"taxes_price": {
"label": "Imp. impuestos",
"placeholder": "",
"desc": "Importe de los impuestos"
},
"total_price": {
"label": "Imp. total",
"placeholder": "",
"description": "Importe total con el descuento ya aplicado"
}
}
},
"common": {},
"components": {
"customer_invoice_taxes_multi_select": {
"label": "Impuestos",

View File

@ -1,7 +1,4 @@
import { IModuleClient, ModuleClientParams } from "@erp/core/client";
import i18next from "i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { CustomerInvoiceRoutes } from "./customer-invoice-routes";
export const MODULE_NAME = "CustomerInvoices";
@ -10,13 +7,13 @@ const MODULE_VERSION = "1.0.0";
export const CustomerInvoicesModuleManifiest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["auth"],
dependencies: ["auth", "Customers"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => {
i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
// i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
// i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
return CustomerInvoiceRoutes(params);
},
};

View File

@ -35,6 +35,7 @@
"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",
@ -43,6 +44,8 @@
"slugify": "^1.6.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"use-debounce": "^10.0.5",
"use-query": "^1.0.2",
"zod": "^3.25.67"
}
}

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import * as z from "zod/v4";
/**
* DTO que transporta los parámetros de la consulta (paginación, filtros, etc.)
* para la búsqueda de clientes.
*
* Este DTO es utilizado por el endpoint:
* `GET /customers` (listado / búsqueda de clientes).
*
*/
const operatorEnum = z.enum([
"CONTAINS",
"NOT_CONTAINS",
"NOT_EQUALS",
"GREATER_THAN",
"GREATER_THAN_OR_EQUAL",
"LOWER_THAN",
"LOWER_THAN_OR_EQUAL",
"EQUALS",
]);
const filterSchema = z.object({
field: z.string(),
operator: operatorEnum,
value: z.string(),
});
export const ListCustomersQuerySchema = z.object({
filters: z.array(filterSchema).optional(),
pageSize: z.coerce.number().int().positive().optional(),
pageNumber: z.coerce.number().int().nonnegative().optional(),
orderBy: z.string().optional(),
order: z.enum(["asc", "desc"]).default("asc").optional(),
});
export type ListCustomersQueryDTO = z.infer<typeof ListCustomersQuerySchema>;

View File

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

View File

@ -0,0 +1,32 @@
import { MetadataSchema, createListViewSchema } from "@erp/core";
import * as z from "zod/v4";
export const ListCustomersResultSchema = createListViewSchema(
z.object({
id: z.uuid(),
reference: z.string().optional(),
is_freelancer: z.boolean(),
name: z.string(),
trade_name: z.string().optional(),
tin: z.string(),
street: z.string(),
city: z.string(),
state: z.string(),
postal_code: z.string(),
country: z.string(),
email: z.email(),
phone: z.string(),
default_tax: z.number(),
status: z.string(),
lang_code: z.string(),
currency_code: z.string(),
metadata: MetadataSchema.optional(),
})
);
export type ListCustomersResultDTO = z.infer<typeof ListCustomersResultSchema>;

View File

@ -0,0 +1,14 @@
{
"common": {},
"components": {
"entity_selector": {
"close": "Close",
"select_entity": "Select entity",
"create_new_entity": "Create new entity",
"search_entity": "Search entity",
"no_entities_found": "No results found for \"{{search}}\"",
"select_or_create": "Select an item from the list or create a new one.",
"create_label": "Create new item"
}
}
}

View File

@ -0,0 +1,14 @@
{
"common": {},
"components": {
"entity_selector": {
"close": "Cerrar",
"select_entity": "Seleccionar entidad",
"create_new_entity": "Crear nueva entidad",
"search_entity": "Buscar entidad",
"no_entities_found": "No se encontraron resultados para \"{{search}}\"",
"select_or_create": "Seleccione un elemento de la lista o cree uno nuevo.",
"create_label": "Crear nuevo elemento"
}
}
}

View File

@ -1,42 +1,58 @@
"use client";
import { LookupDialog } from "@repo/rdx-ui/components";
import DataTable, { TableColumn } from "react-data-table-component";
import { useDebounce } from "use-debounce";
import { generateUUIDv4 } from "@repo/rdx-utils";
import {
Badge,
Button,
Card,
CardContent,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Separator,
TableCell,
} from "@repo/shadcn-ui/components";
import {
Building,
Calendar,
Edit,
Mail,
MapPin,
Phone,
Plus,
Search,
Trash2,
User,
} from "lucide-react";
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react";
import { useCustomersQuery } from "../hooks";
const mockCustomers = [
type Customer = {
id: string;
name: string;
email: string;
company: string;
status: string;
};
const columns: TableColumn<Customer>[] = [
{
name: "Nombre",
selector: (row) => row.name,
sortable: true,
},
{
name: "Email",
selector: (row) => row.email,
},
{
name: "Empresa",
selector: (row) => row.company,
},
{
name: "Estado",
selector: (row) => row.status,
cell: (row) => (
<span className={row.status === "Activo" ? "text-green-600" : "text-gray-400"}>
{row.status}
</span>
),
},
];
const mockCustomers: Customer[] = [
{
id: "a1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Juan Pérez",
@ -79,357 +95,154 @@ const mockCustomers = [
},
];
async function fetchClientes(search: string): Promise<Customer[]> {
await new Promise((res) => setTimeout(res, 500));
const mock: Customer[] = [
{
id: "a1",
name: "Juan Pérez",
email: "juan@email.com",
phone: "+34 600 123 456",
company: "Tech Corp",
address: "Madrid",
createdAt: "2024-01-15",
status: "Activo",
},
{
id: "b1",
name: "María García",
email: "maria@email.com",
phone: "+34 600 789 012",
company: "Design Studio",
address: "Barcelona",
createdAt: "2024-02-20",
status: "Activo",
},
];
return mock.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase()) ||
c.company.toLowerCase().includes(search.toLowerCase())
);
}
export const ClientSelector = () => {
const [open, setOpen] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [newCustomer, setNewCustomer] = useState({
name: "",
email: "",
phone: "",
company: "",
address: "",
const [search, setSearch] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(10);
const [selectedCustomer, setSelectedCustomer] = useState(undefined);
const [debouncedSearch] = useDebounce(search, 400);
const paginated = filtered.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
const { data, isLoading, isError, error, refetch } = useCustomersQuery({
filters: [
{
field: "name",
operator: "CONTAINS",
value: debouncedSearch,
},
{
field: "trade_name",
operator: "CONTAINS",
value: debouncedSearch,
},
],
pageNumber,
pageSize,
});
const handleCreateCustomer = (e) => {
e.preventDefault();
const createdCustomer = {
id: generateUUIDv4(),
...newCustomer,
createdAt: new Date().toISOString().split("T")[0],
status: "Activo",
};
console.log("Cliente creado:", createdCustomer);
setSelectedCustomer(createdCustomer);
setIsCreateModalOpen(false);
setNewCustomer({ name: "", email: "", phone: "", company: "", address: "" });
};
const handleEditCustomer = () => {
console.log("Editar cliente:", selectedCustomer);
setIsDetailsModalOpen(false);
};
const handleDeleteCustomer = () => {
console.log("Eliminar cliente:", selectedCustomer);
setSelectedCustomer(null);
setIsDetailsModalOpen(false);
};
const handleSelectCustomer = (customer) => {
console.log("Seleccionar cliente:", customer);
setSelectedCustomer(customer);
setOpen(false);
};
return (
<div className='w-full max-w-md space-y-4'>
<div className='space-y-0'>
<Label className='m-0'>Cliente</Label>
<Button
variant='outline'
className='w-full justify-start bg-transparent'
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
>
<Search className='mr-2 h-4 w-4' />
{selectedCustomer ? selectedCustomer.name : "Buscar cliente..."}
<div className='space-y-4 max-w-2xl'>
<div className='space-y-1'>
<Label>Cliente</Label>
<Button variant='outline' className='w-full justify-start' onClick={() => setOpen(true)}>
<User className='h-4 w-4 mr-2' />
{selectedCustomer ? selectedCustomer.name : "Seleccionar cliente"}
</Button>
</div>
<CommandDialog
open={open}
onOpenChange={setOpen}
className='[&>div]:max-w-3xl [&>div]:max-h-[80vh] [&>div]:w-full'
>
<CommandInput
placeholder='Buscar cliente por nombre, email o empresa...'
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList className='max-w-screen'>
<CommandEmpty>
<div className='p-6 text-center'>
<User className='h-12 w-12 mx-auto text-muted-foreground mb-4' />
<p className='text-sm text-muted-foreground mb-4'>
No se encontró ningún cliente con "{searchValue}"
</p>
<Button
onClick={(e) => {
e.preventDefault();
setNewCustomer({ ...newCustomer, name: searchValue });
setIsCreateModalOpen(true);
setOpen(false);
}}
>
<Plus className='h-4 w-4 mr-2' />
Crear Cliente
</Button>
</div>
</CommandEmpty>
<CommandGroup heading='Clientes'>
{mockCustomers.map((customer) => (
<CommandItem
key={customer.id}
onSelect={() => handleSelectCustomer(customer)}
className='flex items-center space-x-3 p-3'
>
<User className='h-4 w-4 flex-shrink-0' />
<div className='flex-1 min-w-0'>
<div className='flex items-center space-x-2'>
<p className='font-medium truncate'>{customer.name}</p>
<Badge
variant={customer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
{customer.status}
</Badge>
</div>
<div className='flex items-center space-x-4 text-xs text-muted-foreground'>
<span className='flex items-center'>
<Building className='h-3 w-3 mr-1' />
{customer.company}
</span>
<span className='flex items-center'>
<Mail className='h-3 w-3 mr-1' />
{customer.email}
</span>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
<Separator />
<CommandGroup>
<CommandItem
onSelect={(e) => {
setIsCreateModalOpen(true);
setOpen(false);
}}
className='flex items-center space-x-3 p-3 text-primary'
>
<Plus className='h-4 w-4' />
<span>Crear nuevo cliente</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
{selectedCustomer && (
<Card className='border-primary'>
<CardContent className='p-4'>
<Card>
<CardContent className='p-4 space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex items-center space-x-3'>
<User className='h-8 w-8 text-primary' />
<div>
<div className='flex items-center space-x-2'>
<h3 className='font-semibold'>{selectedCustomer.name}</h3>
<Badge
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
{selectedCustomer.status}
</Badge>
</div>
<p className='text-sm text-muted-foreground'>{selectedCustomer.company}</p>
</div>
</div>
<div className='flex space-x-2'>
<Button
variant='outline'
size='sm'
onClick={(e) => {
e.preventDefault();
setIsDetailsModalOpen(true);
}}
<div className='flex items-center gap-2'>
<User className='h-6 w-6 text-primary' />
<h3 className='font-semibold'>{selectedCustomer.name}</h3>
<Badge
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
Ver Detalles
</Button>
<Button
variant='outline'
size='sm'
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
>
Cambiar
</Button>
{selectedCustomer.status}
</Badge>
</div>
<span className='text-sm text-muted-foreground'>{selectedCustomer.company}</span>
</div>
<p className='text-sm text-muted-foreground'>{selectedCustomer.email}</p>
</CardContent>
</Card>
)}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<LookupDialog
open={open}
onOpenChange={setOpen}
items={data?.items ?? []}
search={search}
onSearchChange={setSearch}
isLoading={isLoading}
title='Seleccionar cliente'
description='Busca un cliente por nombre, email o empresa'
onSelect={(item) => {
setSelectedCustomer(item);
setOpen(false);
}}
onCreate={() => {
setOpen(false);
console.log("Crear nuevo cliente");
}}
page={pageNumber}
perPage={perPage}
totalItems={filtered.length}
onPageChange={setPage}
renderItem={() => null} // No se usa con DataTable
renderContainer={(items) => (
<DataTable
columns={columns}
data={items}
onRowClicked={(item) => {
setSelectedCustomer(item);
setOpen(false);
}}
pagination
paginationServer
paginationPerPage={perPage}
paginationTotalRows={filtered.length}
onChangePage={(p) => setPage(p)}
highlightOnHover
pointerOnHover
noDataComponent='No se encontraron resultados'
/>
)}
/>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center space-x-2'>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' />
<span>Crear Nuevo Cliente</span>
Nuevo Cliente
</DialogTitle>
<DialogDescription>Completa la información del nuevo cliente</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<Label htmlFor='name'>Nombre *</Label>
<Input
id='name'
value={newCustomer.name}
onChange={(e) => setNewCustomer({ ...newCustomer, name: e.target.value })}
placeholder='Nombre completo'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='company'>Empresa</Label>
<Input
id='company'
value={newCustomer.company}
onChange={(e) => setNewCustomer({ ...newCustomer, company: e.target.value })}
placeholder='Nombre de la empresa'
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email *</Label>
<Input
id='email'
type='email'
value={newCustomer.email}
onChange={(e) => setNewCustomer({ ...newCustomer, email: e.target.value })}
placeholder='correo@ejemplo.com'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='phone'>Teléfono</Label>
<Input
id='phone'
value={newCustomer.phone}
onChange={(e) => setNewCustomer({ ...newCustomer, phone: e.target.value })}
placeholder='+34 600 000 000'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='address'>Dirección</Label>
<Input
id='address'
value={newCustomer.address}
onChange={(e) => setNewCustomer({ ...newCustomer, address: e.target.value })}
placeholder='Dirección completa'
/>
</div>
</div>
<p className='text-muted-foreground text-sm mb-4'>Formulario de creación pendiente</p>
<DialogFooter>
<Button
variant='outline'
onClick={(e) => {
e.preventDefault();
setIsCreateModalOpen(false);
}}
>
<Button variant='outline' onClick={() => setIsCreateOpen(false)}>
Cancelar
</Button>
<Button
onClick={handleCreateCustomer}
disabled={!newCustomer.name || !newCustomer.email}
>
<Button>
<Plus className='h-4 w-4 mr-2' />
Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDetailsModalOpen} onOpenChange={setIsDetailsModalOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center space-x-2'>
<User className='h-5 w-5' />
<span>Detalles del Cliente</span>
</DialogTitle>
</DialogHeader>
{selectedCustomer && (
<div className='space-y-6'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='text-lg font-semibold'>{selectedCustomer.name}</h3>
<p className='text-sm text-muted-foreground'>{selectedCustomer.company}</p>
</div>
<Badge variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}>
{selectedCustomer.status}
</Badge>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex items-center space-x-3'>
<Mail className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>EMAIL</Label>
<p className='font-medium'>{selectedCustomer.email}</p>
</div>
</div>
<div className='flex items-center space-x-3'>
<Phone className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>TELÉFONO</Label>
<p className='font-medium'>{selectedCustomer.phone}</p>
</div>
</div>
<div className='flex items-start space-x-3'>
<MapPin className='h-4 w-4 text-muted-foreground mt-1' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>DIRECCIÓN</Label>
<p className='font-medium'>{selectedCustomer.address}</p>
</div>
</div>
<div className='flex items-center space-x-3'>
<Calendar className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>
FECHA DE REGISTRO
</Label>
<p className='font-medium'>
{new Date(selectedCustomer.createdAt).toLocaleDateString("es-ES")}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div className='flex space-x-2'>
<Button className='flex-1'>
<Mail className='h-4 w-4 mr-2' />
Enviar Email
</Button>
<Button variant='outline' onClick={handleEditCustomer}>
<Edit className='h-4 w-4 mr-2' />
Editar
</Button>
<Button variant='outline' onClick={handleDeleteCustomer}>
<Trash2 className='h-4 w-4 mr-2' />
Eliminar
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={() => setIsDetailsModalOpen(false)}>
Cerrar
Crear
</Button>
</DialogFooter>
</DialogContent>
@ -437,3 +250,54 @@ export const ClientSelector = () => {
</div>
);
};
// COMPONENTES VISUALES
const CustomerCard = ({ customer }: { customer: Customer }) => (
<Card>
<CardContent className='p-4 space-y-2'>
<div className='flex items-center gap-2'>
<User className='h-5 w-5' />
<span className='font-semibold'>{customer.name}</span>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}
</Badge>
</div>
<div className='text-sm text-muted-foreground flex flex-col gap-1'>
<div className='flex items-center gap-1'>
<Mail className='h-4 w-4' />
{customer.email}
</div>
<div className='flex items-center gap-1'>
<Building className='h-4 w-4' />
{customer.company}
</div>
<div className='flex items-center gap-1'>
<Phone className='h-4 w-4' />
{customer.phone}
</div>
<div className='flex items-center gap-1'>
<MapPin className='h-4 w-4' />
{customer.address}
</div>
<div className='flex items-center gap-1'>
<Calendar className='h-4 w-4' />
{new Date(customer.createdAt).toLocaleDateString("es-ES")}
</div>
</div>
</CardContent>
</Card>
);
const CustomerRow = ({ customer }: { customer: Customer }) => (
<>
<TableCell>{customer.name}</TableCell>
<TableCell>{customer.email}</TableCell>
<TableCell>{customer.company}</TableCell>
<TableCell>
<Badge variant={customer.status === "Activo" ? "default" : "secondary"}>
{customer.status}
</Badge>
</TableCell>
</>
);

View File

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

View File

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

View File

@ -0,0 +1,24 @@
import { useDataSource, useQueryKey } from "@erp/core/client";
import { ListCustomersQueryDTO, ListCustomersResultDTO } from "@erp/customer-invoices/common/dto";
import { UseQueryResult, useQuery } from "@tanstack/react-query";
type UseCustomersQueryParams = ListCustomersQueryDTO;
// Obtener clientes
export const useCustomersQuery = (
params: UseCustomersQueryParams
): UseQueryResult<ListCustomersResultDTO, Error> => {
const dataSource = useDataSource();
const keys = useQueryKey();
return useQuery<ListCustomersResultDTO, Error>({
queryKey: keys().data().resource("customers").action("list").params(params).get(),
queryFn: (context) => {
const { signal } = context;
return dataSource.getList<ListCustomersResultDTO>("customers", {
signal,
...params,
});
},
});
};

View File

@ -0,0 +1,28 @@
import { useEffect } from "react";
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 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();
useEffect(() => {
addMissingBundles(i18n);
}, [i18n]);
return useI18NextTranslation(MODULE_NAME);
};

View File

@ -5,6 +5,7 @@ export * from "./error-overlay.tsx";
export * from "./form/index.tsx";
export * from "./layout/index.tsx";
export * from "./loading-overlay/index.tsx";
export * from "./lookup-dialog/index.tsx";
export * from "./multi-select.tsx";
export * from "./multiple-selector.tsx";
export * from "./scroll-to-top.tsx";

View File

@ -0,0 +1 @@
export * from "./lookup-dialog.tsx";

View File

@ -0,0 +1,143 @@
import { useTranslation } from "@repo/rdx-ui/locales/i18n.ts";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Separator,
} from "@repo/shadcn-ui/components";
import { PlusIcon, RefreshCwIcon } from "lucide-react";
type LookupDialogProps<T> = {
open: boolean;
onOpenChange: (open: boolean) => void;
items: T[];
isLoading: boolean;
isError?: boolean;
refetch?: () => void;
search: string;
onSearchChange: (value: string) => void;
title: string;
description?: string;
searchPlaceholder?: string;
onSelect: (item: T) => void;
onCreate: () => void;
createLabel?: string;
maxWidth?: "lg" | "xl" | "2xl" | "3xl";
renderItem: (item: T) => React.ReactNode; // no se usa si se usa renderContainer
renderContainer: (items: T[]) => React.ReactNode;
emptyStateComponent?: React.ReactNode;
page?: number;
perPage?: number;
totalItems?: number;
onPageChange?: (page: number) => void;
};
export const LookupDialog = <T,>({
open,
onOpenChange,
items,
isLoading,
isError,
refetch,
search,
onSearchChange,
title,
description,
searchPlaceholder,
onSelect,
onCreate,
createLabel,
maxWidth = "xl",
renderItem,
renderContainer,
emptyStateComponent,
page,
perPage = 10,
totalItems,
onPageChange,
}: LookupDialogProps<T>) => {
const { t } = useTranslation();
const widthClass = {
lg: "custom-dialog-lg",
xl: "custom-dialog-xl",
"2xl": "custom-dialog-2xl",
"3xl": "custom-dialog-3xl",
}[maxWidth];
const showPagination = totalItems !== undefined && onPageChange !== undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={`${widthClass} max-h-[90vh] overflow-y-auto`}>
<DialogHeader>
<DialogTitle>{title || t("components.entity_selector.select_entity")}</DialogTitle>
<DialogDescription>
{description || t("components.entity_selector.select_or_create")}
</DialogDescription>
</DialogHeader>
<div className='flex items-center justify-between mb-4 gap-4'>
<Input
placeholder={searchPlaceholder || t("components.entity_selector.search_entity")}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className='flex-1'
/>
</div>
<Separator />
{isLoading && (
<p className='text-center text-muted-foreground py-8'>
{t("components.entity_selector.loading")}
</p>
)}
{isError && (
<div className='text-center text-red-500 py-8 space-y-2'>
<p>Error al cargar los datos.</p>
{refetch && (
<Button variant='outline' onClick={refetch}>
<RefreshCwIcon className='h-4 w-4 mr-2' />
Reintentar
</Button>
)}
</div>
)}
{!isLoading && !isError && (
<>
{items.length === 0 ? (
emptyStateComponent || (
<p className='text-center text-sm text-muted-foreground mt-8'>
{t("components.entity_selector.no_entities_found")}
</p>
)
) : (
<div className='mt-4'>{renderContainer(items)}</div>
)}
</>
)}
<div className='mt-6 flex justify-center'>
<Button onClick={onCreate}>
<PlusIcon className='h-4 w-4 mr-2' />
{createLabel || t("components.entity_selector.create_label")}
</Button>
</div>
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t("components.entity_selector.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -16,9 +16,10 @@
"multi_select": {
"clear_selection": "Clear",
"close": "Close",
"loading": "Loading...",
"no_results": "No results found.",
"select_options": "Select options",
"select_all": "Select all",
"no_results": "No results found."
"select_all": "Select all"
}
}
}

View File

@ -16,9 +16,10 @@
"multi_select": {
"clear_selection": "Limpiar",
"close": "Cerrar",
"loading": "Cargando...",
"no_results": "No se han encontrado resultados.",
"select_options": "Seleccionar opciones",
"select_all": "Seleccionar todo",
"no_results": "No se han encontrado resultados."
"select_all": "Seleccionar todo"
}
}
}

View File

@ -604,6 +604,9 @@ importers:
react:
specifier: ^19.1.0
version: 19.1.0
react-data-table-component:
specifier: ^7.7.0
version: 7.7.0(react@19.1.0)(styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
@ -628,6 +631,12 @@ importers:
tw-animate-css:
specifier: ^1.3.5
version: 1.3.5
use-debounce:
specifier: ^10.0.5
version: 10.0.5(react@19.1.0)
use-query:
specifier: ^1.0.2
version: 1.0.2
zod:
specifier: ^3.25.67
version: 3.25.67
@ -1333,9 +1342,15 @@ packages:
'@emotion/hash@0.9.2':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
'@emotion/is-prop-valid@1.2.2':
resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==}
'@emotion/is-prop-valid@1.3.1':
resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==}
'@emotion/memoize@0.8.1':
resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
'@emotion/memoize@0.9.0':
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
@ -1367,6 +1382,9 @@ packages:
'@emotion/unitless@0.10.0':
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
'@emotion/unitless@0.8.1':
resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
'@emotion/use-insertion-effect-with-fallbacks@1.2.0':
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
peerDependencies:
@ -3058,6 +3076,9 @@ packages:
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/stylis@4.2.5':
resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==}
'@types/through@0.0.33':
resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==}
@ -3349,6 +3370,9 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001720:
resolution: {integrity: sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==}
@ -3579,9 +3603,16 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
css-what@6.1.0:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
@ -5349,6 +5380,10 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@ -5413,6 +5448,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-data-table-component@7.7.0:
resolution: {integrity: sha512-5knL6zMSKlbvzu9P04KM5Lx8/EyQujb4I9z3rWeoVX++IDJadQ7aR4X5J6EeS90wjK0Xoa6btaVeglnCAqD2ag==}
peerDependencies:
react: '>= 17.0.0'
styled-components: '>= 5.0.0'
react-day-picker@8.10.1:
resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==}
peerDependencies:
@ -5753,6 +5794,9 @@ packages:
shallow-equal-object@1.1.1:
resolution: {integrity: sha512-9DDzYRlzCwF2CemeF0aOFk5T5KMrjG7HldcW7utwYhA/limuGHn3No8KhpDE8BrO7GLaSRJumNKReipZBybd7A==}
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -5912,9 +5956,19 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
styled-components@6.1.19:
resolution: {integrity: sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==}
engines: {node: '>= 16'}
peerDependencies:
react: '>= 16.8.0'
react-dom: '>= 16.8.0'
stylis@4.2.0:
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
stylis@4.3.2:
resolution: {integrity: sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==}
stylus@0.62.0:
resolution: {integrity: sha512-v3YCf31atbwJQIMtPNX8hcQ+okD4NQaTuKGUWfII8eaqn+3otrbttGL1zSMZAAtiPsBztQnujVBugg/cXFUpyg==}
hasBin: true
@ -6108,6 +6162,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -6248,12 +6305,22 @@ packages:
'@types/react':
optional: true
use-debounce@10.0.5:
resolution: {integrity: sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '*'
use-deep-compare-effect@1.8.1:
resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==}
engines: {node: '>=10', npm: '>=6'}
peerDependencies:
react: '>=16.13'
use-query@1.0.2:
resolution: {integrity: sha512-Ypdv/LMbs4OnjCCZ4QtWVCu5XKUUiHZSf0X0dToZahX9BXs5LmVkBCgLN8PVEGcuulNI7fL1SOukFGesWhO2EA==}
engines: {node: '>=6.0.0'}
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
@ -6834,10 +6901,16 @@ snapshots:
'@emotion/hash@0.9.2': {}
'@emotion/is-prop-valid@1.2.2':
dependencies:
'@emotion/memoize': 0.8.1
'@emotion/is-prop-valid@1.3.1':
dependencies:
'@emotion/memoize': 0.9.0
'@emotion/memoize@0.8.1': {}
'@emotion/memoize@0.9.0': {}
'@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0)':
@ -6883,6 +6956,8 @@ snapshots:
'@emotion/unitless@0.10.0': {}
'@emotion/unitless@0.8.1': {}
'@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.0)':
dependencies:
react: 19.1.0
@ -8639,6 +8714,8 @@ snapshots:
'@types/stack-utils@2.0.3': {}
'@types/stylis@4.2.5': {}
'@types/through@0.0.33':
dependencies:
'@types/node': 22.15.32
@ -8969,6 +9046,8 @@ snapshots:
camelcase@6.3.0: {}
camelize@1.0.1: {}
caniuse-lite@1.0.30001720: {}
case@1.6.3: {}
@ -9208,6 +9287,8 @@ snapshots:
crypto-js@4.2.0: {}
css-color-keywords@1.0.0: {}
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
@ -9216,6 +9297,12 @@ snapshots:
domutils: 2.8.0
nth-check: 2.1.1
css-to-react-native@3.2.0:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
css-what@6.1.0: {}
cssesc@3.0.0: {}
@ -11129,6 +11216,12 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.4.49:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@ -11205,6 +11298,12 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-data-table-component@7.7.0(react@19.1.0)(styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)):
dependencies:
deepmerge: 4.3.1
react: 19.1.0
styled-components: 6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-day-picker@8.10.1(date-fns@4.1.0)(react@19.1.0):
dependencies:
date-fns: 4.1.0
@ -11536,6 +11635,8 @@ snapshots:
shallow-equal-object@1.1.1: {}
shallowequal@1.1.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@ -11685,8 +11786,24 @@ snapshots:
strip-json-comments@3.1.1: {}
styled-components@6.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@emotion/is-prop-valid': 1.2.2
'@emotion/unitless': 0.8.1
'@types/stylis': 4.2.5
css-to-react-native: 3.2.0
csstype: 3.1.3
postcss: 8.4.49
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
shallowequal: 1.1.0
stylis: 4.3.2
tslib: 2.6.2
stylis@4.2.0: {}
stylis@4.3.2: {}
stylus@0.62.0:
dependencies:
'@adobe/css-tools': 4.3.3
@ -11929,6 +12046,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.6.2: {}
tslib@2.8.1: {}
tsup@8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3):
@ -12070,12 +12189,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
use-debounce@10.0.5(react@19.1.0):
dependencies:
react: 19.1.0
use-deep-compare-effect@1.8.1(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.6
dequal: 2.0.3
react: 19.1.0
use-query@1.0.2: {}
use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.0):
dependencies:
detect-node-es: 1.1.0