This commit is contained in:
David Arranz 2026-04-29 23:03:57 +02:00
parent d216119a91
commit 482dd5ef56
9 changed files with 287 additions and 63 deletions

View File

@ -15,20 +15,20 @@ interface CustomerCardProps {
function buildAddress(customer: CustomerSummary) {
// Línea 1: calle(s)
const line1 = [customer.street, customer.street2].filter(Boolean).join(", ");
const line1 = [customer.address.street, customer.address.street2].filter(Boolean).join(", ");
// Línea 2: CP + ciudad con espacio no rompible entre CP y ciudad
const line2Parts: string[] = [];
if (customer.postal_code && customer.city) {
line2Parts.push(`${customer.postal_code}\u00A0${customer.city}`); // CP Ciudad
if (customer.address.postal_code && customer.address.city) {
line2Parts.push(`${customer.address.postal_code}\u00A0${customer.address.city}`); // CP Ciudad
} else {
if (customer.postal_code) line2Parts.push(customer.postal_code);
if (customer.city) line2Parts.push(customer.city);
if (customer.address.postal_code) line2Parts.push(customer.address.postal_code);
if (customer.address.city) line2Parts.push(customer.address.city);
}
const line2 = line2Parts.join(" ");
// Línea 3: provincia + país
const line3 = [customer.province, customer.country].filter(Boolean).join(", ");
const line3 = [customer.address.province, customer.address.country].filter(Boolean).join(", ");
const stack = [line1, line2, line3].filter(Boolean);
const inline = stack.join(" · "); // separador compacto

View File

@ -1,5 +1,23 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { Button } from "@repo/shadcn-ui/components";
import {
Badge,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
ExternalLinkIcon,
MailIcon,
MapPinIcon,
PhoneIcon,
PlusIcon,
RefreshCwIcon,
UserIcon,
} from "lucide-react";
import { useTranslation } from "../../../../../i18n";
@ -8,8 +26,40 @@ type SelectedRecipientSummaryProps = {
readOnly?: boolean;
recipient?: CustomerSelectionOption | null;
onChangeClick: () => void;
onCreateClick?: () => void;
onViewClick?: (recipient: CustomerSelectionOption) => void;
};
const buildCustomerAddress = (recipient: CustomerSelectionOption): string => {
return [
recipient.street,
recipient.street2,
[recipient.postalCode, recipient.city].filter(Boolean).join(" "),
recipient.province,
recipient.country,
]
.filter(Boolean)
.join(", ");
};
const getCustomerStatusBadgeClassName = (status: string): string => {
const normalizedStatus = status.toLowerCase();
if (["active", "activo", "enabled"].includes(normalizedStatus)) {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (["inactive", "inactivo", "disabled"].includes(normalizedStatus)) {
return "border-muted bg-muted text-muted-foreground";
}
if (["blocked", "bloqueado"].includes(normalizedStatus)) {
return "border-destructive/20 bg-destructive/10 text-destructive";
}
return "border-border bg-muted/50 text-muted-foreground";
};
export const SelectedRecipientSummary = ({
@ -19,43 +69,177 @@ export const SelectedRecipientSummary = ({
recipient,
onChangeClick,
onCreateClick,
onViewClick,
}: SelectedRecipientSummaryProps) => {
const { t } = useTranslation();
const showActions = !(readOnly || disabled);
const address = recipient ? buildCustomerAddress(recipient) : "";
const phone = recipient?.primaryPhone ?? recipient?.primaryMobile;
return (
<div>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
{recipient ? (
<div className="mt-2 space-y-1">
<p className="truncate font-medium">{recipient.name}</p>
{recipient.tin ? (
<p className="truncate text-sm text-muted-foreground">{recipient.tin}</p>
) : null}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">
{t("customers.selected_customer.empty", "No hay ningún cliente seleccionado")}
</p>
)}
<div
className={cn(
"rounded-lg border p-4",
recipient ? "border-primary/20 bg-primary/[0.03]" : "bg-muted/20",
disabled && "opacity-70"
)}
>
<div className="flex flex-col gap-4 md:flex-row md:justify-between">
<div className="flex min-w-0 flex-1 gap-4">
<div
className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-full",
recipient ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
{recipient?.isCompany ? (
<Building2Icon className="size-6" />
) : (
<UserIcon className="size-6" />
)}
</div>
<div className="min-w-0 space-y-2">
{recipient ? (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.8fr)]">
<div className="min-w-0 space-y-2">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-base font-semibold">{recipient.name}</p>
{recipient.status ? (
<Badge
className={cn(
"border",
getCustomerStatusBadgeClassName(recipient.status)
)}
variant="outline"
>
{recipient.status}
</Badge>
) : null}
</div>
{recipient.tradeName && recipient.tradeName !== recipient.name ? (
<p className="truncate text-sm text-muted-foreground">
{recipient.tradeName}
</p>
) : null}
{recipient.tin ? (
<p className="text-xs text-muted-foreground">{recipient.tin}</p>
) : null}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{recipient.primaryEmail ? (
<div className="flex min-w-0 items-center gap-1">
<MailIcon className="size-3.5 shrink-0" />
<span className="truncate">{recipient.primaryEmail}</span>
</div>
) : null}
{phone ? (
<div className="flex items-center gap-1">
<PhoneIcon className="size-3.5 shrink-0" />
{phone}
</div>
) : null}
</div>
</div>
<div className="min-w-0 space-y-2 lg:border-l lg:pl-4">
{address ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<div className="flex items-start gap-1 text-sm text-muted-foreground">
<MapPinIcon className="mt-0.5 size-3.5 shrink-0" />
<span className="line-clamp-3">{address}</span>
</div>
}
/>
<TooltipContent align="start" className="max-w-md">
{address}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<p className="text-sm text-muted-foreground">
{t("customers.selected_customer.no_address", "Sin dirección registrada")}
</p>
)}
</div>
</div>
) : (
<div>
<p className="font-medium">
{t("customers.selected_customer.empty_title", "Cliente no seleccionado")}
</p>
<p className="text-sm text-muted-foreground">
{t("customers.selected_customer.empty", "Selecciona o crea un cliente")}
</p>
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{onCreateClick && !readOnly && !disabled && (
<Button onClick={onCreateClick} type="button" variant="outline">
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button>
)}
{showActions ? (
<div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch">
{recipient && onViewClick ? (
<Button onClick={() => onViewClick(recipient)} type="button" variant="ghost">
<ExternalLinkIcon className="mr-2 size-4" />
{t("customers.selected_customer.view", "Ver cliente")}
</Button>
) : null}
{onChangeClick && !readOnly && !disabled && (
<Button onClick={onChangeClick} type="button" variant="secondary">
{onCreateClick ? (
<Button onClick={onCreateClick} type="button" variant="outline">
<PlusIcon className="mr-2 size-4" />
{t("customers.selected_customer.new_customer", "Nuevo cliente")}
</Button>
) : null}
<Button
onClick={onChangeClick}
type="button"
variant={recipient ? "secondary" : "default"}
>
<RefreshCwIcon className="mr-2 size-4" />
{recipient
? t("customers.selected_customer.change", "Cambiar cliente")
: t("customers.selected_customer.select", "Seleccionar cliente")}
</Button>
)}
</div>
</div>
) : null}
</div>
</div>
);
};
const buildAddress = (recipient: CustomerSelectionOption): string => {
// Línea 1: calle(s)
const line1 = [recipient.street, recipient.street2].filter(Boolean).join(", ");
// Línea 2: CP + ciudad con espacio no rompible entre CP y ciudad
const line2Parts: string[] = [];
if (recipient.postalCode && recipient.city) {
line2Parts.push(`${recipient.postalCode}\u00A0${recipient.city}`); // CP Ciudad
} else {
if (recipient.postalCode) line2Parts.push(recipient.postalCode);
if (recipient.city) line2Parts.push(recipient.city);
}
const line2 = line2Parts.join(" ");
// Línea 3: provincia + país
const line3 = [recipient.province, recipient.country].filter(Boolean).join(", ");
const stack = [line1, line2, line3].filter(Boolean);
const inline = stack.join(" · "); // separador compacto
const full = stack.join(", ");
return { has: stack.length > 0, stack, inline, full };
};

View File

@ -6,6 +6,7 @@ import {
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
@ -23,6 +24,8 @@ export const ProformaUpdateHeaderEditor = ({
return (
<FormSectionCard
description={t("form_groups.proformas.basic_info.description")}
disabled={disabled}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.proformas.basic_info.title")}
>
<FormSectionGrid>
@ -83,7 +86,6 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.notes.placeholder")}
readOnly={readOnly}
/>
</FormSectionGrid>
</FormSectionCard>
);

View File

@ -1,4 +1,5 @@
import { FormSectionCard } from "@repo/rdx-ui/components";
import { ListIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
@ -19,6 +20,8 @@ export const ProformaUpdateItemsEditor = ({
return (
<FormSectionCard
description={t("form_groups.items.description")}
disabled={disabled}
icon={<ListIcon className="size-5" />}
title={t("form_groups.items.title")}
>
<ProformaLineEditor

View File

@ -1,5 +1,6 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { FormSectionCard } from "@repo/rdx-ui/components";
import { UserIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import { SelectedRecipientSummary } from "../blocks";
@ -24,7 +25,15 @@ export const ProformaUpdateRecipientEditor = ({
const { t } = useTranslation();
return (
<FormSectionCard title={t("form_groups.proformas.customer.title")}>
<FormSectionCard
description={t(
"form_groups.proformas.customer.description",
"Cliente destinatario de la proforma"
)}
disabled={disabled}
icon={<UserIcon className="size-5" />}
title={t("form_groups.proformas.customer.title", "Cliente")}
>
<SelectedRecipientSummary
disabled={disabled}
onChangeClick={onChangeCustomerClick}

View File

@ -76,7 +76,7 @@ export const ProformaUpdatePage = () => {
}}
/>
}
title={t("pages.proformas.update.title")}
title={<>{t("pages.proformas.update.title")}</>}
/>
</AppHeader>
<AppContent className="space-y-4 max-w-5xl mx-auto">

View File

@ -5,25 +5,21 @@
*/
export interface CustomerSelectionOption {
id: string;
//status: string;
status: string;
//isCompany: boolean;
isCompany: boolean;
name: string;
//tradeName: string;
tin: string;
tradeName: string | null;
tin: string | null;
/*street: string;
street2: string;
city: string;
province: string;
postalCode: string;
country: string;
*/
street: string | null;
street2: string | null;
city: string | null;
province: string | null;
postalCode: string | null;
country: string | null;
//emailPrimary: string;
//primaryPhone: string;
//primaryMobile: string;
languageCode: string;
currencyCode: string;
primaryEmail: string | null;
primaryPhone: string | null;
primaryMobile: string | null;
}

View File

@ -13,11 +13,18 @@ export const buildCustomerSelectionOption = (
isCompany: customer.isCompany,
name: customer.name,
tradeName: customer.tradeName,
tin: customer.tin,
EmailPrimary: customer.EmailPrimary,
street: customer.address.street,
street2: null,
city: customer.address.city,
province: customer.address.province,
postalCode: customer.address.postalCode,
country: customer.address.country,
languageCode: customer.languageCode,
currencyCode: customer.currencyCode,
primaryEmail: customer.contact.primaryEmail,
primaryPhone: customer.contact.primaryPhone,
primaryMobile: customer.contact.primaryMobile,
};
};

View File

@ -13,7 +13,11 @@ import type { ReactNode } from "react";
interface FormSectionCardProps {
title?: string;
description?: string;
icon?: ReactNode;
disabled?: boolean;
children: ReactNode;
className?: string;
headerClassName?: string;
contentClassName?: string;
@ -22,7 +26,11 @@ interface FormSectionCardProps {
export const FormSectionCard = ({
title,
description,
icon,
disabled = false,
children,
className,
headerClassName,
contentClassName,
@ -30,17 +38,32 @@ export const FormSectionCard = ({
const hasHeader = Boolean(title || description);
return (
<Card className={cn("", className)}>
<FieldSet>
<Card className={cn(className)}>
<FieldSet disabled={disabled}>
{hasHeader ? (
<CardHeader className={headerClassName}>
<FieldLegend>
<div className="space-y-1">
{title ? (
<CardTitle className="text-base font-semibold tracking-tight">{title}</CardTitle>
<CardHeader className={cn("flex flex-row items-start gap-4", headerClassName)}>
<FieldLegend className="w-full">
<div className="flex items-start gap-3">
{icon ? (
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md",
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
)}
>
{icon}
</div>
) : null}
{description ? <CardDescription>{description}</CardDescription> : null}
<div className="space-y-1">
{title ? (
<CardTitle className="text-base font-semibold tracking-tight">
{title}
</CardTitle>
) : null}
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
</div>
</FieldLegend>
</CardHeader>