Facturas de cliente

This commit is contained in:
David Arranz 2025-10-20 10:02:08 +02:00
parent 29bbd51b0d
commit bdc5637a81
6 changed files with 101 additions and 56 deletions

View File

@ -53,9 +53,9 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
<CustomerModalSelector
value={value}
onValueChange={onChange}
disabled={isDisabled}
readOnly={isReadOnly}
onValueChange={onChange}
initialCustomer={{
...initialRecipient as CustomerSummary
}}

View File

@ -4,6 +4,7 @@ import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react';
import { useNavigate } from "react-router-dom";
import { PageHeader } from '../../components';
import { useInvoicesQuery } from '../../hooks';
import { useTranslation } from "../../i18n";
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';

View File

@ -17,6 +17,7 @@ export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchem
// Tipos (derivados de Zod o DTOs del backend)
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>;
export type CustomerInvoiceRecipient = CustomerInvoice["recipient"];
export type CustomerInvoiceItem = ArrayElement<CustomerInvoice["items"]>;
export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear

View File

@ -14,6 +14,7 @@ export const ListCustomersResponseSchema = createPaginatedListSchema(
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),

View File

@ -7,21 +7,46 @@ import {
} from "@repo/shadcn-ui/components";
import {
EyeIcon,
MapPinIcon,
RefreshCwIcon,
UserPlusIcon
} from "lucide-react";
import React, { useMemo } from 'react';
import { CustomerSummary } from "../../schemas";
interface CustomerCardProps {
customer: CustomerSummary;
onViewCustomer?: () => void;
onChangeCustomer?: () => void;
onAddNewCustomer?: () => void;
className?: string;
}
function buildAddress(customer: CustomerSummary) {
// Línea 1: calle(s)
const line1 = [customer.street, customer.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
} else {
if (customer.postal_code) line2Parts.push(customer.postal_code);
if (customer.city) line2Parts.push(customer.city);
}
const line2 = line2Parts.join(" ");
// Línea 3: provincia + país
const line3 = [customer.province, customer.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 };
}
export const CustomerCard = ({
customer,
onViewCustomer,
@ -29,79 +54,92 @@ export const CustomerCard = ({
onAddNewCustomer,
className,
}: CustomerCardProps) => {
const hasAddress =
customer.street ||
customer.street2 ||
customer.city ||
customer.postal_code ||
customer.province ||
customer.country;
const address = useMemo(() => buildAddress(customer), [customer]);
return (
<Item variant="outline" className={className}>
<ItemContent>
<ItemTitle className='flex gap-2 w-full justify-between'>
<span className='grow'>{customer.name}</span>
<ItemTitle className="flex items-start gap-2 w-full justify-between">
<span className="grow text-balance">{customer.name}</span>
<Button
type='button'
variant='ghost'
size='sm'
className='cursor-pointer'
type="button"
variant="ghost"
size="sm"
className="cursor-pointer"
onClick={onViewCustomer}
aria-label="Ver ficha completa del cliente"
>
<EyeIcon className='size-4 text-muted-foreground' />
<span className='sr-only'>Ver ficha completa</span>
<EyeIcon className="size-4 text-muted-foreground" />
</Button>
</ItemTitle>
<ItemDescription className='text-sm text-muted-foreground'>
{customer.tin && (<span>{customer.tin}</span>)}
{/* Dirección con mejor estructura */}
{hasAddress && (
<div className='x'>
<ItemDescription className="text-sm text-muted-foreground">
{/* TIN en su propia línea si existe */}
{customer.tin && (
<div className="font-mono tabular-nums">{customer.tin}</div>
)}
{customer.street && <div>{customer.street}</div>}
{customer.street2 && <div>{customer.street2}</div>}
<div className='flex flex-wrap gap-x-2'>
{customer.postal_code && <span>{customer.postal_code}</span>}
{customer.city && <span>{customer.city}</span>}
{/* Dirección */}
{address.has ? (
<address
className="not-italic mt-1 text-pretty"
aria-label={address.full}
>
{/* Desktop/tablet: compacto en una línea (o dos por wrap natural) */}
<div className="hidden sm:flex items-center gap-1 flex-wrap">
<MapPinIcon aria-hidden className="size-3.5 translate-y-[1px]" />
{/* Partes con separadores reales para que el wrap ocurra en “ · ” */}
{address.stack.map((part, i) => (
<React.Fragment key={i}>
<span>{part}</span>
{i < address.stack.length - 1 && (
<span aria-hidden className="mx-1">·</span>
)}
</React.Fragment>
))}
</div>
<div className='flex flex-wrap gap-x-2'>
{customer.province && <span>{customer.province}</span>}
{customer.country && <span>{customer.country}</span>}
</div>
</div>
{/* Móvil: apilado (3 líneas máximo) */}
<div className="sm:hidden">
<div className="flex items-start gap-1">
<MapPinIcon aria-hidden className="size-3.5 translate-y-[1px]" />
<div className="flex-1">
{address.stack.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</div>
</div>
</address>
) : (
<span className="italic text-muted-foreground">Sin dirección</span>
)}
</ItemDescription>
</ItemContent>
<ItemFooter className='flex-wrap'>
{/* Footer con acciones */}
<ItemFooter className="flex-wrap gap-2">
<Button
type="button"
variant='outline'
size='sm'
variant="outline"
size="sm"
onClick={onChangeCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
className="flex-1 min-w-36 gap-2 cursor-pointer"
>
<RefreshCwIcon className='size-4' />
<span className='text-sm text-muted-foreground'>
Cambiar de cliente
</span>
</Button>
<Button
type="button"
variant='outline'
size='sm'
onClick={onAddNewCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
>
<UserPlusIcon className='size-4' />
<span className='text-sm text-muted-foreground'>
Nuevo cliente
</span>
<RefreshCwIcon className="size-4" />
<span className="text-sm text-muted-foreground">Cambiar de cliente</span>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddNewCustomer}
className="flex-1 min-w-36 gap-2 cursor-pointer"
>
<UserPlusIcon className="size-4" />
<span className="text-sm text-muted-foreground">Nuevo cliente</span>
</Button>
</ItemFooter>
</Item>
);
};
};

View File

@ -19,18 +19,18 @@ function useDebouncedValue<T>(value: T, delay = 300) {
interface CustomerModalSelectorProps {
value?: string;
onValueChange?: (id: string) => void;
initialCustomer?: CustomerSummary;
disabled?: boolean;
readOnly?: boolean;
initialCustomer?: CustomerSummary;
className?: string;
}
export const CustomerModalSelector = ({
value,
onValueChange,
initialCustomer,
disabled = false,
readOnly = false,
initialCustomer,
className,
}: CustomerModalSelectorProps) => {
@ -41,8 +41,10 @@ export const CustomerModalSelector = ({
// Cliente seleccionado y creación local optimista
const [selected, setSelected] = useState<CustomerSummary | null>(initialCustomer ?? null);
const [newClient, setNewClient] =
useState<Omit<CustomerSummary, "id" | "status" | "company_id">>(defaultCustomerFormData);
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
const {
@ -92,6 +94,8 @@ export const CustomerModalSelector = ({
className={className}
customer={selected}
onChangeCustomer={() => setShowSearch(true)}
onViewCustomer={() => null}
onAddNewCustomer={() => null}
/>
) : (
<CustomerEmptyCard