Clientes y Facturas de cliente
This commit is contained in:
parent
bdc5637a81
commit
e337331650
@ -3,3 +3,4 @@ import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
|||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
|
export * from "./page-header";
|
||||||
|
|||||||
48
modules/core/src/web/components/page-header.tsx
Normal file
48
modules/core/src/web/components/page-header.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Button } from '@repo/shadcn-ui/components';
|
||||||
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
|
import { ChevronLeftIcon } from 'lucide-react';
|
||||||
|
// features/common/components/page-header.tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
/** Icono que aparece a la izquierda del título */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Contenido del título (texto plano o nodo complejo) */
|
||||||
|
title: ReactNode;
|
||||||
|
/** Descripción secundaria debajo del título */
|
||||||
|
description?: ReactNode;
|
||||||
|
/** Estado opcional (ej. "draft", "paid") */
|
||||||
|
status?: string;
|
||||||
|
/** Contenido del lado derecho (botones, menús, etc.) */
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("py-4", className)}>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
{/* Lado izquierdo */}
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer" onClick={() => window.history.back()}>
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
|
{icon && <div className='shrink-0'>{icon}</div>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
|
||||||
|
</div>
|
||||||
|
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado derecho parametrizable */}
|
||||||
|
{rightSlot && <div>{rightSlot}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,5 +6,4 @@ export * from "./customer-invoices-layout";
|
|||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
export * from "./editor/invoice-tax-summary";
|
export * from "./editor/invoice-tax-summary";
|
||||||
export * from "./editor/invoice-totals";
|
export * from "./editor/invoice-totals";
|
||||||
export * from "./page-header";
|
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
import { Button } from '@repo/shadcn-ui/components';
|
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
|
||||||
import { ChevronLeftIcon } from 'lucide-react';
|
|
||||||
// features/common/components/page-header.tsx
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
/** Icono que aparece a la izquierda del título */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** Contenido del título (texto plano o nodo complejo) */
|
|
||||||
title: ReactNode;
|
|
||||||
/** Descripción secundaria debajo del título */
|
|
||||||
description?: ReactNode;
|
|
||||||
/** Estado opcional (ej. "draft", "paid") */
|
|
||||||
status?: string;
|
|
||||||
/** Contenido del lado derecho (botones, menús, etc.) */
|
|
||||||
rightSlot?: ReactNode;
|
|
||||||
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("border-b bg-card -px-4 pt-4", className)}>
|
|
||||||
<div className="mx-auto px-6 py-4">
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
{/* Lado izquierdo */}
|
|
||||||
<div className='flex items-center gap-4'>
|
|
||||||
<Button variant="ghost" size="icon" className="cursor-pointer" onClick={() => window.history.back()}>
|
|
||||||
<ChevronLeftIcon className="size-5" />
|
|
||||||
</Button>
|
|
||||||
{icon && <div className='shrink-0'>{icon}</div>}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
|
|
||||||
{status && <CustomerInvoiceStatusBadge status={status} />}
|
|
||||||
</div>
|
|
||||||
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lado derecho parametrizable */}
|
|
||||||
{rightSlot && <div>{rightSlot}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -41,6 +41,7 @@ export const InvoicesListGrid = ({
|
|||||||
}: InvoiceUpdateCompProps) => {
|
}: InvoiceUpdateCompProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { items, total_items } = invoicesPage;
|
||||||
|
|
||||||
// Hook con Sheet de shadcn
|
// Hook con Sheet de shadcn
|
||||||
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
|
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
|
||||||
@ -57,7 +58,6 @@ export const InvoicesListGrid = ({
|
|||||||
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
|
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
|
||||||
onDelete: (invoice) => null, //confirmDelete(inv.id),
|
onDelete: (invoice) => null, //confirmDelete(inv.id),
|
||||||
});
|
});
|
||||||
const { items, total_items } = invoicesPage;
|
|
||||||
|
|
||||||
// Navegación accesible (click o teclado)
|
// Navegación accesible (click o teclado)
|
||||||
const goToRow = useCallback(
|
const goToRow = useCallback(
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
|
import { PageHeader } from '@erp/core/components';
|
||||||
import { ErrorAlert } from '@erp/customers/components';
|
import { ErrorAlert } from '@erp/customers/components';
|
||||||
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PageHeader } from '../../components';
|
|
||||||
import { useInvoicesQuery } from '../../hooks';
|
import { useInvoicesQuery } from '../../hooks';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
|
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
|
||||||
@ -79,7 +79,6 @@ export const InvoiceListPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
<AppBreadcrumb />
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("pages.list.title")}
|
title={t("pages.list.title")}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { PageHeader } from '@erp/core/components';
|
||||||
import {
|
import {
|
||||||
UnsavedChangesProvider,
|
UnsavedChangesProvider,
|
||||||
UpdateCommitButtonGroup,
|
UpdateCommitButtonGroup,
|
||||||
@ -8,9 +9,6 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
|||||||
import { useMemo } from 'react';
|
import { 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 {
|
|
||||||
PageHeader
|
|
||||||
} from "../../components";
|
|
||||||
import { useInvoiceContext } from '../../context';
|
import { useInvoiceContext } from '../../context';
|
||||||
import { useUpdateCustomerInvoice } from "../../hooks";
|
import { useUpdateCustomerInvoice } from "../../hooks";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
"@repo/rdx-ui": "workspace:*",
|
"@repo/rdx-ui": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"ag-grid-react": "^33.3.0",
|
"ag-grid-react": "^33.3.0",
|
||||||
"lucide-react": "^0.503.0",
|
"lucide-react": "^0.503.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@ -29,9 +29,6 @@ export const ListCustomersResponseSchema = createPaginatedListSchema(
|
|||||||
fax: z.string(),
|
fax: z.string(),
|
||||||
website: z.string(),
|
website: z.string(),
|
||||||
|
|
||||||
//legal_record: z.string(),
|
|
||||||
//default_taxes: z.array(z.string()),
|
|
||||||
|
|
||||||
language_code: z.string(),
|
language_code: z.string(),
|
||||||
currency_code: z.string(),
|
currency_code: z.string(),
|
||||||
|
|
||||||
|
|||||||
@ -1,205 +0,0 @@
|
|||||||
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
|
|
||||||
import type { CellKeyDownEvent, RowClickedEvent, ValueFormatterParams } from "ag-grid-community";
|
|
||||||
import {
|
|
||||||
ColDef,
|
|
||||||
GridOptions,
|
|
||||||
SizeColumnsToContentStrategy,
|
|
||||||
SizeColumnsToFitGridStrategy,
|
|
||||||
SizeColumnsToFitProvidedWidthStrategy,
|
|
||||||
} from "ag-grid-community";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { ErrorOverlay } from "@repo/rdx-ui/components";
|
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
|
||||||
import { AgGridReact } from "ag-grid-react";
|
|
||||||
import { ChevronRightIcon } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useCustomersQuery } from "../hooks";
|
|
||||||
import { useTranslation } from "../i18n";
|
|
||||||
import { CustomerStatusBadge } from "./customer-status-badge";
|
|
||||||
|
|
||||||
// Create new GridExample component
|
|
||||||
export const CustomersListGrid = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: customersData,
|
|
||||||
isLoading: isLoadingCustomers,
|
|
||||||
isError: isLoadError,
|
|
||||||
error: loadError,
|
|
||||||
} = useCustomersQuery({
|
|
||||||
pagination: {
|
|
||||||
pageSize: 999,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Column Definitions: Defines & controls grid columns.
|
|
||||||
const [columnDefs] = useState<ColDef[]>([
|
|
||||||
{ field: "name", headerName: t("pages.list.grid_columns.name"), minWidth: 300 },
|
|
||||||
{
|
|
||||||
field: "tin",
|
|
||||||
headerName: t("pages.list.grid_columns.tin"),
|
|
||||||
maxWidth: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "city",
|
|
||||||
headerName: t("pages.list.grid_columns.city"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "email_primary",
|
|
||||||
headerName: t("pages.list.grid_columns.email"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "phone_primary",
|
|
||||||
headerName: t("pages.list.grid_columns.phone"),
|
|
||||||
maxWidth: 120,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: "mobile_primary",
|
|
||||||
headerName: t("pages.list.grid_columns.mobile"),
|
|
||||||
maxWidth: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "status",
|
|
||||||
headerName: t("pages.list.grid_columns.status"),
|
|
||||||
maxWidth: 135,
|
|
||||||
cellRenderer: (params: ValueFormatterParams) => {
|
|
||||||
return <CustomerStatusBadge status={params.value} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
colId: "actions",
|
|
||||||
headerName: t("pages.list.grid_columns.actions", "Actions"),
|
|
||||||
cellRenderer: (params: ValueFormatterParams) => {
|
|
||||||
const { data } = params;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant='secondary'
|
|
||||||
size='icon'
|
|
||||||
className='size-8'
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/customers/${data.id}/edit`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Navegación centralizada (click/teclado)
|
|
||||||
const goToRow = useCallback(
|
|
||||||
(id: string, newTab = false) => {
|
|
||||||
const url = `/customers/${id}`;
|
|
||||||
if (newTab) {
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
} else {
|
|
||||||
navigate(url);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onRowClicked = useCallback(
|
|
||||||
(e: RowClickedEvent<unknown>) => {
|
|
||||||
if (!e.data) return;
|
|
||||||
// Soporta Ctrl/Cmd click para nueva pestaña
|
|
||||||
const newTab = e.event instanceof MouseEvent && (e.event.metaKey || e.event.ctrlKey);
|
|
||||||
goToRow(e.data.id, newTab);
|
|
||||||
},
|
|
||||||
[goToRow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onCellKeyDown = useCallback(
|
|
||||||
(e: CellKeyDownEvent<unknown>) => {
|
|
||||||
if (!e.data) return;
|
|
||||||
const key = e.event.key;
|
|
||||||
// Enter o Space disparan navegación
|
|
||||||
if (key === "Enter" || key === " ") {
|
|
||||||
e.event.preventDefault();
|
|
||||||
goToRow(e.data.id);
|
|
||||||
}
|
|
||||||
// Ctrl/Cmd+Enter abre en nueva pestaña
|
|
||||||
if ((e.event.ctrlKey || e.event.metaKey) && key === "Enter") {
|
|
||||||
e.event.preventDefault();
|
|
||||||
goToRow(e.data.id, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[goToRow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoSizeStrategy = useMemo<
|
|
||||||
| SizeColumnsToFitGridStrategy
|
|
||||||
| SizeColumnsToFitProvidedWidthStrategy
|
|
||||||
| SizeColumnsToContentStrategy
|
|
||||||
>(() => {
|
|
||||||
return {
|
|
||||||
type: "fitGridWidth",
|
|
||||||
defaultMinWidth: 100,
|
|
||||||
columnLimits: [{ colId: "actions", minWidth: 75, maxWidth: 75 }],
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const gridOptions: GridOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
columnDefs: columnDefs,
|
|
||||||
autoSizeStrategy: autoSizeStrategy,
|
|
||||||
defaultColDef: {
|
|
||||||
editable: false,
|
|
||||||
flex: 1,
|
|
||||||
filter: false,
|
|
||||||
sortable: false,
|
|
||||||
resizable: true,
|
|
||||||
},
|
|
||||||
pagination: true,
|
|
||||||
paginationPageSize: 15,
|
|
||||||
paginationPageSizeSelector: [10, 15, 20, 30, 50],
|
|
||||||
localeText: AG_GRID_LOCALE_ES,
|
|
||||||
|
|
||||||
// Evita conflictos con selección si la usas
|
|
||||||
suppressRowClickSelection: true,
|
|
||||||
// Clase visual de fila clickeable
|
|
||||||
getRowClass: () => "clickable-row",
|
|
||||||
// Accesibilidad con teclado
|
|
||||||
onCellKeyDown,
|
|
||||||
// Click en cualquier parte de la fila
|
|
||||||
onRowClicked,
|
|
||||||
// IDs estables (opcional pero recomendado)
|
|
||||||
getRowId: (params) => params.data.id,
|
|
||||||
}),
|
|
||||||
[autoSizeStrategy, columnDefs, onCellKeyDown, onRowClicked]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadError) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ErrorOverlay
|
|
||||||
errorMessage={
|
|
||||||
(loadError as Error)?.message ??
|
|
||||||
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container: Defines the grid's theme & dimensions.
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='ag-theme-alpine'
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AgGridReact
|
|
||||||
rowData={customersData?.items ?? []}
|
|
||||||
loading={isLoadingCustomers}
|
|
||||||
{...gridOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
export * from "./client-selector-modal";
|
export * from "./client-selector-modal";
|
||||||
export * from "./customer-modal-selector";
|
export * from "./customer-modal-selector";
|
||||||
export * from "./customers-layout";
|
export * from "./customers-layout";
|
||||||
export * from "./customers-list-grid";
|
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
export * from "./error-alert";
|
export * from "./error-alert";
|
||||||
export * from "./not-found-card";
|
export * from "./not-found-card";
|
||||||
|
|||||||
@ -7,9 +7,10 @@ const CustomersLayout = lazy(() =>
|
|||||||
import("./components").then((m) => ({ default: m.CustomersLayout }))
|
import("./components").then((m) => ({ default: m.CustomersLayout }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersList })));
|
const CustomersList = lazy(() => import("./pages").then((m) => ({ default: m.CustomersListPage })));
|
||||||
const CustomerView = lazy(() => import("./pages").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(() => import("./pages").then((m) => ({ default: m.CustomerUpdatePage })));
|
||||||
|
|
||||||
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
||||||
return [
|
return [
|
||||||
@ -21,11 +22,11 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
|
|||||||
</CustomersLayout>
|
</CustomersLayout>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
/*{ 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: <CustomerUpdatePage /> },*/
|
{ path: ":id/edit", element: <CustomerUpdate /> },
|
||||||
|
|
||||||
//
|
//
|
||||||
/*{ path: "create", element: <CustomersList /> },
|
/*{ path: "create", element: <CustomersList /> },
|
||||||
|
|||||||
@ -1,22 +1,40 @@
|
|||||||
import { useDataSource, useQueryKey } from "@erp/core/hooks";
|
import { CriteriaDTO } from '@erp/core';
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useDataSource } from "@erp/core/hooks";
|
||||||
import { CustomersListData } from "../schemas";
|
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
|
// Obtener todos los clientes
|
||||||
export const useCustomersQuery = (params?: any) => {
|
export const useCustomersQuery = (options?: CustomersQueryOptions) => {
|
||||||
const dataSource = useDataSource();
|
const dataSource = useDataSource();
|
||||||
const keys = useQueryKey();
|
const enabled = options?.enabled ?? true;
|
||||||
|
const criteria = options?.criteria ?? {};
|
||||||
|
|
||||||
return useQuery<CustomersListData>({
|
return useQuery<CustomersPage, DefaultError>({
|
||||||
queryKey: keys().data().resource("customers").action("list").params(params).get(),
|
queryKey: CUSTOMERS_QUERY_KEY(criteria),
|
||||||
queryFn: async (context) => {
|
queryFn: async ({ signal }) => {
|
||||||
const { signal } = context;
|
return await dataSource.getList<CustomersPage>("customers", {
|
||||||
const customers = await dataSource.getList("customers", {
|
|
||||||
signal,
|
signal,
|
||||||
...params,
|
...criteria,
|
||||||
});
|
});
|
||||||
|
|
||||||
return customers as CustomersListData;
|
|
||||||
},
|
},
|
||||||
|
enabled,
|
||||||
|
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import { Outlet, useNavigate } from "react-router-dom";
|
|
||||||
import { CustomersListGrid } from "../components";
|
|
||||||
import { useTranslation } from "../i18n";
|
|
||||||
|
|
||||||
export const CustomersList = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
|
||||||
<div className='flex items-center justify-between space-y-6'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.list.title")}</h2>
|
|
||||||
<p className='text-muted-foreground'>{t("pages.list.description")}</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
<Button onClick={() => navigate("/customers/create")} className='cursor-pointer'>
|
|
||||||
<PlusIcon className='w-4 h-4 mr-2' />
|
|
||||||
{t("pages.create.title")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col w-full h-full py-3'>
|
|
||||||
<CustomersListGrid />
|
|
||||||
</div>
|
|
||||||
<Outlet />
|
|
||||||
</AppContent>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./create";
|
export * from "./create";
|
||||||
export * from "./customer-list";
|
export * from "./list";
|
||||||
|
export * from "./update";
|
||||||
export * from "./view";
|
export * from "./view";
|
||||||
|
|||||||
160
modules/customers/src/web/pages/list/customers-list-grid.tsx
Normal file
160
modules/customers/src/web/pages/list/customers-list-grid.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
|
||||||
|
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
|
||||||
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Spinner } from '@repo/shadcn-ui/components';
|
||||||
|
import { SearchIcon, XIcon } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { CustomerSummaryFormData, CustomersPageFormData } from '../../schemas';
|
||||||
|
import { useCustomersListColumns } from './use-customers-list-columns';
|
||||||
|
|
||||||
|
|
||||||
|
export type CustomerUpdateCompProps = {
|
||||||
|
customersPage: CustomersPageFormData;
|
||||||
|
loading?: boolean;
|
||||||
|
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange?: (pageNumber: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
|
||||||
|
searchValue: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
|
||||||
|
onRowClick?: (row: CustomerSummaryFormData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomersListGrid = ({
|
||||||
|
customersPage,
|
||||||
|
loading,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
searchValue, onSearchChange,
|
||||||
|
onRowClick
|
||||||
|
}: CustomerUpdateCompProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { items, total_items } = customersPage;
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState("todas");
|
||||||
|
|
||||||
|
const columns = useCustomersListColumns({
|
||||||
|
onEdit: (customer) => navigate(`/customers/${customer.id}/edit`),
|
||||||
|
onView: (customer) => null, //duplicateInvoice(inv.id),
|
||||||
|
onDelete: (customer) => null, //confirmDelete(inv.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Navegación centralizada (click/teclado)
|
||||||
|
const goToRow = useCallback(
|
||||||
|
(id: string, newTab = false) => {
|
||||||
|
const url = `/customers/${id}`;
|
||||||
|
if (newTab) {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
} else {
|
||||||
|
navigate(url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handlers de búsqueda
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value);
|
||||||
|
const handleClear = () => onSearchChange("");
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
// Envío inmediato: forzar “salto” del debounce
|
||||||
|
onSearchChange((e.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*const handleRowClick = useCallback(
|
||||||
|
(customer: CustomerSummaryFormData, _i: number, e: React.MouseEvent) => {
|
||||||
|
const url = `/customer-invoices/${customer.id}/edit`;
|
||||||
|
if (e.metaKey || e.ctrlKey) { window.open(url, "_blank", "noopener,noreferrer"); return; }
|
||||||
|
preview.open(customer);
|
||||||
|
},
|
||||||
|
[preview]
|
||||||
|
);*/
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SkeletonDataTable
|
||||||
|
columns={columns.length}
|
||||||
|
rows={Math.max(6, pageSize)}
|
||||||
|
showFooter
|
||||||
|
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render principal
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Barra de filtros */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
|
<div className="relative flex-1 max-w-lg" aria-label={t("pages.list.searchPlaceholder")}>
|
||||||
|
<InputGroup className='bg-background' data-disabled={loading}>
|
||||||
|
<InputGroupInput
|
||||||
|
placeholder={t("common.search_placeholder")}
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
inputMode="search"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{loading && <Spinner />}
|
||||||
|
{!searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer'>
|
||||||
|
{t("common.search")}
|
||||||
|
</InputGroupButton>}
|
||||||
|
{searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer' aria-label={t("common.clear")} onClick={handleClear}>
|
||||||
|
<XIcon className="size-4" aria-hidden />
|
||||||
|
<span className="sr-only">{t("common.search")}</span>
|
||||||
|
</InputGroupButton>}
|
||||||
|
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex">
|
||||||
|
<div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */"flex-1"}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
readOnly
|
||||||
|
enableRowSelection
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalItems={total_items}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
onRowClick={() => null /*handleRowClick*/}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*<preview.Preview>
|
||||||
|
{({ item, isPinned, close, togglePin }) => (
|
||||||
|
<InvoicePreviewPanel
|
||||||
|
invoice={item}
|
||||||
|
isPinned={isPinned}
|
||||||
|
onClose={close}
|
||||||
|
onTogglePin={togglePin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</preview.Preview>*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
modules/customers/src/web/pages/list/customers-list-page.tsx
Normal file
120
modules/customers/src/web/pages/list/customers-list-page.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { PageHeader } from '@erp/core/components';
|
||||||
|
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
|
import { ErrorAlert } from '../../components';
|
||||||
|
import { useCustomersQuery } from '../../hooks';
|
||||||
|
import { useTranslation } from "../../i18n";
|
||||||
|
import { CustomersListGrid } from './customers-list-grid';
|
||||||
|
|
||||||
|
export const CustomersListPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedQ = useDebounce(search, 300);
|
||||||
|
|
||||||
|
|
||||||
|
const criteria = useMemo(
|
||||||
|
() => ({
|
||||||
|
q: debouncedQ || "",
|
||||||
|
pageSize,
|
||||||
|
pageNumber: pageIndex,
|
||||||
|
}),
|
||||||
|
[pageSize, pageIndex, debouncedQ]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: customersPageData,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useCustomersQuery({
|
||||||
|
criteria
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePageChange = (newPageIndex: number) => {
|
||||||
|
// TanStack usa pageIndex 0-based → API usa 0-based también
|
||||||
|
setPageIndex(newPageIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newSize: number) => {
|
||||||
|
setPageSize(newSize);
|
||||||
|
setPageIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
// Normalización ligera: recorta y colapsa espacios internos
|
||||||
|
const cleaned = value.trim().replace(/\s+/g, " ");
|
||||||
|
setSearch(cleaned);
|
||||||
|
setPageIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (isError || !customersPageData) {
|
||||||
|
return (
|
||||||
|
<AppContent>
|
||||||
|
<ErrorAlert
|
||||||
|
title={t("pages.list.loadErrorTitle")}
|
||||||
|
message={(error as Error)?.message || "Error al cargar el listado"}
|
||||||
|
/>
|
||||||
|
<BackHistoryButton />
|
||||||
|
</AppContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppHeader>
|
||||||
|
<AppBreadcrumb />
|
||||||
|
<PageHeader
|
||||||
|
title={t("pages.list.title")}
|
||||||
|
rightSlot={
|
||||||
|
<></>}
|
||||||
|
|
||||||
|
|
||||||
|
/>
|
||||||
|
</AppHeader>
|
||||||
|
<AppContent>
|
||||||
|
<div className='flex items-center justify-between space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.list.title")}</h2>
|
||||||
|
<p className='text-muted-foreground'>{t("pages.list.description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/customer-invoices/create")}
|
||||||
|
variant={'default'}
|
||||||
|
aria-label={t("pages.create.title")}
|
||||||
|
className='cursor-pointer'
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" aria-hidden />
|
||||||
|
{t("pages.create.title")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col w-full h-full py-3'>
|
||||||
|
<div className={"flex-1"}>
|
||||||
|
<CustomersListGrid
|
||||||
|
customersPage={customersPageData}
|
||||||
|
loading={isLoading}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Outlet />
|
||||||
|
</AppContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
modules/customers/src/web/pages/list/index.ts
Normal file
1
modules/customers/src/web/pages/list/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./customers-list-page";
|
||||||
@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DropdownMenu, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger
|
||||||
|
} from '@repo/shadcn-ui/components';
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react';
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { CustomerSummaryFormData } from '../../schemas';
|
||||||
|
|
||||||
|
type CustomerActionHandlers = {
|
||||||
|
onEdit?: (customer: CustomerSummaryFormData) => void;
|
||||||
|
onView?: (customer: CustomerSummaryFormData) => void;
|
||||||
|
onDelete?: (customer: CustomerSummaryFormData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortId(id: string) {
|
||||||
|
return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers UI ----
|
||||||
|
const StatusBadge = ({ status }: { status: string }) => {
|
||||||
|
// Map visual simple; ajustar a tu catálogo real
|
||||||
|
const v =
|
||||||
|
status.toLowerCase() === "active"
|
||||||
|
? "default"
|
||||||
|
: status.toLowerCase() === "inactive"
|
||||||
|
? "outline"
|
||||||
|
: "secondary";
|
||||||
|
return (
|
||||||
|
<Badge variant={v as any} className="uppercase tracking-wide text-[11px]">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
|
||||||
|
{isCompany ? "Company" : "Person"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Soft = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<span className="text-muted-foreground">{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex items-center gap-2 group:text-foreground group-hover:text-primary">
|
||||||
|
<MailIcon className="size-3.5 group" />
|
||||||
|
<a className="group" href={`mailto:${customer.email_primary}`}>
|
||||||
|
{customer.email_primary || <Soft>—</Soft>}
|
||||||
|
</a>
|
||||||
|
{customer.email_secondary && <Soft>• {customer.email_secondary}</Soft>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 group:text-foreground group-hover:text-primary">
|
||||||
|
<PhoneIcon className="size-3.5 group" />
|
||||||
|
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
|
||||||
|
{customer.phone_secondary && <Soft>• {customer.phone_secondary}</Soft>}
|
||||||
|
{customer.mobile_secondary && <Soft>• {customer.mobile_secondary}</Soft>}
|
||||||
|
{customer.fax && <Soft>• fax {customer.fax}</Soft>}
|
||||||
|
</div>
|
||||||
|
{customer.website && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GlobeIcon className="size-3.5" />
|
||||||
|
<a className="underline underline-offset-2" href={safeHttp(customer.website)} target="_blank" rel="noreferrer">
|
||||||
|
{customer.website}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AddressCell: React.FC<{ c: CustomerSummaryFormData }> = ({ c }) => {
|
||||||
|
const line1 = [c.street, c.street2].filter(Boolean).join(", ");
|
||||||
|
const line2 = [c.postal_code, c.city].filter(Boolean).join(" ");
|
||||||
|
const line3 = [c.province, c.country].filter(Boolean).join(", ");
|
||||||
|
return (
|
||||||
|
<address className="not-italic grid gap-1 text-sm">
|
||||||
|
<div>{line1 || <Soft>—</Soft>}</div>
|
||||||
|
<div>{[line2, line3].filter(Boolean).join(" • ")}</div>
|
||||||
|
</address>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function initials(name: string) {
|
||||||
|
const parts = name.trim().split(/\s+/).slice(0, 2);
|
||||||
|
return parts.map(p => p[0]?.toUpperCase() ?? "").join("") || "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeHttp(url: string) {
|
||||||
|
if (!url) return "#";
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
return `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomersListColumns(
|
||||||
|
handlers: CustomerActionHandlers = {}
|
||||||
|
): ColumnDef<CustomerSummaryFormData>[] {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
onEdit, onView, onDelete,
|
||||||
|
} = handlers;
|
||||||
|
|
||||||
|
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(() => [
|
||||||
|
// Identidad + estado + metadatos (columna compuesta)
|
||||||
|
{
|
||||||
|
id: "identity",
|
||||||
|
header: "Customer",
|
||||||
|
accessorFn: (row) => row.name, // para ordenar/buscar por nombre
|
||||||
|
enableHiding: false,
|
||||||
|
size: 380,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const c = row.original;
|
||||||
|
const isCompany = String(c.is_company).toLowerCase() === "true";
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 grid gap-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium truncate">{c.name}</span>
|
||||||
|
{c.trade_name && <Soft>({c.trade_name})</Soft>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{c.tin && <span className="font-medium truncate">{c.tin}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={c.status} />
|
||||||
|
<KindBadge isCompany={isCompany} />
|
||||||
|
{c.reference && <Badge variant="secondary">Ref: {c.reference}</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Contacto (emails, teléfonos, web)
|
||||||
|
{
|
||||||
|
id: "contact",
|
||||||
|
header: "Contact",
|
||||||
|
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
|
||||||
|
size: 420,
|
||||||
|
cell: ({ row }) => <ContactCell customer={row.original} />,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dirección (múltiples campos en bloque)
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
header: "Address",
|
||||||
|
accessorFn: (r) =>
|
||||||
|
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
|
||||||
|
size: 360,
|
||||||
|
cell: ({ row }) => <AddressCell c={row.original} />,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
size: 72,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
const { website, email_primary } = customer;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Edit customer"
|
||||||
|
onClick={() => onEdit?.(customer)}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="More actions">
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
|
||||||
|
Visit website
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(email_primary)}>
|
||||||
|
Copy email
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => onDelete?.(customer)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [t, onEdit, onView, onDelete]);
|
||||||
|
}
|
||||||
@ -3,8 +3,8 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
|
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
|
||||||
import {
|
import {
|
||||||
FormCommitButtonGroup,
|
|
||||||
UnsavedChangesProvider,
|
UnsavedChangesProvider,
|
||||||
|
UpdateCommitButtonGroup,
|
||||||
useHookForm,
|
useHookForm,
|
||||||
useUrlParamId,
|
useUrlParamId,
|
||||||
} from "@erp/core/hooks";
|
} from "@erp/core/hooks";
|
||||||
@ -137,7 +137,7 @@ export const CustomerUpdatePage = () => {
|
|||||||
{t("pages.update.description")}
|
{t("pages.update.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FormCommitButtonGroup
|
<UpdateCommitButtonGroup
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
cancel={{
|
cancel={{
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
|
||||||
import {
|
import {
|
||||||
Banknote,
|
Banknote,
|
||||||
@ -42,7 +42,6 @@ export const CustomerViewPage = () => {
|
|||||||
if (isLoadError) {
|
if (isLoadError) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<ErrorAlert
|
<ErrorAlert
|
||||||
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
|
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
|
||||||
@ -62,13 +61,12 @@ export const CustomerViewPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBreadcrumb />
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='space-y-6 max-w-4xl'>
|
<div className='space-y-6 max-w-4xl'>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className='flex items-start justify-between'>
|
<div className='flex items-start justify-between'>
|
||||||
<div className='flex items-start gap-4'>
|
<div className='flex items-start gap-4'>
|
||||||
<div className='flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10'>
|
<div className='flex h-16 w-16 items-center justify-center rounded-lg'>
|
||||||
{customer?.is_company ? (
|
{customer?.is_company ? (
|
||||||
<Building2 className='size-8 text-primary' />
|
<Building2 className='size-8 text-primary' />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { CustomerSummary, CustomersPage } from "./customer.api.schema";
|
||||||
|
|
||||||
|
export type CustomerSummaryFormData = CustomerSummary & {};
|
||||||
|
|
||||||
|
export type CustomersPageFormData = CustomersPage & {
|
||||||
|
items: CustomerSummaryFormData[];
|
||||||
|
};
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { PaginationSchema } from "@erp/core";
|
||||||
import { ArrayElement } from "@repo/rdx-utils";
|
import { ArrayElement } from "@repo/rdx-utils";
|
||||||
import {
|
import {
|
||||||
CreateCustomerRequestSchema,
|
CreateCustomerRequestSchema,
|
||||||
GetCustomerByIdResponseSchema,
|
GetCustomerByIdResponseSchema,
|
||||||
ListCustomersResponseDTO,
|
ListCustomersResponseSchema,
|
||||||
UpdateCustomerByIdRequestSchema,
|
UpdateCustomerByIdRequestSchema,
|
||||||
} from "../../common";
|
} from "../../common";
|
||||||
|
|
||||||
@ -19,7 +20,12 @@ export type CustomerCreateInput = z.infer<typeof CustomerCreateSchema>; // Cuerp
|
|||||||
export type CustomerUpdateInput = z.infer<typeof CustomerUpdateSchema>; // Cuerpo para actualizar
|
export type CustomerUpdateInput = z.infer<typeof CustomerUpdateSchema>; // Cuerpo para actualizar
|
||||||
|
|
||||||
// Resultado de consulta con criteria (paginado, etc.)
|
// Resultado de consulta con criteria (paginado, etc.)
|
||||||
export type CustomersPage = ListCustomersResponseDTO;
|
export const CustomersPageSchema = ListCustomersResponseSchema.omit({
|
||||||
|
metadata: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaginatedResponse = z.infer<typeof PaginationSchema>;
|
||||||
|
export type CustomersPage = z.infer<typeof CustomersPageSchema>;
|
||||||
|
|
||||||
// Ítem simplificado dentro del listado (no toda la entidad)
|
// Ítem simplificado dentro del listado (no toda la entidad)
|
||||||
export type CustomerSummary = Omit<ArrayElement<CustomersPage["items"]>, "metadata">;
|
export type CustomerSummary = Omit<ArrayElement<CustomersPage["items"]>, "metadata">;
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
|
export * from "./customer-resume.form.schema";
|
||||||
export * from "./customer.api.schema";
|
export * from "./customer.api.schema";
|
||||||
export * from "./customer.form.schema";
|
export * from "./customer.form.schema";
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function TextAreaField<TFormValues extends FieldValues>({
|
|||||||
{...inputRest}
|
{...inputRest}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
className={cn(inputClassName)}
|
className={cn("bg-background", inputClassName)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export function TextField<TFormValues extends FieldValues>({
|
|||||||
{...inputRest}
|
{...inputRest}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
className={cn(inputClassName)}
|
className={cn("bg-background", inputClassName)}
|
||||||
/>
|
/>
|
||||||
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const AppContent = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"app-content flex flex-1 flex-col gap-4 p-6 pt-8 bg-sidebar/25 min-h-screen",
|
"app-content flex flex-1 flex-col gap-4 p-6 pt-8 bg-primary/5 min-h-screen",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export const AppHeader = ({
|
|||||||
...props
|
...props
|
||||||
}: PropsWithChildren<{ className?: string }>) => {
|
}: PropsWithChildren<{ className?: string }>) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("app-header bg-background", className)} {...props}>
|
<div className={cn("app-header bg-background gap-4 px-6 pt-0 border-b bg-card", className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const AppLayout = () => {
|
|||||||
>
|
>
|
||||||
<AppSidebar variant='inset' />
|
<AppSidebar variant='inset' />
|
||||||
{/* Aquí está el MAIN */}
|
{/* Aquí está el MAIN */}
|
||||||
<SidebarInset className='app-main'>
|
<SidebarInset className='app-main bg-background'>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|||||||
@ -613,6 +613,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.74.11
|
specifier: ^5.74.11
|
||||||
version: 5.90.2(react@19.2.0)
|
version: 5.90.2(react@19.2.0)
|
||||||
|
'@tanstack/react-table':
|
||||||
|
specifier: ^8.21.3
|
||||||
|
version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
ag-grid-community:
|
ag-grid-community:
|
||||||
specifier: ^33.3.0
|
specifier: ^33.3.0
|
||||||
version: 33.3.2
|
version: 33.3.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user