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) { function buildAddress(customer: CustomerSummary) {
// Línea 1: calle(s) // 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 // Línea 2: CP + ciudad con espacio no rompible entre CP y ciudad
const line2Parts: string[] = []; const line2Parts: string[] = [];
if (customer.postal_code && customer.city) { if (customer.address.postal_code && customer.address.city) {
line2Parts.push(`${customer.postal_code}\u00A0${customer.city}`); // CP Ciudad line2Parts.push(`${customer.address.postal_code}\u00A0${customer.address.city}`); // CP Ciudad
} else { } else {
if (customer.postal_code) line2Parts.push(customer.postal_code); if (customer.address.postal_code) line2Parts.push(customer.address.postal_code);
if (customer.city) line2Parts.push(customer.city); if (customer.address.city) line2Parts.push(customer.address.city);
} }
const line2 = line2Parts.join(" "); const line2 = line2Parts.join(" ");
// Línea 3: provincia + país // 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 stack = [line1, line2, line3].filter(Boolean);
const inline = stack.join(" · "); // separador compacto const inline = stack.join(" · "); // separador compacto

View File

@ -1,5 +1,23 @@
import type { CustomerSelectionOption } from "@erp/customers"; 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"; import { useTranslation } from "../../../../../i18n";
@ -8,8 +26,40 @@ type SelectedRecipientSummaryProps = {
readOnly?: boolean; readOnly?: boolean;
recipient?: CustomerSelectionOption | null; recipient?: CustomerSelectionOption | null;
onChangeClick: () => void; onChangeClick: () => void;
onCreateClick?: () => 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 = ({ export const SelectedRecipientSummary = ({
@ -19,43 +69,177 @@ export const SelectedRecipientSummary = ({
recipient, recipient,
onChangeClick, onChangeClick,
onCreateClick, onCreateClick,
onViewClick,
}: SelectedRecipientSummaryProps) => { }: SelectedRecipientSummaryProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const showActions = !(readOnly || disabled);
const address = recipient ? buildCustomerAddress(recipient) : "";
const phone = recipient?.primaryPhone ?? recipient?.primaryMobile;
return ( return (
<div> <div
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> className={cn(
<div className="min-w-0"> "rounded-lg border p-4",
{recipient ? ( recipient ? "border-primary/20 bg-primary/[0.03]" : "bg-muted/20",
<div className="mt-2 space-y-1"> disabled && "opacity-70"
<p className="truncate font-medium">{recipient.name}</p> )}
{recipient.tin ? ( >
<p className="truncate text-sm text-muted-foreground">{recipient.tin}</p> <div className="flex flex-col gap-4 md:flex-row md:justify-between">
) : null} <div className="flex min-w-0 flex-1 gap-4">
</div> <div
) : ( className={cn(
<p className="mt-2 text-sm text-muted-foreground"> "flex size-14 shrink-0 items-center justify-center rounded-full",
{t("customers.selected_customer.empty", "No hay ningún cliente seleccionado")} recipient ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
</p> )}
)} >
{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>
<div className="flex flex-wrap items-center gap-2"> {showActions ? (
{onCreateClick && !readOnly && !disabled && ( <div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch">
<Button onClick={onCreateClick} type="button" variant="outline"> {recipient && onViewClick ? (
{t("customers.selected_customer.new_customer", "Nuevo cliente")} <Button onClick={() => onViewClick(recipient)} type="button" variant="ghost">
</Button> <ExternalLinkIcon className="mr-2 size-4" />
)} {t("customers.selected_customer.view", "Ver cliente")}
</Button>
) : null}
{onChangeClick && !readOnly && !disabled && ( {onCreateClick ? (
<Button onClick={onChangeClick} type="button" variant="secondary"> <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 {recipient
? t("customers.selected_customer.change", "Cambiar cliente") ? t("customers.selected_customer.change", "Cambiar cliente")
: t("customers.selected_customer.select", "Seleccionar cliente")} : t("customers.selected_customer.select", "Seleccionar cliente")}
</Button> </Button>
)} </div>
</div> ) : null}
</div> </div>
</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, TextAreaField,
TextField, TextField,
} from "@repo/rdx-ui/components"; } from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -23,6 +24,8 @@ export const ProformaUpdateHeaderEditor = ({
return ( return (
<FormSectionCard <FormSectionCard
description={t("form_groups.proformas.basic_info.description")} description={t("form_groups.proformas.basic_info.description")}
disabled={disabled}
icon={<FileTextIcon className="size-5" />}
title={t("form_groups.proformas.basic_info.title")} title={t("form_groups.proformas.basic_info.title")}
> >
<FormSectionGrid> <FormSectionGrid>
@ -83,7 +86,6 @@ export const ProformaUpdateHeaderEditor = ({
placeholder={t("form_fields.proformas.notes.placeholder")} placeholder={t("form_fields.proformas.notes.placeholder")}
readOnly={readOnly} readOnly={readOnly}
/> />
</FormSectionGrid> </FormSectionGrid>
</FormSectionCard> </FormSectionCard>
); );

View File

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

View File

@ -1,5 +1,6 @@
import type { CustomerSelectionOption } from "@erp/customers"; import type { CustomerSelectionOption } from "@erp/customers";
import { FormSectionCard } from "@repo/rdx-ui/components"; import { FormSectionCard } from "@repo/rdx-ui/components";
import { UserIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { SelectedRecipientSummary } from "../blocks"; import { SelectedRecipientSummary } from "../blocks";
@ -24,7 +25,15 @@ export const ProformaUpdateRecipientEditor = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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 <SelectedRecipientSummary
disabled={disabled} disabled={disabled}
onChangeClick={onChangeCustomerClick} onChangeClick={onChangeCustomerClick}

View File

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

View File

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

View File

@ -13,11 +13,18 @@ export const buildCustomerSelectionOption = (
isCompany: customer.isCompany, isCompany: customer.isCompany,
name: customer.name, name: customer.name,
tradeName: customer.tradeName,
tin: customer.tin, 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, primaryEmail: customer.contact.primaryEmail,
currencyCode: customer.currencyCode, primaryPhone: customer.contact.primaryPhone,
primaryMobile: customer.contact.primaryMobile,
}; };
}; };

View File

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