This commit is contained in:
David Arranz 2026-03-30 20:59:25 +02:00
parent 366f90d403
commit 8f9f161794
6 changed files with 126 additions and 82 deletions

View File

@ -21,13 +21,21 @@
"description": "Manage your customers", "description": "Manage your customers",
"list": { "list": {
"title": "Customer list", "title": "Customer list",
"description": "List all customers", "description": "Manage your customers and their contact information",
"grid_columns": { "grid_columns": {
"customer": "Customer", "customer": "Customer",
"status": "Status", "status": "Status",
"contact": "Contact", "contact": "Contact",
"address": "Address", "address": "Address",
"actions": "Actions" "actions": "Actions"
},
"actions": {
"more": "More",
"view": "View",
"edit": "Edit customer",
"delete": "Delete customer",
"visit_website": "Visit website",
"copy_email": "Copy email"
} }
}, },
"create": { "create": {
@ -192,6 +200,9 @@
"no_entities_found": "No results found for \"{{search}}\"", "no_entities_found": "No results found for \"{{search}}\"",
"select_or_create": "Select an item from the list or create a new one.", "select_or_create": "Select an item from the list or create a new one.",
"create_label": "Create new item" "create_label": "Create new item"
},
"address_cell": {
"open_in_google_maps": "Open address in Google Maps"
} }
} }
} }

View File

@ -21,13 +21,21 @@
"description": "Gestiona tus clientes", "description": "Gestiona tus clientes",
"list": { "list": {
"title": "Lista de clientes", "title": "Lista de clientes",
"description": "Lista todos los clientes", "description": "Gestiona la información de tus clientes y sus datos de contacto",
"grid_columns": { "grid_columns": {
"customer": "Cliente", "customer": "Cliente",
"status": "Estado", "status": "Estado",
"contact": "Contacto", "contact": "Contacto",
"address": "Dirección", "address": "Dirección",
"actions": "Acciones" "actions": "Acciones"
},
"actions": {
"more": "Más",
"view": "Ver",
"edit": "Editar cliente",
"delete": "Eliminar cliente",
"visit_website": "Visitar sitio web",
"copy_email": "Copiar email"
} }
}, },
"create": { "create": {
@ -53,8 +61,8 @@
"customer_type": { "customer_type": {
"label": "Este cliente es...", "label": "Este cliente es...",
"description": "Seleccione el tipo de cliente", "description": "Seleccione el tipo de cliente",
"company": "una empresa", "company": "Empresa",
"individual": "una persona" "individual": "Particular"
}, },
"name": { "name": {
"label": "Nombre", "label": "Nombre",
@ -194,6 +202,9 @@
"no_entities_found": "No se encontraron resultados para \"{{search}}\"", "no_entities_found": "No se encontraron resultados para \"{{search}}\"",
"select_or_create": "Seleccione un elemento de la lista o cree uno nuevo.", "select_or_create": "Seleccione un elemento de la lista o cree uno nuevo.",
"create_label": "Crear nuevo elemento" "create_label": "Crear nuevo elemento"
},
"address_cell": {
"open_in_google_maps": "Abrir dirección en Google Maps"
} }
} }
} }

View File

@ -40,7 +40,7 @@ export function useCustomersGridColumns(
<DataTableColumnHeader <DataTableColumnHeader
className="text-left" className="text-left"
column={column} column={column}
title={t("pages.customers.list.gridColumns.customer")} title={t("pages.list.grid_columns.customer")}
/> />
), ),
accessorFn: (row) => row.name, accessorFn: (row) => row.name,
@ -51,45 +51,53 @@ export function useCustomersGridColumns(
const customer = row.original; const customer = row.original;
return ( return (
<div className="flex items-start gap-3"> <button
<Avatar className="size-10 border-2 border-background shadow-sm"> className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer"
<AvatarFallback onClick={onViewClick ? () => onViewClick(customer) : undefined}
className={ type="button"
customer.status === "active" >
? "bg-blue-100 text-blue-700" <div className="flex items-start gap-3">
: "bg-muted text-muted-foreground" <Avatar className="size-10 border-2 border-background shadow-sm">
} <AvatarFallback
> className={
<Initials name={customer.name} /> customer.status === "active"
</AvatarFallback> ? "bg-blue-100 text-blue-700"
</Avatar> : "bg-muted text-muted-foreground"
}
>
<Initials name={customer.name} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-foreground">{customer.name}</span> <span className="font-medium text-foreground hover:underline">
{customer.trade_name && ( {customer.name}
<span className="text-muted-foreground">({customer.trade_name})</span> </span>
)} {customer.trade_name && (
</div> <span className="text-muted-foreground">({customer.trade_name})</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{customer.tin}</span>
<Badge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{customer.is_company ? (
<>
<Building2Icon className="size-3" />
{t("pages.customers.list.customerKind.company")}
</>
) : (
<>
<UserIcon className="size-3" />
{t("pages.customers.list.customerKind.individual")}
</>
)} )}
</Badge> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{customer.tin}</span>
<Badge className="gap-1 px-1.5 py-0 text-xs font-normal" variant="outline">
{customer.is_company ? (
<>
<Building2Icon className="size-3" />
{t("form_fields.customer_type.company")}
</>
) : (
<>
<UserIcon className="size-3" />
{t("form_fields.customer_type.individual")}
</>
)}
</Badge>
</div>
</div> </div>
</div> </div>
</div> </button>
); );
}, },
}, },
@ -99,11 +107,12 @@ export function useCustomersGridColumns(
<DataTableColumnHeader <DataTableColumnHeader
className="text-left" className="text-left"
column={column} column={column}
title={t("pages.customers.list.gridColumns.contact")} title={t("pages.list.grid_columns.contact")}
/> />
), ),
accessorFn: (row) => accessorFn: (row) =>
`${row.email_primary} ${row.phone_primary} ${row.mobile_primary} ${row.website}`, `${row.email_primary} ${row.phone_primary} ${row.mobile_primary} ${row.website}`,
enableSorting: false,
size: 140, size: 140,
minSize: 120, minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />, cell: ({ row }) => <ContactCell customer={row.original} />,
@ -114,11 +123,12 @@ export function useCustomersGridColumns(
<DataTableColumnHeader <DataTableColumnHeader
className="text-left" className="text-left"
column={column} column={column}
title={t("pages.customers.list.gridColumns.address")} title={t("pages.list.grid_columns.address")}
/> />
), ),
accessorFn: (row) => accessorFn: (row) =>
`${row.street} ${row.street2} ${row.city} ${row.postal_code} ${row.province} ${row.country}`, `${row.street} ${row.street2} ${row.city} ${row.postal_code} ${row.province} ${row.country}`,
enableSorting: false,
size: 140, size: 140,
minSize: 120, minSize: 120,
cell: ({ row }) => <AddressCell customer={row.original} />, cell: ({ row }) => <AddressCell customer={row.original} />,
@ -129,7 +139,7 @@ export function useCustomersGridColumns(
<DataTableColumnHeader <DataTableColumnHeader
className="text-right" className="text-right"
column={column} column={column}
title={t("pages.customers.list.gridColumns.actions")} title={t("pages.list.grid_columns.actions")}
/> />
), ),
size: 64, size: 64,
@ -144,7 +154,7 @@ export function useCustomersGridColumns(
<div className="flex justify-end"> <div className="flex justify-end">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
aria-label={t("pages.customers.list.actions.view")} aria-label={t("pages.list.actions.view")}
onClick={() => onViewClick?.(customer)} onClick={() => onViewClick?.(customer)}
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -154,26 +164,22 @@ export function useCustomersGridColumns(
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button aria-label={t("pages.list.actions.more")} size="icon" variant="ghost">
aria-label={t("pages.customers.list.actions.more")}
size="icon"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>{t("pages.customers.list.actions.label")}</DropdownMenuLabel> <DropdownMenuLabel>{t("pages.list.grid_columns.actions")}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onViewClick?.(customer)}> <DropdownMenuItem onClick={() => onViewClick?.(customer)}>
{t("pages.customers.list.actions.open")} {t("pages.list.actions.view")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditClick?.(customer)}> <DropdownMenuItem onClick={() => onEditClick?.(customer)}>
{t("pages.customers.list.actions.edit")} {t("pages.list.actions.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -184,14 +190,14 @@ export function useCustomersGridColumns(
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer") window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
} }
> >
{t("pages.customers.list.actions.visitWebsite")} {t("pages.list.actions.visit_website")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={!email_primary} disabled={!email_primary}
onClick={() => navigator.clipboard.writeText(email_primary)} onClick={() => navigator.clipboard.writeText(email_primary)}
> >
{t("pages.customers.list.actions.copyEmail")} {t("pages.list.actions.copy_email")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -200,7 +206,7 @@ export function useCustomersGridColumns(
className="text-destructive" className="text-destructive"
onClick={() => onDeleteClick?.(customer)} onClick={() => onDeleteClick?.(customer)}
> >
{t("pages.customers.list.actions.delete")} {t("pages.list.actions.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -1,19 +1,36 @@
import type { CustomerListRow } from "@erp/customers/web/shared";
import { MapPinIcon } from "lucide-react"; import { MapPinIcon } from "lucide-react";
import { useTranslation } from "../../../i18n";
import type { CustomerListRow } from "../../../shared";
const getGoogleMapsUrl = (customer: CustomerListRow) => {
const fullAddress = `${customer.street}, ${customer.postal_code} ${customer.city}, ${customer.province}, ${customer.country}`;
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 line1 = [customer.street, customer.street2].filter(Boolean).join(", "); const line1 = [customer.street, customer.street2].filter(Boolean).join(", ");
const line2 = [customer.postal_code, customer.city].filter(Boolean).join(" "); const line2 = [customer.postal_code, customer.city].filter(Boolean).join(" ");
const line3 = [customer.province, customer.country].filter(Boolean).join(", "); const line3 = [customer.province, customer.country].filter(Boolean).join(", ");
return ( return (
<address className="not-italic flex items-start gap-2 text-muted-foreground"> <address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
<MapPinIcon className="mt-0.5 size-3.5" /> <a
<div className="text-sm"> aria-label={t("components.address_cell.open_in_google_maps")}
<p>{line1}</p> className="group/address flex items-start gap-2 rounded-md p-1 -m-1 transition-colors hover:bg-muted"
<p> href={getGoogleMapsUrl(customer)}
{line2} · {line3} rel="noopener noreferrer"
</p> target="_blank"
</div> title={t("components.address_cell.open_in_google_maps")}
>
<MapPinIcon className="mt-0.5 size-3.5" />
<div className="text-sm">
<p>{line1}</p>
<p>
{line2} · {line3}
</p>
</div>
</a>
</address> </address>
); );
}; };

View File

@ -3,13 +3,16 @@ 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 ">
<a {customer.email_primary && (
className="flex items-center gap-2 hover:text-foreground" <a
href={`mailto:${customer.email_primary}`} className="flex items-center gap-2 hover:text-foreground"
> href={`mailto:${customer.email_primary}`}
<MailIcon className="size-3.5" /> >
{customer.email_primary} <MailIcon className="size-3.5" />
</a> {customer.email_primary}
</a>
)}
{customer.email_secondary && ( {customer.email_secondary && (
<a <a
className="flex items-center gap-2 hover:text-foreground" className="flex items-center gap-2 hover:text-foreground"
@ -19,7 +22,7 @@ export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
{customer.email_secondary} {customer.email_secondary}
</a> </a>
)} )}
{customer.phone_primary ? ( {customer.phone_primary && (
<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.phone_primary}`}
@ -27,10 +30,6 @@ export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
<PhoneIcon className="size-3.5" /> <PhoneIcon className="size-3.5" />
{customer.phone_primary} {customer.phone_primary}
</a> </a>
) : (
<span className="flex items-center gap-2 text-muted-foreground/50">
<PhoneIcon className="size-3.5" />-
</span>
)} )}
</div> </div>
); );

View File

@ -25,8 +25,8 @@ export const ListCustomersPage = () => {
return ( return (
<AppContent> <AppContent>
<ErrorAlert <ErrorAlert
message={(listCtrl.error as Error)?.message || t("pages.customers.list.loadErrorMessage")} message={(listCtrl.error as Error)?.message || t("pages.list.loadErrorMessage")}
title={t("pages.customers.list.loadErrorTitle")} title={t("pages.list.loadErrorTitle")}
/> />
<BackHistoryButton /> <BackHistoryButton />
</AppContent> </AppContent>
@ -37,17 +37,17 @@ export const ListCustomersPage = () => {
<> <>
<AppHeader> <AppHeader>
<PageHeader <PageHeader
description={t("pages.customers.list.description")} description={t("pages.list.description")}
rightSlot={ rightSlot={
<Button <Button
aria-label={t("pages.customers.create.title")} aria-label={t("pages.create.title")}
onClick={() => navigate("/customers/create")} onClick={() => navigate("/customers/create")}
> >
<PlusIcon aria-hidden className="mr-2 size-4" /> <PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.customers.create.title")} {t("pages.create.title")}
</Button> </Button>
} }
title={t("pages.customers.list.title")} title={t("pages.list.title")}
/> />
</AppHeader> </AppHeader>
<AppContent> <AppContent>