Facturas de cliente
This commit is contained in:
parent
c9d375f40c
commit
2fc9deea85
@ -12,7 +12,7 @@ export const formatCurrency = (
|
|||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
maximumFractionDigits: scale,
|
maximumFractionDigits: scale,
|
||||||
minimumFractionDigits: Number.isInteger(amount) ? 0 : 0,
|
minimumFractionDigits: Number.isInteger(amount) ? 0 : scale,
|
||||||
useGrouping: true,
|
useGrouping: true,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@ -53,7 +54,7 @@ export const CancelFormButton = ({
|
|||||||
type='button'
|
type='button'
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
className={className}
|
className={cn("cursor-pointer", className)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ const alignToJustify: Record<Align, string> = {
|
|||||||
between: "justify-between",
|
between: "justify-between",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormCommitButtonGroup = ({
|
export const UpdateCommitButtonGroup = ({
|
||||||
className,
|
className,
|
||||||
align = "end",
|
align = "end",
|
||||||
gap = "gap-2",
|
gap = "gap-2",
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export const SubmitFormButton = ({
|
|||||||
data-state={dataState}
|
data-state={dataState}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
className={cn("min-w-[100px] font-medium", hasChanges && "ring-2 ring-primary/20", className)}
|
className={cn("min-w-[100px] cursor-pointer font-medium", hasChanges && "ring-2 ring-primary/20", className)}
|
||||||
>
|
>
|
||||||
{children ? (
|
{children ? (
|
||||||
children
|
children
|
||||||
|
|||||||
@ -25,19 +25,19 @@ export const InvoiceEditForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)} >
|
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)} >
|
||||||
<section className={cn("space-y-6", className)}>
|
<section className={cn("bg-white rounded-xl border shadow-xl space-y-6", className)}>
|
||||||
<div className="w-full border p-6 bg-background">
|
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<InvoiceBasicInfoFields className="flex flex-col" />
|
<InvoiceRecipient className="flex flex-col" />
|
||||||
<InvoiceRecipient className='lg:col-span-1 border p-6 bg-background' />
|
<InvoiceBasicInfoFields className="flex flex-col lg:col-span-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full gap-6'>
|
<div className='w-full gap-6 px-6'>
|
||||||
<InvoiceItems />
|
<InvoiceItems />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full border p-6 bg-background">
|
<div className="w-full p-6 grid grid-cols-1 lg:grid-cols-2">
|
||||||
<InvoiceTotals />
|
<InvoiceTotals className='lg:col-start-2' />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full border p-6 bg-background">
|
<div className="w-full p-6">
|
||||||
<FormDebug />
|
<FormDebug />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { formatCurrency } from "@erp/core";
|
import { formatCurrency } from "@erp/core";
|
||||||
import { FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator } from '@repo/shadcn-ui/components';
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator } from '@repo/shadcn-ui/components';
|
||||||
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { ReceiptIcon } from "lucide-react";
|
import { ReceiptIcon } from "lucide-react";
|
||||||
import { ComponentProps } from "react";
|
import { ComponentProps } from "react";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
@ -22,51 +23,42 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldSet {...props}>
|
<FieldSet {...props}>
|
||||||
<FieldLegend>
|
<FieldLegend className='hidden'>
|
||||||
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
|
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
|
||||||
</FieldLegend>
|
</FieldLegend>
|
||||||
|
|
||||||
<FieldDescription>{t("form_groups.totals.description")}</FieldDescription>
|
<FieldDescription className='hidden'>{t("form_groups.totals.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1 border rounded-lg bg-muted/10 p-6 gap-4'>
|
||||||
{/* Sección: Subtotal y Descuentos */}
|
|
||||||
<div className="space-y-1.5">
|
<div className='space-y-1.5'>
|
||||||
<div className="flex items-center justify-between">
|
{/* Sección: Subtotal y Descuentos */}
|
||||||
<span className="font-semibold text-base">Subtotal</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-bold text-lg tabular-nums pr-4">
|
<span className="text-muted-foreground">Subtotal sin descuento</span>
|
||||||
{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
|
<span className="font-medium tabular-nums text-muted-foreground">
|
||||||
|
{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<div className="flex justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="space-y-1.5">
|
<span className="text-muted-foreground">Descuento global</span>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Descuento global</h3>
|
<PercentageInputField
|
||||||
|
control={control}
|
||||||
<div className="rounded-lg bg-accent/30 p-4 space-y-2.5">
|
name={"discount_percentage"}
|
||||||
<div className="flex items-center justify-between gap-4">
|
readOnly={readOnly}
|
||||||
<div className="flex items-center gap-3">
|
inputId={"header-discount-percentage"}
|
||||||
<span className='text-sm font-medium'>Descuento global</span>
|
showSuffix={true}
|
||||||
<PercentageInputField
|
className={cn("w-20 text-right tabular-nums bg-background", "border-input border text-sm shadow-xs")}
|
||||||
control={control}
|
/>
|
||||||
name={"discount_percentage"}
|
|
||||||
readOnly={readOnly}
|
|
||||||
inputId={"header-discount-percentage"}
|
|
||||||
showSuffix={true}
|
|
||||||
className='w-20 h-9 text-right tabular-nums'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-destructive tabular-nums">-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-medium text-destructive tabular-nums">-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Sección: Base Imponible */}
|
{/* Sección: Base Imponible */}
|
||||||
<div className="space-y-1.5">
|
<div className="flex justify-between text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-foreground">Base imponible</span>
|
||||||
<span className="font-semibold text-base">Base imponible</span>
|
<span className="font-medium tabular-nums">
|
||||||
<span className="font-bold text-lg tabular-nums pr-4">
|
|
||||||
{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
|
{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -77,7 +69,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
{/* Sección: Impuestos */}
|
{/* Sección: Impuestos */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h3
|
<h3
|
||||||
className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"
|
className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Impuestos y retenciones
|
Impuestos y retenciones
|
||||||
</h3>
|
</h3>
|
||||||
@ -98,7 +90,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
return (
|
return (
|
||||||
|
|
||||||
|
|
||||||
<div key={`tax-group-${group}`} className="rounded-lg bg-accent/30 p-4 space-y-1.5">
|
<div key={`tax-group-${group}`} className="space-y-1.5 leading-3">
|
||||||
{taxesInGroup?.map((item) => {
|
{taxesInGroup?.map((item) => {
|
||||||
const tax = taxCatalog.findByCode(item.tax_code).match(
|
const tax = taxCatalog.findByCode(item.tax_code).match(
|
||||||
(t) => t,
|
(t) => t,
|
||||||
@ -107,10 +99,10 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${group}:${item.tax_code}`}
|
key={`${group}:${item.tax_code}`}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between text-sm"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{tax?.name}</span>
|
<span className="text-muted-foreground text-sm">{tax?.name}</span>
|
||||||
<span className="font-medium tabular-nums">
|
<span className="font-medium tabular-nums text-sm text-muted-foreground">
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
item.taxes_amount,
|
item.taxes_amount,
|
||||||
2,
|
2,
|
||||||
@ -126,23 +118,23 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm mt-3">
|
||||||
<div className="flex items-center justify-between text-base mt-3">
|
<span className="text-foreground">Total de impuestos</span>
|
||||||
<span className="font-semibold text-base">Total de impuestos</span>
|
<span className="font-medium tabular-nums">
|
||||||
<span className="font-bold text-lg tabular-nums pr-4">{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
|
{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className='bg-foreground' />
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<div className="rounded-lg bg-primary p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-lg font-bold text-background/90">Total de la factura</span>
|
|
||||||
<span className="text-2xl font-bold text-background">{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm mt-3">
|
||||||
|
<span className="font-semibold text-foreground">Total de la factura</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet >
|
</FieldSet >
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { DataTable, useWithRowSelection } from '@repo/rdx-ui/components';
|
import { DataTable, useWithRowSelection } from '@repo/rdx-ui/components';
|
||||||
import { Table } from '@tanstack/react-table';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
import { useInvoiceContext } from '../../../context';
|
import { useInvoiceContext } from '../../../context';
|
||||||
@ -35,17 +34,16 @@ export const ItemsEditor = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
<DataTable columns={columns as any} data={fields}
|
<DataTable columns={columns as any} data={fields}
|
||||||
getRowId={row => String(row?.index)}
|
|
||||||
meta={{
|
meta={{
|
||||||
tableOps: {
|
tableOps: {
|
||||||
onAdd: () => append({ ...createEmptyItem() }),
|
onAdd: () => append({ ...createEmptyItem() }),
|
||||||
appendItem: (item: any) => append(item),
|
//appendItem: (item: any) => append(item),
|
||||||
},
|
},
|
||||||
rowOps: {
|
rowOps: {
|
||||||
remove: (i: number) => remove(i),
|
remove: (i: number) => remove(i),
|
||||||
move: (from: number, to: number) => move(from, to),
|
move: (from: number, to: number) => move(from, to),
|
||||||
insertItem: (index: number, item: any) => insert(index, item),
|
//insertItem: (index: number, item: any) => insert(index, item),
|
||||||
duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
|
/*duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
|
||||||
const items = getValues("items") || [];
|
const items = getValues("items") || [];
|
||||||
// duplicate in descending order to keep indexes stable
|
// duplicate in descending order to keep indexes stable
|
||||||
[...indexes].sort((a, b) => b - a).forEach(i => {
|
[...indexes].sort((a, b) => b - a).forEach(i => {
|
||||||
@ -55,12 +53,12 @@ export const ItemsEditor = () => {
|
|||||||
append({ ...rest });
|
append({ ...rest });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},*/
|
||||||
deleteItems: (indexes: number[]) => {
|
/*deleteItems: (indexes: number[]) => {
|
||||||
// remove in descending order to avoid shifting issues
|
// remove in descending order to avoid shifting issues
|
||||||
[...indexes].sort((a, b) => b - a).forEach(i => remove(i));
|
[...indexes].sort((a, b) => b - a).forEach(i => remove(i));
|
||||||
},
|
},*/
|
||||||
updateItem: (index: number, item: any) => update(index, item),
|
//updateItem: (index: number, item: any) => update(index, item),
|
||||||
},
|
},
|
||||||
bulkOps: {
|
bulkOps: {
|
||||||
duplicateSelected: (indexes, table) => {
|
duplicateSelected: (indexes, table) => {
|
||||||
|
|||||||
@ -18,10 +18,12 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
|||||||
{t('form_groups.recipient.title')}
|
{t('form_groups.recipient.title')}
|
||||||
</FieldLegend>
|
</FieldLegend>
|
||||||
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
|
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
|
||||||
|
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
|
||||||
<RecipientModalSelectorField
|
<RecipientModalSelectorField
|
||||||
control={control}
|
control={control}
|
||||||
name='customer_id'
|
name='customer_id'
|
||||||
|
label={t('form_groups.customer.title')}
|
||||||
initialRecipient={recipient}
|
initialRecipient={recipient}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import { CustomerModalSelector } from "@erp/customers/components";
|
import { CustomerModalSelector } from "@erp/customers/components";
|
||||||
import { FormField, FormItem } from "@repo/shadcn-ui/components";
|
import { Field, FieldLabel } from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { CustomerSummary } from 'node_modules/@erp/customers/src/web/schemas';
|
import { CustomerSummary } from 'node_modules/@erp/customers/src/web/schemas';
|
||||||
|
|
||||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
|
type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
|
||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
|
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -17,6 +24,13 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
|
|||||||
export function RecipientModalSelectorField<TFormValues extends FieldValues>({
|
export function RecipientModalSelectorField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
|
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
|
||||||
|
orientation = 'vertical',
|
||||||
|
|
||||||
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
@ -28,14 +42,15 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => {
|
render={({ field, fieldState }) => {
|
||||||
const { name, value, onChange, onBlur, ref } = field;
|
const { name, value, onChange, onBlur, ref } = field;
|
||||||
//console.log({ name, value, onChange, onBlur, ref });
|
|
||||||
return (
|
return (
|
||||||
<FormItem className={className}>
|
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}>
|
||||||
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||||
<CustomerModalSelector
|
<CustomerModalSelector
|
||||||
value={value}
|
value={value}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
@ -45,7 +60,7 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
|
|||||||
...initialRecipient as CustomerSummary
|
...initialRecipient as CustomerSummary
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</Field>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { Button } from '@repo/shadcn-ui/components';
|
||||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
|
import { ChevronLeftIcon } from 'lucide-react';
|
||||||
// features/common/components/page-header.tsx
|
// features/common/components/page-header.tsx
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
|
||||||
@ -20,15 +22,19 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
|
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("border-b bg-card -px-4", className)}>
|
<div className={cn("border-b bg-card -px-4 pt-4", className)}>
|
||||||
<div className='mx-auto w-full px-6 pt-2 pb-8'>
|
<div className="mx-auto px-6 py-4">
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
{/* Lado izquierdo */}
|
{/* Lado izquierdo */}
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-4'>
|
||||||
|
<Button variant="ghost" size="icon" className="cursor-pointer" onClick={() => window.history.back()}>
|
||||||
|
<ChevronLeftIcon className="size-5" />
|
||||||
|
</Button>
|
||||||
{icon && <div className='shrink-0'>{icon}</div>}
|
{icon && <div className='shrink-0'>{icon}</div>}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<h1 className='text-lg font-semibold text-foreground'>{title}</h1>
|
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
|
||||||
{status && <CustomerInvoiceStatusBadge status={status} />}
|
{status && <CustomerInvoiceStatusBadge status={status} />}
|
||||||
</div>
|
</div>
|
||||||
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
||||||
|
|||||||
103
modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx
Normal file
103
modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export type UseInvoicePreviewOptions<T> = {
|
||||||
|
persistKey?: string; // clave para guardar el pin en localStorage
|
||||||
|
pinnedWidthClass?: string; // ancho al anclar (Tailwind), p.ej. "w-[500px]"
|
||||||
|
onOpenChange?: (open: boolean) => void; // callback opcional
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InvoicePreviewHook<T> = {
|
||||||
|
isOpen: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
item: T | null;
|
||||||
|
open: (item: T) => void;
|
||||||
|
close: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
|
||||||
|
/** Añade margen derecho al listado si está anclado */
|
||||||
|
containerClassName: string;
|
||||||
|
|
||||||
|
/** Renderiza el preview en un portal (body). Debes pasar tu Card como children render-prop */
|
||||||
|
PreviewPortal: FC<{
|
||||||
|
children: (p: {
|
||||||
|
item: T;
|
||||||
|
isOpen: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
}) => ReactNode
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useInvoicePreview<T = unknown>(
|
||||||
|
opts?: UseInvoicePreviewOptions<T>
|
||||||
|
): InvoicePreviewHook<T> {
|
||||||
|
const { persistKey = "invoice-preview-pin", pinnedWidthClass = "w-[500px]", onOpenChange } = opts ?? {};
|
||||||
|
const [isOpen, setOpen] = useState(false);
|
||||||
|
const [item, setItem] = useState<T | null>(null);
|
||||||
|
const [isPinned, setPinned] = useState<boolean>(() => {
|
||||||
|
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardar y restaurar foco al cerrar (mejor accesibilidad)
|
||||||
|
const lastFocusedRef = useRef<HTMLElement | null>(null);
|
||||||
|
const rememberFocus = () => { lastFocusedRef.current = (document.activeElement as HTMLElement) ?? null; };
|
||||||
|
const restoreFocus = () => { lastFocusedRef.current?.focus?.(); };
|
||||||
|
|
||||||
|
const open = useCallback((next: T) => {
|
||||||
|
rememberFocus();
|
||||||
|
setItem(next);
|
||||||
|
setOpen(true);
|
||||||
|
onOpenChange?.(true);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
if (isPinned) return; // anclado no se cierra
|
||||||
|
setOpen(false);
|
||||||
|
onOpenChange?.(false);
|
||||||
|
setTimeout(restoreFocus, 0);
|
||||||
|
}, [isPinned, onOpenChange]);
|
||||||
|
|
||||||
|
const togglePin = useCallback(() => {
|
||||||
|
setPinned((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem(persistKey, next ? "1" : "0"); } catch { }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [persistKey]);
|
||||||
|
|
||||||
|
// Bloqueo de scroll cuando está abierto y NO anclado
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !isPinned) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
}, [isOpen, isPinned]);
|
||||||
|
|
||||||
|
// Cerrar con ESC (solo si no está anclado)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || isPinned) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [isOpen, isPinned, close]);
|
||||||
|
|
||||||
|
const containerClassName = isPinned ? `mr-[--preview-width] [--preview-width:theme(spacing.0)] ${pinnedWidthClass ? "" : ""}` : "";
|
||||||
|
// Nota: preferimos aplicar el margen directamente en el layout (ver uso abajo)
|
||||||
|
|
||||||
|
const PreviewPortal: InvoicePreviewHook<T>["PreviewPortal"] = useCallback(({ children }) => {
|
||||||
|
if (!item) return null;
|
||||||
|
const node = children({
|
||||||
|
item,
|
||||||
|
isOpen,
|
||||||
|
isPinned,
|
||||||
|
onClose: close,
|
||||||
|
onTogglePin: togglePin,
|
||||||
|
});
|
||||||
|
return createPortal(node as ReactNode, document.body);
|
||||||
|
}, [item, isOpen, isPinned, close, togglePin]);
|
||||||
|
|
||||||
|
return { isOpen, isPinned, item, open, close, togglePin, containerClassName, PreviewPortal };
|
||||||
|
}
|
||||||
@ -1,75 +1,95 @@
|
|||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@repo/shadcn-ui/components";
|
import { Sheet, SheetContent } from "@repo/shadcn-ui/components";
|
||||||
// hooks/use-pinned-preview-sheet.ts
|
// hooks/use-pinned-preview-sheet.ts
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
type UsePinnedPreviewSheetOptions<T> = {
|
type UsePinnedPreviewSheetOptions<T> = {
|
||||||
/** Persiste el pin en localStorage (clave). Si omites, no persiste. */
|
persistKey?: string; // clave localStorage para “pin”
|
||||||
persistKey?: string;
|
widthClass?: string; // ancho del panel: p. ej. "w-[500px]"
|
||||||
/** Anchura del panel anclado (Tailwind). */
|
onOpenChange?: (open: boolean) => void;
|
||||||
pinnedWidthClass?: string; // p.ej. "w-[420px]"
|
title?: string | ((item: T | null) => string); // Título del Sheet (no anclado)
|
||||||
/** Título del Sheet (no anclado). */
|
|
||||||
title?: string | ((item: T | null) => string);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PinnedPreviewSheetAPI<T> = {
|
export type PinnedPreviewSheet<T> = {
|
||||||
/** Estado */
|
/** Estado */
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
item: T | null;
|
item: T | null;
|
||||||
|
|
||||||
/** Acciones */
|
|
||||||
open: (item: T) => void;
|
open: (item: T) => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
togglePin: () => void;
|
togglePin: () => void;
|
||||||
setItem: (item: T | null) => void;
|
|
||||||
|
|
||||||
/** Renderizado: coloca este nodo cerca del listado (al final de la página/feature) */
|
/** Añade margen al contenedor de la lista cuando está anclado */
|
||||||
PreviewContainer: React.FC<{
|
listRightMarginClass: string;
|
||||||
/** Render del cuerpo del preview */
|
|
||||||
children: (item: T) => React.ReactNode;
|
/** Renderiza el panel (Sheet o aside) */
|
||||||
/** Cabecera opcional (si quieres sustituir el SheetHeader) */
|
Preview: React.FC<{
|
||||||
header?: React.ReactNode;
|
children: (ctx: {
|
||||||
|
item: T;
|
||||||
|
isPinned: boolean;
|
||||||
|
close: () => void;
|
||||||
|
togglePin: () => void;
|
||||||
|
}) => React.ReactNode
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePinnedPreviewSheet<T = unknown>(
|
export function usePinnedPreviewSheet<T = unknown>({
|
||||||
opts?: UsePinnedPreviewSheetOptions<T>
|
persistKey = "preview-pin",
|
||||||
): PinnedPreviewSheetAPI<T> {
|
widthClass = "w-[500px]",
|
||||||
const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {};
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
}: UsePinnedPreviewSheetOptions<T> = {}): PinnedPreviewSheet<T> {
|
||||||
const [isOpen, setOpen] = React.useState(false);
|
const [isOpen, setOpen] = React.useState(false);
|
||||||
const [item, setItem] = React.useState<T | null>(null);
|
const [item, setItem] = React.useState<T | null>(null);
|
||||||
const [isPinned, setPinned] = React.useState<boolean>(() => {
|
const [isPinned, setPinned] = React.useState<boolean>(() => {
|
||||||
if (!persistKey) return false;
|
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
|
||||||
try {
|
|
||||||
return localStorage.getItem(persistKey) === "1";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// recordar/restaurar foco para accesibilidad
|
||||||
|
const lastFocused = React.useRef<HTMLElement | null>(null);
|
||||||
|
const rememberFocus = () => { lastFocused.current = document.activeElement as HTMLElement | null; };
|
||||||
|
const restoreFocus = () => { lastFocused.current?.focus?.(); };
|
||||||
|
|
||||||
const open = React.useCallback((next: T) => {
|
const open = React.useCallback((next: T) => {
|
||||||
|
rememberFocus();
|
||||||
setItem(next);
|
setItem(next);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, []);
|
onOpenChange?.(true);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
const close = React.useCallback(() => {
|
const close = React.useCallback(() => {
|
||||||
if (isPinned) return; // Anclado: no cerrar
|
if (isPinned) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [isPinned]);
|
onOpenChange?.(false);
|
||||||
|
setTimeout(restoreFocus, 0);
|
||||||
|
}, [isPinned, onOpenChange]);
|
||||||
|
|
||||||
const togglePin = React.useCallback(() => {
|
const togglePin = React.useCallback(() => {
|
||||||
setPinned((p) => {
|
setPinned((p) => {
|
||||||
const next = !p;
|
const n = !p;
|
||||||
if (persistKey) {
|
try { localStorage.setItem(persistKey, n ? "1" : "0"); } catch { }
|
||||||
try {
|
return n;
|
||||||
localStorage.setItem(persistKey, next ? "1" : "0");
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
// Si se fija el pin y hay item, abre “estático”; si se desancla, mostramos Sheet si había abierto
|
|
||||||
if (!next && item) setOpen(true);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}, [persistKey, item]);
|
}, [persistKey]);
|
||||||
|
|
||||||
|
// Bloquea scroll solo en modo Sheet
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen && !isPinned) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
}, [isOpen, isPinned]);
|
||||||
|
|
||||||
|
// Cerrar con ESC solo en modo Sheet
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen || isPinned) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [isOpen, isPinned, close]);
|
||||||
|
|
||||||
|
const listRightMarginClass = isPinned ? "mr-[500px]" : ""; // ajusta si cambias widthClass
|
||||||
|
|
||||||
const HeaderTitle = React.useMemo(() => {
|
const HeaderTitle = React.useMemo(() => {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
@ -77,64 +97,32 @@ export function usePinnedPreviewSheet<T = unknown>(
|
|||||||
return title ?? "";
|
return title ?? "";
|
||||||
}, [item, title]);
|
}, [item, title]);
|
||||||
|
|
||||||
const PreviewContainer: PinnedPreviewSheetAPI<T>["PreviewContainer"] = React.useCallback(
|
const Preview: PinnedPreviewSheet<T>["Preview"] = React.useCallback(({ children }) => {
|
||||||
({ children, header }) => {
|
if (!item) return null;
|
||||||
if (!item) return null;
|
|
||||||
|
|
||||||
// Modo anclado: aside fijo sin overlay, no bloquea scroll, accesible.
|
// Panel anclado: aside estático sin overlay
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
return (
|
return createPortal(
|
||||||
<aside
|
<aside
|
||||||
aria-label="Vista previa anclada"
|
aria-label="Vista previa anclada"
|
||||||
className={`fixed inset-y-0 right-0 ${pinnedWidthClass} bg-background border-l z-30 flex flex-col`}
|
className={`fixed inset-y-0 right-0 ${widthClass} bg-background border-l z-40`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3 border-b">
|
{children({ item, isPinned: true, close, togglePin })}
|
||||||
{header ?? (
|
</aside>,
|
||||||
<div className="flex items-center gap-2">
|
document.body
|
||||||
<span className="text-sm font-medium">{HeaderTitle}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">(anclado)</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={togglePin}
|
|
||||||
className="text-xs underline"
|
|
||||||
aria-label="Desanclar panel"
|
|
||||||
>
|
|
||||||
Desanclar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-3">{children(item)}</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modo Sheet (deslizable desde la derecha)
|
|
||||||
return (
|
|
||||||
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
|
|
||||||
<SheetContent side="right" className={pinnedWidthClass}>
|
|
||||||
{header ?? (
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle className="text-base">{HeaderTitle}</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
)}
|
|
||||||
<div className="mt-3">{children(item)}</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={togglePin}
|
|
||||||
className="text-xs underline"
|
|
||||||
aria-label="Anclar panel"
|
|
||||||
>
|
|
||||||
Anclar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
[HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer };
|
// Panel no anclado: Sheet de shadcn/ui (con overlay y accesibilidad)
|
||||||
}
|
return createPortal(
|
||||||
|
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
|
||||||
|
<SheetContent side="right" className={`${widthClass} p-0`}>
|
||||||
|
{children({ item, isPinned: false, close, togglePin })}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}, [item, isPinned, isOpen, widthClass, close, togglePin]);
|
||||||
|
|
||||||
|
return { isOpen, isPinned, item, open, close, togglePin, listRightMarginClass, Preview };
|
||||||
|
}
|
||||||
@ -1,349 +0,0 @@
|
|||||||
import { Badge, Button, Card, CardContent, Separator } from "@repo/shadcn-ui/components";
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
Copy,
|
|
||||||
CreditCard,
|
|
||||||
Download,
|
|
||||||
Edit,
|
|
||||||
FileText,
|
|
||||||
Hash,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Pin,
|
|
||||||
Receipt,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { InvoiceSummaryFormData } from '../../schemas';
|
|
||||||
|
|
||||||
export type InvoicePreviewCardProps = {
|
|
||||||
invoice: InvoiceSummaryFormData
|
|
||||||
isOpen: boolean
|
|
||||||
isPinned: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onTogglePin: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const InvoicePreviewCard = ({
|
|
||||||
invoice,
|
|
||||||
isOpen,
|
|
||||||
isPinned,
|
|
||||||
onClose,
|
|
||||||
onTogglePin
|
|
||||||
}: InvoicePreviewCardProps) => {
|
|
||||||
|
|
||||||
return <>
|
|
||||||
{/* Overlay - only show when not pinned */}
|
|
||||||
{isOpen && !isPinned && (
|
|
||||||
<div className='fixed inset-0 bg-black/20 z-40 transition-opacity' onClick={onClose} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sheet */}
|
|
||||||
<div
|
|
||||||
className={`fixed top-0 right-0 h-full bg-white shadow-2xl z-50 transition-transform duration-300 ease-in-out ${isOpen ? "translate-x-0" : "translate-x-full"
|
|
||||||
} ${isPinned ? "w-[500px]" : "w-[600px]"}`}
|
|
||||||
>
|
|
||||||
{/* Header with gradient */}
|
|
||||||
<div className='bg-gradient-to-r from-blue-600 to-violet-600 p-6 text-white'>
|
|
||||||
<div className='flex items-start justify-between mb-4'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-2xl font-bold mb-1'>
|
|
||||||
{invoice.is_proforma ? "Proforma" : "Factura"} {invoice.invoice_number}
|
|
||||||
</h2>
|
|
||||||
<p className='text-blue-100 text-sm'>
|
|
||||||
Serie: {invoice.series} • Ref: {invoice.reference}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Button
|
|
||||||
size='icon'
|
|
||||||
variant='ghost'
|
|
||||||
onClick={onTogglePin}
|
|
||||||
className={`text-white hover:bg-white/20 ${isPinned ? "bg-white/30" : ""}`}
|
|
||||||
title={isPinned ? "Desanclar" : "Anclar"}
|
|
||||||
>
|
|
||||||
<Pin className={`h-4 w-4 ${isPinned ? "fill-current" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size='icon'
|
|
||||||
variant='ghost'
|
|
||||||
onClick={onClose}
|
|
||||||
className='text-white hover:bg-white/20'
|
|
||||||
>
|
|
||||||
<X className='h-4 w-4' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
<Badge variant='outline' className='bg-white/20 text-white border-white/30'>
|
|
||||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className='p-6 overflow-y-auto h-[calc(100%-180px)]'>
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='flex items-center gap-2 mb-3'>
|
|
||||||
<User className='h-5 w-5 text-blue-600' />
|
|
||||||
<h3 className='font-semibold text-gray-900'>Cliente</h3>
|
|
||||||
</div>
|
|
||||||
<div className='bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg space-y-2'>
|
|
||||||
<p className='text-gray-700 font-semibold text-lg'>{invoice.recipient.name}</p>
|
|
||||||
<div className='flex items-start gap-2 text-sm'>
|
|
||||||
<Hash className='h-4 w-4 text-gray-500 mt-0.5' />
|
|
||||||
<div>
|
|
||||||
<span className='text-gray-500'>TIN:</span>
|
|
||||||
<span className='ml-2 text-gray-700 font-medium'>{invoice.recipient.tin}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-start gap-2 text-sm'>
|
|
||||||
<MapPin className='h-4 w-4 text-gray-500 mt-0.5' />
|
|
||||||
<div className='text-gray-600'>
|
|
||||||
<div>{invoice.recipient.street}</div>
|
|
||||||
{invoice.recipient.street2 && <div>{invoice.recipient.street2}</div>}
|
|
||||||
<div>
|
|
||||||
{invoice.recipient.postal_code} {invoice.recipient.city}, {invoice.recipient.province}
|
|
||||||
</div>
|
|
||||||
<div>{invoice.recipient.country}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className='my-6' />
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='flex items-center gap-2 mb-3'>
|
|
||||||
<Calendar className='h-5 w-5 text-blue-600' />
|
|
||||||
<h3 className='font-semibold text-gray-900'>Fechas</h3>
|
|
||||||
</div>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<div className='bg-blue-50 p-3 rounded-lg'>
|
|
||||||
<span className='text-xs font-medium text-gray-600 block mb-1'>Fecha factura</span>
|
|
||||||
<p className='text-gray-900 font-semibold'>{invoice.invoice_date}</p>
|
|
||||||
</div>
|
|
||||||
<div className='bg-violet-50 p-3 rounded-lg'>
|
|
||||||
<span className='text-xs font-medium text-gray-600 block mb-1'>
|
|
||||||
Fecha operación
|
|
||||||
</span>
|
|
||||||
<p className='text-gray-900 font-semibold'>{invoice.operation_date}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className='my-6' />
|
|
||||||
|
|
||||||
{invoice.description && (
|
|
||||||
<>
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='flex items-center gap-2 mb-3'>
|
|
||||||
<FileText className='h-5 w-5 text-blue-600' />
|
|
||||||
<h3 className='font-semibold text-gray-900'>Descripción</h3>
|
|
||||||
</div>
|
|
||||||
<p className='text-gray-600 text-sm bg-gray-50 p-3 rounded-lg'>
|
|
||||||
{invoice.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator className='my-6' />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invoice.items && invoice.items.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='flex items-center gap-2 mb-3'>
|
|
||||||
<Receipt className='h-5 w-5 text-blue-600' />
|
|
||||||
<h3 className='font-semibold text-gray-900'>Conceptos</h3>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{invoice.items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className='bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg'
|
|
||||||
>
|
|
||||||
<div className='flex justify-between items-start mb-2'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<span className='font-semibold text-gray-900 block'>{item.concepto}</span>
|
|
||||||
<span className='text-sm text-gray-600'>{item.descripcion}</span>
|
|
||||||
</div>
|
|
||||||
<span className='font-bold text-gray-900 ml-4'>
|
|
||||||
{formatCurrency(item.total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center justify-between text-sm text-gray-600 mt-2'>
|
|
||||||
<div>
|
|
||||||
{item.cantidad} × {formatCurrency(item.precio)}
|
|
||||||
</div>
|
|
||||||
{item.descuento > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant='outline'
|
|
||||||
className='bg-amber-100 text-amber-700 border-amber-300 text-xs'
|
|
||||||
>
|
|
||||||
-{item.descuento}% dto.
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{item.impuestos && item.impuestos.length > 0 && (
|
|
||||||
<div className='flex gap-1 mt-2'>
|
|
||||||
{item.impuestos.map((tax, taxIndex) => (
|
|
||||||
<Badge
|
|
||||||
key={taxIndex}
|
|
||||||
variant='outline'
|
|
||||||
className='bg-blue-100 text-blue-700 border-blue-300 text-xs'
|
|
||||||
>
|
|
||||||
{tax}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator className='my-6' />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='mb-6'>
|
|
||||||
<div className='flex items-center gap-2 mb-3'>
|
|
||||||
<CreditCard className='h-5 w-5 text-blue-600' />
|
|
||||||
<h3 className='font-semibold text-gray-900'>Resumen Financiero</h3>
|
|
||||||
</div>
|
|
||||||
<div className='bg-gradient-to-br from-blue-50 to-violet-50 p-4 rounded-lg space-y-3'>
|
|
||||||
<div className='flex justify-between text-sm'>
|
|
||||||
<span className='text-gray-600'>Subtotal</span>
|
|
||||||
<span className='text-gray-900 font-medium'>
|
|
||||||
{invoice.subtotal_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invoice.discount_amount > 0 && (
|
|
||||||
<>
|
|
||||||
<div className='flex justify-between text-sm'>
|
|
||||||
<span className='text-gray-600'>
|
|
||||||
Descuento ({invoice.discount_percentage}%)
|
|
||||||
</span>
|
|
||||||
<span className='text-red-600 font-medium'>
|
|
||||||
-{invoice.discount_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex justify-between text-sm'>
|
|
||||||
<span className='text-gray-600'>Base imponible</span>
|
|
||||||
<span className='text-gray-900 font-medium'>
|
|
||||||
{invoice.taxable_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/*invoice.taxes && invoice.taxes.length > 0 && (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{invoice.taxes.map((tax, index) => (
|
|
||||||
<div key={index} className='flex justify-between text-sm'>
|
|
||||||
<span className='text-gray-600'>
|
|
||||||
{tax.name} {tax.rate}%
|
|
||||||
</span>
|
|
||||||
<span className='text-gray-900 font-medium'>
|
|
||||||
{invoice.taxable_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)*/}
|
|
||||||
|
|
||||||
<div className='flex justify-between text-sm'>
|
|
||||||
<span className='text-gray-600'>Total impuestos</span>
|
|
||||||
<span className='text-gray-900 font-medium'>
|
|
||||||
{invoice.taxes_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className='flex justify-between items-center pt-2'>
|
|
||||||
<span className='font-bold text-gray-900 text-lg'>Total</span>
|
|
||||||
<span className='text-3xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent'>
|
|
||||||
{invoice.total_amount_fmt}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions Footer */}
|
|
||||||
<div className='absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-r from-blue-50 to-violet-50 border-t border-gray-200'>
|
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent'
|
|
||||||
>
|
|
||||||
<Edit className='mr-2 h-4 w-4' />
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent'
|
|
||||||
>
|
|
||||||
<Copy className='mr-2 h-4 w-4' />
|
|
||||||
Duplicar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent'
|
|
||||||
>
|
|
||||||
<Download className='mr-2 h-4 w-4' />
|
|
||||||
Descargar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent'
|
|
||||||
>
|
|
||||||
<Mail className='mr-2 h-4 w-4' />
|
|
||||||
Enviar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='w-full mt-3 border-red-200 text-red-600 hover:bg-red-50 bg-transparent'
|
|
||||||
>
|
|
||||||
<Trash2 className='mr-2 h-4 w-4' />
|
|
||||||
Eliminar factura
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="shadow-none border-0">
|
|
||||||
<CardContent className="p-0 space-y-2 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Nº</span>
|
|
||||||
<span className="font-medium tabular-nums">{invoice.invoice_number}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Cliente</span>
|
|
||||||
<span className="font-medium truncate max-w-[220px]" title={invoice.recipient?.name}>
|
|
||||||
{invoice.recipient?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Fecha</span>
|
|
||||||
<span className="tabular-nums">{invoice.invoice_date}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Importe</span>
|
|
||||||
<span className="font-semibold tabular-nums">{invoice.total_amount?.formatted}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground">Estado</span>
|
|
||||||
<span className="font-medium">{invoice.status}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
import { Badge, Button, Separator } from "@repo/shadcn-ui/components";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Download,
|
||||||
|
Edit,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Pin,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
|
import { InvoiceSummaryFormData } from '../../schemas';
|
||||||
|
|
||||||
|
|
||||||
|
export type InvoicePreviewPanelProps = {
|
||||||
|
invoice: InvoiceSummaryFormData;
|
||||||
|
isPinned: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoicePreviewPanel({
|
||||||
|
invoice,
|
||||||
|
isPinned,
|
||||||
|
onClose,
|
||||||
|
onTogglePin,
|
||||||
|
}: InvoicePreviewPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-violet-600 p-6 text-white">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-1">
|
||||||
|
{invoice.is_proforma ? "Proforma" : "Factura"} {invoice.invoice_number}
|
||||||
|
</h2>
|
||||||
|
<p className="text-blue-100 text-sm">
|
||||||
|
Serie: {invoice.series} • Ref: {invoice.reference}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onTogglePin}
|
||||||
|
className={`text-white hover:bg-white/20 ${isPinned ? "bg-white/30" : ""}`}
|
||||||
|
title={isPinned ? "Desanclar" : "Anclar"}
|
||||||
|
aria-label={isPinned ? "Desanclar panel" : "Anclar panel"}
|
||||||
|
>
|
||||||
|
<Pin className={`h-4 w-4 ${isPinned ? "fill-current" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
{!isPinned && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
aria-label="Cerrar vista previa"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="bg-white/20 text-white border-white/30">
|
||||||
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<User className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="font-semibold text-gray-900">Cliente</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg space-y-2">
|
||||||
|
<p className="text-gray-700 font-semibold text-lg">{invoice.recipient.name}</p>
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<Hash className="h-4 w-4 text-gray-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">TIN:</span>
|
||||||
|
<span className="ml-2 text-gray-700 font-medium">{invoice.recipient.tin}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<MapPin className="h-4 w-4 text-gray-500 mt-0.5" />
|
||||||
|
<div className="text-gray-600">
|
||||||
|
<div>{invoice.recipient.street}</div>
|
||||||
|
{invoice.recipient.street2 && <div>{invoice.recipient.street2}</div>}
|
||||||
|
<div>
|
||||||
|
{invoice.recipient.postal_code} {invoice.recipient.city}, {invoice.recipient.province}
|
||||||
|
</div>
|
||||||
|
<div>{invoice.recipient.country}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Calendar className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="font-semibold text-gray-900">Fechas</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-blue-50 p-3 rounded-lg">
|
||||||
|
<span className="text-xs font-medium text-gray-600 block mb-1">Fecha factura</span>
|
||||||
|
<p className="text-gray-900 font-semibold">{invoice.invoice_date}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-violet-50 p-3 rounded-lg">
|
||||||
|
<span className="text-xs font-medium text-gray-600 block mb-1">Fecha operación</span>
|
||||||
|
<p className="text-gray-900 font-semibold">{invoice.operation_date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-6" />
|
||||||
|
|
||||||
|
{!!invoice.description && (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileText className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="font-semibold text-gray-900">Descripción</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm bg-gray-50 p-3 rounded-lg">{invoice.description}</p>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600" />
|
||||||
|
<h3 className="font-semibold text-gray-900">Resumen Financiero</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-blue-50 to-violet-50 p-4 rounded-lg space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Subtotal</span>
|
||||||
|
<span className="text-gray-900 font-medium">{invoice.subtotal_amount_fmt}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invoice.discount_amount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Descuento ({invoice.discount_percentage}%)</span>
|
||||||
|
<span className="text-red-600 font-medium">-{invoice.discount_amount_fmt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Base imponible</span>
|
||||||
|
<span className="text-gray-900 font-medium">{invoice.taxable_amount_fmt}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Total impuestos</span>
|
||||||
|
<span className="text-gray-900 font-medium">{invoice.taxes_amoun_fmt}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-2">
|
||||||
|
<span className="font-bold text-gray-900 text-lg">Total</span>
|
||||||
|
<span className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent">
|
||||||
|
{invoice.total_amount_fmt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer acciones */}
|
||||||
|
<div className="p-6 bg-gradient-to-r from-blue-50 to-violet-50 border-t border-gray-200">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> Editar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent">
|
||||||
|
<Copy className="mr-2 h-4 w-4" /> Duplicar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Descargar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent">
|
||||||
|
<Mail className="mr-2 h-4 w-4" /> Enviar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full mt-3 border-red-200 text-red-600 hover:bg-red-50 bg-transparent">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> Eliminar factura
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@ import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components';
|
|||||||
import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components';
|
import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components';
|
||||||
import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
|
import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { usePinnedPreviewSheet } from '../../hooks';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
|
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
|
||||||
|
import { InvoicePreviewPanel } from './invoice-preview-panel';
|
||||||
import { useInvoicesListColumns } from './use-invoices-list-columns';
|
import { useInvoicesListColumns } from './use-invoices-list-columns';
|
||||||
|
|
||||||
export type InvoiceUpdateCompProps = {
|
export type InvoiceUpdateCompProps = {
|
||||||
@ -40,9 +42,21 @@ export const InvoicesListGrid = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Hook con Sheet de shadcn
|
||||||
|
const preview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
|
||||||
|
persistKey: "invoice-preview-pin",
|
||||||
|
widthClass: "w-[500px]",
|
||||||
|
});
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState("todas");
|
const [statusFilter, setStatusFilter] = useState("todas");
|
||||||
|
|
||||||
const columns = useInvoicesListColumns();
|
const columns = useInvoicesListColumns({
|
||||||
|
onEdit: (invoice) => navigate(`/customer-invoices/${invoice.id}/edit`),
|
||||||
|
onDuplicate: (invoice) => null, //duplicateInvoice(inv.id),
|
||||||
|
onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id),
|
||||||
|
onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id),
|
||||||
|
onDelete: (invoice) => null, //confirmDelete(inv.id),
|
||||||
|
});
|
||||||
const { items, total_items } = invoicesPage;
|
const { items, total_items } = invoicesPage;
|
||||||
|
|
||||||
// Navegación accesible (click o teclado)
|
// Navegación accesible (click o teclado)
|
||||||
@ -97,12 +111,12 @@ export const InvoicesListGrid = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => {
|
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
|
||||||
const url = `/customer-invoices/${invoice.id}/edit`;
|
const url = `/customer-invoices/${invoice.id}/edit`;
|
||||||
if (e.metaKey || e.ctrlKey) window.open(url, "_blank", "noopener,noreferrer");
|
if (e.metaKey || e.ctrlKey) { window.open(url, "_blank", "noopener,noreferrer"); return; }
|
||||||
else navigate(url);
|
preview.open(invoice);
|
||||||
},
|
},
|
||||||
[navigate]
|
[preview]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -123,9 +137,7 @@ export const InvoicesListGrid = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Barra de filtros */}
|
{/* Barra de filtros */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="relative flex-1" role="search" aria-label={t("pages.list.searchPlaceholder")}>
|
<div className="relative flex-1" aria-label={t("pages.list.searchPlaceholder")}>
|
||||||
|
|
||||||
|
|
||||||
<InputGroup className='bg-background' data-disabled={loading}>
|
<InputGroup className='bg-background' data-disabled={loading}>
|
||||||
<InputGroupInput
|
<InputGroupInput
|
||||||
placeholder={t("common.search_placeholder")}
|
placeholder={t("common.search_placeholder")}
|
||||||
@ -170,23 +182,34 @@ export const InvoicesListGrid = ({
|
|||||||
Exportar
|
Exportar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="relative flex">
|
||||||
|
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
readOnly
|
||||||
|
enableRowSelection
|
||||||
|
enablePagination
|
||||||
|
manualPagination
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalItems={total_items}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<preview.Preview>
|
||||||
columns={columns}
|
{({ item, isPinned, close, togglePin }) => (
|
||||||
data={items}
|
<InvoicePreviewPanel
|
||||||
readOnly
|
invoice={item}
|
||||||
enableRowSelection
|
isPinned={isPinned}
|
||||||
enablePagination
|
onClose={close}
|
||||||
|
onTogglePin={togglePin}
|
||||||
manualPagination
|
/>
|
||||||
pageIndex={pageIndex} // DataTable usa 0-based
|
)}
|
||||||
pageSize={pageSize}
|
</preview.Preview>
|
||||||
totalItems={total_items}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onPageSizeChange={onPageSizeChange}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
import { ErrorAlert } from '@erp/customers/components';
|
import { ErrorAlert } from '@erp/customers/components';
|
||||||
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { FilePenIcon, PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { InvoicesListGrid, PageHeader } from '../../components';
|
import { InvoicesListGrid, PageHeader } from '../../components';
|
||||||
import { useInvoicesQuery, usePinnedPreviewSheet } from '../../hooks';
|
import { useInvoicesQuery } from '../../hooks';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { InvoiceSummaryFormData } from '../../schemas';
|
|
||||||
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
|
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
|
||||||
import { InvoicePreviewCard } from './invoice-preview-card';
|
|
||||||
|
|
||||||
export const InvoiceListPage = () => {
|
export const InvoiceListPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -48,12 +46,6 @@ export const InvoiceListPage = () => {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
|
||||||
const invoicePreview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
|
|
||||||
persistKey: "invoice-preview-pin",
|
|
||||||
pinnedWidthClass: "w-[440px]",
|
|
||||||
title: (inv) => (inv ? `Factura ${inv.invoice_number}` : "Proforma"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePageChange = (newPageIndex: number) => {
|
const handlePageChange = (newPageIndex: number) => {
|
||||||
// TanStack usa pageIndex 0-based → API usa 0-based también
|
// TanStack usa pageIndex 0-based → API usa 0-based también
|
||||||
setPageIndex(newPageIndex);
|
setPageIndex(newPageIndex);
|
||||||
@ -71,18 +63,6 @@ export const InvoiceListPage = () => {
|
|||||||
setPageIndex(0);
|
setPageIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
|
||||||
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent<HTMLTableRowElement>) => {
|
|
||||||
const url = `/customer-invoices/${invoice.id}/edit`;
|
|
||||||
if (e.metaKey || e.ctrlKey) {
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
invoicePreview.open(invoice); // <-- abre o actualiza el preview
|
|
||||||
},
|
|
||||||
[invoicePreview]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isError || !invoicesPageData) {
|
if (isError || !invoicesPageData) {
|
||||||
return (
|
return (
|
||||||
<AppContent>
|
<AppContent>
|
||||||
@ -101,7 +81,6 @@ export const InvoiceListPage = () => {
|
|||||||
<AppBreadcrumb />
|
<AppBreadcrumb />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("pages.list.title")}
|
title={t("pages.list.title")}
|
||||||
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
|
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<></>}
|
<></>}
|
||||||
|
|
||||||
@ -117,8 +96,9 @@ export const InvoiceListPage = () => {
|
|||||||
<div className='flex items-center space-x-2'>
|
<div className='flex items-center space-x-2'>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/customer-invoices/create")}
|
onClick={() => navigate("/customer-invoices/create")}
|
||||||
className="bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-700 hover:to-violet-700 text-white shadow-lg shadow-blue-500/30"
|
variant={'default'}
|
||||||
aria-label={t("pages.create.title")}
|
aria-label={t("pages.create.title")}
|
||||||
|
className='cursor-pointer'
|
||||||
>
|
>
|
||||||
<PlusIcon className="mr-2 h-4 w-4" aria-hidden />
|
<PlusIcon className="mr-2 h-4 w-4" aria-hidden />
|
||||||
{t("pages.create.title")}
|
{t("pages.create.title")}
|
||||||
@ -126,7 +106,7 @@ export const InvoiceListPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col w-full h-full py-3'>
|
<div className='flex flex-col w-full h-full py-3'>
|
||||||
<div className={invoicePreview.isPinned ? "flex-1 mr-[440px]" : "flex-1"}>
|
<div className={"flex-1"}>
|
||||||
<InvoicesListGrid
|
<InvoicesListGrid
|
||||||
invoicesPage={invoicesPageData}
|
invoicesPage={invoicesPageData}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@ -136,20 +116,8 @@ export const InvoiceListPage = () => {
|
|||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onRowClick={handleRowClick}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Contenedor del preview (Sheet o aside anclado) */}
|
|
||||||
<invoicePreview.PreviewContainer>
|
|
||||||
{(invoice) => <InvoicePreviewCard invoice={invoice}
|
|
||||||
isOpen={invoicePreview.isOpen}
|
|
||||||
isPinned={invoicePreview.isPinned}
|
|
||||||
onClose={invoicePreview.close}
|
|
||||||
onTogglePin={invoicePreview.togglePin}
|
|
||||||
/>}
|
|
||||||
</invoicePreview.PreviewContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,120 +1,233 @@
|
|||||||
import { formatDate } from '@erp/core/client';
|
import { formatDate } from '@erp/core/client';
|
||||||
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
|
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
|
||||||
|
import {
|
||||||
|
Button, DropdownMenu, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
Tooltip, TooltipContent, TooltipTrigger
|
||||||
|
} from '@repo/shadcn-ui/components';
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { CopyIcon, DownloadIcon, EditIcon, MailIcon, MoreVerticalIcon, Trash2Icon } from 'lucide-react';
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { CustomerInvoiceStatusBadge } from '../../components';
|
import { CustomerInvoiceStatusBadge } from '../../components';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { InvoiceSummaryFormData } from '../../schemas/invoice-resume.form.schema';
|
import { InvoiceSummaryFormData } from '../../schemas';
|
||||||
|
|
||||||
|
type InvoiceActionHandlers = {
|
||||||
|
onEdit?: (invoice: InvoiceSummaryFormData) => void;
|
||||||
|
onDuplicate?: (invoice: InvoiceSummaryFormData) => void;
|
||||||
|
onDownloadPdf?: (invoice: InvoiceSummaryFormData) => void;
|
||||||
|
onSendEmail?: (invoice: InvoiceSummaryFormData) => void;
|
||||||
|
onDelete?: (invoice: InvoiceSummaryFormData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useInvoicesListColumns(
|
||||||
export function useInvoicesListColumns(): ColumnDef<InvoiceSummaryFormData>[] {
|
handlers: InvoiceActionHandlers = {}
|
||||||
//const { t, readOnly, currency_code, language_code } = useInvoiceContext();
|
): ColumnDef<InvoiceSummaryFormData>[] {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete,
|
||||||
|
} = handlers;
|
||||||
|
|
||||||
// Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla
|
|
||||||
return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(() => [
|
return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(() => [
|
||||||
|
// Nº
|
||||||
{
|
{
|
||||||
accessorKey: "invoice_number",
|
accessorKey: "invoice_number",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_number")} className='text-left' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_number")} className="text-left" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-semibold text-left text-primary'>
|
<div className="font-semibold text-left text-primary">
|
||||||
{row.getValue('invoice_number')}
|
{row.getValue("invoice_number")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 160,
|
||||||
}, {
|
minSize: 120,
|
||||||
|
},
|
||||||
|
// Estado
|
||||||
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className='text-left' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className="text-left" />
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<CustomerInvoiceStatusBadge status={row.getValue('status')} />
|
|
||||||
),
|
),
|
||||||
|
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.getValue("status")} />,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 140,
|
||||||
}, {
|
minSize: 120,
|
||||||
|
},
|
||||||
|
// Serie
|
||||||
|
{
|
||||||
accessorKey: "series",
|
accessorKey: "series",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className='text-left' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className="text-left" />
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className='font-medium text-left'>
|
|
||||||
{row.getValue('series')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
),
|
),
|
||||||
|
cell: ({ row }) => <div className="font-medium text-left">{row.getValue("series")}</div>,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 120,
|
||||||
}, {
|
minSize: 100,
|
||||||
|
},
|
||||||
|
// Fecha factura
|
||||||
|
{
|
||||||
accessorKey: "invoice_date",
|
accessorKey: "invoice_date",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_date")} className='text-left tabular-nums' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_date")} className="text-left tabular-nums" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-medium text-left tabular-nums'>
|
<div className="font-medium text-left tabular-nums">
|
||||||
{formatDate(row.getValue('invoice_date'))}
|
{formatDate(row.getValue("invoice_date"))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 140,
|
||||||
}, {
|
minSize: 120,
|
||||||
|
},
|
||||||
|
// Fecha operación
|
||||||
|
{
|
||||||
accessorKey: "operation_date",
|
accessorKey: "operation_date",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.operation_date")} className='text-left tabular-nums' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.operation_date")} className="text-left tabular-nums" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-medium text-left tabular-nums'>
|
<div className="font-medium text-left tabular-nums">
|
||||||
{formatDate(row.getValue('operation_date'))}
|
{formatDate(row.getValue("operation_date"))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 140,
|
||||||
}, {
|
minSize: 120,
|
||||||
|
},
|
||||||
|
// TIN
|
||||||
|
{
|
||||||
id: "recipient_tin",
|
id: "recipient_tin",
|
||||||
accessorKey: "recipient.tin",
|
accessorKey: "recipient.tin",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_tin")} className='text-left tabular-nums' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_tin")} className="text-left tabular-nums" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-medium text-left tabular-nums'>
|
<div className="font-medium text-left tabular-nums">{row.getValue("recipient_tin")}</div>
|
||||||
{row.getValue('recipient_tin')}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 160,
|
||||||
}, {
|
minSize: 140,
|
||||||
|
},
|
||||||
|
// Cliente
|
||||||
|
{
|
||||||
accessorKey: "recipient.name",
|
accessorKey: "recipient.name",
|
||||||
id: "recipient_name",
|
id: "recipient_name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_name")} className='text-left tabular-nums' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_name")} className="text-left" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-semibold text-left tabular-nums'>
|
<div className="font-semibold text-left truncate" title={row.getValue("recipient_name")}>
|
||||||
{row.getValue('recipient_name')}
|
{row.getValue("recipient_name")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 260,
|
||||||
}, {
|
minSize: 200,
|
||||||
|
},
|
||||||
|
// Total
|
||||||
|
{
|
||||||
accessorKey: "total_amount_fmt",
|
accessorKey: "total_amount_fmt",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.total_amount")} className='text-right tabular-nums' />
|
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.total_amount")} className="text-right tabular-nums" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='font-semibold text-right tabular-nums'>
|
<div className="font-semibold text-right tabular-nums">{row.getValue("total_amount_fmt")}</div>
|
||||||
{row.getValue('total_amount_fmt')}
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 32,
|
size: 140,
|
||||||
}
|
minSize: 120,
|
||||||
|
},
|
||||||
|
|
||||||
], [t]);
|
// ─────────────────────────────
|
||||||
|
// Acciones
|
||||||
|
// ─────────────────────────────
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">{t("common.actions")}</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
size: 110,
|
||||||
|
minSize: 96,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1 pr-1" onClick={stop} onKeyDown={stop}>
|
||||||
|
{/* Editar (acción primaria) */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className='cursor-pointer text-muted-foreground hover:text-primary'
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit?.(invoice);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("common.edit")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Menú demás acciones */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className='cursor-pointer text-muted-foreground hover:text-primary'
|
||||||
|
aria-label={t("common.more_actions")}
|
||||||
|
onClick={stop}
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDuplicate?.(invoice)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CopyIcon className="mr-2 size-4" />
|
||||||
|
{t("common.duplicate")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDownloadPdf?.(invoice)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="mr-2 size-4" />
|
||||||
|
{t("common.download_pdf")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onSendEmail?.(invoice)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<MailIcon className="mr-2 size-4" />
|
||||||
|
{t("common.send_email")}
|
||||||
|
</DropdownMenuItem> <DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete?.(invoice)}
|
||||||
|
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
FormCommitButtonGroup,
|
|
||||||
UnsavedChangesProvider,
|
UnsavedChangesProvider,
|
||||||
|
UpdateCommitButtonGroup,
|
||||||
useHookForm
|
useHookForm
|
||||||
} from "@erp/core/hooks";
|
} from "@erp/core/hooks";
|
||||||
import { AppBreadcrumb, AppContent, AppHeader } from "@repo/rdx-ui/components";
|
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { FilePenIcon } from "lucide-react";
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FieldErrors, FormProvider } from "react-hook-form";
|
import { FieldErrors, FormProvider } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@ -86,24 +85,25 @@ export const InvoiceUpdateComp = ({
|
|||||||
return (
|
return (
|
||||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
<AppBreadcrumb />
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
|
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
|
||||||
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
|
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<FormCommitButtonGroup
|
<UpdateCommitButtonGroup
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
submit={{ formId: "invoice-update-form", disabled: isPending }}
|
|
||||||
|
submit={{ formId: "invoice-update-form", variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
|
||||||
cancel={{ to: "/customer-invoices/list" }}
|
cancel={{ to: "/customer-invoices/list" }}
|
||||||
onBack={() => navigate(-1)}
|
onBack={() => navigate(-1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
|
|
||||||
<InvoiceEditForm
|
<InvoiceEditForm
|
||||||
formId="invoice-update-form"
|
formId="invoice-update-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
CopyIcon,
|
||||||
|
EyeIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
RotateCcwIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button";
|
||||||
|
import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
|
||||||
|
|
||||||
|
type Align = "start" | "center" | "end" | "between";
|
||||||
|
|
||||||
|
type GroupSubmitButtonProps = Omit<SubmitButtonProps, "isLoading" | "preventDoubleSubmit">;
|
||||||
|
|
||||||
|
export type FormCommitButtonGroupProps = {
|
||||||
|
className?: string;
|
||||||
|
align?: Align; // default "end"
|
||||||
|
gap?: string; // default "gap-2"
|
||||||
|
reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil)
|
||||||
|
|
||||||
|
isLoading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
|
||||||
|
|
||||||
|
cancel?: CancelFormButtonProps & { show?: boolean };
|
||||||
|
submit?: GroupSubmitButtonProps; // props directas a SubmitButton
|
||||||
|
|
||||||
|
onReset?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onPreview?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignToJustify: Record<Align, string> = {
|
||||||
|
start: "justify-start",
|
||||||
|
center: "justify-center",
|
||||||
|
end: "justify-end",
|
||||||
|
between: "justify-between",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormCommitButtonGroup = ({
|
||||||
|
className,
|
||||||
|
align = "end",
|
||||||
|
gap = "gap-2",
|
||||||
|
reverseOrderOnMobile = true,
|
||||||
|
|
||||||
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
|
preventDoubleSubmit = true,
|
||||||
|
|
||||||
|
cancel,
|
||||||
|
submit,
|
||||||
|
|
||||||
|
onReset,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onDuplicate,
|
||||||
|
onBack,
|
||||||
|
}: FormCommitButtonGroupProps) => {
|
||||||
|
const showCancel = cancel?.show ?? true;
|
||||||
|
const hasSecondaryActions = onReset || onPreview || onDuplicate || onBack || onDelete;
|
||||||
|
|
||||||
|
// ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading
|
||||||
|
let rhfIsSubmitting = false;
|
||||||
|
try {
|
||||||
|
const ctx = useFormContext();
|
||||||
|
rhfIsSubmitting = !!ctx?.formState?.isSubmitting;
|
||||||
|
} catch {
|
||||||
|
// No hay provider de RHF; ignorar
|
||||||
|
}
|
||||||
|
const busy = isLoading ?? rhfIsSubmitting;
|
||||||
|
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
|
||||||
|
alignToJustify[align],
|
||||||
|
gap,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submit && <SubmitFormButton {...submit} />}
|
||||||
|
{showCancel && <CancelFormButton {...cancel} />}
|
||||||
|
|
||||||
|
{/* Menú de acciones adicionales */}
|
||||||
|
{hasSecondaryActions && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' size='sm' disabled={computedDisabled} className='px-2'>
|
||||||
|
<MoreHorizontalIcon className='h-4 w-4' />
|
||||||
|
<span className='sr-only'>Más acciones</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end' className='w-48'>
|
||||||
|
{onReset && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={computedDisabled}
|
||||||
|
className='text-muted-foreground'
|
||||||
|
>
|
||||||
|
<RotateCcwIcon className='mr-2 h-4 w-4' />
|
||||||
|
Deshacer cambios
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onPreview && (
|
||||||
|
<DropdownMenuItem onClick={onPreview} className='text-muted-foreground'>
|
||||||
|
<EyeIcon className='mr-2 h-4 w-4' />
|
||||||
|
Vista previa
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onDuplicate && (
|
||||||
|
<DropdownMenuItem onClick={onDuplicate} className='text-muted-foreground'>
|
||||||
|
<CopyIcon className='mr-2 h-4 w-4' />
|
||||||
|
Duplicar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onBack && (
|
||||||
|
<DropdownMenuItem onClick={onBack} className='text-muted-foreground'>
|
||||||
|
<ArrowLeftIcon className='mr-2 h-4 w-4' />
|
||||||
|
Volver
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onDelete}
|
||||||
|
className='text-destructive focus:text-destructive'
|
||||||
|
>
|
||||||
|
<Trash2Icon className='mr-2 h-4 w-4' />
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,14 @@
|
|||||||
import { Button, Separator } from "@repo/shadcn-ui/components";
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
Button, Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemFooter,
|
||||||
|
ItemTitle
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
import {
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
MapPinHouseIcon,
|
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
UserIcon,
|
UserPlusIcon
|
||||||
UserPlusIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CustomerSummary } from "../../schemas";
|
import { CustomerSummary } from "../../schemas";
|
||||||
|
|
||||||
@ -16,7 +19,7 @@ interface CustomerCardProps {
|
|||||||
onChangeCustomer?: () => void;
|
onChangeCustomer?: () => void;
|
||||||
onAddNewCustomer?: () => void;
|
onAddNewCustomer?: () => void;
|
||||||
|
|
||||||
className: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerCard = ({
|
export const CustomerCard = ({
|
||||||
@ -35,83 +38,70 @@ export const CustomerCard = ({
|
|||||||
customer.country;
|
customer.country;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<Item variant="outline" className={className}>
|
||||||
<div className='flex items-start gap-4'>
|
<ItemContent>
|
||||||
{/* Avatar mejorado con gradiente sutil */}
|
<ItemTitle className='flex gap-2 w-full justify-between'>
|
||||||
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'>
|
<span className='grow'>{customer.name}</span>
|
||||||
<UserIcon className='size-6 text-muted-foreground group-hover:text-primary' />
|
<Button
|
||||||
</div>
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
<div className='flex-1 min-w-0 '>
|
size='sm'
|
||||||
{/* Nombre del cliente */}
|
className='cursor-pointer'
|
||||||
<h3 className='font-semibold text-foreground text-lg leading-tight mb-1 text-left text-balance'>
|
onClick={onViewCustomer}
|
||||||
{customer.name}
|
>
|
||||||
</h3>
|
<EyeIcon className='size-4 text-muted-foreground' />
|
||||||
|
<span className='sr-only'>Ver ficha completa</span>
|
||||||
{/* NIF/CIF con icono */}
|
</Button>
|
||||||
{customer.tin && (
|
</ItemTitle>
|
||||||
<div className='flex items-center gap-2 text-sm text-muted-foreground mb-3'>
|
<ItemDescription className='text-sm text-muted-foreground'>
|
||||||
<CreditCard className='h-4 w-4 shrink-0' />
|
{customer.tin && (<span>{customer.tin}</span>)}
|
||||||
<span className='font-medium'>{customer.tin}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separador si hay dirección */}
|
|
||||||
{hasAddress && <Separator className='my-3' />}
|
|
||||||
|
|
||||||
{/* Dirección con mejor estructura */}
|
{/* Dirección con mejor estructura */}
|
||||||
{hasAddress && (
|
{hasAddress && (
|
||||||
<div className='space-y-2'>
|
<div className='x'>
|
||||||
<div className='flex items-start gap-2 text-sm text-muted-foreground'>
|
|
||||||
<MapPinHouseIcon className='h-4 w-4 shrink-0 mt-0.5 text-primary/60' />
|
{customer.street && <div>{customer.street}</div>}
|
||||||
<div className='space-y-0.5 leading-relaxed flex-1 text-left'>
|
{customer.street2 && <div>{customer.street2}</div>}
|
||||||
{customer.street && <div>{customer.street}</div>}
|
<div className='flex flex-wrap gap-x-2'>
|
||||||
{customer.street2 && <div>{customer.street2}</div>}
|
{customer.postal_code && <span>{customer.postal_code}</span>}
|
||||||
<div className='flex flex-wrap gap-x-2'>
|
{customer.city && <span>{customer.city}</span>}
|
||||||
{customer.postal_code && <span>{customer.postal_code}</span>}
|
</div>
|
||||||
{customer.city && <span>{customer.city}</span>}
|
<div className='flex flex-wrap gap-x-2'>
|
||||||
</div>
|
{customer.province && <span>{customer.province}</span>}
|
||||||
<div className='flex flex-wrap gap-x-2'>
|
{customer.country && <span>{customer.country}</span>}
|
||||||
{customer.province && <span>{customer.province}</span>}
|
|
||||||
{customer.country && <span>{customer.country}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className='my-4' />
|
)}
|
||||||
<div className='flex flex-wrap gap-2'>
|
</ItemDescription>
|
||||||
<Button
|
</ItemContent>
|
||||||
variant='outline'
|
<ItemFooter>
|
||||||
size='sm'
|
<div className='flex flex-wrap gap-2'>
|
||||||
onClick={onViewCustomer}
|
<Button
|
||||||
className='flex-1 min-w-[140px] gap-2 bg-transparent'
|
type="button"
|
||||||
>
|
variant='outline'
|
||||||
<EyeIcon className='h-4 w-4' />
|
size='sm'
|
||||||
Ver ficha completa
|
onClick={onChangeCustomer}
|
||||||
</Button>
|
className='flex-1 min-w-36 gap-2 cursor-pointer'
|
||||||
<Button
|
>
|
||||||
variant='outline'
|
<RefreshCwIcon className='size-4' />
|
||||||
size='sm'
|
<span className='text-sm text-muted-foreground'>
|
||||||
onClick={onChangeCustomer}
|
Cambiar de cliente
|
||||||
className='flex-1 min-w-[140px] gap-2 bg-transparent'
|
</span>
|
||||||
>
|
</Button>
|
||||||
<RefreshCwIcon className='h-4 w-4' />
|
<Button
|
||||||
Cambiar de cliente
|
type="button"
|
||||||
</Button>
|
variant='outline'
|
||||||
<Button
|
size='sm'
|
||||||
variant='outline'
|
onClick={onAddNewCustomer}
|
||||||
size='sm'
|
className='flex-1 min-w-36 gap-2 cursor-pointer'
|
||||||
onClick={onAddNewCustomer}
|
>
|
||||||
className='flex-1 min-w-[140px] gap-2 bg-transparent'
|
<UserPlusIcon className='size-4' />
|
||||||
>
|
<span className='text-sm text-muted-foreground'>
|
||||||
<UserPlusIcon className='h-4 w-4' />
|
Nuevo cliente
|
||||||
Nuevo cliente
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ItemFooter>
|
||||||
|
</Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { FormCommitButtonGroup, UnsavedChangesProvider, useHookForm } from "@erp/core/hooks";
|
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { FieldErrors, FormProvider } from "react-hook-form";
|
import { FieldErrors, FormProvider } from "react-hook-form";
|
||||||
import { CustomerEditForm, ErrorAlert } from "../../components";
|
import { CustomerEditForm, ErrorAlert } from "../../components";
|
||||||
@ -74,7 +74,7 @@ export const CustomerCreatePage = () => {
|
|||||||
{t("pages.create.description")}
|
{t("pages.create.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<FormCommitButtonGroup
|
<UpdateCommitButtonGroup
|
||||||
isLoading={isCreating}
|
isLoading={isCreating}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
cancel={{
|
cancel={{
|
||||||
|
|||||||
@ -74,8 +74,10 @@ export function DataTableToolbar<TData>({
|
|||||||
{/* Botón añadir */}
|
{/* Botón añadir */}
|
||||||
{!readOnly && meta?.tableOps?.onAdd && (
|
{!readOnly && meta?.tableOps?.onAdd && (
|
||||||
<Button
|
<Button
|
||||||
|
className='cursor-pointer'
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant={'outline'}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
aria-label={t("components.datatable.actions.add")}
|
aria-label={t("components.datatable.actions.add")}
|
||||||
>
|
>
|
||||||
@ -91,6 +93,7 @@ export function DataTableToolbar<TData>({
|
|||||||
|
|
||||||
{!readOnly && meta?.bulkOps?.duplicateSelected && (
|
{!readOnly && meta?.bulkOps?.duplicateSelected && (
|
||||||
<Button
|
<Button
|
||||||
|
className='cursor-pointer'
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -182,8 +182,7 @@ export function DataTable<TData, TValue>({
|
|||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="overflow-hidden transition-[max-height] duration-300 ease-in-out"
|
className="transition-[max-height] duration-300 ease-in-out"
|
||||||
style={{ maxHeight: `${table.getRowModel().rows.length * 56}px` }} // 56≈altura fila
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<DataTableToolbar table={table} showViewOptions={!readOnly} />
|
<DataTableToolbar table={table} showViewOptions={!readOnly} />
|
||||||
@ -191,7 +190,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<div className="overflow-hidden rounded-md border">
|
<div className="overflow-hidden rounded-md border">
|
||||||
<TableComp className="w-full text-sm">
|
<TableComp className="w-full text-sm">
|
||||||
{/* CABECERA */}
|
{/* CABECERA */}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||||
{table.getHeaderGroups().map((hg) => (
|
{table.getHeaderGroups().map((hg) => (
|
||||||
<TableRow key={hg.id}>
|
<TableRow key={hg.id}>
|
||||||
{hg.headers.map((h) => {
|
{hg.headers.map((h) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user