This commit is contained in:
David Arranz 2026-03-10 18:10:11 +01:00
parent 64b48d707b
commit 24f7bd0fb9
44 changed files with 918 additions and 151 deletions

View File

@ -0,0 +1,15 @@
interface ErrorAlertProps {
title: string;
message: string;
}
export const ErrorAlert = ({ title, message }: ErrorAlertProps) => (
<div
aria-live="assertive"
className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4"
role="alert"
>
<p className="font-semibold text-destructive-foreground">{title}</p>
<p className="text-sm text-destructive-foreground/90">{message}</p>
</div>
);

View File

@ -1,2 +1,3 @@
export * from "./error-alert";
export * from "./form"; export * from "./form";
export * from "./page-header"; export * from "./page-header";

View File

@ -1,2 +1,2 @@
export * from "./customer-edit-form"; //export * from "./customer-edit-form";
export * from "./customer-editor-skeleton"; export * from "../../view/ui/components/customer-editor-skeleton";

View File

@ -1,16 +0,0 @@
// components/ErrorAlert.tsx
interface ErrorAlertProps {
title: string;
message: string;
}
export const ErrorAlert = ({ title, message }: ErrorAlertProps) => (
<div
className='mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4'
role='alert'
aria-live='assertive'
>
<p className='font-semibold text-destructive-foreground'>{title}</p>
<p className='text-sm text-destructive-foreground/90'>{message}</p>
</div>
);

View File

@ -1,5 +1,5 @@
export * from "./client-selector-modal"; //export * from "./client-selector-modal";
export * from "./customer-modal-selector"; //export * from "./customer-modal-selector";
export * from "./editor"; //export * from "./editor";
export * from "./error-alert"; export * from "../../../../core/src/web/components/error-alert";
export * from "./not-found-card"; //export * from "./not-found-card";

View File

@ -5,8 +5,8 @@ import { Outlet, type RouteObject } from "react-router-dom";
// Lazy load components // Lazy load components
const CustomerLayout = lazy(() => import("./ui").then((m) => ({ default: m.CustomerLayout }))); const CustomerLayout = lazy(() => import("./ui").then((m) => ({ default: m.CustomerLayout })));
const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.CustomerListPage }))); const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.CustomerListPage })));
const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage })));
//const CustomerView = lazy(() => import("./pages").then((m) => ({ default: m.CustomerViewPage })));
//const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage }))); //const CustomerAdd = lazy(() => import("./pages").then((m) => ({ default: m.CustomerCreatePage })));
/*const CustomerUpdate = lazy(() => /*const CustomerUpdate = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerUpdatePage })) import("./pages").then((m) => ({ default: m.CustomerUpdatePage }))
@ -25,7 +25,7 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
{ path: "", index: true, element: <CustomersList /> }, // index { path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> }, { path: "list", element: <CustomersList /> },
//{ path: "create", element: <CustomerAdd /> }, //{ path: "create", element: <CustomerAdd /> },
//{ path: ":id", element: <CustomerView /> }, { path: ":id", element: <CustomerView /> },
//{ path: ":id/edit", element: <CustomerUpdate /> }, //{ path: ":id/edit", element: <CustomerUpdate /> },
// //

View File

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

View File

@ -1,9 +1,11 @@
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 { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { ZodError } from "zod/v4"; import type { ZodError } from "zod/v4";
import { CreateCustomerRequestSchema } from "../../common"; import { CreateCustomerRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas"; import type { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query"; import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
import { setCustomerDetailCache } from "./use-customer-query"; import { setCustomerDetailCache } from "./use-customer-query";

View File

@ -1,8 +1,10 @@
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 { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../common"; import { UpdateCustomerByIdRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas"; import type { Customer, CustomerFormData } from "../schemas";
import { toValidationErrors } from "./use-create-customer-mutation"; import { toValidationErrors } from "./use-create-customer-mutation";
import { import {
invalidateCustomerListCache, invalidateCustomerListCache,

View File

@ -1,4 +1,5 @@
import type { CustomerSummaryPage, CustomerSummaryPageData } from "../../types"; import type { CustomerSummaryPage } from "../api";
import type { CustomerSummaryPageData } from "../types";
/** /**
* Convierte el DTO completo de API a datos numéricos para el formulario. * Convierte el DTO completo de API a datos numéricos para el formulario.

View File

@ -0,0 +1,6 @@
import type { ListCustomersResponseDTO } from "@erp/customers/common";
import type { ArrayElement } from "@repo/rdx-utils";
// Resultado de consulta con criteria (paginado, etc.)
export type CustomerSummaryPage = Omit<ListCustomersResponseDTO, "metadata">;
export type CustomerSummary = Omit<ArrayElement<CustomerSummaryPage>, "metadata">;

View File

@ -1,7 +1,7 @@
import type { CriteriaDTO } from "@erp/core"; import type { CriteriaDTO } from "@erp/core";
import type { IDataSource } from "@erp/core/client"; import type { IDataSource } from "@erp/core/client";
import type { CustomerSummaryPage } from "../../types"; import type { CustomerSummaryPage } from "./api-types";
export async function getCustomerListApi( export async function getCustomerListApi(
dataSource: IDataSource, dataSource: IDataSource,
@ -13,6 +13,5 @@ export async function getCustomerListApi(
...criteria, ...criteria,
}); });
//return mapIssuedInvoiceList(raw);
return response; return response;
} }

View File

@ -1 +1,2 @@
export * from "./api-types";
export * from "./get-customer-list.api"; export * from "./get-customer-list.api";

View File

@ -1,10 +1,14 @@
import type { CriteriaDTO } from "@erp/core"; import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query"; import {
type DefaultError,
type QueryKey,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import type { CustomerSummaryPage } from "../../types"; import { type CustomerSummaryPage, getCustomerListApi } from "../api";
import { getCustomerListApi } from "../api";
export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [ export const CUSTOMERS_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => [
"customers", "customers",
@ -23,7 +27,9 @@ type CustomersQueryOptions = {
criteria?: CriteriaDTO; criteria?: CriteriaDTO;
}; };
export const useCustomerListQuery = (options?: CustomersQueryOptions) => { export const useCustomerListQuery = (
options?: CustomersQueryOptions
): UseQueryResult<CustomerSummaryPage, DefaultError> => {
const dataSource = useDataSource(); const dataSource = useDataSource();
const enabled = options?.enabled ?? true; const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {}; const criteria = options?.criteria ?? {};
@ -37,7 +43,6 @@ export const useCustomerListQuery = (options?: CustomersQueryOptions) => {
}; };
/* /*
export function cancelCustomerListQueries(qc: QueryClient) { export function cancelCustomerListQueries(qc: QueryClient) {
return qc.cancelQueries({ queryKey: CUSTOMERS_LIST_KEY }); return qc.cancelQueries({ queryKey: CUSTOMERS_LIST_KEY });
} }

View File

@ -0,0 +1 @@
export * from "./types";

View File

@ -1,6 +1,5 @@
import type { CustomerSummary } from "../schemas"; import type { CustomerSummary } from "../../schemas";
import type { CustomerSummaryPage } from "../api";
import type { CustomerSummaryPage } from "./customer.api.schema";
export type CustomerSummaryData = CustomerSummary; export type CustomerSummaryData = CustomerSummary;

View File

@ -3,7 +3,7 @@ import type { ColumnDef } from "@tanstack/react-table";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { CustomerSummaryData, CustomerSummaryPageData } from "../../../../types"; import type { CustomerSummaryData, CustomerSummaryPageData } from "../../../types";
interface CustomersGridProps { interface CustomersGridProps {
data: CustomerSummaryPageData; data: CustomerSummaryPageData;
@ -58,4 +58,4 @@ export const CustomersGrid = ({
totalItems={total_items} totalItems={total_items}
/> />
); );
} };

View File

@ -2,24 +2,21 @@ import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
Button, DropdownMenu, Button,
DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { import { EyeIcon, MoreHorizontalIcon } from "lucide-react";
EyeIcon, MoreHorizontalIcon
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import {
type CustomerSummaryData,
} from "../../../../types";
import { CustomerStatusBadge } from "../../../../ui"; import { CustomerStatusBadge } from "../../../../ui";
import type { CustomerSummaryData } from "../../../types";
import { AddressCell, ContactCell, Initials } from "../../components"; import { AddressCell, ContactCell, Initials } from "../../components";
import { KindBadge } from "../../components/kind-badge"; import { KindBadge } from "../../components/kind-badge";
import { Soft } from "../../components/soft"; import { Soft } from "../../components/soft";
@ -86,12 +83,14 @@ export function useCustomersGridColumns(
return ( return (
<div className="flex items-start gap-1 my-1.5"> <div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden"> <Avatar className="size-10 hidden">
<AvatarFallback aria-label={customer.name}><Initials name={customer.name} /></AvatarFallback> <AvatarFallback aria-label={customer.name}>
<Initials name={customer.name} />
</AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 grid gap-1"> <div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<CustomerStatusBadge status={customer.status} />{" "} <CustomerStatusBadge status={customer.status} />{" "}
<span className="font-medium truncate text-primary">{customer.name}</span> <span className="font-bold truncate text-primary">{customer.name}</span>
{customer.trade_name && <Soft>({customer.trade_name})</Soft>} {customer.trade_name && <Soft>({customer.trade_name})</Soft>}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -105,22 +104,22 @@ export function useCustomersGridColumns(
}, },
// Contacto (emails, teléfonos, web) // Contacto (emails, teléfonos, web)
{ {
id: "contact", id: "contact",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader <DataTableColumnHeader
className="text-left" className="text-left"
column={column} column={column}
title={t("pages.list.grid_columns.contact")} title={t("pages.list.grid_columns.contact")}
/> />
), ),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`, accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
size: 140, size: 140,
minSize: 120, minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />, cell: ({ row }) => <ContactCell customer={row.original} />,
}, },
// Dirección (múltiples campos en bloque) // Dirección (múltiples campos en bloque)
{ {
id: "address", id: "address",
header: t("pages.list.grid_columns.address"), header: t("pages.list.grid_columns.address"),
@ -159,37 +158,37 @@ export function useCustomersGridColumns(
<EyeIcon className="size-4" /> <EyeIcon className="size-4" />
</Button> </Button>
{0 === false && ( <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button aria-label="More actions" size="icon" variant="ghost">
<Button aria-label="More actions" size="icon" variant="ghost"> <MoreHorizontalIcon className="size-4" />
<MoreHorizontalIcon className="size-4" /> </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent align="end">
<DropdownMenuContent align="end"> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuSeparator />
<DropdownMenuSeparator /> <DropdownMenuItem onClick={() => actionHandlers.onViewClick?.(customer)}>
<DropdownMenuItem onClick={() => actionHandlers.onViewClick?.(customer)}>Open</DropdownMenuItem> Open
<DropdownMenuItem onClick={() => actionHandlers.onEditClick?.(customer)}>Edit</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuItem onClick={() => actionHandlers.onEditClick?.(customer)}>
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}> Edit
Visit website </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
onClick={() => navigator.clipboard.writeText(email_primary)} Visit website
> </DropdownMenuItem>
Copy email <DropdownMenuItem onClick={() => navigator.clipboard.writeText(email_primary)}>
</DropdownMenuItem> Copy email
<DropdownMenuSeparator /> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuSeparator />
className="text-destructive" <DropdownMenuItem
onClick={() => actionHandlers.onDeleteClick?.(customer)} className="text-destructive"
> onClick={() => actionHandlers.onDeleteClick?.(customer)}
Delete >
</DropdownMenuItem> Delete
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
)} </DropdownMenu>
</div> </div>
</div> </div>
); );

View File

@ -1,11 +1,8 @@
import { import type { CustomerSummaryData } from "../../types";
type CustomerSummaryData,
} from "../../../types";
import { Soft } from './soft'; import { Soft } from "./soft";
export const AddressCell = ({ customer }: { customer: CustomerSummaryData }) => {
export const AddressCell = ({ customer }: { customer: CustomerSummaryData; }) => {
const line1 = [customer.street, customer.street2].filter(Boolean).join(", "); const line1 = [customer.street, customer.street2].filter(Boolean).join(", ");
const line2 = [customer.postal_code, customer.city].filter(Boolean).join(" "); const line2 = [customer.postal_code, customer.city].filter(Boolean).join(" ");
const line3 = [customer.province, customer.country].filter(Boolean).join(", "); const line3 = [customer.province, customer.country].filter(Boolean).join(", ");

View File

@ -1,9 +1,8 @@
import { MailIcon, PhoneIcon } from 'lucide-react'; import { MailIcon, PhoneIcon } from "lucide-react";
import { Soft } from './soft';
import {
type CustomerSummaryData,
} from "../../../types";
import type { CustomerSummaryData } from "../../types";
import { Soft } from "./soft";
export const ContactCell = ({ customer }: { customer: CustomerSummaryData }) => ( export const ContactCell = ({ customer }: { customer: CustomerSummaryData }) => (
<div className="grid gap-1 text-foreground text-sm my-1.5"> <div className="grid gap-1 text-foreground text-sm my-1.5">
@ -32,4 +31,4 @@ export const ContactCell = ({ customer }: { customer: CustomerSummaryData }) =>
</div> </div>
{false} {false}
</div> </div>
); );

View File

@ -1,10 +1,13 @@
import { PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { useCustomerListPageController } from '../../controllers'; import { ErrorAlert } from "../../../ui";
import { useCustomersGridColumns } from '../blocks'; import { useCustomerListPageController } from "../../controllers";
import { AppContent, BackHistoryButton } from '@repo/rdx-ui/components'; import { CustomersGrid, useCustomersGridColumns } from "../blocks";
import { ErrorAlert } from '../../../ui';
export const CustomerListPage = () => { export const CustomerListPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -12,9 +15,11 @@ export const CustomerListPage = () => {
const { listCtrl } = useCustomerListPageController(); const { listCtrl } = useCustomerListPageController();
const columns = useCustomersGridColumns({}); const columns = useCustomersGridColumns({
onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`),
onViewClick: (customer) => navigate(`/customers/${customer.id}`),
onDeleteClick: (customer) => null, //confirmDelete(inv.id),
});
if (listCtrl.isError || !listCtrl.data) { if (listCtrl.isError || !listCtrl.data) {
return ( return (
@ -26,10 +31,8 @@ export const CustomerListPage = () => {
<BackHistoryButton /> <BackHistoryButton />
</AppContent> </AppContent>
); );
} }
return <h1>gika</h1>;
/*
return ( return (
<> <>
<AppHeader> <AppHeader>
@ -68,6 +71,5 @@ export const CustomerListPage = () => {
/> />
</AppContent> </AppContent>
</> </>
);*/ );
}; };

View File

@ -1,7 +1,7 @@
import { z } from "zod/v4"; import type { PaginationSchema } from "@erp/core";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
import { PaginationSchema } from "@erp/core";
import { ArrayElement } from "@repo/rdx-utils";
import { import {
CreateCustomerRequestSchema, CreateCustomerRequestSchema,
GetCustomerByIdResponseSchema, GetCustomerByIdResponseSchema,

View File

@ -1,17 +0,0 @@
import {
GetCustomerByIdResponseSchema,
type ListCustomersResponseDTO,
} from "@erp/customers/common";
import type { ArrayElement } from "@repo/rdx-utils";
import type { z } from "zod/v4";
// IssuedInvoices
export const CustomerSchema = GetCustomerByIdResponseSchema.omit({
metadata: true,
});
export type Customer = z.infer<typeof CustomerSchema>;
// Resultado de consulta con criteria (paginado, etc.)
export type CustomerSummaryPage = Omit<ListCustomersResponseDTO, "metadata">;
export type CustomerSummary = Omit<ArrayElement<CustomerSummaryPage>, "metadata">;

View File

@ -1,2 +0,0 @@
export * from "./customer.api.schema";
export * from "./customer-summary.web.schema";

View File

@ -0,0 +1,13 @@
import type { Customer } from "../api";
import type { CustomerData } from "../types";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const CustomerDtoAdapter = {
fromDto(customerDto: Customer, context?: unknown): CustomerData {
return {
...customerDto,
};
},
};

View File

@ -0,0 +1 @@
export * from "./customer-dto.adapter";

View File

@ -0,0 +1,3 @@
import type { GetCustomerByIdResponseDTO } from "@erp/customers/common";
export type Customer = Omit<GetCustomerByIdResponseDTO, "metadata">;

View File

@ -0,0 +1,9 @@
import type { IDataSource } from "@erp/core/client";
import type { Customer } from "./api-types";
export async function getCustomerById(dataSource: IDataSource, signal: AbortSignal, id?: string) {
if (!id) throw new Error("customerId is required");
const response = dataSource.getOne<Customer>("customers", id, { signal });
return response;
}

View File

@ -0,0 +1,2 @@
export * from "./api-types";
export * from "./get-customer-by-ip.api";

View File

@ -0,0 +1,2 @@
export * from "./use-customer-view.controller";
export * from "./use-customer-view-page.controller";

View File

@ -0,0 +1,9 @@
import { useCustomerViewController } from "./use-customer-view.controller";
export function useCustomerViewPageController() {
const viewCtrl = useCustomerViewController();
return {
viewCtrl,
};
}

View File

@ -0,0 +1,21 @@
import { useMemo, useState } from "react";
import { CustomerDtoAdapter } from "../adapters";
import { useCustomerGetQuery } from "../hooks";
export const useCustomerViewController = () => {
const [customerId, setCustomerId] = useState("");
const query = useCustomerGetQuery(customerId);
const data = useMemo(
() => (query.data ? CustomerDtoAdapter.fromDto(query.data) : undefined),
[query.data]
);
return {
...query,
data,
customerId,
setCustomerId,
};
};

View File

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

View File

@ -0,0 +1,47 @@
import { useDataSource } from "@erp/core/hooks";
import {
type DefaultError,
type QueryKey,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { type Customer, getCustomerById } from "../api";
export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [
"customers:detail",
{
customerId,
},
];
type CustomerQueryOptions = {
enabled?: boolean;
};
export const useCustomerGetQuery = (
customerId?: string,
options?: CustomerQueryOptions
): UseQueryResult<Customer, DefaultError> => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? Boolean(customerId);
return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId),
queryFn: async ({ signal }) => getCustomerById(dataSource, signal, customerId),
enabled,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};
/*export function invalidateCustomerDetailCache(qc: QueryClient, id: string) {
return qc.invalidateQueries({
queryKey: getCustomerQueryKey(id ?? "unknown"),
exact: Boolean(id),
});
}
export function setCustomerDetailCache(qc: QueryClient, id: string, data: unknown) {
qc.setQueryData(getCustomerQueryKey(id), data);
}
*/

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./types";

View File

@ -0,0 +1,3 @@
import type { Customer } from "../api";
export type CustomerData = Customer;

View File

@ -2,7 +2,7 @@
import { 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";
export const CustomerEditorSkeleton = () => { export const CustomerEditorSkeleton = () => {
const { t } = useTranslation(); const { t } = useTranslation();

View File

@ -0,0 +1 @@
export * from "./customer-editor-skeleton";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1,333 @@
import { PageHeader } from "@erp/core/components";
import { useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import {
Banknote,
EditIcon,
FileText,
Globe,
Languages,
Mail,
MapPin,
MoreVertical,
Phone,
Smartphone,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CustomerEditorSkeleton, ErrorAlert } from "../../../components";
import { useTranslation } from "../../../i18n";
import { useCustomerGetQuery } from "../../hooks";
export const CustomerViewPage2 = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
const {
data: customer,
isLoading: isLoadingCustomer,
isError: isLoadError,
error: loadError,
} = useCustomerGetQuery(customerId, { enabled: !!customerId });
if (isLoadingCustomer) {
return <CustomerEditorSkeleton />;
}
if (isLoadError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
return (
<>
<AppHeader>
<PageHeader
backIcon
description={
<div className="mt-2 flex items-center gap-3">
<Badge className="font-mono" variant="secondary">
{customer?.tin}
</Badge>
<Badge variant="outline">{customer?.is_company ? "Empresa" : "Persona"}</Badge>
</div>
}
rightSlot={
<div className="flex gap-2">
<Button onClick={() => navigate("/customers/list")} size="icon" variant="outline">
<MoreVertical className="h-4 w-4" />
</Button>
<Button>
<EditIcon className="mr-2 h-4 w-4" />
Editar
</Button>
</div>
}
title={
<div className="flex flex-wrap items-center gap-2">
{customer?.name}{" "}
{customer?.trade_name && (
<span className="text-muted-foreground">({customer.trade_name})</span>
)}
</div>
}
/>
</AppHeader>
<AppContent>
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Información Básica */}
<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-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Nombre</dt>
<dd className="mt-1 text-base text-foreground">{customer?.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono text-base text-foreground">{customer?.reference}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Registro Legal</dt>
<dd className="mt-1 text-base text-foreground">{customer?.legal_record}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Impuestos por Defecto</dt>
<dd className="mt-1">
{customer?.default_taxes.map((tax) => (
<Badge key={tax} variant={"secondary"}>
{tax}
</Badge>
))}
</dd>
</div>
</CardContent>
</Card>
{/* Dirección */}
<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-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Calle</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.street}
{customer?.street2 && (
<>
<br />
{customer?.street2}
</>
)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Ciudad</dt>
<dd className="mt-1 text-base text-foreground">{customer?.city}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Código Postal</dt>
<dd className="mt-1 text-base text-foreground">{customer?.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Provincia</dt>
<dd className="mt-1 text-base text-foreground">{customer?.province}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">País</dt>
<dd className="mt-1 text-base text-foreground">{customer?.country}</dd>
</div>
</div>
</CardContent>
</Card>
{/* Información de Contacto */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Información de Contacto
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
{/* Contacto Principal */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Principal</h3>
{customer?.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.email_primary}
</dd>
</div>
</div>
)}
{customer?.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.mobile_primary}
</dd>
</div>
</div>
)}
{customer?.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.phone_primary}
</dd>
</div>
</div>
)}
</div>
{/* Contacto Secundario */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Secundario</h3>
{customer?.email_secondary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.email_secondary}
</dd>
</div>
</div>
)}
{customer?.mobile_secondary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.mobile_secondary}
</dd>
</div>
</div>
)}
{customer?.phone_secondary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">
{customer?.phone_secondary}
</dd>
</div>
</div>
)}
</div>
{/* Otros Contactos */}
{(customer?.website || customer?.fax) && (
<div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground">Otros</h3>
<div className="grid gap-4 md:grid-cols-2">
{customer?.website && (
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Sitio Web</dt>
<dd className="mt-1 text-base text-primary hover:underline">
<a href={customer?.website} rel="noopener noreferrer" target="_blank">
{customer?.website}
</a>
</dd>
</div>
</div>
)}
{customer?.fax && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Fax</dt>
<dd className="mt-1 text-base text-foreground">{customer?.fax}</dd>
</div>
</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Preferencias */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Languages className="size-5 text-primary" />
Preferencias
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Idioma Preferido</dt>
<dd className="mt-1 text-base text-foreground">{customer?.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Moneda Preferida</dt>
<dd className="mt-1 text-base text-foreground">{customer?.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</AppContent>
</>
);
};

View File

@ -0,0 +1,324 @@
import { ErrorAlert, PageHeader } from "@erp/core/components";
import { useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import {
Banknote,
EditIcon,
FileText,
Globe,
Languages,
Mail,
MapPin,
MoreVertical,
Phone,
Smartphone,
} from "lucide-react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useCustomerViewPageController } from "../../controllers/use-customer-view-page.controller";
import { CustomerEditorSkeleton } from "../components";
export const CustomerViewPage = () => {
const initialCustomerId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const {
viewCtrl: { setCustomerId, customerId, data, isError, error, isLoading },
} = useCustomerViewPageController();
useEffect(() => {
if (initialCustomerId && customerId !== initialCustomerId) {
setCustomerId(initialCustomerId);
}
}, [initialCustomerId, customerId, setCustomerId]);
if (isError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(error as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
if (isLoading) {
return <CustomerEditorSkeleton />;
}
return (
<>
<AppHeader>
<PageHeader
backIcon
description={
<div className="mt-2 flex items-center gap-3">
<Badge className="font-mono" variant="secondary">
{data?.tin}
</Badge>
<Badge variant="outline">{data?.is_company ? "Empresa" : "Persona"}</Badge>
</div>
}
rightSlot={
<div className="flex gap-2">
<Button onClick={() => navigate("/customers/list")} size="icon" variant="outline">
<MoreVertical className="h-4 w-4" />
</Button>
<Button>
<EditIcon className="mr-2 h-4 w-4" />
Editar
</Button>
</div>
}
title={
<div className="flex flex-wrap items-center gap-2">
{data?.name}{" "}
{data?.trade_name && (
<span className="text-muted-foreground">({data.trade_name})</span>
)}
</div>
}
/>
</AppHeader>
<AppContent>
{/* Main Content Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Información Básica */}
<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-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Nombre</dt>
<dd className="mt-1 text-base text-foreground">{data?.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono text-base text-foreground">{data?.reference}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Registro Legal</dt>
<dd className="mt-1 text-base text-foreground">{data?.legal_record}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Impuestos por Defecto</dt>
<dd className="mt-1">
{data?.default_taxes.map((tax) => (
<Badge key={tax} variant={"secondary"}>
{tax}
</Badge>
))}
</dd>
</div>
</CardContent>
</Card>
{/* Dirección */}
<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-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Calle</dt>
<dd className="mt-1 text-base text-foreground">
{data?.street}
{data?.street2 && (
<>
<br />
{data?.street2}
</>
)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Ciudad</dt>
<dd className="mt-1 text-base text-foreground">{data?.city}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Código Postal</dt>
<dd className="mt-1 text-base text-foreground">{data?.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground">Provincia</dt>
<dd className="mt-1 text-base text-foreground">{data?.province}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">País</dt>
<dd className="mt-1 text-base text-foreground">{data?.country}</dd>
</div>
</div>
</CardContent>
</Card>
{/* Información de Contacto */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Información de Contacto
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
{/* Contacto Principal */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Principal</h3>
{data?.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">{data?.email_primary}</dd>
</div>
</div>
)}
{data?.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">{data?.mobile_primary}</dd>
</div>
</div>
)}
{data?.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">{data?.phone_primary}</dd>
</div>
</div>
)}
</div>
{/* Contacto Secundario */}
<div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Secundario</h3>
{data?.email_secondary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground">{data?.email_secondary}</dd>
</div>
</div>
)}
{data?.mobile_secondary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground">{data?.mobile_secondary}</dd>
</div>
</div>
)}
{data?.phone_secondary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground">{data?.phone_secondary}</dd>
</div>
</div>
)}
</div>
{/* Otros Contactos */}
{(data?.website || data?.fax) && (
<div className="space-y-4 md:col-span-2">
<h3 className="font-semibold text-foreground">Otros</h3>
<div className="grid gap-4 md:grid-cols-2">
{data?.website && (
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Sitio Web</dt>
<dd className="mt-1 text-base text-primary hover:underline">
<a href={data?.website} rel="noopener noreferrer" target="_blank">
{data?.website}
</a>
</dd>
</div>
</div>
)}
{data?.fax && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Fax</dt>
<dd className="mt-1 text-base text-foreground">{data?.fax}</dd>
</div>
</div>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Preferencias */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Languages className="size-5 text-primary" />
Preferencias
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-2">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Idioma Preferido</dt>
<dd className="mt-1 text-base text-foreground">{data?.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Moneda Preferida</dt>
<dd className="mt-1 text-base text-foreground">{data?.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</AppContent>
</>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-view-page";

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"], "include": ["src", "../core/src/web/components/error-alert.tsx"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }