Facturas de cliente
This commit is contained in:
parent
29bbd51b0d
commit
bdc5637a81
@ -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
|
||||
}}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user