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>} {label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
<CustomerModalSelector <CustomerModalSelector
value={value} value={value}
onValueChange={onChange}
disabled={isDisabled} disabled={isDisabled}
readOnly={isReadOnly} readOnly={isReadOnly}
onValueChange={onChange}
initialCustomer={{ initialCustomer={{
...initialRecipient as CustomerSummary ...initialRecipient as CustomerSummary
}} }}

View File

@ -4,6 +4,7 @@ import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PageHeader } from '../../components';
import { useInvoicesQuery } from '../../hooks'; import { useInvoicesQuery } from '../../hooks';
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter'; 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) // Tipos (derivados de Zod o DTOs del backend)
export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>; export type CustomerInvoice = z.infer<typeof CustomerInvoiceSchema>;
export type CustomerInvoiceRecipient = CustomerInvoice["recipient"];
export type CustomerInvoiceItem = ArrayElement<CustomerInvoice["items"]>; export type CustomerInvoiceItem = ArrayElement<CustomerInvoice["items"]>;
export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear export type CustomerInvoiceCreateInput = z.infer<typeof CustomerInvoiceCreateSchema>; // Cuerpo para crear

View File

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

View File

@ -7,21 +7,46 @@ import {
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { import {
EyeIcon, EyeIcon,
MapPinIcon,
RefreshCwIcon, RefreshCwIcon,
UserPlusIcon UserPlusIcon
} from "lucide-react"; } from "lucide-react";
import React, { useMemo } from 'react';
import { CustomerSummary } from "../../schemas"; import { CustomerSummary } from "../../schemas";
interface CustomerCardProps { interface CustomerCardProps {
customer: CustomerSummary; customer: CustomerSummary;
onViewCustomer?: () => void; onViewCustomer?: () => void;
onChangeCustomer?: () => void; onChangeCustomer?: () => void;
onAddNewCustomer?: () => void; onAddNewCustomer?: () => void;
className?: string; 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 = ({ export const CustomerCard = ({
customer, customer,
onViewCustomer, onViewCustomer,
@ -29,79 +54,92 @@ export const CustomerCard = ({
onAddNewCustomer, onAddNewCustomer,
className, className,
}: CustomerCardProps) => { }: CustomerCardProps) => {
const hasAddress = const address = useMemo(() => buildAddress(customer), [customer]);
customer.street ||
customer.street2 ||
customer.city ||
customer.postal_code ||
customer.province ||
customer.country;
return ( return (
<Item variant="outline" className={className}> <Item variant="outline" className={className}>
<ItemContent> <ItemContent>
<ItemTitle className='flex gap-2 w-full justify-between'> <ItemTitle className="flex items-start gap-2 w-full justify-between">
<span className='grow'>{customer.name}</span> <span className="grow text-balance">{customer.name}</span>
<Button <Button
type='button' type="button"
variant='ghost' variant="ghost"
size='sm' size="sm"
className='cursor-pointer' className="cursor-pointer"
onClick={onViewCustomer} onClick={onViewCustomer}
aria-label="Ver ficha completa del cliente"
> >
<EyeIcon className='size-4 text-muted-foreground' /> <EyeIcon className="size-4 text-muted-foreground" />
<span className='sr-only'>Ver ficha completa</span>
</Button> </Button>
</ItemTitle> </ItemTitle>
<ItemDescription className='text-sm text-muted-foreground'> <ItemDescription className="text-sm text-muted-foreground">
{customer.tin && (<span>{customer.tin}</span>)} {/* TIN en su propia línea si existe */}
{/* Dirección con mejor estructura */} {customer.tin && (
{hasAddress && ( <div className="font-mono tabular-nums">{customer.tin}</div>
<div className='x'> )}
{customer.street && <div>{customer.street}</div>} {/* Dirección */}
{customer.street2 && <div>{customer.street2}</div>} {address.has ? (
<div className='flex flex-wrap gap-x-2'> <address
{customer.postal_code && <span>{customer.postal_code}</span>} className="not-italic mt-1 text-pretty"
{customer.city && <span>{customer.city}</span>} 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>
<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> </ItemDescription>
</ItemContent> </ItemContent>
<ItemFooter className='flex-wrap'>
{/* Footer con acciones */}
<ItemFooter className="flex-wrap gap-2">
<Button <Button
type="button" type="button"
variant='outline' variant="outline"
size='sm' size="sm"
onClick={onChangeCustomer} 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' /> <RefreshCwIcon className="size-4" />
<span className='text-sm text-muted-foreground'> <span className="text-sm text-muted-foreground">Cambiar de cliente</span>
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> </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> </ItemFooter>
</Item> </Item>
); );
}; };

View File

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