Clientes
This commit is contained in:
parent
ae6afd2c28
commit
d90eaccde0
@ -42,13 +42,9 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
|
||||
getBaseUrl: () => (client as AxiosInstance).getUri(),
|
||||
|
||||
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, {
|
||||
params: {
|
||||
...pagination,
|
||||
},
|
||||
});
|
||||
const res = await (client as AxiosInstance).get<T[]>(resource, { signal, params: rest });
|
||||
return <R>res.data;
|
||||
},
|
||||
|
||||
|
||||
@ -143,7 +143,7 @@ export default (database: Sequelize) => {
|
||||
is_proforma: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
|
||||
status: {
|
||||
|
||||
@ -34,7 +34,6 @@ export const InvoiceBasicInfoFields = () => {
|
||||
<TextField
|
||||
control={control}
|
||||
name='invoice_number'
|
||||
required
|
||||
readOnly
|
||||
label={t("form_fields.invoice_number.label")}
|
||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
||||
|
||||
@ -138,6 +138,8 @@ export class CustomerRepository
|
||||
company_id: companyId.toString(),
|
||||
};
|
||||
|
||||
console.log(query);
|
||||
|
||||
const { rows, count } = await CustomerModel.findAndCountAll({
|
||||
...query,
|
||||
transaction,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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<T>(value: T, delay = 300) {
|
||||
const [debounced, setDebounced] = useState<T>(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<Client[]>(mockClients);
|
||||
const [selectedClient, setSelectedClient] = useState<Client | null>(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<CustomerSummary | null>(null);
|
||||
const [newClient, setNewClient] = useState<Omit<CustomerSummary, "id">>({ name: "", email: "" });
|
||||
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
|
||||
|
||||
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<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) {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => 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 ? (
|
||||
<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'>{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>
|
||||
{selected ? <CustomerCard customer={selected} /> : <CustomerEmptyCard />}
|
||||
</button>
|
||||
|
||||
<Dialog open={showClientSelector} onOpenChange={setShowClientSelector}>
|
||||
<DialogContent className='sm:max-w-[600px] bg-card border-border p-0'>
|
||||
<DialogHeader className='px-6 pt-6 pb-4'>
|
||||
<DialogTitle className='flex items-center justify-between text-foreground'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<User className='h-5 w-5' />
|
||||
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>
|
||||
<CustomerSearchDialog
|
||||
open={showSearch}
|
||||
onOpenChange={setShowSearch}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
customers={customers}
|
||||
selectedClient={selected}
|
||||
onSelectClient={(c) => {
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<CreateCustomerFormDialog
|
||||
open={showForm}
|
||||
onOpenChange={setShowForm}
|
||||
client={newClient}
|
||||
onChange={setNewClient}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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<CustomerData, DefaultError, CreateCustomerPayload>({
|
||||
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
|
||||
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: () => {
|
||||
|
||||
@ -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<CustomerData, DefaultError>({
|
||||
return useQuery<Customer, DefaultError>({
|
||||
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<CustomerData>("customers", customerId, {
|
||||
return await dataSource.getOne<Customer>("customers", customerId, {
|
||||
signal,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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[],
|
||||
});
|
||||
};
|
||||
@ -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<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"]>;
|
||||
|
||||
@ -106,7 +106,7 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
|
||||
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",
|
||||
|
||||
@ -73,7 +73,10 @@ export function SelectField<TFormValues extends FieldValues>({
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isDisabled}>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full bg-background h-8'>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
<SelectValue
|
||||
placeholder={placeholder}
|
||||
className=' placeholder:font-normal placeholder:italic '
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
||||
@ -62,7 +62,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
||||
<Textarea
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
className={"bg-background"}
|
||||
className={"placeholder:font-normal placeholder:italic bg-background"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -330,7 +330,10 @@ export function TextField<TFormValues extends FieldValues>({
|
||||
{label && (
|
||||
<div className='mb-1 flex justify-between 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}
|
||||
</FormLabel>
|
||||
{required && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user