Customers

This commit is contained in:
David Arranz 2026-03-31 10:21:48 +02:00
parent 51adb8d2b0
commit 8e547ff551
29 changed files with 689 additions and 141 deletions

View File

@ -226,7 +226,8 @@
"useValidAnchor": "error", "useValidAnchor": "error",
"useValidAriaProps": "error", "useValidAriaProps": "error",
"useValidAriaValues": "error", "useValidAriaValues": "error",
"useValidLang": "error" "useValidLang": "error",
"useButtonType": "info"
}, },
"performance": { "performance": {
"noAccumulatingSpread": "warn", "noAccumulatingSpread": "warn",

View File

@ -46,7 +46,7 @@ export class CustomerFullSnapshotBuilder implements ICustomerFullSnapshotBuilder
legal_record: maybeToEmptyString(customer.legalRecord, (value) => value.toPrimitive()), legal_record: maybeToEmptyString(customer.legalRecord, (value) => value.toPrimitive()),
default_taxes: customer.defaultTaxes.getAll().map((tax) => tax.toString()), default_taxes: customer.defaultTaxes.toKey(),
language_code: customer.languageCode.toPrimitive(), language_code: customer.languageCode.toPrimitive(),
currency_code: customer.currencyCode.toPrimitive(), currency_code: customer.currencyCode.toPrimitive(),

View File

@ -30,7 +30,7 @@ export interface ICustomerFullSnapshot {
legal_record: string; legal_record: string;
default_taxes: string[]; default_taxes: string;
language_code: string; language_code: string;
currency_code: string; currency_code: string;

View File

@ -30,7 +30,7 @@ export const GetCustomerByIdResponseSchema = z.object({
legal_record: z.string(), legal_record: z.string(),
default_taxes: z.array(z.string()), default_taxes: z.string(),
status: z.string(), status: z.string(),
language_code: z.string(), language_code: z.string(),
currency_code: z.string(), currency_code: z.string(),

View File

@ -0,0 +1,48 @@
import { useSheetState } from "@repo/rdx-ui/hooks";
import { useCallback, useState } from "react";
import { useCustomerGetQuery } from "../../shared/hooks";
export const useCustomerSheetController = (initialCustomerId = "") => {
const [customerId, setCustomerId] = useState(initialCustomerId);
const sheetState = useSheetState({
defaultOpen: false,
defaultPinned: false,
defaultMode: "view",
});
const query = useCustomerGetQuery(customerId, {
enabled: Boolean(customerId),
});
const openCustomerSheet = useCallback(
(nextCustomerId: string) => {
setCustomerId(nextCustomerId);
sheetState.openInMode("view");
},
[sheetState]
);
const closeCustomerSheet = useCallback(() => {
sheetState.closeSheet();
}, [sheetState]);
return {
customer: query.data,
customerId,
setCustomerId,
openCustomerSheet,
closeCustomerSheet,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
sheetState,
};
};

View File

@ -1,9 +1,13 @@
import { useCustomerSheetController } from "./use-customer-sheet.controller";
import { useListCustomersController } from "./use-list-customers.controller"; import { useListCustomersController } from "./use-list-customers.controller";
export function useListCustomersPageController() { export function useListCustomersPageController() {
const listCtrl = useListCustomersController(); const listCtrl = useListCustomersController();
const sheetCtrl = useCustomerSheetController();
return { return {
listCtrl, listCtrl,
sheetCtrl,
}; };
} }

View File

@ -0,0 +1,400 @@
import {
Avatar,
AvatarFallback,
Badge,
Button,
Separator,
Sheet,
SheetContent,
SheetTitle,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
CopyIcon,
ExternalLinkIcon,
FileTextIcon,
GlobeIcon,
MailIcon,
MapPinIcon,
PencilIcon,
PhoneIcon,
PinIcon,
PinOffIcon,
TrendingUpIcon,
UserIcon,
XIcon,
} from "lucide-react";
import type { Customer } from "../../../../shared";
interface CustomerSheetProps {
customer?: Customer;
open: boolean;
onOpenChange: (open: boolean) => void;
pinned: boolean;
onPinnedChange: (pinned: boolean) => void;
onEdit?: (customer: Customer) => void;
}
// Datos de ejemplo ERP para demostración
const mockERPData = {
totalPurchases: 45750.8,
purchasesThisYear: 12350.25,
lastInvoices: [
{
id: "1",
number: "FAC-2024-0156",
date: "2024-01-15",
amount: 1250.0,
status: "paid" as const,
},
{
id: "2",
number: "FAC-2024-0142",
date: "2024-01-08",
amount: 890.5,
status: "paid" as const,
},
{
id: "3",
number: "FAC-2024-0128",
date: "2023-12-22",
amount: 2100.0,
status: "pending" as const,
},
{
id: "4",
number: "FAC-2023-0098",
date: "2023-11-30",
amount: 750.25,
status: "overdue" as const,
},
],
};
export const CustomerSheet = ({
customer,
open,
onOpenChange,
pinned,
onPinnedChange,
onEdit,
}: CustomerSheetProps) => {
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const getGoogleMapsUrl = (customer: Customer) => {
const fullAddress = `${customer.street}, ${customer.postalCode} ${customer.city}, ${customer.province}, ${customer.country}`;
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const getInvoiceStatusBadge = (status: "paid" | "pending" | "overdue") => {
switch (status) {
case "paid":
return (
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200" variant="outline">
Pagada
</Badge>
);
case "pending":
return (
<Badge className="bg-amber-50 text-amber-700 border-amber-200" variant="outline">
Pendiente
</Badge>
);
case "overdue":
return (
<Badge className="bg-red-50 text-red-700 border-red-200" variant="outline">
Vencida
</Badge>
);
}
};
// Combinar datos del cliente con datos mock de ERP
const customerWithERP = { ...customer, ...mockERPData };
return (
<Sheet modal={!pinned} onOpenChange={onOpenChange} open={open}>
<SheetContent
className={cn("flex w-full flex-col p-0 sm:max-w-md", pinned && "shadow-none")}
showCloseButton={false}
side="right"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<SheetTitle className="sr-only">Ficha de cliente</SheetTitle>
<div className="flex items-center gap-2">
<Button
onClick={() => onPinnedChange(!pinned)}
size="icon-sm"
title={pinned ? "Desfijar panel" : "Fijar panel"}
variant="ghost"
>
{pinned ? <PinOffIcon className="size-4" /> : <PinIcon className="size-4" />}
</Button>
</div>
<div className="flex items-center gap-1">
<Button onClick={() => null /*onEdit?.(customer?)*/} size="icon-sm" variant="ghost">
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
{!pinned && (
<Button onClick={() => onOpenChange(false)} size="icon-sm" variant="ghost">
<XIcon className="size-4" />
<span className="sr-only">Cerrar</span>
</Button>
)}
</div>
</div>
{/* Contenido scrolleable */}
{customer ? (
<div className="flex-1 overflow-y-auto">
{/* Info principal del cliente */}
<div className="p-4">
<div className="flex items-start gap-4">
<Avatar className="size-14 border-2 border-background shadow-md">
<AvatarFallback
className={
customer.status === "active"
? "bg-emerald-100 text-emerald-700 text-lg"
: "bg-muted text-muted-foreground text-lg"
}
>
{getInitials(customer.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-foreground truncate">
{customer.name}
</h2>
<Badge
className="shrink-0"
variant={customer.status === "active" ? "default" : "secondary"}
>
{customer.status === "active" ? "Activo" : "Inactivo"}
</Badge>
</div>
{customer.tradeName && (
<p className="text-sm text-muted-foreground truncate">{customer.tradeName}</p>
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
Empresa
</>
) : (
<>
<UserIcon className="size-3" />
Particular
</>
)}
</Badge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground transition-colors"
onClick={() => copyToClipboard(customer.tin)}
title="Copiar NIF/CIF"
type="button"
>
{customer.tin}
<CopyIcon className="size-3" />
</button>
</div>
</div>
</div>
</div>
<Separator />
{/* Datos de contacto */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">Contacto</h3>
<div className="space-y-2.5">
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`mailto:${customer.primaryEmail}`}
>
<MailIcon className="size-4 shrink-0" />
<span className="truncate">{customer.primaryEmail}</span>
</a>
{customer.secondaryEmail && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`mailto:${customer.secondaryEmail}`}
>
<MailIcon className="size-4 shrink-0" />
<span className="truncate">{customer.secondaryEmail}</span>
</a>
)}
{customer.primaryPhone && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`tel:${customer.primaryPhone}`}
>
<PhoneIcon className="size-4 shrink-0" />
<span>{customer.primaryPhone}</span>
</a>
)}
{customerWithERP.primaryMobile && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`tel:${customerWithERP.primaryMobile}`}
>
<PhoneIcon className="size-4 shrink-0" />
<span>{customerWithERP.primaryMobile}</span>
</a>
)}
{customerWithERP.website && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={customerWithERP.website}
rel="noopener noreferrer"
target="_blank"
>
<GlobeIcon className="size-4 shrink-0" />
<span className="truncate">{customerWithERP.website}</span>
<ExternalLinkIcon className="size-3 shrink-0" />
</a>
)}
</div>
</div>
<Separator />
{/* Dirección */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">Dirección</h3>
<a
className="group flex items-start gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-muted"
href={getGoogleMapsUrl(customer)}
rel="noopener noreferrer"
target="_blank"
>
<MapPinIcon className="size-4 shrink-0 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="text-sm text-muted-foreground group-hover:text-foreground">
<p>{customer.street}</p>
<p>
{customer.postalCode} {customer.city}
</p>
<p>
{customer.province}, {customer.country}
</p>
</div>
<ExternalLinkIcon className="size-3 shrink-0 mt-0.5 text-muted-foreground opacity-0 group-hover:opacity-100" />
</a>
</div>
<Separator />
{/* Volumen de compras */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<TrendingUpIcon className="size-4" />
Volumen de compras
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Total histórico</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(customerWithERP.totalPurchases || 0)}
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Este año</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(customerWithERP.purchasesThisYear || 0)}
</p>
</div>
</div>
</div>
<Separator />
{/* Últimas facturas */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<FileTextIcon className="size-4" />
Últimas facturas
</h3>
<div className="space-y-2">
{customerWithERP.lastInvoices?.map((invoice) => (
<button
className="w-full flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-muted"
key={invoice.id}
onClick={() => console.log("Ver factura:", invoice.number)}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{invoice.number}
</p>
<p className="text-xs text-muted-foreground">{formatDate(invoice.date)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-medium">{formatCurrency(invoice.amount)}</span>
{getInvoiceStatusBadge(invoice.status)}
</div>
</button>
))}
</div>
<Button className="w-full mt-3" size="sm" variant="outline">
Ver todas las facturas
</Button>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-sm text-muted-foreground">Selecciona un cliente</p>
</div>
)}
{/* Footer con acciones */}
<div className="border-t p-4">
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => console.log("Crear proforma para:", customer?.id)}
variant="outline"
>
<FileTextIcon className="size-4 mr-2" />
Nueva proforma
</Button>
<Button className="flex-1" onClick={() => null /*onEdit?.(customer?)*/}>
<PencilIcon className="size-4 mr-2" />
Editar cliente
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
};

View File

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

View File

@ -6,7 +6,7 @@ import { useTranslation } from "../../../../i18n";
import type { CustomerList, CustomerListRow } from "../../../../shared"; import type { CustomerList, CustomerListRow } from "../../../../shared";
interface CustomersGridProps { interface CustomersGridProps {
data: CustomerList; data?: CustomerList;
loading: boolean; loading: boolean;
fetching?: boolean; fetching?: boolean;
@ -33,7 +33,7 @@ export const CustomersGrid = ({
}: CustomersGridProps) => { }: CustomersGridProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { items, total_items } = data; const { items, total_items } = data || { items: [], total_items: 0 };
if (loading) if (loading)
return ( return (

View File

@ -23,6 +23,7 @@ import { AddressCell, ContactCell, Initials } from "../../components";
type GridActionHandlers = { type GridActionHandlers = {
onEditClick?: (customer: CustomerListRow) => void; onEditClick?: (customer: CustomerListRow) => void;
onViewClick?: (customer: CustomerListRow) => void; onViewClick?: (customer: CustomerListRow) => void;
onSummaryClick?: (customer: CustomerListRow) => void;
onDeleteClick?: (customer: CustomerListRow) => void; onDeleteClick?: (customer: CustomerListRow) => void;
}; };
@ -30,7 +31,7 @@ export function useCustomersGridColumns(
actionHandlers: GridActionHandlers = {} actionHandlers: GridActionHandlers = {}
): ColumnDef<CustomerListRow, unknown>[] { ): ColumnDef<CustomerListRow, unknown>[] {
const { t } = useTranslation(); const { t } = useTranslation();
const { onEditClick, onViewClick, onDeleteClick } = actionHandlers; const { onEditClick, onViewClick, onDeleteClick, onSummaryClick } = actionHandlers;
return React.useMemo<ColumnDef<CustomerListRow, unknown>[]>( return React.useMemo<ColumnDef<CustomerListRow, unknown>[]>(
() => [ () => [
@ -53,7 +54,7 @@ export function useCustomersGridColumns(
return ( return (
<button <button
className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer" className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer"
onClick={onViewClick ? () => onViewClick(customer) : undefined} onClick={onSummaryClick ? () => onSummaryClick(customer) : undefined}
type="button" type="button"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -74,15 +75,15 @@ export function useCustomersGridColumns(
<span className="font-medium text-foreground hover:underline"> <span className="font-medium text-foreground hover:underline">
{customer.name} {customer.name}
</span> </span>
{customer.trade_name && ( {customer.tradeName && (
<span className="text-muted-foreground">({customer.trade_name})</span> <span className="text-muted-foreground">({customer.tradeName})</span>
)} )}
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{customer.tin}</span> <span className="font-mono">{customer.tin}</span>
<Badge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline"> <Badge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{customer.is_company ? ( {customer.isCompany ? (
<> <>
<Building2Icon className="size-3" /> <Building2Icon className="size-3" />
{t("form_fields.customer_type.company")} {t("form_fields.customer_type.company")}
@ -111,7 +112,7 @@ export function useCustomersGridColumns(
/> />
), ),
accessorFn: (row) => accessorFn: (row) =>
`${row.email_primary} ${row.phone_primary} ${row.mobile_primary} ${row.website}`, `${row.primaryEmail} ${row.primaryPhone} ${row.primaryMobile} ${row.website}`,
enableSorting: false, enableSorting: false,
size: 140, size: 140,
minSize: 120, minSize: 120,
@ -127,7 +128,7 @@ export function useCustomersGridColumns(
/> />
), ),
accessorFn: (row) => accessorFn: (row) =>
`${row.street} ${row.street2} ${row.city} ${row.postal_code} ${row.province} ${row.country}`, `${row.street} ${row.street2} ${row.city} ${row.postalCode} ${row.province} ${row.country}`,
enableSorting: false, enableSorting: false,
size: 140, size: 140,
minSize: 120, minSize: 120,
@ -148,7 +149,7 @@ export function useCustomersGridColumns(
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {
const customer = row.original; const customer = row.original;
const { website, email_primary } = customer; const { website, primaryEmail: email_primary } = customer;
return ( return (
<div className="flex justify-end"> <div className="flex justify-end">
@ -216,6 +217,6 @@ export function useCustomersGridColumns(
}, },
}, },
], ],
[t, onDeleteClick, onEditClick, onViewClick] [t, onDeleteClick, onEditClick, onViewClick, onSummaryClick]
); );
} }

View File

@ -1 +1,2 @@
export * from "./customer-sheet";
export * from "./customers-grid"; export * from "./customers-grid";

View File

@ -4,14 +4,14 @@ import { useTranslation } from "../../../i18n";
import type { CustomerListRow } from "../../../shared"; import type { CustomerListRow } from "../../../shared";
const getGoogleMapsUrl = (customer: CustomerListRow) => { const getGoogleMapsUrl = (customer: CustomerListRow) => {
const fullAddress = `${customer.street}, ${customer.postal_code} ${customer.city}, ${customer.province}, ${customer.country}`; const fullAddress = `${customer.street}, ${customer.postalCode} ${customer.city}, ${customer.province}, ${customer.country}`;
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`; return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
}; };
export const AddressCell = ({ customer }: { customer: CustomerListRow }) => { export const AddressCell = ({ customer }: { customer: CustomerListRow }) => {
const { t } = useTranslation(); const { t } = useTranslation();
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.postalCode, customer.city].filter(Boolean).join(" ");
const line3 = [customer.province, customer.country].filter(Boolean).join(", "); const line3 = [customer.province, customer.country].filter(Boolean).join(", ");
return ( return (
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors"> <address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">

View File

@ -3,32 +3,32 @@ import { MailIcon, PhoneIcon } from "lucide-react";
export const ContactCell = ({ customer }: { customer: CustomerListRow }) => ( export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground transition-colors "> <div className="flex flex-col gap-1.5 text-sm text-muted-foreground transition-colors ">
{customer.email_primary && ( {customer.primaryEmail && (
<a <a
className="flex items-center gap-2 hover:text-foreground" className="flex items-center gap-2 hover:text-foreground"
href={`mailto:${customer.email_primary}`} href={`mailto:${customer.primaryEmail}`}
> >
<MailIcon className="size-3.5" /> <MailIcon className="size-3.5" />
{customer.email_primary} {customer.primaryEmail}
</a> </a>
)} )}
{customer.email_secondary && ( {customer.secondaryEmail && (
<a <a
className="flex items-center gap-2 hover:text-foreground" className="flex items-center gap-2 hover:text-foreground"
href={`mailto:${customer.email_secondary}`} href={`mailto:${customer.secondaryEmail}`}
> >
<MailIcon className="size-3.5" /> <MailIcon className="size-3.5" />
{customer.email_secondary} {customer.secondaryEmail}
</a> </a>
)} )}
{customer.phone_primary && ( {customer.primaryPhone && (
<a <a
className="flex items-center gap-2 hover:text-foreground" className="flex items-center gap-2 hover:text-foreground"
href={`tel:${customer.phone_primary}`} href={`tel:${customer.primaryPhone}`}
> >
<PhoneIcon className="size-3.5" /> <PhoneIcon className="size-3.5" />
{customer.phone_primary} {customer.primaryPhone}
</a> </a>
)} )}
</div> </div>

View File

@ -7,21 +7,22 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n"; import { useTranslation } from "../../../i18n";
import { ErrorAlert } from "../../../shared/ui"; import { ErrorAlert } from "../../../shared/ui";
import { useListCustomersPageController } from "../../controllers"; import { useListCustomersPageController } from "../../controllers";
import { CustomersGrid, useCustomersGridColumns } from "../blocks"; import { CustomerSheet, CustomersGrid, useCustomersGridColumns } from "../blocks";
export const ListCustomersPage = () => { export const ListCustomersPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { listCtrl } = useListCustomersPageController(); const { listCtrl, sheetCtrl } = useListCustomersPageController();
const columns = useCustomersGridColumns({ const columns = useCustomersGridColumns({
onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`), onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`),
onViewClick: (customer) => navigate(`/customers/${customer.id}`), onViewClick: (customer) => navigate(`/customers/${customer.id}`),
onSummaryClick: (customer) => sheetCtrl.openCustomerSheet(customer.id),
//onDeleteClick: (customer) => null, //confirmDelete(inv.id), //onDeleteClick: (customer) => null, //confirmDelete(inv.id),
}); });
if (listCtrl.isError || !listCtrl.data) { if (listCtrl.isError) {
return ( return (
<AppContent> <AppContent>
<ErrorAlert <ErrorAlert
@ -67,10 +68,21 @@ export const ListCustomersPage = () => {
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller // acciones rápidas del grid → page controller
//onRowClick={(id) => navigate(`/proformas/${id}`)} onRowClick={(id) => null}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize} pageSize={listCtrl.pageSize}
/> />
{/* Customer Sheet */}
<CustomerSheet
customer={sheetCtrl.customer}
//mode={sheetCtrl.sheet.mode}
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
onOpenChange={sheetCtrl.sheetState.onOpenChange}
onPinnedChange={sheetCtrl.sheetState.setPinned}
open={sheetCtrl.sheetState.sheetIsOpen}
pinned={sheetCtrl.sheetState.sheetIsPinned}
/>
</AppContent> </AppContent>
</> </>
); );

View File

@ -1,39 +1,46 @@
import type { GetCustomerByIdResponseDTO } from "../../../common"; import type { GetCustomerByIdResponseDTO } from "../../../common";
import type { Customer } from "../entities"; import type { Customer } from "../entities";
export const mapGetCustomerByIdResponseDtoToCustomerAdapter = ( export const GetCustomerByIdAdapter = {
dto: GetCustomerByIdResponseDTO fromDTO(dto: GetCustomerByIdResponseDTO, context?: unknown): Customer {
): Customer => ({ const taxesAdapter = (taxes: string) => taxes.split(";").filter((item) => item !== "#") || [];
id: dto.id,
companyId: dto.company_id,
reference: dto.reference,
isCompany: dto.is_company, const defaultTaxes = taxesAdapter(dto.default_taxes);
name: dto.name, console.log("defaultTaxes", defaultTaxes);
tradeName: dto.trade_name,
tin: dto.tin,
street: dto.street, return {
street2: dto.street2, id: dto.id,
city: dto.city, companyId: dto.company_id,
province: dto.province, reference: dto.reference,
postalCode: dto.postal_code,
country: dto.country,
primaryEmail: dto.email_primary, isCompany: dto.is_company,
secondaryEmail: dto.email_secondary, name: dto.name,
primaryPhone: dto.phone_primary, tradeName: dto.trade_name,
secondaryPhone: dto.phone_secondary, tin: dto.tin,
primaryMobile: dto.mobile_primary,
secondaryMobile: dto.mobile_secondary,
fax: dto.fax, street: dto.street,
website: dto.website, street2: dto.street2,
city: dto.city,
province: dto.province,
postalCode: dto.postal_code,
country: dto.country,
legalRecord: dto.legal_record, primaryEmail: dto.email_primary,
secondaryEmail: dto.email_secondary,
primaryPhone: dto.phone_primary,
secondaryPhone: dto.phone_secondary,
primaryMobile: dto.mobile_primary,
secondaryMobile: dto.mobile_secondary,
defaultTaxes: dto.default_taxes, fax: dto.fax,
status: dto.status, website: dto.website,
languageCode: dto.language_code,
currencyCode: dto.currency_code, legalRecord: dto.legal_record,
});
defaultTaxes: defaultTaxes,
status: dto.status,
languageCode: dto.language_code,
currencyCode: dto.currency_code,
};
},
};

View File

@ -25,33 +25,33 @@ const ListCustomersRowAdapter = {
return { return {
id: rowDto.id, id: rowDto.id,
company_id: rowDto.company_id, companyId: rowDto.company_id,
status: rowDto.status, status: rowDto.status,
reference: rowDto.reference, reference: rowDto.reference,
is_company: rowDto.is_company === "1", isCompany: rowDto.is_company === "1",
name: rowDto.name, name: rowDto.name,
trade_name: rowDto.trade_name, tradeName: rowDto.trade_name,
tin: rowDto.tin, tin: rowDto.tin,
street: rowDto.street, street: rowDto.street,
street2: rowDto.street2, street2: rowDto.street2,
city: rowDto.city, city: rowDto.city,
province: rowDto.province, province: rowDto.province,
postal_code: rowDto.postal_code, postalCode: rowDto.postal_code,
country: rowDto.country, country: rowDto.country,
email_primary: rowDto.email_primary, primaryEmail: rowDto.email_primary,
email_secondary: rowDto.email_secondary, secondaryEmail: rowDto.email_secondary,
phone_primary: rowDto.phone_primary, primaryPhone: rowDto.phone_primary,
phone_secondary: rowDto.phone_secondary, secondaryPhone: rowDto.phone_secondary,
mobile_primary: rowDto.mobile_primary, primaryMobile: rowDto.mobile_primary,
mobile_secondary: rowDto.mobile_secondary, secondaryMobile: rowDto.mobile_secondary,
fax: rowDto.fax, fax: rowDto.fax,
website: rowDto.website, website: rowDto.website,
language_code: rowDto.language_code, languageCode: rowDto.language_code,
currency_code: rowDto.currency_code, currencyCode: rowDto.currency_code,
}; };
}, },
}; };

View File

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

View File

@ -1,30 +1,31 @@
export interface CustomerListRow { export interface CustomerListRow {
id: string; id: string;
company_id: string; companyId: string;
status: string; status: string;
reference: string; reference: string;
is_company: boolean; isCompany: boolean;
name: string; name: string;
trade_name: string; tradeName: string;
tin: string; tin: string;
street: string; street: string;
street2: string; street2: string;
city: string; city: string;
province: string; province: string;
postal_code: string; postalCode: string;
country: string; country: string;
email_primary: string; primaryEmail: string;
email_secondary: string; secondaryEmail: string;
phone_primary: string; primaryPhone: string;
phone_secondary: string; secondaryPhone: string;
mobile_primary: string; primaryMobile: string;
mobile_secondary: string; secondaryMobile: string;
fax: string; fax: string;
website: string; website: string;
language_code: string; languageCode: string;
currency_code: string; currencyCode: string;
} }

View File

@ -6,7 +6,9 @@ import {
useQuery, useQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { type Customer, getCustomerById } from "../api"; import { GetCustomerByIdAdapter } from "../adapters";
import { getCustomerById } from "../api";
import type { Customer } from "../entities";
export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [ export const CUSTOMER_QUERY_KEY = (customerId?: string): QueryKey => [
"customers:detail", "customers:detail",
@ -28,9 +30,12 @@ export const useCustomerGetQuery = (
return useQuery<Customer, DefaultError>({ return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId), queryKey: CUSTOMER_QUERY_KEY(customerId),
queryFn: async ({ signal }) => getCustomerById(dataSource, signal, customerId), queryFn: async ({ signal }) => {
const dto = await getCustomerById(dataSource, signal, customerId);
return GetCustomerByIdAdapter.fromDTO(dto);
},
enabled, enabled,
placeholderData: (previousData, _previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`) placeholderData: (previousData) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
}); });
}; };

View File

@ -1,13 +0,0 @@
import type { Customer } from "../../shared";
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

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

View File

@ -1,20 +1,22 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { useCustomerGetQuery } from "../../shared/hooks"; import { useCustomerGetQuery } from "../../shared/hooks";
import { CustomerDtoAdapter } from "../adapters";
export const useCustomerViewController = () => { export const useCustomerViewController = (initialCustomerId = "") => {
const [customerId, setCustomerId] = useState(""); const [customerId, setCustomerId] = useState(initialCustomerId);
const query = useCustomerGetQuery(customerId); const query = useCustomerGetQuery(customerId);
const data = useMemo(
() => (query.data ? CustomerDtoAdapter.fromDto(query.data) : undefined),
[query.data]
);
return { return {
...query, data: query.data,
data, isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
customerId, customerId,
setCustomerId, setCustomerId,
}; };

View File

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

View File

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

View File

@ -77,7 +77,7 @@ export const CustomerViewPage = () => {
<Badge className="font-mono" variant="secondary"> <Badge className="font-mono" variant="secondary">
{customerData?.tin} {customerData?.tin}
</Badge> </Badge>
<Badge variant="outline">{customerData?.is_company ? "Empresa" : "Persona"}</Badge> <Badge variant="outline">{customerData?.isCompany ? "Empresa" : "Persona"}</Badge>
</div> </div>
} }
rightSlot={ rightSlot={
@ -102,8 +102,8 @@ export const CustomerViewPage = () => {
title={ title={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{customerData?.name}{" "} {customerData?.name}{" "}
{customerData?.trade_name && ( {customerData?.tradeName && (
<span className="text-muted-foreground">({customerData.trade_name})</span> <span className="text-muted-foreground">({customerData.tradeName})</span>
)} )}
</div> </div>
} }
@ -133,12 +133,12 @@ export const CustomerViewPage = () => {
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground">Registro Legal</dt> <dt className="text-sm font-medium text-muted-foreground">Registro Legal</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.legal_record}</dd> <dd className="mt-1 text-base text-foreground">{customerData?.legalRecord}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground">Impuestos por Defecto</dt> <dt className="text-sm font-medium text-muted-foreground">Impuestos por Defecto</dt>
<dd className="mt-1"> <dd className="mt-1">
{customerData?.default_taxes.map((tax) => ( {customerData?.defaultTaxes.map((tax: string) => (
<Badge key={tax} variant={"secondary"}> <Badge key={tax} variant={"secondary"}>
{tax} {tax}
</Badge> </Badge>
@ -176,7 +176,7 @@ export const CustomerViewPage = () => {
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground">Código Postal</dt> <dt className="text-sm font-medium text-muted-foreground">Código Postal</dt>
<dd className="mt-1 text-base text-foreground">{customerData?.postal_code}</dd> <dd className="mt-1 text-base text-foreground">{customerData?.postalCode}</dd>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -205,35 +205,35 @@ export const CustomerViewPage = () => {
{/* Contacto Principal */} {/* Contacto Principal */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Principal</h3> <h3 className="font-semibold text-foreground">Contacto Principal</h3>
{customerData?.email_primary && ( {customerData?.primaryEmail && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt> <dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.email_primary} {customerData?.primaryEmail}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.mobile_primary && ( {customerData?.primaryMobile && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt> <dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.mobile_primary} {customerData?.primaryMobile}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.phone_primary && ( {customerData?.primaryPhone && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt> <dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.phone_primary} {customerData?.primaryPhone}
</dd> </dd>
</div> </div>
</div> </div>
@ -243,35 +243,35 @@ export const CustomerViewPage = () => {
{/* Contacto Secundario */} {/* Contacto Secundario */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-foreground">Contacto Secundario</h3> <h3 className="font-semibold text-foreground">Contacto Secundario</h3>
{customerData?.email_secondary && ( {customerData?.secondaryEmail && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Mail className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Email</dt> <dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.email_secondary} {customerData?.secondaryEmail}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.mobile_secondary && ( {customerData?.secondaryMobile && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Smartphone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Móvil</dt> <dt className="text-sm font-medium text-muted-foreground">Móvil</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.mobile_secondary} {customerData?.secondaryMobile}
</dd> </dd>
</div> </div>
</div> </div>
)} )}
{customerData?.phone_secondary && ( {customerData?.secondaryPhone && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Phone className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Teléfono</dt> <dt className="text-sm font-medium text-muted-foreground">Teléfono</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">
{customerData?.phone_secondary} {customerData?.secondaryPhone}
</dd> </dd>
</div> </div>
</div> </div>
@ -330,18 +330,14 @@ export const CustomerViewPage = () => {
<Languages className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Languages className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Idioma Preferido</dt> <dt className="text-sm font-medium text-muted-foreground">Idioma Preferido</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">{customerData?.languageCode}</dd>
{customerData?.language_code}
</dd>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Banknote className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<dt className="text-sm font-medium text-muted-foreground">Moneda Preferida</dt> <dt className="text-sm font-medium text-muted-foreground">Moneda Preferida</dt>
<dd className="mt-1 text-base text-foreground"> <dd className="mt-1 text-base text-foreground">{customerData?.currencyCode}</dd>
{customerData?.currency_code}
</dd>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,2 +1,3 @@
export * from "./sheet/index.ts";
export * from "./use-device-info.ts"; export * from "./use-device-info.ts";
export * from "./use-row-selection.ts"; export * from "./use-row-selection.ts";

View File

@ -0,0 +1,2 @@
export * from "./sheet-state-types.ts";
export * from "./use-sheet-state.ts";

View File

@ -0,0 +1,28 @@
export type SheetMode = "view" | "edit" | "create";
export interface SheetStateOptions {
defaultOpen?: boolean;
defaultPinned?: boolean;
defaultMode?: SheetMode;
}
export interface SheetState {
sheetIsOpen: boolean;
sheetIsPinned: boolean;
sheetIsModal: boolean;
sheetMode: SheetMode;
}
export interface SheetStateActions {
openSheet: () => void;
closeSheet: () => void;
togglePinned: () => void;
setOpen: (next: boolean) => void;
setPinned: (next: boolean) => void;
setMode: (next: SheetMode) => void;
onOpenChange: (next: boolean) => void;
openInMode: (nextMode: SheetMode) => void;
resetSheet: () => void;
}
export type SheetStateController = SheetState & SheetStateActions;

View File

@ -0,0 +1,56 @@
import { useCallback, useState } from "react";
import type { SheetMode, SheetStateController, SheetStateOptions } from "./sheet-state-types.ts";
const DEFAULT_MODE: SheetMode = "view";
export const useSheetState = (options: SheetStateOptions = {}): SheetStateController => {
const { defaultOpen = false, defaultPinned = false, defaultMode = DEFAULT_MODE } = options;
const [open, setOpen] = useState<boolean>(defaultOpen);
const [pinned, setPinned] = useState<boolean>(defaultPinned);
const [mode, setMode] = useState<SheetMode>(defaultMode);
const openSheet = useCallback(() => {
setOpen(true);
}, []);
const closeSheet = useCallback(() => {
setOpen(false);
}, []);
const togglePinned = useCallback(() => {
setPinned((prev) => !prev);
}, []);
const onOpenChange = useCallback((next: boolean) => {
setOpen(next);
}, []);
const openInMode = useCallback((nextMode: SheetMode) => {
setMode(nextMode);
setOpen(true);
}, []);
const resetSheet = useCallback(() => {
setOpen(defaultOpen);
setPinned(defaultPinned);
setMode(defaultMode);
}, [defaultMode, defaultOpen, defaultPinned]);
return {
sheetIsOpen: open,
sheetIsPinned: pinned,
sheetIsModal: !pinned,
sheetMode: mode,
openSheet,
closeSheet,
togglePinned,
setOpen,
setPinned,
setMode,
onOpenChange,
openInMode,
resetSheet,
};
};