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",
"list": {
"title": "Customer list",
"description": "List all customers",
"description": "Manage your customers and their contact information",
"grid_columns": {
"customer": "Customer",
"status": "Status",
"contact": "Contact",
"address": "Address",
"actions": "Actions"
},
"actions": {
"more": "More",
"view": "View",
"edit": "Edit customer",
"delete": "Delete customer",
"visit_website": "Visit website",
"copy_email": "Copy email"
}
},
"create": {
@ -192,6 +200,9 @@
"no_entities_found": "No results found for \"{{search}}\"",
"select_or_create": "Select an item from the list or create a new one.",
"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",
"list": {
"title": "Lista de clientes",
"description": "Lista todos los clientes",
"description": "Gestiona la información de tus clientes y sus datos de contacto",
"grid_columns": {
"customer": "Cliente",
"status": "Estado",
"contact": "Contacto",
"address": "Dirección",
"actions": "Acciones"
},
"actions": {
"more": "Más",
"view": "Ver",
"edit": "Editar cliente",
"delete": "Eliminar cliente",
"visit_website": "Visitar sitio web",
"copy_email": "Copiar email"
}
},
"create": {
@ -53,8 +61,8 @@
"customer_type": {
"label": "Este cliente es...",
"description": "Seleccione el tipo de cliente",
"company": "una empresa",
"individual": "una persona"
"company": "Empresa",
"individual": "Particular"
},
"name": {
"label": "Nombre",
@ -194,6 +202,9 @@
"no_entities_found": "No se encontraron resultados para \"{{search}}\"",
"select_or_create": "Seleccione un elemento de la lista o cree uno nuevo.",
"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
className="text-left"
column={column}
title={t("pages.customers.list.gridColumns.customer")}
title={t("pages.list.grid_columns.customer")}
/>
),
accessorFn: (row) => row.name,
@ -51,6 +51,11 @@ export function useCustomersGridColumns(
const customer = row.original;
return (
<button
className="flex items-start gap-3 text-left transition-colors hover:opacity-80 cursor-pointer"
onClick={onViewClick ? () => onViewClick(customer) : undefined}
type="button"
>
<div className="flex items-start gap-3">
<Avatar className="size-10 border-2 border-background shadow-sm">
<AvatarFallback
@ -66,7 +71,9 @@ export function useCustomersGridColumns(
<div className="flex flex-col gap-1">
<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.name}
</span>
{customer.trade_name && (
<span className="text-muted-foreground">({customer.trade_name})</span>
)}
@ -78,18 +85,19 @@ export function useCustomersGridColumns(
{customer.is_company ? (
<>
<Building2Icon className="size-3" />
{t("pages.customers.list.customerKind.company")}
{t("form_fields.customer_type.company")}
</>
) : (
<>
<UserIcon className="size-3" />
{t("pages.customers.list.customerKind.individual")}
{t("form_fields.customer_type.individual")}
</>
)}
</Badge>
</div>
</div>
</div>
</button>
);
},
},
@ -99,11 +107,12 @@ export function useCustomersGridColumns(
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.customers.list.gridColumns.contact")}
title={t("pages.list.grid_columns.contact")}
/>
),
accessorFn: (row) =>
`${row.email_primary} ${row.phone_primary} ${row.mobile_primary} ${row.website}`,
enableSorting: false,
size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />,
@ -114,11 +123,12 @@ export function useCustomersGridColumns(
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.customers.list.gridColumns.address")}
title={t("pages.list.grid_columns.address")}
/>
),
accessorFn: (row) =>
`${row.street} ${row.street2} ${row.city} ${row.postal_code} ${row.province} ${row.country}`,
enableSorting: false,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell customer={row.original} />,
@ -129,7 +139,7 @@ export function useCustomersGridColumns(
<DataTableColumnHeader
className="text-right"
column={column}
title={t("pages.customers.list.gridColumns.actions")}
title={t("pages.list.grid_columns.actions")}
/>
),
size: 64,
@ -144,7 +154,7 @@ export function useCustomersGridColumns(
<div className="flex justify-end">
<div className="flex items-center gap-1">
<Button
aria-label={t("pages.customers.list.actions.view")}
aria-label={t("pages.list.actions.view")}
onClick={() => onViewClick?.(customer)}
size="icon"
variant="ghost"
@ -154,26 +164,22 @@ export function useCustomersGridColumns(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t("pages.customers.list.actions.more")}
size="icon"
variant="ghost"
>
<Button aria-label={t("pages.list.actions.more")} size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("pages.customers.list.actions.label")}</DropdownMenuLabel>
<DropdownMenuLabel>{t("pages.list.grid_columns.actions")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onViewClick?.(customer)}>
{t("pages.customers.list.actions.open")}
{t("pages.list.actions.view")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditClick?.(customer)}>
{t("pages.customers.list.actions.edit")}
{t("pages.list.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
@ -184,14 +190,14 @@ export function useCustomersGridColumns(
window.open(safeHTTPUrl(website), "_blank", "noopener,noreferrer")
}
>
{t("pages.customers.list.actions.visitWebsite")}
{t("pages.list.actions.visit_website")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!email_primary}
onClick={() => navigator.clipboard.writeText(email_primary)}
>
{t("pages.customers.list.actions.copyEmail")}
{t("pages.list.actions.copy_email")}
</DropdownMenuItem>
<DropdownMenuSeparator />
@ -200,7 +206,7 @@ export function useCustomersGridColumns(
className="text-destructive"
onClick={() => onDeleteClick?.(customer)}
>
{t("pages.customers.list.actions.delete")}
{t("pages.list.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

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

View File

@ -3,6 +3,7 @@ import { MailIcon, PhoneIcon } from "lucide-react";
export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
<div className="flex flex-col gap-1.5 text-sm text-muted-foreground transition-colors ">
{customer.email_primary && (
<a
className="flex items-center gap-2 hover:text-foreground"
href={`mailto:${customer.email_primary}`}
@ -10,6 +11,8 @@ export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
<MailIcon className="size-3.5" />
{customer.email_primary}
</a>
)}
{customer.email_secondary && (
<a
className="flex items-center gap-2 hover:text-foreground"
@ -19,7 +22,7 @@ export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
{customer.email_secondary}
</a>
)}
{customer.phone_primary ? (
{customer.phone_primary && (
<a
className="flex items-center gap-2 hover:text-foreground"
href={`tel:${customer.phone_primary}`}
@ -27,10 +30,6 @@ export const ContactCell = ({ customer }: { customer: CustomerListRow }) => (
<PhoneIcon className="size-3.5" />
{customer.phone_primary}
</a>
) : (
<span className="flex items-center gap-2 text-muted-foreground/50">
<PhoneIcon className="size-3.5" />-
</span>
)}
</div>
);

View File

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