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>}
|
{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
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user