Clientes y Facturas de cliente
This commit is contained in:
parent
cad3a403b9
commit
7cad265aff
@ -1,3 +1,4 @@
|
|||||||
|
import { INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,3 +32,21 @@ export const CriteriaSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CriteriaDTO = z.infer<typeof CriteriaSchema>;
|
export type CriteriaDTO = z.infer<typeof CriteriaSchema>;
|
||||||
|
|
||||||
|
export function normalizeCriteriaDTO(criteria: CriteriaDTO = {}) {
|
||||||
|
const {
|
||||||
|
pageNumber = INITIAL_PAGE_SIZE,
|
||||||
|
pageSize = INITIAL_PAGE_SIZE,
|
||||||
|
q = "",
|
||||||
|
filters = [],
|
||||||
|
orderBy = "",
|
||||||
|
order = "",
|
||||||
|
} = criteria;
|
||||||
|
|
||||||
|
// Para mantener un orden estable de filtros
|
||||||
|
const stableFilters = [...filters].sort(
|
||||||
|
(a, b) => a.field.localeCompare(b.field) || a.op.localeCompare(b.op)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pageNumber, pageSize, q, filters: stableFilters, orderBy, order };
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useTranslation } from "../../../i18n.ts";
|
|||||||
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
|
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
|
||||||
|
|
||||||
export type CancelFormButtonProps = {
|
export type CancelFormButtonProps = {
|
||||||
|
formId?: string;
|
||||||
to?: string; /// Ruta a la que navegar si no se pasa onCancel
|
to?: string; /// Ruta a la que navegar si no se pasa onCancel
|
||||||
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
|
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
|
||||||
label?: string;
|
label?: string;
|
||||||
@ -19,6 +20,7 @@ export type CancelFormButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CancelFormButton = ({
|
export const CancelFormButton = ({
|
||||||
|
formId,
|
||||||
to,
|
to,
|
||||||
onCancel,
|
onCancel,
|
||||||
label,
|
label,
|
||||||
@ -51,6 +53,7 @@ export const CancelFormButton = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
form={formId}
|
||||||
type='button'
|
type='button'
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
|
|||||||
@ -33,7 +33,6 @@ export type UpdateCommitButtonGroupProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
|
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
|
||||||
|
|
||||||
preview?
|
|
||||||
cancel?: CancelFormButtonProps & { show?: boolean };
|
cancel?: CancelFormButtonProps & { show?: boolean };
|
||||||
submit?: GroupSubmitButtonProps; // props directas a SubmitButton
|
submit?: GroupSubmitButtonProps; // props directas a SubmitButton
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// components/CustomerSkeleton.tsx
|
// components/CustomerSkeleton.tsx
|
||||||
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
|
|
||||||
@ -7,7 +7,6 @@ export const CustomerInvoiceEditorSkeleton = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='space-y-2' aria-hidden='true'>
|
<div className='space-y-2' aria-hidden='true'>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
import { AppContent } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useCreateCustomerInvoiceMutation } from "../../hooks";
|
import { useCreateCustomerInvoiceMutation } from "../../hooks";
|
||||||
@ -50,7 +50,6 @@ export const CustomerInvoiceCreate = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='flex items-center justify-between space-y-2'>
|
<div className='flex items-center justify-between space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
} from "@erp/core/hooks";
|
} from "@erp/core/hooks";
|
||||||
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { useMemo } from 'react';
|
import { useId, useMemo } from 'react';
|
||||||
import { FieldErrors, FormProvider } from "react-hook-form";
|
import { FieldErrors, FormProvider } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useInvoiceContext } from '../../context';
|
import { useInvoiceContext } from '../../context';
|
||||||
@ -33,6 +33,7 @@ export const InvoiceUpdateComp = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { invoice_id } = useInvoiceContext(); // ahora disponible desde el inicio
|
const { invoice_id } = useInvoiceContext(); // ahora disponible desde el inicio
|
||||||
const context = useInvoiceContext();
|
const context = useInvoiceContext();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
const isPending = !invoiceData;
|
const isPending = !invoiceData;
|
||||||
|
|
||||||
@ -57,12 +58,13 @@ export const InvoiceUpdateComp = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (formData: InvoiceFormData) => {
|
const handleSubmit = (formData: InvoiceFormData) => {
|
||||||
|
console.log('Guardo factura')
|
||||||
const dto = invoiceDtoToFormAdapter.toDto(formData, context)
|
const dto = invoiceDtoToFormAdapter.toDto(formData, context)
|
||||||
console.log("dto => ", dto);
|
console.log("dto => ", dto);
|
||||||
mutate(
|
mutate(
|
||||||
{ id: invoice_id, data: dto as Partial<InvoiceFormData> },
|
{ id: invoice_id, data: dto as Partial<InvoiceFormData> },
|
||||||
{
|
{
|
||||||
onSuccess: () => showSuccessToast(t("pages.update.successTitle")),
|
onSuccess: () => showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message")),
|
||||||
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
|
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -91,8 +93,8 @@ export const InvoiceUpdateComp = ({
|
|||||||
<UpdateCommitButtonGroup
|
<UpdateCommitButtonGroup
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
|
|
||||||
submit={{ formId: "invoice-update-form", variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
|
submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
|
||||||
cancel={{ to: "/customer-invoices/list" }}
|
cancel={{ formId, to: "/customer-invoices/list" }}
|
||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -102,7 +104,7 @@ export const InvoiceUpdateComp = ({
|
|||||||
<AppContent>
|
<AppContent>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<InvoiceUpdateForm
|
<InvoiceUpdateForm
|
||||||
formId="invoice-update-form"
|
formId={formId}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className="bg-white rounded-xl border shadow-xl max-w-full"
|
className="bg-white rounded-xl border shadow-xl max-w-full"
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const InvoiceUpdateForm = ({
|
|||||||
const form = useFormContext<InvoiceFormData>();
|
const form = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)} >
|
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
||||||
<section className={cn("p-6 space-y-6", className)}>
|
<section className={cn("p-6 space-y-6", className)}>
|
||||||
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<InvoiceRecipient className="flex flex-col" />
|
<InvoiceRecipient className="flex flex-col" />
|
||||||
@ -37,7 +37,6 @@ export const InvoiceUpdateForm = ({
|
|||||||
<div className="w-full p-6">
|
<div className="w-full p-6">
|
||||||
<FormDebug />
|
<FormDebug />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,36 +1,7 @@
|
|||||||
import { MetadataSchema } from "@erp/core";
|
import {
|
||||||
import { z } from "zod/v4";
|
GetCustomerByIdResponseDTO,
|
||||||
|
GetCustomerByIdResponseSchema,
|
||||||
|
} from "./get-customer-by-id.response.dto";
|
||||||
|
|
||||||
export const CreateCustomerResponseSchema = z.object({
|
export const CreateCustomerResponseSchema = GetCustomerByIdResponseSchema;
|
||||||
id: z.uuid(),
|
export type CustomerCreationResponseDTO = GetCustomerByIdResponseDTO;
|
||||||
company_id: z.uuid(),
|
|
||||||
reference: z.string(),
|
|
||||||
|
|
||||||
is_company: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
trade_name: z.string(),
|
|
||||||
tin: z.string(),
|
|
||||||
|
|
||||||
street: z.string(),
|
|
||||||
street2: z.string(),
|
|
||||||
city: z.string(),
|
|
||||||
province: z.string(),
|
|
||||||
postal_code: z.string(),
|
|
||||||
country: z.string(),
|
|
||||||
|
|
||||||
email: z.string(),
|
|
||||||
phone: z.string(),
|
|
||||||
fax: z.string(),
|
|
||||||
website: z.string(),
|
|
||||||
|
|
||||||
legal_record: z.string(),
|
|
||||||
|
|
||||||
default_taxes: z.array(z.string()),
|
|
||||||
status: z.string(),
|
|
||||||
language_code: z.string(),
|
|
||||||
currency_code: z.string(),
|
|
||||||
|
|
||||||
metadata: MetadataSchema.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CustomerCreationResponseDTO = z.infer<typeof CreateCustomerResponseSchema>;
|
|
||||||
|
|||||||
@ -3,7 +3,12 @@
|
|||||||
"more_details": "More details",
|
"more_details": "More details",
|
||||||
"back_to_list": "Back to the list",
|
"back_to_list": "Back to the list",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save"
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"validating_form": {
|
||||||
|
"title": "Check the fields",
|
||||||
|
"message": "There are validation errors in the form"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"status": {
|
"status": {
|
||||||
@ -30,11 +35,19 @@
|
|||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "New customer",
|
"title": "New customer",
|
||||||
"description": "Create a new customer"
|
"description": "Create a new customer",
|
||||||
|
"error": {
|
||||||
|
"title": "Error creating",
|
||||||
|
"message": "The new customer could not be created"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Update customer",
|
"title": "Update customer",
|
||||||
"description": "Update a customer"
|
"description": "Update a customer",
|
||||||
|
"success": {
|
||||||
|
"title": "Customer updated",
|
||||||
|
"message": "The customer has been successfully updated"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form_fields": {
|
"form_fields": {
|
||||||
|
|||||||
@ -3,7 +3,12 @@
|
|||||||
"more_details": "Más detalles",
|
"more_details": "Más detalles",
|
||||||
"back_to_list": "Back to the list",
|
"back_to_list": "Back to the list",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"save": "Guardar"
|
"save": "Guardar",
|
||||||
|
"saving": "Guardando...",
|
||||||
|
"validating_form": {
|
||||||
|
"title": "Revisa los campos",
|
||||||
|
"message": "Hay errores de validación en el formulario"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"status": {
|
"status": {
|
||||||
@ -31,12 +36,20 @@
|
|||||||
"create": {
|
"create": {
|
||||||
"title": "Nuevo cliente",
|
"title": "Nuevo cliente",
|
||||||
"description": "Crear un nuevo cliente",
|
"description": "Crear un nuevo cliente",
|
||||||
"back_to_list": "Volver a la lista"
|
"back_to_list": "Volver a la lista",
|
||||||
|
"error": {
|
||||||
|
"title": "Error al crear",
|
||||||
|
"message": "No se pudo crear el nuevo cliente"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Modificación de cliente",
|
"title": "Modificación de cliente",
|
||||||
"description": "Modificar los datos de un cliente",
|
"description": "Modificar los datos de un cliente",
|
||||||
"back_to_list": "Back to the list"
|
"back_to_list": "Back to the list",
|
||||||
|
"success": {
|
||||||
|
"title": "Cliente actualizado",
|
||||||
|
"message": "El cliente ha sido actualizado correctamente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form_fields": {
|
"form_fields": {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
|
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ListCustomersResponseDTO } from "../../common";
|
import { ListCustomersResponseDTO } from "../../common";
|
||||||
import { useCustomersQuery } from "../hooks";
|
import { useCustomerListQuery } from "../hooks";
|
||||||
|
|
||||||
type Customer = ListCustomersResponseDTO["items"][number];
|
type Customer = ListCustomersResponseDTO["items"][number];
|
||||||
|
|
||||||
@ -131,7 +131,7 @@ export const ClientSelectorModal = () => {
|
|||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 400);
|
const [debouncedSearch] = useDebounce(search, 400);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useCustomersQuery({
|
const { data, isLoading, isError, refetch } = useCustomerListQuery({
|
||||||
filters: buildTextFilters(["name", "email", "trade_name"], debouncedSearch),
|
filters: buildTextFilters(["name", "email", "trade_name"], debouncedSearch),
|
||||||
pageSize,
|
pageSize,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
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='size-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,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Button, Item,
|
Button, Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
ItemDescription,
|
|
||||||
ItemFooter,
|
ItemFooter,
|
||||||
ItemTitle
|
ItemTitle
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import {
|
import {
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
@ -61,18 +61,27 @@ export const CustomerCard = ({
|
|||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle className="flex items-start gap-2 w-full justify-between">
|
<ItemTitle className="flex items-start gap-2 w-full justify-between">
|
||||||
<span className="grow text-balance">{customer.name}</span>
|
<span className="grow text-balance">{customer.name}</span>
|
||||||
<Button
|
{/* Eye solo si onViewCustomer existe */}
|
||||||
type="button"
|
{onViewCustomer && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type='button'
|
||||||
className="cursor-pointer"
|
variant='ghost'
|
||||||
onClick={onViewCustomer}
|
size='sm'
|
||||||
aria-label="Ver ficha completa del cliente"
|
className='cursor-pointer'
|
||||||
>
|
onClick={onViewCustomer}
|
||||||
<EyeIcon className="size-4 text-muted-foreground" />
|
aria-label='Ver ficha completa del cliente'
|
||||||
</Button>
|
>
|
||||||
|
<EyeIcon className='size-4 text-muted-foreground' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
<ItemDescription className="text-sm text-muted-foreground">
|
<div
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
"text-sm text-muted-foreground"
|
||||||
|
)}>
|
||||||
{/* TIN en su propia línea si existe */}
|
{/* TIN en su propia línea si existe */}
|
||||||
{customer.tin && (
|
{customer.tin && (
|
||||||
<div className="font-mono tabular-nums">{customer.tin}</div>
|
<div className="font-mono tabular-nums">{customer.tin}</div>
|
||||||
@ -113,32 +122,35 @@ export const CustomerCard = ({
|
|||||||
) : (
|
) : (
|
||||||
<span className="italic text-muted-foreground">Sin dirección</span>
|
<span className="italic text-muted-foreground">Sin dirección</span>
|
||||||
)}
|
)}
|
||||||
</ItemDescription>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
{/* Footer con acciones */}
|
{/* Footer con acciones */}
|
||||||
<ItemFooter className="flex-wrap gap-2">
|
<ItemFooter className="flex-wrap gap-2">
|
||||||
<Button
|
{onChangeCustomer && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type='button'
|
||||||
size="sm"
|
variant='outline'
|
||||||
onClick={onChangeCustomer}
|
size='sm'
|
||||||
className="flex-1 min-w-36 gap-2 cursor-pointer"
|
onClick={onChangeCustomer}
|
||||||
>
|
className='flex-1 min-w-36 gap-2 cursor-pointer'
|
||||||
<RefreshCwIcon className="size-4" />
|
>
|
||||||
<span className="text-sm text-muted-foreground">Cambiar de cliente</span>
|
<RefreshCwIcon className='size-4' />
|
||||||
</Button>
|
<span className='text-sm text-muted-foreground'>Cambiar de cliente</span>
|
||||||
|
</Button>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
{onAddNewCustomer && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
type='button'
|
||||||
onClick={onAddNewCustomer}
|
variant='outline'
|
||||||
className="flex-1 min-w-36 gap-2 cursor-pointer"
|
size='sm'
|
||||||
>
|
onClick={onAddNewCustomer}
|
||||||
<UserPlusIcon className="size-4" />
|
className='flex-1 min-w-36 gap-2 cursor-pointer'
|
||||||
<span className="text-sm text-muted-foreground">Nuevo cliente</span>
|
>
|
||||||
</Button>
|
<UserPlusIcon className='size-4' />
|
||||||
|
<span className='text-sm text-muted-foreground'>Nuevo cliente</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ItemFooter>
|
</ItemFooter>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
|
||||||
|
import { UnsavedChangesProvider, useUnsavedChangesContext } from "@erp/core/hooks";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { useCustomerCreateController } from '../../pages/create/use-customer-create-controller';
|
||||||
|
import { CustomerFormData } from "../../schemas";
|
||||||
|
import { CustomerEditForm } from '../editor';
|
||||||
|
|
||||||
|
|
||||||
|
type CustomerCreateModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
client: CustomerFormData;
|
||||||
|
onChange: (customer: CustomerFormData) => void;
|
||||||
|
onSubmit: () => void; // ← mantenemos tu firma (no se usa directamente aquí)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomerCreateModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: CustomerCreateModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
form, isCreating, isCreateError, createError,
|
||||||
|
handleSubmit, handleError, FormProvider
|
||||||
|
} = useCustomerCreateController();
|
||||||
|
|
||||||
|
const { isDirty } = form.formState;
|
||||||
|
|
||||||
|
const guardClose = async (nextOpen: boolean) => {
|
||||||
|
if (nextOpen) return onOpenChange(true);
|
||||||
|
if (isCreating) return;
|
||||||
|
const { requestConfirm } = useUnsavedChangesContext();
|
||||||
|
const ok = await requestConfirm();
|
||||||
|
if (ok) onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnsavedChangesProvider isDirty={isDirty}>
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Dialog open={open} onOpenChange={guardClose}>
|
||||||
|
<DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:max-w-[min(100vw-3rem,1280px)] h-[calc(100dvh-2rem)]">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="size-5" /> {t("pages.create.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 overflow-y-auto h:[calc(100%-8rem)]">
|
||||||
|
|
||||||
|
<CustomerEditForm
|
||||||
|
formId={formId}
|
||||||
|
onSubmit={(data: CustomerFormData) => handleSubmit(data, () => onOpenChange(false))}
|
||||||
|
onError={handleError}
|
||||||
|
className="max-w-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isCreateError && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-destructive">
|
||||||
|
{(createError as Error)?.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="px-6 py-4 border-t bg-card">
|
||||||
|
<Button type="button" form={formId} variant="outline" className='cursor-pointer' onClick={() => guardClose(false)} disabled={isCreating}>
|
||||||
|
{t('common.cancel', "Cancelar")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form={formId} disabled={isCreating} className='cursor-pointer'>
|
||||||
|
{isCreating ? <span aria-live="polite">{t('common.saving', "Guardando")}</span> : <span>{t('common.save', "Guardar")}</span>}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</FormProvider>
|
||||||
|
</UnsavedChangesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,7 +7,6 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
|
|||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@ -15,9 +14,8 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
|
|||||||
export function CustomerModalSelectorField<TFormValues extends FieldValues>({
|
export function CustomerModalSelectorField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
disabled = false,
|
disabled = false, // Solo lectura y sin botones
|
||||||
required = false,
|
readOnly = false, // Solo se puede ver la ficha del cliente
|
||||||
readOnly = false,
|
|
||||||
className,
|
className,
|
||||||
}: CustomerModalSelectorFieldProps<TFormValues>) {
|
}: CustomerModalSelectorFieldProps<TFormValues>) {
|
||||||
const isDisabled = disabled;
|
const isDisabled = disabled;
|
||||||
@ -29,10 +27,14 @@ export function CustomerModalSelectorField<TFormValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const { name, value, onChange, onBlur, ref } = field;
|
const { name, value, onChange, onBlur, ref } = field;
|
||||||
console.log({ name, value, onChange, onBlur, ref });
|
|
||||||
return (
|
return (
|
||||||
<FormItem className={className}>
|
<FormItem className={className}>
|
||||||
<CustomerModalSelector value={value} onValueChange={onChange} />
|
<CustomerModalSelector
|
||||||
|
value={value as string | undefined}
|
||||||
|
onValueChange={onChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import { useCustomersSearchQuery } from "../../hooks";
|
import { useCustomerListQuery } from "../../hooks";
|
||||||
import { CustomerSummary, defaultCustomerFormData } from "../../schemas";
|
import { CustomerFormData, CustomerSummary, defaultCustomerFormData } from "../../schemas";
|
||||||
import { CreateCustomerFormDialog } from "./create-customer-form-dialog";
|
|
||||||
import { CustomerCard } from "./customer-card";
|
import { CustomerCard } from "./customer-card";
|
||||||
|
import { CustomerCreateModal } from './customer-create-modal';
|
||||||
import { CustomerEmptyCard } from "./customer-empty-card";
|
import { CustomerEmptyCard } from "./customer-empty-card";
|
||||||
import { CustomerSearchDialog } from "./customer-search-dialog";
|
import { CustomerSearchDialog } from "./customer-search-dialog";
|
||||||
|
import { CustomerViewDialog } from './customer-view-dialog';
|
||||||
|
|
||||||
// Debounce pequeño y tipado
|
// Debounce pequeño y tipado
|
||||||
function useDebouncedValue<T>(value: T, delay = 300) {
|
function useDebouncedValue<T>(value: T, delay = 300) {
|
||||||
@ -16,11 +17,12 @@ function useDebouncedValue<T>(value: T, delay = 300) {
|
|||||||
return debounced;
|
return debounced;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerModalSelectorProps {
|
|
||||||
|
type CustomerModalSelectorProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
onValueChange?: (id: string) => void;
|
onValueChange?: (id: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean; // Solo lectura total (sin botones ni selección)
|
||||||
readOnly?: boolean;
|
readOnly?: boolean; // Ver ficha, pero no cambiar/crear
|
||||||
initialCustomer?: CustomerSummary;
|
initialCustomer?: CustomerSummary;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@ -34,57 +36,77 @@ export const CustomerModalSelector = ({
|
|||||||
className,
|
className,
|
||||||
}: CustomerModalSelectorProps) => {
|
}: CustomerModalSelectorProps) => {
|
||||||
|
|
||||||
|
const dialogId = useId();
|
||||||
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
const [showView, setShowView] = useState(false);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
// Cliente seleccionado y creación local optimista
|
// Cliente seleccionado + creados localmente (optimista)
|
||||||
const [selected, setSelected] = useState<CustomerSummary | null>(initialCustomer ?? null);
|
const [selected, setSelected] = useState<CustomerSummary | null>(initialCustomer ?? null);
|
||||||
|
|
||||||
const [newClient, setNewClient] =
|
|
||||||
useState<Omit<CustomerSummary, "id" | "status" | "company_id">>(defaultCustomerFormData);
|
|
||||||
|
|
||||||
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
|
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
|
||||||
|
|
||||||
|
const [newClient, setNewClient] =
|
||||||
|
useState<Omit<CustomerFormData, "id" | "status" | "company_id">>(defaultCustomerFormData);
|
||||||
|
|
||||||
|
const criteria = useMemo(
|
||||||
|
() => ({
|
||||||
|
q: debouncedQuery || "",
|
||||||
|
pageSize: 5,
|
||||||
|
orderBy: "updated_at" as const,
|
||||||
|
order: "asc" as const,
|
||||||
|
}),
|
||||||
|
[debouncedQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Consulta solo cuando el diálogo de búsqueda está abierto
|
||||||
const {
|
const {
|
||||||
data: remoteCustomers = [],
|
data: remoteCustomersPage,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useCustomersSearchQuery({
|
} = useCustomerListQuery(
|
||||||
q: debouncedQuery,
|
{
|
||||||
pageSize: 5,
|
enabled: showSearch, // <- evita llamadas innecesarias
|
||||||
orderBy: "updated_at",
|
criteria
|
||||||
order: "asc",
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
// Combinar locales optimistas + remotos
|
// Combinar locales optimistas + remotos
|
||||||
const customers: CustomerSummary[] = useMemo(() => {
|
const customers: CustomerSummary[] = useMemo(() => {
|
||||||
|
const remoteCustomers = remoteCustomersPage ? remoteCustomersPage.items : []
|
||||||
const byId = new Map<string, CustomerSummary>();
|
const byId = new Map<string, CustomerSummary>();
|
||||||
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
|
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
|
||||||
return Array.from(byId.values());
|
return Array.from(byId.values());
|
||||||
}, [localCreated, remoteCustomers]);
|
}, [localCreated, remoteCustomersPage]);
|
||||||
|
|
||||||
// Sync con `value`
|
|
||||||
|
// Sync con value e initialCustomer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const found = customers.find((c) => c.id === value) ?? initialCustomer;
|
const found = customers.find((c) => c.id === value) ?? initialCustomer ?? null;
|
||||||
setSelected(found);
|
setSelected(found ?? null);
|
||||||
}, [value, customers]);
|
}, [value, customers, initialCustomer]);
|
||||||
|
|
||||||
|
|
||||||
|
// Crear cliente (optimista) mapeando desde CustomerDraft -> CustomerSummary
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!newClient.name || !newClient.email) return;
|
if (!newClient.name || !newClient.email_primary) return;
|
||||||
const newCustomer: CustomerSummary = {
|
|
||||||
id: crypto.randomUUID?.() ?? Date.now().toString(),
|
const newCustomer: CustomerSummary = defaultCustomerFormData as CustomerSummary;
|
||||||
...newClient,
|
|
||||||
};
|
|
||||||
setLocalCreated((prev) => [newCustomer, ...prev]);
|
setLocalCreated((prev) => [newCustomer, ...prev]);
|
||||||
onValueChange?.(newCustomer.id); // <- ahora el "source of truth" es React Hook Form
|
onValueChange?.(newCustomer.id); // RHF es el source of truth
|
||||||
setShowForm(false);
|
setShowNewForm(false);
|
||||||
setShowSearch(false);
|
setShowSearch(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(selected);
|
// Handlers de tarjeta según modo
|
||||||
|
const canChange = !disabled && !readOnly;
|
||||||
|
const canCreate = !disabled && !readOnly;
|
||||||
|
const canView = !!selected && !disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -93,15 +115,24 @@ export const CustomerModalSelector = ({
|
|||||||
<CustomerCard
|
<CustomerCard
|
||||||
className={className}
|
className={className}
|
||||||
customer={selected}
|
customer={selected}
|
||||||
onChangeCustomer={() => setShowSearch(true)}
|
onViewCustomer={canView ? () => setShowView(true) : undefined}
|
||||||
onViewCustomer={() => null}
|
onChangeCustomer={canChange ? () => setShowSearch(true) : undefined}
|
||||||
onAddNewCustomer={() => null}
|
onAddNewCustomer={canCreate ? () => setShowNewForm(true) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CustomerEmptyCard
|
<CustomerEmptyCard
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => setShowSearch(true)}
|
onClick={!disabled && !readOnly ? () => setShowSearch(true) : undefined}
|
||||||
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)}
|
onKeyDown={
|
||||||
|
!disabled && !readOnly
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowSearch(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-controls={dialogId}
|
||||||
|
aria-disabled={disabled || readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -119,19 +150,24 @@ export const CustomerModalSelector = ({
|
|||||||
setShowSearch(false);
|
setShowSearch(false);
|
||||||
}}
|
}}
|
||||||
onCreateClient={(name) => {
|
onCreateClient={(name) => {
|
||||||
setNewClient({ name: name ?? "", email: "" });
|
setNewClient((prev) => ({ ...prev, name: name ?? "" }));
|
||||||
setShowForm(true);
|
setShowNewForm(true);
|
||||||
}}
|
}}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
errorMessage={
|
errorMessage={isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined}
|
||||||
isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateCustomerFormDialog
|
<CustomerViewDialog
|
||||||
open={showForm}
|
customerId={selected?.id ?? null}
|
||||||
onOpenChange={setShowForm}
|
open={showView}
|
||||||
|
onOpenChange={setShowView}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Diálogo de alta rápida */}
|
||||||
|
<CustomerCreateModal
|
||||||
|
open={showNewForm}
|
||||||
|
onOpenChange={setShowNewForm}
|
||||||
client={newClient}
|
client={newClient}
|
||||||
onChange={setNewClient}
|
onChange={setNewClient}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
|
|||||||
@ -71,7 +71,8 @@ export const CustomerSearchDialog = ({
|
|||||||
<div className='px-6 pb-3'>
|
<div className='px-6 pb-3'>
|
||||||
<Command className='border rounded-lg' shouldFilter={false}>
|
<Command className='border rounded-lg' shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder='Buscar cliente...'
|
autoFocus
|
||||||
|
placeholder="Buscar cliente..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={onSearchQueryChange}
|
onValueChange={onSearchQueryChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
Badge, Button, Card, CardContent, CardHeader, CardTitle,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { Banknote, FileText, Languages, Mail, MapPin, Phone, Smartphone, X } from "lucide-react";
|
||||||
|
// CustomerViewDialog.tsx
|
||||||
|
import { useCustomerQuery } from "../../hooks";
|
||||||
|
|
||||||
|
type CustomerViewDialogProps = {
|
||||||
|
customerId: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomerViewDialog = ({
|
||||||
|
customerId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: CustomerViewDialogProps) => {
|
||||||
|
const {
|
||||||
|
data: customer,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useCustomerQuery(customerId ?? "", { enabled: open && !!customerId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl bg-card border-border p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<DialogTitle id="customer-view-title" className="flex items-center justify-between">
|
||||||
|
<span className="text-balance">
|
||||||
|
{customer?.name ?? "Cliente"}
|
||||||
|
{customer?.trade_name && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({customer.trade_name})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="px-0">
|
||||||
|
{customer?.tin ? (
|
||||||
|
<Badge variant="secondary" className="ml-0 font-mono">{customer.tin}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Ficha del cliente</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
{isLoading && <p role="status" aria-live="polite">Cargando…</p>}
|
||||||
|
{isError && (
|
||||||
|
<p role="alert" className="text-destructive">
|
||||||
|
{(error as Error)?.message ?? "No se pudo cargar el cliente"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && customer && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 max-h-[70vh] overflow-y-auto pr-1">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FileText className="size-5 text-primary" />
|
||||||
|
Información Básica
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Nombre</dt>
|
||||||
|
<dd className="mt-1">{customer.name}</dd>
|
||||||
|
</div>
|
||||||
|
{customer.reference && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Referencia</dt>
|
||||||
|
<dd className="mt-1 font-mono">{customer.reference}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.legal_record && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Registro Legal</dt>
|
||||||
|
<dd className="mt-1">{customer.legal_record}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!customer.default_taxes?.length && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Impuestos por defecto</dt>
|
||||||
|
<dd className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{customer.default_taxes.map((tax: string) => (
|
||||||
|
<Badge key={tax} variant="secondary">{tax}</Badge>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<MapPin className="size-5 text-primary" />
|
||||||
|
Dirección
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Calle</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
{customer.street}
|
||||||
|
{customer.street2 && (<><br />{customer.street2}</>)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Ciudad</dt>
|
||||||
|
<dd className="mt-1">{customer.city}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Código Postal</dt>
|
||||||
|
<dd className="mt-1">{customer.postal_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">Provincia</dt>
|
||||||
|
<dd className="mt-1">{customer.province}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-muted-foreground">País</dt>
|
||||||
|
<dd className="mt-1">{customer.country}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Mail className="size-5 text-primary" />
|
||||||
|
Contacto y Preferencias
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{customer.email_primary && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Mail className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<dt className="text-sm text-muted-foreground">Email</dt>
|
||||||
|
<dd className="mt-1">{customer.email_primary}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.mobile_primary && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Smartphone className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<dt className="text-sm text-muted-foreground">Móvil</dt>
|
||||||
|
<dd className="mt-1">{customer.mobile_primary}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customer.phone_primary && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Phone className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<dt className="text-sm text-muted-foreground">Teléfono</dt>
|
||||||
|
<dd className="mt-1">{customer.phone_primary}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Languages className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<dt className="text-sm text-muted-foreground">Idioma</dt>
|
||||||
|
<dd className="mt-1">{customer.language_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Banknote className="mt-0.5 size-4 text-muted-foreground" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<dt className="text-sm text-muted-foreground">Moneda</dt>
|
||||||
|
<dd className="mt-1">{customer.currency_code}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,21 +11,32 @@ import {
|
|||||||
RadioGroup,
|
RadioGroup,
|
||||||
RadioGroupItem
|
RadioGroupItem
|
||||||
} from '@repo/shadcn-ui/components';
|
} from '@repo/shadcn-ui/components';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
|
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerFormData } from "../../schemas";
|
import { CustomerFormData } from "../../schemas";
|
||||||
|
|
||||||
export const CustomerBasicInfoFields = () => {
|
interface CustomerBasicInfoFieldsProps {
|
||||||
|
focusRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomerBasicInfoFields = ({ focusRef }: CustomerBasicInfoFieldsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<CustomerFormData>();
|
const { control } = useFormContext<CustomerFormData>();
|
||||||
|
|
||||||
|
// Enfoca el primer campo recibido
|
||||||
|
useEffect(() => {
|
||||||
|
focusRef?.current?.focus?.();
|
||||||
|
}, [focusRef]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet>
|
<FieldSet>
|
||||||
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
||||||
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||||
<Field className='lg:col-span-2'>
|
<Field className='lg:col-span-2' ref={focusRef}>
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name='name'
|
name='name'
|
||||||
|
|||||||
@ -8,14 +8,15 @@ import { CustomerAddressFields } from "./customer-address-fields";
|
|||||||
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
||||||
import { CustomerContactFields } from './customer-contact-fields';
|
import { CustomerContactFields } from './customer-contact-fields';
|
||||||
|
|
||||||
interface CustomerFormProps {
|
type CustomerFormProps = {
|
||||||
formId: string;
|
formId: string;
|
||||||
onSubmit: (data: CustomerFormData) => void;
|
onSubmit: (data: CustomerFormData) => void;
|
||||||
onError: (errors: FieldErrors<CustomerFormData>) => void;
|
onError: (errors: FieldErrors<CustomerFormData>) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
focusRef?: React.RefObject<HTMLInputElement>;
|
||||||
|
};
|
||||||
|
|
||||||
export const CustomerEditForm = ({ formId, onSubmit, onError, className }: CustomerFormProps) => {
|
export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => {
|
||||||
const form = useFormContext<CustomerFormData>();
|
const form = useFormContext<CustomerFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -26,7 +27,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError, className }: Custo
|
|||||||
<FormDebug />
|
<FormDebug />
|
||||||
</div>
|
</div>
|
||||||
<div className='w-full xl:grow space-y-6'>
|
<div className='w-full xl:grow space-y-6'>
|
||||||
<CustomerBasicInfoFields />
|
<CustomerBasicInfoFields focusRef={focusRef} />
|
||||||
<CustomerContactFields />
|
<CustomerContactFields />
|
||||||
<CustomerAddressFields />
|
<CustomerAddressFields />
|
||||||
<CustomerAdditionalConfigFields />
|
<CustomerAdditionalConfigFields />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// components/CustomerSkeleton.tsx
|
// components/CustomerSkeleton.tsx
|
||||||
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
|
|
||||||
@ -7,7 +7,6 @@ export const CustomerEditorSkeleton = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='space-y-2' aria-hidden='true'>
|
<div className='space-y-2' aria-hidden='true'>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
export * from "./use-create-customer-mutation";
|
export * from "./use-create-customer-mutation";
|
||||||
|
export * from "./use-customer-list-query";
|
||||||
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-search-query";
|
|
||||||
export * from "./use-update-customer-mutation";
|
export * from "./use-update-customer-mutation";
|
||||||
|
|||||||
@ -1,49 +1,82 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
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 { ZodError } from "zod/v4";
|
||||||
import { CreateCustomerRequestSchema } from "../../common";
|
import { CreateCustomerRequestSchema } from "../../common";
|
||||||
import { Customer, CustomerFormData } from "../schemas";
|
import { Customer, CustomerFormData } from "../schemas";
|
||||||
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
|
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
|
||||||
|
import { getCustomerQueryKey } from "./use-customer-query";
|
||||||
|
|
||||||
|
export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const;
|
||||||
|
|
||||||
type CreateCustomerPayload = {
|
type CreateCustomerPayload = {
|
||||||
data: CustomerFormData;
|
data: CustomerFormData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helpers de validación a errores de dominio
|
||||||
|
export function toValidationErrors(error: ZodError<unknown>) {
|
||||||
|
return error.issues.map((err) => ({
|
||||||
|
field: err.path.join("."),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateCustomer() {
|
export function useCreateCustomer() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const schema = CreateCustomerRequestSchema;
|
const schema = CreateCustomerRequestSchema;
|
||||||
|
|
||||||
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
|
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
|
||||||
mutationKey: ["customer:create"],
|
mutationKey: CUSTOMER_CREATE_KEY,
|
||||||
|
|
||||||
mutationFn: async (payload) => {
|
mutationFn: async (data) => {
|
||||||
const { data } = payload;
|
const id = UniqueID.generateNewID().toString();
|
||||||
const customerId = UniqueID.generateNewID();
|
const payload = { ...data, id };
|
||||||
|
|
||||||
const newCustomerData = {
|
console.log("payload => ", payload);
|
||||||
...data,
|
const result = schema.safeParse(payload);
|
||||||
id: customerId.toString(),
|
|
||||||
};
|
result.error;
|
||||||
|
|
||||||
const result = schema.safeParse(newCustomerData);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Construye errores detallados
|
const errorDetails = toValidationErrors(result.error);
|
||||||
const validationErrors = result.error.issues.map((err) => ({
|
console.log(errorDetails);
|
||||||
field: err.path.join("."),
|
throw new ValidationErrorCollection("Validation failed", errorDetails);
|
||||||
message: err.message,
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new ValidationErrorCollection("Validation failed", validationErrors);
|
|
||||||
}
|
}
|
||||||
|
const created = await dataSource.createOne("customers", payload);
|
||||||
const created = await dataSource.createOne("customers", newCustomerData);
|
|
||||||
return created as Customer;
|
return created as Customer;
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: (created) => {
|
||||||
// Invalida el listado para refrescar desde servidor
|
// Invalida el listado para refrescar desde servidor
|
||||||
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
|
invalidateCustomerListCache(queryClient);
|
||||||
|
|
||||||
|
// Sincroniza detalle
|
||||||
|
queryClient.setQueryData(getCustomerQueryKey(created.id), created);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
// Refresca todos los listados
|
||||||
|
invalidateCustomerListCache(queryClient);
|
||||||
|
},
|
||||||
|
|
||||||
|
onMutate: async ({ data }) => {
|
||||||
|
// Cancelar queries del listado para evitar overwrite
|
||||||
|
await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] });
|
||||||
|
|
||||||
|
const optimisticId = UniqueID.generateNewID().toString();
|
||||||
|
const optimisticCustomer: Customer = { ...data, id: optimisticId } as Customer;
|
||||||
|
|
||||||
|
// Snapshot previo
|
||||||
|
const previous = queryClient.getQueryData<Customer[]>([CUSTOMERS_LIST_KEY]);
|
||||||
|
|
||||||
|
// Optimista: prepend
|
||||||
|
queryClient.setQueryData<Customer[]>([CUSTOMERS_LIST_KEY], (old) => [
|
||||||
|
optimisticCustomer,
|
||||||
|
...(old ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { previous, optimisticId };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
82
modules/customers/src/web/hooks/use-customer-list-query.tsx
Normal file
82
modules/customers/src/web/hooks/use-customer-list-query.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { CriteriaDTO, normalizeCriteriaDTO } from '@erp/core';
|
||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { DefaultError, QueryClient, QueryKey, useQuery } from "@tanstack/react-query";
|
||||||
|
import { CustomerSummary, CustomersPage } from '../schemas';
|
||||||
|
|
||||||
|
export const CUSTOMERS_LIST_KEY = ["customers", "list"] as const
|
||||||
|
|
||||||
|
export const getCustomersListQueryKey = (criteria: CriteriaDTO) =>
|
||||||
|
[...CUSTOMERS_LIST_KEY, normalizeCriteriaDTO(criteria)] satisfies QueryKey;
|
||||||
|
|
||||||
|
type CustomerListQueryOptions = {
|
||||||
|
enabled?: boolean;
|
||||||
|
criteria?: CriteriaDTO
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener todos los clientes
|
||||||
|
export const useCustomerListQuery = (options?: CustomerListQueryOptions) => {
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
const enabled = options?.enabled ?? true;
|
||||||
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
|
return useQuery<CustomersPage, DefaultError>({
|
||||||
|
queryKey: getCustomersListQueryKey(criteria),
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
return await dataSource.getList<CustomersPage>("customers", { signal, ...criteria });
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cancelCustomerListQueries(qc: QueryClient) {
|
||||||
|
return qc.cancelQueries({ queryKey: CUSTOMERS_LIST_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function invalidateCustomerListCache(qc: QueryClient) {
|
||||||
|
return qc.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCustomerListQueryKeys(qc: QueryClient): QueryKey[] {
|
||||||
|
// Nota: `getQueriesData` devuelve pares [key, data]
|
||||||
|
const entries = qc.getQueriesData<CustomersPage>({ queryKey: [CUSTOMERS_LIST_KEY] });
|
||||||
|
return entries.map(([key]) => key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inserta o reemplaza un customer en todas las páginas donde aparezca
|
||||||
|
export function upsertCustomerIntoListCaches(qc: QueryClient, customer: CustomerSummary) {
|
||||||
|
const keys = getAllCustomerListQueryKeys(qc);
|
||||||
|
for (const key of keys) {
|
||||||
|
|
||||||
|
const page = qc.getQueryData<CustomersPage>(key);
|
||||||
|
if (!page) continue;
|
||||||
|
|
||||||
|
const idx = page.items.findIndex((c) => c.id === customer.id);
|
||||||
|
if (idx === -1) continue;
|
||||||
|
|
||||||
|
const nextItems = page.items.slice();
|
||||||
|
nextItems[idx] = { ...page.items[idx], ...customer };
|
||||||
|
|
||||||
|
qc.setQueryData<CustomersPage>(key, { ...page, items: nextItems });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCustomerIntoListCaches(qc: QueryClient, customerId: string): {
|
||||||
|
snapshots: Array<{ key: QueryKey; page?: CustomersPage }>;
|
||||||
|
} {
|
||||||
|
const snapshots = getAllCustomerListQueryKeys(qc).map((key) => ({
|
||||||
|
key,
|
||||||
|
page: qc.getQueryData<CustomersPage>(key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const { key, page } of snapshots) {
|
||||||
|
if (!page) continue;
|
||||||
|
qc.setQueryData<CustomersPage>(key, {
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((c) => c.id !== customerId),
|
||||||
|
total_items: Math.max(0, page.total_items - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { snapshots: snapshots as Array<{ key: QueryKey; page?: CustomersPage }> };
|
||||||
|
}
|
||||||
@ -1,48 +1,49 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
|
import { DefaultError, QueryClient, type QueryKey, useQuery } from "@tanstack/react-query";
|
||||||
import { Customer } from "../schemas";
|
import { Customer } from "../schemas";
|
||||||
|
|
||||||
export const CUSTOMER_QUERY_KEY = (id: string): QueryKey => ["customer", id] as const;
|
export const CUSTOMER_DETAIL_SCOPE = "customers:detail" as const;
|
||||||
|
|
||||||
type CustomerQueryOptions = {
|
export const getCustomerQueryKey = (id: string) =>
|
||||||
|
[CUSTOMER_DETAIL_SCOPE, { id }] satisfies QueryKey;
|
||||||
|
|
||||||
|
type CustomerQueryOptions<TSelected> = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
staleTime?: number;
|
||||||
|
select?: (data: Customer) => TSelected;
|
||||||
|
placeholderData?: Customer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCustomerQuery(customerId?: string, options?: CustomerQueryOptions) {
|
export function useCustomerQuery<TSelected = Customer>(
|
||||||
|
customerId?: string,
|
||||||
|
options?: CustomerQueryOptions<TSelected>
|
||||||
|
) {
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const enabled = (options?.enabled ?? true) && !!customerId;
|
const enabled = (options?.enabled ?? true) && Boolean(customerId);
|
||||||
|
|
||||||
return useQuery<Customer, DefaultError>({
|
return useQuery<Customer, DefaultError, TSelected>({
|
||||||
queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"),
|
queryKey: getCustomerQueryKey(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<Customer>("customers", customerId, {
|
return await dataSource.getOne<Customer>("customers", customerId, { signal });
|
||||||
signal,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
|
staleTime: options?.staleTime ?? 60_000, // 1 min por defecto
|
||||||
|
select: options?.select,
|
||||||
|
placeholderData: options?.placeholderData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export function invalidateCustomerDetailCache(qc: QueryClient, id: string) {
|
||||||
export function useQuery<
|
return qc.invalidateQueries({
|
||||||
TQueryFnData = unknown,
|
queryKey: getCustomerQueryKey(id ?? "unknown"),
|
||||||
TError = unknown,
|
exact: Boolean(id),
|
||||||
TData = TQueryFnData,
|
});
|
||||||
TQueryKey extends QueryKey = QueryKey
|
}
|
||||||
>
|
|
||||||
|
|
||||||
TQueryFnData: the type returned from the queryFn.
|
export function setCustomerDetailCache(qc: QueryClient, id: string, data: unknown) {
|
||||||
TError: the type of Errors to expect from the queryFn.
|
qc.setQueryData(getCustomerQueryKey(id), data);
|
||||||
TData: the type our data property will eventually have.
|
}
|
||||||
Only relevant if you use the select option,
|
|
||||||
because then the data property can be different
|
|
||||||
from what the queryFn returns.
|
|
||||||
Otherwise, it will default to whatever the queryFn returns.
|
|
||||||
TQueryKey: the type of our queryKey, only relevant
|
|
||||||
if you use the queryKey that is passed to your queryFn.
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
import { CriteriaDTO } from '@erp/core';
|
|
||||||
import { useDataSource } from "@erp/core/hooks";
|
|
||||||
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
|
|
||||||
import { CustomersPage } from '../schemas';
|
|
||||||
|
|
||||||
|
|
||||||
export const CUSTOMERS_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
|
|
||||||
"customer_invoices", {
|
|
||||||
pageNumber: criteria.pageNumber ?? 0,
|
|
||||||
pageSize: criteria.pageSize ?? 10,
|
|
||||||
q: criteria.q ?? "",
|
|
||||||
filters: criteria.filters ?? [],
|
|
||||||
orderBy: criteria.orderBy ?? "",
|
|
||||||
order: criteria.order ?? "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type CustomersQueryOptions = {
|
|
||||||
enabled?: boolean;
|
|
||||||
criteria?: CriteriaDTO
|
|
||||||
};
|
|
||||||
|
|
||||||
// Obtener todos los clientes
|
|
||||||
export const useCustomersQuery = (options?: CustomersQueryOptions) => {
|
|
||||||
const dataSource = useDataSource();
|
|
||||||
const enabled = options?.enabled ?? true;
|
|
||||||
const criteria = options?.criteria ?? {};
|
|
||||||
|
|
||||||
return useQuery<CustomersPage, DefaultError>({
|
|
||||||
queryKey: CUSTOMERS_QUERY_KEY(criteria),
|
|
||||||
queryFn: async ({ signal }) => {
|
|
||||||
return await dataSource.getList<CustomersPage>("customers", {
|
|
||||||
signal,
|
|
||||||
...criteria,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled,
|
|
||||||
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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[],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
|
import { DefaultError, QueryKey, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { CustomersPage } from "../schemas";
|
||||||
|
import { cancelCustomerListQueries, deleteCustomerIntoListCaches } from "./use-customer-list-query";
|
||||||
|
import { invalidateCustomerDetailCache } from "./use-customer-query";
|
||||||
|
|
||||||
|
export const CUSTOMER_DELETE_KEY = ["customers", "delete"] as const;
|
||||||
|
|
||||||
|
type DeleteCustomerPayload = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDeleteCustomer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const dataSource = useDataSource();
|
||||||
|
|
||||||
|
return useMutation<{ id: string }, DefaultError, DeleteCustomerPayload>({
|
||||||
|
mutationKey: CUSTOMER_DELETE_KEY,
|
||||||
|
|
||||||
|
mutationFn: async ({ id: customerId }) => {
|
||||||
|
if (!customerId) {
|
||||||
|
throw new Error("customerId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await dataSource.deleteOne("customers", customerId);
|
||||||
|
return { id: customerId };
|
||||||
|
},
|
||||||
|
|
||||||
|
onMutate: async ({ id }) => {
|
||||||
|
await cancelCustomerListQueries(queryClient);
|
||||||
|
return deleteCustomerIntoListCaches(queryClient, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_e, _v, ctx) => {
|
||||||
|
if (!ctx) return;
|
||||||
|
const { snapshots } = ctx as ReturnType<typeof deleteCustomerIntoListCaches>;
|
||||||
|
for (const snap of snapshots as Array<{ key: QueryKey; page?: CustomersPage }>) {
|
||||||
|
if (snap.page) queryClient.setQueryData(snap.key, snap.page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
invalidateCustomerDetailCache(queryClient, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: (data) => {
|
||||||
|
deleteCustomerIntoListCaches(queryClient, data?.id ?? "unknown");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import { useDataSource } from "@erp/core/hooks";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
import { ValidationErrorCollection } from "@repo/rdx-ddd";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdRequestSchema } from "../../common";
|
import { UpdateCustomerByIdRequestSchema } from "../../common";
|
||||||
import { CustomerFormData } from "../schemas";
|
import { Customer, CustomerFormData } from "../schemas";
|
||||||
import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
|
import { toValidationErrors } from "./use-create-customer-mutation";
|
||||||
|
import { upsertCustomerIntoListCaches } from "./use-customer-list-query";
|
||||||
|
import { setCustomerDetailCache } from "./use-customer-query";
|
||||||
|
|
||||||
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
|
export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const;
|
||||||
|
|
||||||
type UpdateCustomerContext = {};
|
type UpdateCustomerContext = {};
|
||||||
|
|
||||||
@ -19,8 +21,8 @@ export function useUpdateCustomer() {
|
|||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const schema = UpdateCustomerByIdRequestSchema;
|
const schema = UpdateCustomerByIdRequestSchema;
|
||||||
|
|
||||||
return useMutation<CustomerFormData, Error, UpdateCustomerPayload, UpdateCustomerContext>({
|
return useMutation<Customer, Error, UpdateCustomerPayload, UpdateCustomerContext>({
|
||||||
mutationKey: ["customer:update"], //, customerId],
|
mutationKey: CUSTOMER_UPDATE_KEY,
|
||||||
|
|
||||||
mutationFn: async (payload) => {
|
mutationFn: async (payload) => {
|
||||||
const { id: customerId, data } = payload;
|
const { id: customerId, data } = payload;
|
||||||
@ -30,32 +32,21 @@ export function useUpdateCustomer() {
|
|||||||
|
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(data);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Construye errores detallados
|
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
|
||||||
const validationErrors = result.error.issues.map((err) => ({
|
|
||||||
field: err.path.join("."),
|
|
||||||
message: err.message,
|
|
||||||
}));
|
|
||||||
|
|
||||||
throw new ValidationErrorCollection("Validation failed", validationErrors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await dataSource.updateOne("customers", customerId, data);
|
const updated = await dataSource.updateOne("customers", customerId, data);
|
||||||
return updated as CustomerFormData;
|
return updated as Customer;
|
||||||
},
|
},
|
||||||
onSuccess: (updated: CustomerFormData, variables) => {
|
|
||||||
|
onSuccess: (updated: Customer, variables) => {
|
||||||
const { id: customerId } = variables;
|
const { id: customerId } = variables;
|
||||||
|
|
||||||
// Refresca inmediatamente el detalle
|
// Actualiza detalle
|
||||||
queryClient.setQueryData<UpdateCustomerByIdRequestDTO>(
|
setCustomerDetailCache(queryClient, customerId, updated);
|
||||||
CUSTOMER_QUERY_KEY(customerId),
|
|
||||||
updated
|
|
||||||
);
|
|
||||||
|
|
||||||
// Otra opción es invalidar el detalle para forzar refetch:
|
// Actualiza todas las páginas donde aparezca
|
||||||
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });
|
upsertCustomerIntoListCaches(queryClient, { ...updated });
|
||||||
|
|
||||||
// Invalida el listado para refrescar desde servidor
|
|
||||||
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,60 +2,20 @@ import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { PageHeader } from '@erp/core/components';
|
import { PageHeader } from '@erp/core/components';
|
||||||
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
|
||||||
import { FieldErrors, FormProvider } from "react-hook-form";
|
|
||||||
import { CustomerEditForm, ErrorAlert } from "../../components";
|
import { CustomerEditForm, ErrorAlert } from "../../components";
|
||||||
import { useCreateCustomer } from "../../hooks";
|
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
import { useCustomerCreateController } from './use-customer-create-controller';
|
||||||
|
|
||||||
export const CustomerCreatePage = () => {
|
export const CustomerCreatePage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 1) Estado de creación (mutación)
|
|
||||||
const {
|
const {
|
||||||
mutate,
|
form, isCreating, isCreateError, createError,
|
||||||
isPending: isCreating,
|
handleSubmit, handleError, FormProvider
|
||||||
isError: isCreateError,
|
} = useCustomerCreateController();
|
||||||
error: createError,
|
|
||||||
} = useCreateCustomer();
|
|
||||||
|
|
||||||
// 2) Form hook
|
|
||||||
const form = useHookForm<CustomerFormData>({
|
|
||||||
resolverSchema: CustomerFormSchema,
|
|
||||||
initialValues: defaultCustomerFormData,
|
|
||||||
disabled: isCreating,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3) Submit con navegación condicionada por éxito
|
|
||||||
const handleSubmit = (formData: CustomerFormData) => {
|
|
||||||
mutate(
|
|
||||||
{ data: formData },
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
showSuccessToast(t("pages.create.successTitle"), t("pages.create.successMsg"));
|
|
||||||
|
|
||||||
// 🔹 limpiar el form e isDirty pasa a false
|
|
||||||
form.reset(defaultCustomerFormData);
|
|
||||||
|
|
||||||
navigate("/customers/list", {
|
|
||||||
state: { customerId: data.id, isNew: true },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
showErrorToast(t("pages.create.errorTitle"), error.message);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (errors: FieldErrors<CustomerFormData>) => {
|
|
||||||
console.error("Errores en el formulario:", errors);
|
|
||||||
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
@ -100,7 +60,11 @@ export const CustomerCreatePage = () => {
|
|||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<CustomerEditForm
|
<CustomerEditForm
|
||||||
formId='customer-create-form'
|
formId='customer-create-form'
|
||||||
onSubmit={handleSubmit}
|
onSubmit={(data) =>
|
||||||
|
handleSubmit(data, ({ id }) =>
|
||||||
|
navigate("/customers/list", { state: { customerId: id, isNew: true }, replace: true })
|
||||||
|
)
|
||||||
|
}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
|
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
import { useHookForm } from "@erp/core/hooks";
|
||||||
|
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
|
||||||
|
import { FieldErrors, FormProvider } from "react-hook-form";
|
||||||
|
import { useCreateCustomer } from "../../hooks";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
|
||||||
|
|
||||||
|
export const useCustomerCreateController = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 1) Estado de creación (mutación)
|
||||||
|
const {
|
||||||
|
mutate,
|
||||||
|
isPending: isCreating,
|
||||||
|
isError: isCreateError,
|
||||||
|
error: createError,
|
||||||
|
} = useCreateCustomer();
|
||||||
|
|
||||||
|
// 2) Form hook
|
||||||
|
const form = useHookForm<CustomerFormData>({
|
||||||
|
resolverSchema: CustomerFormSchema,
|
||||||
|
initialValues: defaultCustomerFormData,
|
||||||
|
disabled: isCreating,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (formData: CustomerFormData, onSuccess?: (data: { id: string }) => void) => {
|
||||||
|
console.log(formData);
|
||||||
|
mutate(
|
||||||
|
{ data: formData },
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
form.reset(defaultCustomerFormData);
|
||||||
|
showSuccessToast(
|
||||||
|
t("pages.create.successTitle", "Cliente creado"),
|
||||||
|
t("pages.create.successMsg", "Se ha creado correctamente.")
|
||||||
|
);
|
||||||
|
onSuccess?.({ id: data.id });
|
||||||
|
},
|
||||||
|
onError(err: unknown) {
|
||||||
|
console.log("No se pudo crear el cliente.");
|
||||||
|
const msg =
|
||||||
|
(err as Error)?.message ??
|
||||||
|
t("pages.create.error.message", "No se pudo crear el cliente.");
|
||||||
|
showErrorToast(t("pages.create.error.title", "Error al crear"), msg);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (errors: FieldErrors<CustomerFormData>) => {
|
||||||
|
// 1) Enfoca el primer error
|
||||||
|
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
|
||||||
|
if (firstKey) {
|
||||||
|
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
|
||||||
|
el?.focus();
|
||||||
|
}
|
||||||
|
// 2) Toast informativo
|
||||||
|
showWarningToast(
|
||||||
|
t("forms.validation.title", "Revisa los campos"),
|
||||||
|
t("forms.validation.msg", "Hay errores de validación en el formulario.")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
isCreating,
|
||||||
|
isCreateError,
|
||||||
|
createError,
|
||||||
|
handleSubmit,
|
||||||
|
handleError,
|
||||||
|
FormProvider, // para no re-importar fuera
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -5,7 +5,7 @@ import { PlusIcon } from "lucide-react";
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
import { ErrorAlert } from '../../components';
|
import { ErrorAlert } from '../../components';
|
||||||
import { useCustomersQuery } from '../../hooks';
|
import { useCustomerListQuery } from '../../hooks';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { CustomersListGrid } from './customers-list-grid';
|
import { CustomersListGrid } from './customers-list-grid';
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ export const CustomersListPage = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useCustomersQuery({
|
} = useCustomerListQuery({
|
||||||
criteria
|
criteria
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -68,7 +68,6 @@ export const CustomersListPage = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEd
|
|||||||
{ id: customerId!, data: patchData },
|
{ id: customerId!, data: patchData },
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
|
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
|
||||||
|
|
||||||
// 🔹 limpiar el form e isDirty pasa a false
|
// 🔹 limpiar el form e isDirty pasa a false
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const CustomerUpdatePage = () => {
|
|||||||
{ id: customerId!, data: patchData },
|
{ id: customerId!, data: patchData },
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
|
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
|
||||||
|
|
||||||
// 🔹 limpiar el form e isDirty pasa a false
|
// 🔹 limpiar el form e isDirty pasa a false
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
|
export * from "./use-device-info.ts";
|
||||||
export * from "./use-row-selection.ts";
|
export * from "./use-row-selection.ts";
|
||||||
|
|||||||
155
packages/rdx-ui/src/hooks/use-device-info.ts
Normal file
155
packages/rdx-ui/src/hooks/use-device-info.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export const DEFAULT_PHONE_MAX = 768;
|
||||||
|
export const DEFAULT_TABLET_MAX = 1024;
|
||||||
|
|
||||||
|
type DeviceType = "phone" | "tablet" | "desktop";
|
||||||
|
|
||||||
|
type DeviceInfo = {
|
||||||
|
isMobile: boolean;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
|
||||||
|
// Señales útiles para decisiones finas de UX:
|
||||||
|
widthNarrow: boolean; // <= phoneMax
|
||||||
|
widthTablet: boolean; // > phoneMax && <= tabletMax
|
||||||
|
pointerCoarse: boolean; // puntero táctil grueso
|
||||||
|
hoverNone: boolean; // no hay hover
|
||||||
|
maxTouchPoints: number;
|
||||||
|
|
||||||
|
// Info de plataforma cuando está disponible
|
||||||
|
platform?: string;
|
||||||
|
model?: string;
|
||||||
|
uaMobileHint?: boolean; // de UA/Client Hints si se puede
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
phoneMax?: number; // por defecto DEFAULT_PHONE_MAX
|
||||||
|
tabletMax?: number; // por defecto DEFAULT_TABLET_MAX
|
||||||
|
|
||||||
|
// Para SSR/Next: primera suposición que evitará saltos visuales
|
||||||
|
serverGuess?: Partial<Pick<DeviceInfo, "deviceType" | "isMobile">>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hook base para media queries */
|
||||||
|
function useMediaQuery(query: string, fallback = false) {
|
||||||
|
const [matches, setMatches] = useState(fallback);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return; // SSR
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
setMatches(mql.matches);
|
||||||
|
mql.addEventListener?.("change", onChange);
|
||||||
|
return () => mql.removeEventListener?.("change", onChange);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clasificador a partir de señales */
|
||||||
|
function classifyDevice(
|
||||||
|
widthNarrow: boolean,
|
||||||
|
widthTablet: boolean,
|
||||||
|
pointerCoarse: boolean,
|
||||||
|
hoverNone: boolean
|
||||||
|
): DeviceType {
|
||||||
|
// Heurística práctica:
|
||||||
|
if (pointerCoarse && (widthNarrow || hoverNone)) return "phone";
|
||||||
|
if (pointerCoarse && widthTablet) return "tablet";
|
||||||
|
return "desktop";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeviceInfo(opts: Options = {}): DeviceInfo {
|
||||||
|
const phoneMax = opts.phoneMax ?? DEFAULT_PHONE_MAX;
|
||||||
|
const tabletMax = opts.tabletMax ?? DEFAULT_TABLET_MAX;
|
||||||
|
|
||||||
|
// Inicialización segura para SSR para evitar hydration mismatch.
|
||||||
|
// Si hay serverGuess, úsalo; si no, asume desktop hasta montar.
|
||||||
|
const [initial, setInitial] = useState<DeviceInfo | null>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
const deviceType = opts.serverGuess?.deviceType ?? "desktop";
|
||||||
|
const isMobile = opts.serverGuess?.isMobile ?? deviceType !== "desktop";
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
deviceType,
|
||||||
|
widthNarrow: deviceType === "phone",
|
||||||
|
widthTablet: deviceType === "tablet",
|
||||||
|
pointerCoarse: deviceType !== "desktop",
|
||||||
|
hoverNone: deviceType !== "desktop",
|
||||||
|
maxTouchPoints: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null; // en cliente, calcularemos real en useEffect
|
||||||
|
});
|
||||||
|
|
||||||
|
const widthNarrow = useMediaQuery(`(max-width: ${phoneMax}px)`, initial?.widthNarrow ?? false);
|
||||||
|
const widthTablet = useMediaQuery(
|
||||||
|
`(min-width: ${phoneMax + 1}px) and (max-width: ${tabletMax}px)`,
|
||||||
|
initial?.widthTablet ?? false
|
||||||
|
);
|
||||||
|
const pointerCoarse = useMediaQuery("(pointer: coarse)", initial?.pointerCoarse ?? false);
|
||||||
|
const hoverNone = useMediaQuery("(hover: none)", initial?.hoverNone ?? false);
|
||||||
|
|
||||||
|
const [maxTouchPoints, setMaxTouchPoints] = useState(initial?.maxTouchPoints ?? 0);
|
||||||
|
const [uaHints, setUaHints] = useState<{ platform?: string; model?: string; mobile?: boolean }>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
// maxTouchPoints es útil para detectar iPadOS que se disfraza de Mac
|
||||||
|
setMaxTouchPoints(navigator.maxTouchPoints || 0);
|
||||||
|
|
||||||
|
// Client Hints (Chromium). No bloquea si no existen.
|
||||||
|
(async () => {
|
||||||
|
const nav = navigator as any;
|
||||||
|
if (nav.userAgentData?.getHighEntropyValues) {
|
||||||
|
try {
|
||||||
|
const { platform, mobile, model } = await nav.userAgentData.getHighEntropyValues([
|
||||||
|
"platform",
|
||||||
|
"mobile",
|
||||||
|
"model",
|
||||||
|
]);
|
||||||
|
setUaHints({ platform, mobile, model });
|
||||||
|
} catch {
|
||||||
|
// ignorar excepciones
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deviceType = useMemo(() => {
|
||||||
|
// Si es Mac pero con touchPoints > 1 => probablemente iPadOS en modo desktop
|
||||||
|
const forceTouch =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
/Macintosh/i.test(navigator.userAgent || "") &&
|
||||||
|
maxTouchPoints > 1;
|
||||||
|
|
||||||
|
return classifyDevice(
|
||||||
|
widthNarrow,
|
||||||
|
widthTablet,
|
||||||
|
pointerCoarse || forceTouch,
|
||||||
|
hoverNone || forceTouch
|
||||||
|
);
|
||||||
|
}, [widthNarrow, widthTablet, pointerCoarse, hoverNone, maxTouchPoints]);
|
||||||
|
|
||||||
|
const isMobile = deviceType !== "desktop";
|
||||||
|
|
||||||
|
// Si teníamos estado inicial en SSR, al primer render en cliente lo reemplazamos por el real
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setInitial(null);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
deviceType,
|
||||||
|
widthNarrow,
|
||||||
|
widthTablet,
|
||||||
|
pointerCoarse,
|
||||||
|
hoverNone,
|
||||||
|
maxTouchPoints,
|
||||||
|
platform: uaHints.platform,
|
||||||
|
model: uaHints.model,
|
||||||
|
uaMobileHint: uaHints.mobile,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user