This commit is contained in:
David Arranz 2025-09-30 17:58:04 +02:00
parent ae6afd2c28
commit d90eaccde0
18 changed files with 469 additions and 433 deletions

View File

@ -42,13 +42,9 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
getBaseUrl: () => (client as AxiosInstance).getUri(), getBaseUrl: () => (client as AxiosInstance).getUri(),
getList: async <T, R>(resource: string, params?: Record<string, unknown>): Promise<R> => { getList: async <T, R>(resource: string, params?: Record<string, unknown>): Promise<R> => {
const { pagination } = params as any; const { signal, ...rest } = params as any; // en 'rest' puede venir el "criteria".
const res = await (client as AxiosInstance).get<T[]>(resource, { const res = await (client as AxiosInstance).get<T[]>(resource, { signal, params: rest });
params: {
...pagination,
},
});
return <R>res.data; return <R>res.data;
}, },

View File

@ -143,7 +143,7 @@ export default (database: Sequelize) => {
is_proforma: { is_proforma: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false, defaultValue: true,
}, },
status: { status: {

View File

@ -34,7 +34,6 @@ export const InvoiceBasicInfoFields = () => {
<TextField <TextField
control={control} control={control}
name='invoice_number' name='invoice_number'
required
readOnly readOnly
label={t("form_fields.invoice_number.label")} label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")} placeholder={t("form_fields.invoice_number.placeholder")}

View File

@ -138,6 +138,8 @@ export class CustomerRepository
company_id: companyId.toString(), company_id: companyId.toString(),
}; };
console.log(query);
const { rows, count } = await CustomerModel.findAndCountAll({ const { rows, count } = await CustomerModel.findAndCountAll({
...query, ...query,
transaction, transaction,

View File

@ -0,0 +1,47 @@
import { ArrayElement } from "@repo/rdx-utils";
import { Building2Icon, FileTextIcon, Mail, Phone, Search, User } from "lucide-react";
import { CustomersListData } from "../../schemas";
interface CustomerCardProps {
customer: ArrayElement<CustomersListData["items"]>;
}
export const CustomerCard = ({ customer }: CustomerCardProps) => {
return (
<div className='flex items-start gap-4'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 border border-primary/20'>
<User className='h-6 w-6 text-primary' />
</div>
<div className='flex-1 space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='font-semibold text-foreground text-lg'>{customer.name}</h3>
<Search className='size-4 text-muted-foreground' />
</div>
<div className='space-y-1 text-sm text-muted-foreground'>
<div className='flex items-center gap-2'>
<Mail className='h-3 w-3' />
{customer.email_primary}
</div>
{customer.mobile_primary && (
<div className='flex items-center gap-2'>
<Phone className='h-3 w-3' />
{customer.mobile_primary}
</div>
)}
{customer.trade_name && (
<div className='flex items-center gap-2'>
<Building2Icon className='h-3 w-3' />
{customer.trade_name}
</div>
)}
{customer.tin && (
<div className='flex items-center gap-2'>
<FileTextIcon className='h-3 w-3' />
{customer.tin}
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
import { SearchIcon, UserPlusIcon } from "lucide-react";
export const CustomerEmptyCard = () => {
return (
<div className='flex items-center gap-4 group-hover:text-primary'>
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'>
<UserPlusIcon className='size-6 text-muted-foreground group-hover:text-primary' />
</div>
<div className='flex-1 text-left'>
<h3 className='font-medium text-muted-foreground mb-1 group-hover:text-primary'>
Seleccionar Cliente
</h3>
<p className='text-sm text-muted-foreground/70 group-hover:text-primary'>
Haz clic para buscar un cliente existente o crear uno nuevo
</p>
</div>
<SearchIcon className='size-5 text-muted-foreground group-hover:text-primary' />
</div>
);
};

View File

@ -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<Client, "id">;
onChange: (c: Omit<Client, "id">) => void;
onSubmit: () => void;
}
export const CreateCustomerFormDialog = ({
open,
onOpenChange,
client,
onChange,
onSubmit,
}: CustomerFormDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px] bg-card border-border'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' /> Agregar Nuevo Cliente
</DialogTitle>
<DialogDescription>
Complete la información del cliente. Los campos marcados con * son obligatorios.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
{/* Nombre */}
<div className='grid gap-2'>
<Label htmlFor='name'>Nombre completo *</Label>
<Input
id='name'
value={client.name}
onChange={(e) => onChange({ ...client, name: e.target.value })}
/>
</div>
{/* Email */}
<div className='grid gap-2'>
<Label htmlFor='email'>Email *</Label>
<Input
id='email'
type='email'
value={client.email}
onChange={(e) => onChange({ ...client, email: e.target.value })}
/>
</div>
{/* Teléfono / NIF */}
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='phone'>Teléfono</Label>
<Input
id='phone'
value={client.phone ?? ""}
onChange={(e) => onChange({ ...client, phone: e.target.value })}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='taxId'>NIF/CIF</Label>
<Input
id='taxId'
value={client.taxId ?? ""}
onChange={(e) => onChange({ ...client, taxId: e.target.value })}
/>
</div>
</div>
{/* Empresa */}
<div className='grid gap-2'>
<Label htmlFor='company'>Empresa</Label>
<Input
id='company'
value={client.company ?? ""}
onChange={(e) => onChange({ ...client, company: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={onSubmit} disabled={!client.name || !client.email}>
<Plus className='mr-2 size-4' /> Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,436 +1,122 @@
"use client"; import { useEffect, useMemo, useState } from "react";
import { useCustomersSearchQuery } from "../../hooks";
import { import { CustomerSummary } from "../../schemas";
Badge, import { CustomerCard } from "./customer-card";
Button, import { CustomerEmptyCard } from "./customer-empty-card";
Command, import { CreateCustomerFormDialog } from "./customer-form-dialog";
CommandEmpty, import { CustomerSearchDialog } from "./customer-search-dialog";
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;
}
interface ClientSelectorProps {
value?: string;
onValueChange?: (clientId: string) => void;
placeholder?: string;
}
// 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<Client[]>(mockClients);
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [showNewClientDialog, setShowNewClientDialog] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [newClient, setNewClient] = useState({
name: "",
email: "",
phone: "",
company: "",
taxId: "",
});
// Debounce pequeño y tipado
function useDebouncedValue<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => { useEffect(() => {
if (value) { const id = setTimeout(() => setDebounced(value), delay);
const client = clients.find((c) => c.id === value); return () => clearTimeout(id);
setSelectedClient(client || null); }, [value, delay]);
return debounced;
} }
}, [value, clients]);
const filteredClients = clients.filter( interface CustomerModalSelectorProps {
(client) => value?: string;
client.name.toLowerCase().includes(searchQuery.toLowerCase()) || onValueChange?: (id: string) => void;
client.email.toLowerCase().includes(searchQuery.toLowerCase()) || }
client.company?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSelectClient = (client: Client) => { export const CustomerModalSelector = ({ value, onValueChange }: CustomerModalSelectorProps) => {
setSelectedClient(client); // UI state
onValueChange?.(client.id); const [showSearch, setShowSearch] = useState(false);
setShowClientSelector(false); const [showForm, setShowForm] = useState(false);
}; const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 300);
const handleCreateClient = () => { // Cliente seleccionado y creación local optimista
if (!newClient.name || !newClient.email) return; const [selected, setSelected] = useState<CustomerSummary | null>(null);
const [newClient, setNewClient] = useState<Omit<CustomerSummary, "id">>({ name: "", email: "" });
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
const client: Client = { const {
id: Date.now().toString(), data: remoteCustomers = [],
name: newClient.name, isLoading,
email: newClient.email, isError,
phone: newClient.phone || undefined, error,
company: newClient.company || undefined, } = useCustomersSearchQuery({
taxId: newClient.taxId || undefined, q: debouncedQuery,
}; pageSize: 5,
orderBy: "updated_at",
setClients((prev) => [...prev, client]); order: "asc",
setSelectedClient(client);
onValueChange?.(client.id);
setShowNewClientDialog(false);
setShowClientSelector(false);
setNewClient({
name: "",
email: "",
phone: "",
company: "",
taxId: "",
}); });
// Combinar locales optimistas + remotos
const customers: CustomerSummary[] = useMemo(() => {
const byId = new Map<string, CustomerSummary>();
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
return Array.from(byId.values());
}, [localCreated, remoteCustomers]);
// Sync con `value`
useEffect(() => {
if (!value) return;
const found = customers.find((c) => c.id === value) ?? null;
setSelected(found);
}, [value, customers]);
const handleCreate = () => {
if (!newClient.name || !newClient.email) return;
const client: CustomerSummary = {
id: crypto.randomUUID?.() ?? Date.now().toString(),
...newClient,
};
setLocalCreated((prev) => [client, ...prev]);
setSelected(client);
onValueChange?.(client.id);
setShowForm(false);
setShowSearch(false);
}; };
const openNewClientDialog = () => { console.log(customers);
setNewClient({ ...newClient, name: searchQuery });
setShowNewClientDialog(true);
};
return ( return (
<> <>
<div <button
role='button'
tabIndex={0} tabIndex={0}
onClick={() => setShowClientSelector(true)} type='button'
onKeyDown={(e) => { onClick={() => setShowSearch(true)}
if (e.key === "Enter" || e.key === " ") { onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)}
e.preventDefault(); className='group w-full cursor-pointer rounded-lg border border-border bg-card p-4 transition hover:bg-accent/50 hover:border-primary'
setShowClientSelector(true); aria-label='Seleccionar cliente'
} >
{selected ? <CustomerCard customer={selected} /> : <CustomerEmptyCard />}
</button>
<CustomerSearchDialog
open={showSearch}
onOpenChange={setShowSearch}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
customers={customers}
selectedClient={selected}
onSelectClient={(c) => {
setSelected(c);
onValueChange?.(c.id);
setShowSearch(false);
}} }}
className='group w-full cursor-pointer rounded-lg border border-border bg-card p-4 transition-all hover:bg-accent/50 hover:border-primary' onCreateClient={(name) => {
> /*setNewClient({ name: name ?? "", email: "" });
{selectedClient ? ( setShowForm(true);*/
<div className='flex items-start gap-4'> }}
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 border border-primary/20'> isLoading={isLoading}
<User className='h-6 w-6 text-primary' /> isError={isError}
</div> errorMessage={
<div className='flex-1 space-y-2'> isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined
<div className='flex items-center justify-between'> }
<h3 className='font-semibold text-foreground text-lg'>{selectedClient.name}</h3> />
<Search className='size-4 text-muted-foreground' />
</div>
<div className='space-y-1'>
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Mail className='h-3 w-3' />
{selectedClient.email}
</div>
{selectedClient.phone && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Phone className='h-3 w-3' />
{selectedClient.phone}
</div>
)}
{selectedClient.company && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Building2Icon className='h-3 w-3' />
{selectedClient.company}
</div>
)}
{selectedClient.taxId && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<FileTextIcon className='h-3 w-3' />
{selectedClient.taxId}
</div>
)}
</div>
</div>
</div>
) : (
<div className='flex items-center gap-4 group-hover:text-primary'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted border-2 border-dashed border-muted-foreground/30 group-hover:border-primary/60'>
<PlusIcon className='h-6 w-6 text-muted-foreground group-hover:text-primary' />
</div>
<div className='flex-1'>
<h3 className='font-medium text-muted-foreground mb-1 group-hover:text-primary'>
Seleccionar Cliente
</h3>
<p className='text-sm text-muted-foreground/70 group-hover:text-primary'>
Haz clic para buscar un cliente existente o crear uno nuevo
</p>
</div>
<SearchIcon className='size-5 text-muted-foreground group-hover:text-primary' />
</div>
)}
</div>
<Dialog open={showClientSelector} onOpenChange={setShowClientSelector}> <CreateCustomerFormDialog
<DialogContent className='sm:max-w-[600px] bg-card border-border p-0'> open={showForm}
<DialogHeader className='px-6 pt-6 pb-4'> onOpenChange={setShowForm}
<DialogTitle className='flex items-center justify-between text-foreground'> client={newClient}
<div className='flex items-center gap-2'> onChange={setNewClient}
<User className='h-5 w-5' /> onSubmit={handleCreate}
Seleccionar Cliente
</div>
<Button
variant='ghost'
size='sm'
onClick={() => setShowClientSelector(false)}
className='size-6 p-0 hover:bg-accent'
>
<XIcon className='size-4' />
</Button>
</DialogTitle>
<DialogDescription className='text-muted-foreground'>
Busca un cliente existente o crea uno nuevo.
</DialogDescription>
</DialogHeader>
<div className='px-6 pb-6'>
<Command className='border border-border rounded-lg'>
<div className='flex items-center border-b border-border px-3'>
<Search className='mr-2 size-4 shrink-0 opacity-50' />
<CommandInput
placeholder='Buscar cliente por nombre, email o empresa...'
value={searchQuery}
onValueChange={setSearchQuery}
className='flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'
/> />
</div>
<CommandList className='max-h-[400px]'>
<CommandEmpty className='py-6 text-center text-sm text-muted-foreground'>
<div className='flex flex-col items-center gap-2'>
<User className='h-8 w-8 text-muted-foreground/50' />
<p>No se encontraron clientes</p>
{searchQuery && (
<Button
variant='outline'
size='sm'
onClick={openNewClientDialog}
className='mt-2 bg-transparent'
>
<Plus className='mr-2 size-4' />
Crear cliente "{searchQuery}"
</Button>
)}
</div>
</CommandEmpty>
<CommandGroup>
{filteredClients.map((client) => (
<CommandItem
key={client.id}
value={client.id}
onSelect={() => handleSelectClient(client)}
className='flex items-center gap-3 p-3 cursor-pointer hover:bg-accent'
>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10'>
<User className='size-4 text-primary' />
</div>
<div className='flex-1 space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-foreground'>{client.name}</span>
{client.company && (
<Badge variant='secondary' className='text-xs'>
{client.company}
</Badge>
)}
</div>
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
<div className='flex items-center gap-1'>
<Mail className='h-3 w-3' />
{client.email}
</div>
{client.phone && (
<div className='flex items-center gap-1'>
<Phone className='h-3 w-3' />
{client.phone}
</div>
)}
</div>
</div>
<Check
className={cn(
"ml-auto size-4",
selectedClient?.id === client.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
{filteredClients.length > 0 && (
<CommandGroup>
<CommandItem
onSelect={openNewClientDialog}
className='flex items-center gap-3 p-3 cursor-pointer hover:bg-accent border-t border-border'
>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary'>
<Plus className='size-4 text-primary-foreground' />
</div>
<span className='font-medium text-foreground'>Agregar nuevo cliente</span>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</div>
</DialogContent>
</Dialog>
<Dialog open={showNewClientDialog} onOpenChange={setShowNewClientDialog}>
<DialogContent className='sm:max-w-[500px] bg-card border-border'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2 text-foreground'>
<Plus className='h-5 w-5' />
Agregar Nuevo Cliente
</DialogTitle>
<DialogDescription className='text-muted-foreground'>
Complete la información del cliente. Los campos marcados con * son obligatorios.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='name' className='text-foreground'>
Nombre completo *
</Label>
<Input
id='name'
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
placeholder='Ej: María García'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='email' className='text-foreground'>
Email *
</Label>
<Input
id='email'
type='email'
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
placeholder='Ej: maria@empresa.com'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='phone' className='text-foreground'>
Teléfono
</Label>
<Input
id='phone'
value={newClient.phone}
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
placeholder='Ej: +34 666 123 456'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='taxId' className='text-foreground'>
NIF/CIF
</Label>
<Input
id='taxId'
value={newClient.taxId}
onChange={(e) => setNewClient({ ...newClient, taxId: e.target.value })}
placeholder='Ej: 12345678Z'
className='bg-input border-border text-foreground'
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='company' className='text-foreground'>
Empresa
</Label>
<Input
id='company'
value={newClient.company}
onChange={(e) => setNewClient({ ...newClient, company: e.target.value })}
placeholder='Ej: Empresa ABC S.L.'
className='bg-input border-border text-foreground'
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowNewClientDialog(false)}
className='border-border text-foreground hover:bg-accent'
>
Cancelar
</Button>
<Button
onClick={handleCreateClient}
disabled={!newClient.name || !newClient.email}
className='bg-primary text-primary-foreground hover:bg-primary/90'
>
<Plus className='mr-2 size-4' />
Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} };

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl bg-card border-border p-0'>
<DialogHeader className='px-6 pt-6 pb-4'>
<DialogTitle className='flex items-center justify-between'>
<span className='flex items-center gap-2'>
<User className='h-5 w-5' />
Seleccionar Cliente
</span>
</DialogTitle>
<DialogDescription>Busca un cliente existente o crea uno nuevo.</DialogDescription>
</DialogHeader>
<div className='px-6 pb-6'>
<Command className='border rounded-lg'>
<CommandInput
placeholder='Buscar cliente...'
value={searchQuery}
onValueChange={onSearchQueryChange}
/>
<CommandList className='max-h-[400px]'>
<CommandEmpty>
<div className='flex flex-col items-center gap-2 py-6 text-sm'>
<User className='h-8 w-8 text-muted-foreground/50' />
<p>No se encontraron clientes</p>
{searchQuery && (
<Button variant='outline' size='sm' onClick={() => onCreateClient(searchQuery)}>
<Plus className='mr-2 size-4' />
Crear cliente "{searchQuery}"
</Button>
)}
</div>
</CommandEmpty>
<CommandGroup>
{customers.map((customer) => (
<CommandItem
key={customer.id}
value={customer.id}
onSelect={() => onSelectClient(customer)}
className='flex items-center gap-x-4 py-5 cursor-pointer'
>
<div className='flex size-12 items-center justify-center rounded-full bg-primary/10'>
<UserIcon className='size-8 stroke-1 text-primary' />
</div>
<div className='flex-1 space-y-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold'>{customer.name}</span>
{customer.trade_name && (
<Badge variant='secondary' className='text-sm'>
{customer.trade_name}
</Badge>
)}
</div>
<div className='flex items-center gap-4 text-sm font-medium text-muted-foreground'>
{customer.tin && (
<span className='flex items-center gap-1'>
<AsteriskIcon className='size-4' /> {customer.tin}
</span>
)}
{customer.email_primary && (
<span className='flex items-center gap-1'>
<MailIcon className='size-4' /> {customer.email_primary}
</span>
)}
{customer.mobile_primary && (
<span className='flex items-center gap-1'>
<SmartphoneIcon className='size-4' /> {customer.mobile_primary}
</span>
)}
</div>
</div>
<Check
className={cn(
"ml-auto size-4",
selectedClient?.id === customer.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
<CommandGroup>
<CommandItem
onSelect={() => onCreateClient()}
className='flex items-center gap-3 p-3 border-t'
>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary'>
<Plus className='size-4 text-primary-foreground' />
</div>
<span className='font-medium'>Agregar nuevo cliente</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -2,4 +2,5 @@ export * from "./use-create-customer-mutation";
export * from "./use-customer-query"; export * from "./use-customer-query";
export * from "./use-customers-context"; export * from "./use-customers-context";
export * from "./use-customers-query"; export * from "./use-customers-query";
export * from "./use-customers-search-query";
export * from "./use-update-customer-mutation"; export * from "./use-update-customer-mutation";

View File

@ -2,7 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd"; import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateCustomerRequestSchema } from "../../common"; import { CreateCustomerRequestSchema } from "../../common";
import { CustomerData, CustomerFormData } from "../schemas"; import { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation"; import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
type CreateCustomerPayload = { type CreateCustomerPayload = {
@ -14,7 +14,7 @@ export function useCreateCustomer() {
const dataSource = useDataSource(); const dataSource = useDataSource();
const schema = CreateCustomerRequestSchema; const schema = CreateCustomerRequestSchema;
return useMutation<CustomerData, DefaultError, CreateCustomerPayload>({ return useMutation<Customer, DefaultError, CreateCustomerPayload>({
mutationKey: ["customer:create"], mutationKey: ["customer:create"],
mutationFn: async (payload) => { mutationFn: async (payload) => {
@ -38,7 +38,7 @@ export function useCreateCustomer() {
} }
const created = await dataSource.createOne("customers", newCustomerData); const created = await dataSource.createOne("customers", newCustomerData);
return created as CustomerData; return created as Customer;
}, },
onSuccess: () => { onSuccess: () => {

View File

@ -1,6 +1,6 @@
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; 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; 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 dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!customerId; const enabled = (options?.enabled ?? true) && !!customerId;
return useQuery<CustomerData, DefaultError>({ return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"), queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"),
queryFn: async (context) => { queryFn: async (context) => {
const { signal } = context; const { signal } = context;
if (!customerId) { if (!customerId) {
if (!customerId) throw new Error("customerId is required"); if (!customerId) throw new Error("customerId is required");
} }
return await dataSource.getOne<CustomerData>("customers", customerId, { return await dataSource.getOne<Customer>("customers", customerId, {
signal, signal,
}); });
}, },

View File

@ -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[],
});
};

View File

@ -1,5 +1,6 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { ArrayElement } from "@repo/rdx-utils";
import { import {
CreateCustomerRequestSchema, CreateCustomerRequestSchema,
GetCustomerByIdResponseSchema, GetCustomerByIdResponseSchema,
@ -7,12 +8,18 @@ import {
UpdateCustomerByIdRequestSchema, UpdateCustomerByIdRequestSchema,
} from "../../common"; } from "../../common";
// Esquemas (Zod) provenientes del servidor
export const CustomerSchema = GetCustomerByIdResponseSchema.omit({ metadata: true });
export const CustomerCreateSchema = CreateCustomerRequestSchema; export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema; export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
export const CustomerSchema = GetCustomerByIdResponseSchema.omit({
metadata: true,
});
export type CustomerData = z.infer<typeof CustomerSchema>; // Tipos (derivados de Zod o DTOs del backend)
export type Customer = z.infer<typeof CustomerSchema>; // Entidad completa (GET/POST/PUT result)
export type CustomerCreateInput = z.infer<typeof CustomerCreateSchema>; // Cuerpo para crear
export type CustomerUpdateInput = z.infer<typeof CustomerUpdateSchema>; // 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<CustomersPage["items"]>;

View File

@ -106,7 +106,7 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
readOnly={isReadOnly} readOnly={isReadOnly}
disabled={isDisabled} disabled={isDisabled}
className={cn( 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", isDisabled && "bg-muted text-muted-foreground cursor-not-allowed",
isReadOnly && "bg-muted text-foreground cursor-default", isReadOnly && "bg-muted text-foreground cursor-default",
!isDisabled && !isReadOnly && "bg-white text-foreground", !isDisabled && !isReadOnly && "bg-white text-foreground",

View File

@ -73,7 +73,10 @@ export function SelectField<TFormValues extends FieldValues>({
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isDisabled}> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={isDisabled}>
<FormControl> <FormControl>
<SelectTrigger className='w-full bg-background h-8'> <SelectTrigger className='w-full bg-background h-8'>
<SelectValue placeholder={placeholder} /> <SelectValue
placeholder={placeholder}
className=' placeholder:font-normal placeholder:italic '
/>
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>

View File

@ -62,7 +62,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
<Textarea <Textarea
disabled={isDisabled} disabled={isDisabled}
placeholder={placeholder} placeholder={placeholder}
className={"bg-background"} className={"placeholder:font-normal placeholder:italic bg-background"}
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@ -330,7 +330,10 @@ export function TextField<TFormValues extends FieldValues>({
{label && ( {label && (
<div className='mb-1 flex justify-between gap-2'> <div className='mb-1 flex justify-between gap-2'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<FormLabel htmlFor={name} className='m-0'> <FormLabel
htmlFor={name}
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
>
{label} {label}
</FormLabel> </FormLabel>
{required && ( {required && (