Facturas de cliente

This commit is contained in:
David Arranz 2025-11-14 16:48:09 +01:00
parent 5bacdcc2fc
commit 8dd8e3e5f4
116 changed files with 2104 additions and 1636 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges-server",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",

View File

@ -1,4 +1,5 @@
import dotenv from "dotenv";
import { asBoolean, asNumber, required } from "./config-helpers";
// Carga de variables de entorno (.env). Si ya están en el entorno, no se sobreescriben.

View File

@ -1,7 +1,7 @@
{
"name": "@erp/factuges-web",
"private": true,
"version": "0.0.12",
"version": "0.0.13",
"type": "module",
"scripts": {
"dev": "vite --host --clearScreen false",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/core",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customer-invoices",
"version": "0.0.12",
"version": "0.0.13",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,2 +1,3 @@
export * from "./get-issue-invoice.use-case";
export * from "./list-issue-invoices.use-case";
export * from "./report-issue-invoice.use-case";

View File

@ -0,0 +1,56 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize";
import type { ListIssueInvoicesResponseDTO } from "../../../../common/dto";
import type { ListCustomerInvoicesPresenter } from "../../presenters";
import type { CustomerInvoiceApplicationService } from "../../services";
type ListIssueInvoicesUseCaseInput = {
companyId: UniqueID;
criteria: Criteria;
};
export class ListIssueInvoicesUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public execute(
params: ListIssueInvoicesUseCaseInput
): Promise<Result<ListIssueInvoicesResponseDTO, Error>> {
const { criteria, companyId } = params;
const presenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
projection: "LIST",
}) as ListCustomerInvoicesPresenter;
return this.transactionManager.complete(async (transaction: Transaction) => {
try {
const result = await this.service.findIssueInvoiceByCriteriaInCompany(
companyId,
criteria,
transaction
);
if (result.isFailure) {
return Result.fail(result.error);
}
const invoices = result.data;
const dto = presenter.toOutput({
customerInvoices: invoices,
criteria,
});
return Result.ok(dto);
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -41,9 +41,9 @@ export class ListProformasUseCase {
return Result.fail(result.error);
}
const customerInvoices = result.data;
const proformas = result.data;
const dto = presenter.toOutput({
customerInvoices,
customerInvoices: proformas,
criteria,
});

View File

@ -27,7 +27,7 @@ export class ReportProformaUseCase {
return Result.fail(idOrError.error);
}
const invoiceId = idOrError.data;
const proformaId = idOrError.data;
const pdfPresenter = this.presenterRegistry.getPresenter({
resource: "customer-invoice",
projection: "REPORT",
@ -38,7 +38,7 @@ export class ReportProformaUseCase {
try {
const proformaOrError = await this.service.getProformaByIdInCompany(
companyId,
invoiceId,
proformaId,
transaction
);
if (proformaOrError.isFailure) {

View File

@ -1,8 +1,9 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Presenter } from "@erp/core/api";
import * as handlebars from "handlebars";
import Handlebars from "handlebars";
import type { CustomerInvoice } from "../../../../../domain";
import type {
@ -10,6 +11,18 @@ import type {
CustomerInvoiceReportPresenter,
} from "../../../../presenters";
/** Helper para trabajar relativo al fichero actual (ESM) */
export function fromHere(metaUrl: string) {
const file = fileURLToPath(metaUrl);
const dir = dirname(file);
return {
file, // ruta absoluta al fichero actual
dir, // ruta absoluta al directorio actual
resolve: (...parts: string[]) => resolve(dir, ...parts),
join: (...parts: string[]) => join(dir, ...parts),
};
}
export class CustomerInvoiceReportHTMLPresenter extends Presenter {
toOutput(customerInvoice: CustomerInvoice): string {
const dtoPresenter = this.presenterRegistry.getPresenter({
@ -27,10 +40,11 @@ export class CustomerInvoiceReportHTMLPresenter extends Presenter {
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const templateHtml = readFileSync(
path.join(__dirname, "./templates/customer-invoice/template.hbs")
).toString();
const template = handlebars.compile(templateHtml, {});
const here = fromHere(import.meta.url);
const templatePath = here.resolve("./templates/customer-invoice/template.hbs");
const templateHtml = readFileSync(templatePath).toString();
const template = Handlebars.compile(templateHtml, {});
return template(prettyDTO);
}
}

View File

@ -24,6 +24,8 @@ export class CustomerInvoiceReportPDFPresenter extends Presenter<
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--disable-extensions",
"--no-sandbox",

View File

@ -20,11 +20,14 @@ import {
CustomerInvoiceReportPresenter,
CustomerInvoiceTaxesReportPresenter,
DeleteProformaUseCase,
GetIssueInvoiceUseCase,
GetProformaUseCase,
IssueProformaInvoiceUseCase,
ListCustomerInvoicesPresenter,
ListIssueInvoicesUseCase,
ListProformasUseCase,
RecipientInvoiceFullPresenter,
ReportIssueInvoiceUseCase,
ReportProformaUseCase,
UpdateProformaUseCase,
} from "../application";
@ -51,6 +54,10 @@ export type CustomerInvoiceDeps = {
report_proforma: () => ReportProformaUseCase;
issue_proforma: () => IssueProformaInvoiceUseCase;
changeStatus_proforma: () => ChangeStatusProformaUseCase;
list_issue_invoices: () => ListIssueInvoicesUseCase;
get_issue_invoice: () => GetIssueInvoiceUseCase;
report_issue_invoice: () => ReportIssueInvoiceUseCase;
};
};
@ -125,6 +132,7 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
]);
const useCases: CustomerInvoiceDeps["useCases"] = {
// Proformas
list_proformas: () =>
new ListProformasUseCase(appService, transactionManager, presenterRegistry),
get_proforma: () => new GetProformaUseCase(appService, transactionManager, presenterRegistry),
@ -138,6 +146,14 @@ export function buildCustomerInvoiceDependencies(params: ModuleParams): Customer
issue_proforma: () =>
new IssueProformaInvoiceUseCase(appService, transactionManager, presenterRegistry),
changeStatus_proforma: () => new ChangeStatusProformaUseCase(appService, transactionManager),
// Issue Invoices
list_issue_invoices: () =>
new ListIssueInvoicesUseCase(appService, transactionManager, presenterRegistry),
get_issue_invoice: () =>
new GetIssueInvoiceUseCase(appService, transactionManager, presenterRegistry),
report_issue_invoice: () =>
new ReportIssueInvoiceUseCase(appService, transactionManager, presenterRegistry),
};
return {

View File

@ -1,10 +1,10 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import type { GetProformaUseCase } from "../../../../application";
import type { GetIssueInvoiceUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class GetIssueInvoiceController extends ExpressController {
public constructor(private readonly useCase: GetProformaUseCase) {
public constructor(private readonly useCase: GetIssueInvoiceUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;
@ -19,7 +19,7 @@ export class GetIssueInvoiceController extends ExpressController {
}
const { invoice_id } = this.req.params;
const result = await this.useCase.execute({ proforma_id: invoice_id, companyId });
const result = await this.useCase.execute({ invoice_id, companyId });
return result.match(
(data) => this.ok(data),

View File

@ -1,2 +1,3 @@
export * from "./get-issue-invoice.controller";
export * from "./list-issue-invoices.controller";
export * from "./report-issue-invoice.controller";

View File

@ -1,11 +1,11 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import { Criteria } from "@repo/rdx-criteria/server";
import type { ListProformasUseCase } from "../../../../application";
import type { ListIssueInvoicesUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class ListIssueInvoicesController extends ExpressController {
public constructor(private readonly useCase: ListProformasUseCase) {
public constructor(private readonly useCase: ListIssueInvoicesUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;

View File

@ -0,0 +1,29 @@
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
import type { ReportIssueInvoiceUseCase } from "../../../../application";
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
export class ReportIssueInvoiceController extends ExpressController {
public constructor(private readonly useCase: ReportIssueInvoiceUseCase) {
super();
this.errorMapper = customerInvoicesApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { invoice_id } = this.req.params;
const result = await this.useCase.execute({ invoice_id, companyId });
return result.match(
({ data, filename }) => this.downloadPDF(data, filename),
(err) => this.handleError(err)
);
}
}

View File

@ -12,9 +12,9 @@ import {
import { buildCustomerInvoiceDependencies } from "../dependencies";
import {
GetProformaController,
ListProformasController,
ReportProformaController,
GetIssueInvoiceController,
ListIssueInvoicesController,
ReportIssueInvoiceController,
} from "./controllers";
export const issueInvoicesRouter = (params: ModuleParams) => {
@ -51,8 +51,8 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ListIssueInvoicesRequestSchema, "params"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.list_proformas();
const controller = new ListProformasController(useCase /*, deps.presenters.list */);
const useCase = deps.useCases.list_issue_invoices();
const controller = new ListIssueInvoicesController(useCase /*, deps.presenters.list */);
return controller.execute(req, res, next);
}
);
@ -62,8 +62,8 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.get_proforma();
const controller = new GetProformaController(useCase);
const useCase = deps.useCases.get_issue_invoice();
const controller = new GetIssueInvoiceController(useCase);
return controller.execute(req, res, next);
}
);
@ -73,11 +73,11 @@ export const issueInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ReportIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.report_proforma();
const controller = new ReportProformaController(useCase);
const useCase = deps.useCases.report_issue_invoice();
const controller = new ReportIssueInvoiceController(useCase);
return controller.execute(req, res, next);
}
);
app.use(`${baseRoutePath}/customer-invoices`, router);
app.use(`${baseRoutePath}/issue-invoices`, router);
};

View File

@ -1,7 +1,7 @@
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
import customerInvoiceModelInit from "./models/customer-invoice.model";
import customerInvoiceItemModelInit from "./models/customer-invoice-item.model";
import customerInvoiceItemTaxesModelInit from "./models/customer-invoice-item-tax.model";
import customerInvoiceTaxesModelInit from "./models/customer-invoice-tax.model";
export * from "./customer-invoice.repository";
export * from "./models";

View File

@ -1,5 +1,5 @@
import type { Criteria } from "@repo/rdx-criteria/server";
import { literal, Op, OrderItem, WhereOptions } from "sequelize";
import { Op, type OrderItem, type WhereOptions, literal } from "sequelize";
// Campos físicos (DB) que permitimos filtrar/ordenar
const ALLOWED_FILTERS = {

View File

@ -1,12 +1,13 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
type InferAttributes,
type InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
type NonAttribute,
type Sequelize,
} from "sequelize";
import { CustomerInvoiceItem } from "../../../domain";
import type { CustomerInvoiceItem } from "../../../domain";
export type CustomerInvoiceItemTaxCreationAttributes = InferCreationAttributes<
CustomerInvoiceItemTaxModel,

View File

@ -1,17 +1,18 @@
import {
CreationOptional,
type CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
type InferAttributes,
type InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
type NonAttribute,
type Sequelize,
} from "sequelize";
import {
import type { CustomerInvoiceModel } from "./customer-invoice.model";
import type {
CustomerInvoiceItemTaxCreationAttributes,
CustomerInvoiceItemTaxModel,
} from "./customer-invoice-item-tax.model";
import { CustomerInvoiceModel } from "./customer-invoice.model";
export type CustomerInvoiceItemCreationAttributes = InferCreationAttributes<
CustomerInvoiceItemModel,

View File

@ -1,12 +1,13 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
type InferAttributes,
type InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
type NonAttribute,
type Sequelize,
} from "sequelize";
import { CustomerInvoice } from "../../../domain";
import type { CustomerInvoice } from "../../../domain";
export type CustomerInvoiceTaxCreationAttributes = InferCreationAttributes<
CustomerInvoiceTaxModel,

View File

@ -1,20 +1,19 @@
import { CustomerModel } from "@erp/customers/api";
import type { CustomerModel } from "@erp/customers/api";
import {
CreationOptional,
type CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
type InferAttributes,
type InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
type NonAttribute,
type Sequelize,
} from "sequelize";
import {
import type {
CustomerInvoiceItemCreationAttributes,
CustomerInvoiceItemModel,
} from "./customer-invoice-item.model";
import {
import type {
CustomerInvoiceTaxCreationAttributes,
CustomerInvoiceTaxModel,
} from "./customer-invoice-tax.model";

View File

@ -1,10 +1,11 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { literal, Transaction, WhereOptions } from "sequelize";
import type { UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { type Transaction, type WhereOptions, literal } from "sequelize";
import {
CustomerInvoiceNumber,
CustomerInvoiceSerie,
ICustomerInvoiceNumberGenerator,
type CustomerInvoiceSerie,
type ICustomerInvoiceNumberGenerator,
} from "../../domain";
import { CustomerInvoiceModel } from "../sequelize";

View File

@ -0,0 +1,2 @@
export * from "./invoice-dto.adapter";
export * from "./invoice-resume-dto.adapter";

View File

@ -5,8 +5,7 @@ import type {
UpdateCustomerInvoiceByIdRequestDTO,
} from "../../common";
import type { InvoiceContextValue } from "../context";
import type { InvoiceFormData } from "./invoice.form.schema";
import type { InvoiceFormData } from "../schemas/invoice.form.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.

View File

@ -1,7 +1,7 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { InvoiceSummaryFormData } from "./invoice-resume.form.schema";
import type { CustomerInvoiceSummary } from "./invoices.api.schema";
import type { InvoiceSummaryFormData } from "../schemas/invoice-resume.form.schema";
import type { CustomerInvoiceSummary } from "../schemas/invoices.api.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.

View File

@ -1,34 +0,0 @@
// components/CustomerSkeleton.tsx
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../i18n";
export const CustomerInvoiceEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>
<div className='h-7 w-64 rounded-md bg-muted animate-pulse' />
<div className='h-5 w-96 rounded-md bg-muted animate-pulse' />
</div>
<div className='flex items-center gap-2'>
<BackHistoryButton />
<Button disabled aria-busy>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div className='mt-6 grid gap-4' aria-hidden='true'>
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-28 w-full rounded-md bg-muted animate-pulse' />
</div>
<span className='sr-only'>
{t("pages.update.loading", "Cargando factura de cliente...")}
</span>
</AppContent>
</>
);
};

View File

@ -1,5 +0,0 @@
import { PropsWithChildren } from "react";
export const InvoicesLayout = ({ children }: PropsWithChildren) => {
return <div>{children}</div>;
};

View File

@ -1,68 +0,0 @@
import { formatCurrency } from '@erp/core';
import { Badge, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { ReceiptIcon } from "lucide-react";
import { ComponentProps } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from '../../context';
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<InvoiceFormData>();
const { currency_code, language_code } = useInvoiceContext();
const taxes = useWatch({
control,
name: "taxes",
defaultValue: [],
});
const displayTaxes = taxes || [];
return (
<FieldGroup>
<FieldSet {...props}>
<FieldLegend className='flex items-center gap-2 text-foreground'>
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
</FieldLegend>
<FieldDescription>{t("form_groups.tax_resume.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
{displayTaxes.map((tax, index) => (
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2 text-base '>
<div className='flex items-center justify-between mb-2 '>
<Badge variant='secondary' className='text-sm font-semibold'>
{tax.tax_label}
</Badge>
</div>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span className='text-current'>Base para el impuesto:</span>
<span className='text-base text-current tabular-nums'>{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}</span>
</div>
<div className='flex justify-between'>
<span className='text-current font-semibold'>Importe de impuesto:</span>
<span className='text-base text-current font-semibold tabular-nums'>
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
</div>
</div>
))}
{displayTaxes.length === 0 && (
<div className='text-center py-6 text-muted-foreground'>
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
<p className='text-sm'>No hay impuestos aplicados</p>
</div>
)}
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
);
};

View File

@ -1,133 +0,0 @@
import { formatCurrency } from '@erp/core';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
HoverCard, HoverCardContent, HoverCardTrigger
} from "@repo/shadcn-ui/components";
import { PropsWithChildren } from 'react';
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useTranslation } from "../../../i18n";
type HoverCardTotalsSummaryProps = PropsWithChildren & {
rowIndex: number;
}
/**
* Muestra un desglose financiero del total de línea.
* Lee directamente los importes del formulario vía react-hook-form.
*/
// Aparcado por ahora
export const HoverCardTotalsSummary = ({
children,
rowIndex,
}: HoverCardTotalsSummaryProps) => <>{children}</>
const HoverCardTotalsSummary2 = ({
children,
rowIndex,
}: HoverCardTotalsSummaryProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
const { currency_code, language_code } = useInvoiceContext();
// Observar los valores actuales del formulario
const [subtotal, discountPercentage, discountAmount, taxableBase, total] =
useWatch({
control,
name: [
`items.${rowIndex}.subtotal_amount`,
`items.${rowIndex}.discount_percentage`,
`items.${rowIndex}.discount_amount`,
`items.${rowIndex}.taxable_base`,
`items.${rowIndex}.total_amount`,
],
});
const SummaryBlock = () => (
<div className="space-y-2">
<h4 className="text-sm font-semibold mb-3">
{t("components.hover_card_totals_summary.label")}
</h4>
{/* Subtotal */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.subtotal_amount")}:
</span>
<span className="font-mono tabular-nums">{formatCurrency(subtotal, 4, currency_code, language_code)}</span>
</div>
{/* Descuento (si aplica) */}
{discountPercentage && Number(discountPercentage.value) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t(
"components.hover_card_totals_summary.fields.discount_percentage"
)}{" "}
({discountPercentage && discountPercentage.value
? (Number(discountPercentage.value) /
10 ** Number(discountPercentage.scale)) *
100
: 0}
%):
</span>
<span className="font-mono tabular-nums text-destructive">
-{formatCurrency(discountAmount, 4, currency_code, language_code)}
</span>
</div>
)}
{/* Base imponible */}
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.taxable_amount")}:
</span>
<span className="font-mono tabular-nums font-medium">
{formatCurrency(taxableBase, 4, currency_code, language_code)}
</span>
</div>
{/* Total final */}
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
<span>
{t("components.hover_card_totals_summary.fields.total_amount")}:
</span>
<span className="font-mono tabular-nums">{formatCurrency(total, 4, currency_code, language_code)}</span>
</div>
</div>
);
return (
<>
{/* Variante móvil (Dialog) */}
<div className="md:hidden">
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("components.hover_card_totals_summary.label")}
</DialogTitle>
</DialogHeader>
<SummaryBlock />
</DialogContent>
</Dialog>
</div>
{/* Variante escritorio (HoverCard) */}
<div className="hidden md:block">
<HoverCard>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent className="w-64" align="end">
<SummaryBlock />
</HoverCardContent>
</HoverCard>
</div>
</>
);
};

View File

@ -1,43 +0,0 @@
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { InvoiceFormData, InvoiceItemFormData } from '../../../schemas';
export function ItemRowEditor({
row,
index,
onClose,
}: {
row: InvoiceItemFormData
index: number
onClose: () => void
}) {
// Editor simple reutilizando el mismo RHF
const { register } = useFormContext<InvoiceFormData>();
return (
<div className="grid gap-3">
<h3 className="text-base font-semibold">Edit line #{index + 1}</h3>
<div>
<Label htmlFor={`desc-${index}`}>Description</Label>
<Textarea id={`desc-${index}`} rows={4} {...register(`items.${index}.description`)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor={`qty-${index}`}>Qty</Label>
<Input id={`qty-${index}`} type="number" step="1" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
</div>
<div>
<Label htmlFor={`unit-${index}`}>Unit</Label>
<Input id={`unit-${index}`} type="number" step="0.01" {...register(`items.${index}.unit_amount`, { valueAsNumber: true })} />
</div>
<div>
<Label htmlFor={`disc-${index}`}>Discount %</Label>
<Input id={`disc-${index}`} type="number" step="0.01" {...register(`items.${index}.discount_percentage`, { valueAsNumber: true })} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button onClick={onClose}>OK</Button>
</div>
</div>
);
}

View File

@ -1,244 +0,0 @@
import { Button, Checkbox, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { Control, Controller, FieldValues } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useTranslation } from '../../../i18n';
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { AmountInputField } from './amount-input-field';
import { HoverCardTotalsSummary } from './hover-card-total-summary';
import { PercentageInputField } from './percentage-input-field';
import { QuantityInputField } from './quantity-input-field';
export type ItemRowProps<TFieldValues extends FieldValues = FieldValues> = {
control: Control<TFieldValues>,
rowIndex: number;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
readOnly: boolean;
onToggleSelect: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
export const ItemRow = <TFieldValues extends FieldValues = FieldValues>({
control,
rowIndex,
isSelected,
isFirst,
isLast,
readOnly,
onToggleSelect,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove, }: ItemRowProps<TFieldValues>) => {
const { t } = useTranslation();
const { currency_code, language_code } = useInvoiceContext();
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className='align-top' data-col-index={1}>
<div className='h-5'>
<Checkbox
aria-label={`Seleccionar fila ${rowIndex + 1}`}
className="block size-5 leading-none align-middle"
checked={isSelected}
onCheckedChange={onToggleSelect}
disabled={readOnly}
data-cell-focus
/>
</div>
</TableCell>
{/* # */}
<TableCell className='text-left pt-[6px]' data-col-index={2}>
<span className='block translate-y-[-1px] text-muted-foreground tabular-nums text-xs'>
{rowIndex + 1}
</span>
</TableCell>
{/* description */}
<TableCell data-col-index={3}>
<Controller
control={control}
name={`items.${rowIndex}.description`}
render={({ field }) => (
<textarea
{...field}
aria-label={t("form_fields.item.description.label")}
className={cn(
"w-full bg-transparent p-0 pt-1.5 resize-none border-0 shadow-none h-8",
"hover:bg-background hover:border-ring hover:ring-ring/50 hover:ring-[2px] focus-within:resize-y",
)}
rows={1}
spellCheck
readOnly={readOnly}
onFocus={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
data-cell-focus
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className='text-right' data-col-index={4}>
<QuantityInputField
control={control}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
inputId={`quantity-${rowIndex}`}
emptyMode="blank"
data-row-index={rowIndex}
data-col-index={4}
data-cell-focus
className='font-medium'
/>
</TableCell>
{/* unit */}
<TableCell className='text-right' data-col-index={5}>
<AmountInputField
control={control}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
inputId={`unit-amount-${rowIndex}`}
scale={4}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={rowIndex}
data-col-index={5}
data-cell-focus
className='font-medium'
/>
</TableCell>
{/* discount */}
<TableCell className='text-right' data-col-index={6}>
<PercentageInputField
control={control}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
inputId={`discount-percentage-${rowIndex}`}
showSuffix
data-row-index={rowIndex}
data-col-index={6}
data-cell-focus
className='font-medium'
/>
</TableCell>
{/* taxes */}
<TableCell data-col-index={7}>
<Controller
control={control}
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
value={field.value}
onChange={field.onChange}
data-row-index={rowIndex}
data-col-index={7}
/>
)}
data-cell-focus
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className='text-right tabular-nums pt-[6px] leading-5' data-col-index={8}>
<HoverCardTotalsSummary rowIndex={rowIndex} >
<AmountInputField
control={control}
name={`items.${rowIndex}.total_amount`}
readOnly
inputId={`total-amount-${rowIndex}`}
currencyCode={currency_code}
languageCode={language_code}
className='font-semibold'
/>
</HoverCardTotalsSummary>
</TableCell>
{/* actions */}
<TableCell className='pt-[4px]' data-col-index={9}>
<div className='flex justify-end gap-0'>
{onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={readOnly}
aria-label='Duplicar fila'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<CopyIcon className='size-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar</TooltipContent>
</Tooltip>
)}
{onMoveUp && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={readOnly || isFirst}
aria-label='Mover arriba'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<ArrowUpIcon className='size-4' />
</Button>
)}
{onMoveDown && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={readOnly || isLast}
aria-label='Mover abajo'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<ArrowDownIcon className='size-4' />
</Button>
)}
{onRemove && (
<Button
size='icon'
variant='ghost'
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={readOnly}
aria-label='Eliminar fila'
className='size-8 self-start -translate-y-[1px]'
data-cell-focus
>
<Trash2Icon className='size-4' />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}

View File

@ -1,145 +0,0 @@
import { CheckedState, useRowSelection } from '@repo/rdx-ui/hooks';
import { Checkbox, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@repo/shadcn-ui/components";
import { useCallback } from 'react';
import { useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context';
import { useInvoiceAutoRecalc, useItemsTableNavigation } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceFormData, InvoiceItemFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { ItemRow } from './item-row';
import { ItemsEditorToolbar } from './items-editor-toolbar';
interface ItemsEditorProps {
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const context = useInvoiceContext();
const form = useFormContext<InvoiceFormData>();
const { control } = form;
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const { fieldArray: { fields } } = tableNav;
const {
selectedRows,
selectedIndexes,
selectAllState,
toggleRow,
setSelectAll,
clearSelection,
} = useRowSelection(fields.length);
useInvoiceAutoRecalc(form, context);
const handleAddSelection = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const handleDuplicateSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveUpSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDownSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemoveSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={handleAddSelection}
onDuplicate={handleDuplicateSelection}
onMoveUp={handleMoveUpSelection}
onMoveDown={handleMoveDownSelection}
onRemove={handleRemoveSelection} />
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead className='w-[1%] h-5'>
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked: CheckedState) => setSelectAll(checked)}
/>
</TableHead>
<TableHead className='w-[1%]' aria-hidden="true">#</TableHead>
<TableHead className='w-[40%]'>{t("form_fields.item.description.label")}</TableHead>
<TableHead className="w-[4%] text-right">{t("form_fields.item.quantity.label")}</TableHead>
<TableHead className="w-[10%] text-right">{t("form_fields.item.unit_amount.label")}</TableHead>
<TableHead className="w-[4%] text-right">{t("form_fields.item.discount_percentage.label")}</TableHead>
<TableHead className="w-[16%] text-right">{t("form_fields.item.tax_codes.label")}</TableHead>
<TableHead className="w-[8%] text-right">{t("form_fields.item.total_amount.label")}</TableHead>
<TableHead className='w-[1%]' aria-hidden="true" />
</TableRow>
</TableHeader>
<TableBody className='text-sm'>
{fields.map((f, rowIndex: number) => (
<ItemRow
key={f.id}
control={control}
rowIndex={rowIndex}
isSelected={selectedRows.has(rowIndex)}
isFirst={rowIndex === 0}
isLast={rowIndex === fields.length - 1}
readOnly={readOnly}
onToggleSelect={() => toggleRow(rowIndex)}
onDuplicate={() => tableNav.duplicate(rowIndex)}
onMoveUp={() => tableNav.moveUp(rowIndex)}
onMoveDown={() => tableNav.moveDown(rowIndex)}
onRemove={() => tableNav.remove(rowIndex)}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={9} className='p-0 m-0'>
<ItemsEditorToolbar
readOnly={readOnly}
selectedIndexes={selectedIndexes}
onAdd={() => tableNav.addEmpty(true)}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div >
);
}

View File

@ -1,133 +0,0 @@
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { CopyPlusIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
export const ItemsEditorToolbar = ({
readOnly,
selectedIndexes,
onAdd,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: {
readOnly: boolean;
selectedIndexes: number[];
onAdd?: () => void;
onDuplicate?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onRemove?: () => void;
}) => {
const { t } = useTranslation();
// memoiza valores derivados
const hasSel = selectedIndexes.length > 0;
const selectedCount = useMemo(() => selectedIndexes.length, [selectedIndexes]);
return (
<nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
<div className="flex items-center gap-2">
{onAdd && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
onClick={onAdd}
disabled={readOnly}
>
<PlusIcon className="size-4 mr-1" />
{t("common.append_empty_row")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
)}
{onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onDuplicate}
disabled={!hasSel || readOnly}
>
<CopyPlusIcon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
)}
{/*<Separator orientation="vertical" className="mx-2" />
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={!hasSel || readOnly}
>
{t("common.move_up")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={!hasSel || readOnly}
>
{t("common.move_down")}
</Button>
<Separator orientation="vertical" className="mx-2" />
*/}
{onRemove && (
<>
<Separator orientation="vertical" className="mx-2" />
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onRemove}
disabled={!hasSel || readOnly}
aria-label={t("common.remove_selected_rows")}
>
<Trash2Icon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
{t("common.remove_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t("common.remove_selected_rows_tooltip")}
</TooltipContent>
</Tooltip>
</>
)}
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-normal">
{t("common.rows_selected", { count: selectedCount })}
</p>
</div>
</nav>
);
}

View File

@ -4,7 +4,7 @@ import { Outlet, type RouteObject } from "react-router-dom";
// Lazy load components
const InvoicesLayout = lazy(() =>
import("./components").then((m) => ({ default: m.InvoicesLayout }))
import("./shared/ui").then((m) => ({ default: m.CustomerInvoicesLayout }))
);
const ProformaListPage = lazy(() =>

View File

@ -3,7 +3,7 @@ import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateProformaRequestSchema } from "../../common";
import type { Proforma } from "../proformas/proforma.api.schema";
import type { Proforma } from "../proformas/schema/proforma.api.schema";
import type { InvoiceFormData } from "../schemas";
type CreateCustomerInvoicePayload = {

View File

@ -1,10 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import * as z from "zod";
import { CustomerModalSelector } from "@erp/customers/components";
import { DevTool } from "@hookform/devtools";
import { zodResolver } from "@hookform/resolvers/zod";
import { TextAreaField, TextField } from "@repo/rdx-ui/components";
import {
Button,
@ -38,10 +34,14 @@ import {
import { format } from "date-fns";
import { es } from "date-fns/locale";
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
import { CustomerInvoicePricesCard } from "../../components";
import { CustomerInvoiceItemsCardEditor } from "../../components/items";
import { useFieldArray, useForm } from "react-hook-form";
import * as z from "zod";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceData } from "./customer-invoice.schema";
import { CustomerInvoicePricesCard } from "../../shared/ui/components";
import { CustomerInvoiceItemsCardEditor } from "../../shared/ui/components/items";
import type { CustomerInvoiceData } from "./customer-invoice.schema";
import { formatCurrency } from "./utils";
const invoiceFormSchema = z.object({
@ -279,159 +279,159 @@ export const CreateCustomerInvoiceEditForm = ({
return (
<Form {...form}>
<form
className="grid grid-cols-1 md:gap-6 md:grid-cols-2"
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className='grid grid-cols-1 md:gap-6 md:grid-cols-2'
>
<Card className='border-0 shadow-none md:grid-span-2'>
<Card className="border-0 shadow-none md:grid-span-2">
<CardHeader>
<CardTitle>Cliente</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
<Button variant='link'>Sign Up</Button>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
<Button variant="link">Sign Up</Button>
</CardAction>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
<CardContent className="grid grid-cols-1 gap-4 space-y-6">
<div>
<div className='space-y-1'>
<h4 className='text-sm leading-none font-medium'>Radix Primitives</h4>
<p className='text-muted-foreground text-sm'>
<div className="space-y-1">
<h4 className="text-sm leading-none font-medium">Radix Primitives</h4>
<p className="text-muted-foreground text-sm">
An open-source UI component library.
</p>
</div>
<Separator className='my-4' />
<div className='flex h-5 items-center space-x-4 text-sm'>
<Separator className="my-4" />
<div className="flex h-5 items-center space-x-4 text-sm">
<div>Blog</div>
<Separator orientation='vertical' />
<Separator orientation="vertical" />
<div>Docs</div>
<Separator orientation='vertical' />
<Separator orientation="vertical" />
<div>Source</div>
</div>
</div>
</CardContent>
<CardFooter className='flex-col gap-2'>
<Button type='submit' className='w-full'>
<CardFooter className="flex-col gap-2">
<Button className="w-full" type="submit">
Login
</Button>
<Button variant='outline' className='w-full'>
<Button className="w-full" variant="outline">
Login with Google
</Button>
</CardFooter>{" "}
</Card>
{/* Información básica */}
<Card className='border-0 shadow-none '>
<Card className="border-0 shadow-none ">
<CardHeader>
<CardTitle>Información Básica</CardTitle>
<CardDescription>Detalles generales de la factura</CardDescription>
</CardHeader>
<CardContent className='space-y-8'>
<div className='grid gap-y-6 gap-x-8 md:grid-cols-4'>
<CardContent className="space-y-8">
<div className="grid gap-y-6 gap-x-8 md:grid-cols-4">
<TextField
control={form.control}
name='invoice_number'
required
disabled
readOnly
label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")}
description={t("form_fields.invoice_number.description")}
disabled
label={t("form_fields.invoice_number.label")}
name="invoice_number"
placeholder={t("form_fields.invoice_number.placeholder")}
readOnly
required
/>
<DatePickerInputField
control={form.control}
name='invoice_date'
required
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<TextField
control={form.control}
name='invoice_series'
required
label={t("form_fields.invoice_series.label")}
placeholder={t("form_fields.invoice_series.placeholder")}
description={t("form_fields.invoice_series.description")}
label={t("form_fields.invoice_series.label")}
name="invoice_series"
placeholder={t("form_fields.invoice_series.placeholder")}
required
/>
</div>
<div className='grid gap-y-6 gap-x-8 grid-cols-1'>
<div className="grid gap-y-6 gap-x-8 grid-cols-1">
<TextField
control={form.control}
name='description'
required
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
name="description"
placeholder={t("form_fields.description.placeholder")}
required
/>
</div>
<div className='grid gap-y-6 gap-x-8 grid-cols-1'>
<div className="grid gap-y-6 gap-x-8 grid-cols-1">
<TextAreaField
control={form.control}
name='notes'
required
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
label={t("form_fields.notes.label")}
name="notes"
placeholder={t("form_fields.notes.placeholder")}
required
/>
</div>
</CardContent>
</Card>
{/* Cliente */}
<Card className='col-span-full'>
<Card className="col-span-full">
<CardHeader>
<CardTitle>Cliente</CardTitle>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
<CardContent className="grid grid-cols-1 gap-4 space-y-6">
<CustomerModalSelector />
<TextField
control={form.control}
name='customer_id'
required
label={t("form_fields.customer_id.label")}
placeholder={t("form_fields.customer_id.placeholder")}
description={t("form_fields.customer_id.description")}
label={t("form_fields.customer_id.label")}
name="customer_id"
placeholder={t("form_fields.customer_id.placeholder")}
required
/>
</CardContent>
</Card>
{/*Items */}
<CustomerInvoiceItemsCardEditor
className="col-span-full"
defaultValues={defaultInvoiceData}
className='col-span-full'
/>
{/* Items */}
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Artículos</CardTitle>
<CardDescription>Lista de productos o servicios facturados</CardDescription>
</div>
<Button type='button' onClick={addItem} size='sm'>
<PlusIcon className='h-4 w-4 mr-2' />
<Button onClick={addItem} size="sm" type="button">
<PlusIcon className="h-4 w-4 mr-2" />
Agregar Item
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className="space-y-4">
{fields.map((field, index) => (
<Card key={field.id} className='p-4'>
<div className='flex justify-between items-start mb-4'>
<div className='flex items-center gap-2'>
<h4 className='font-medium'>Item {index + 1}</h4>
<Card className="p-4" key={field.id}>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-2">
<h4 className="font-medium">Item {index + 1}</h4>
</div>
{fields.length > 1 && (
<Button type='button' variant='outline' size='sm' onClick={() => remove(index)}>
<Trash2Icon className='h-4 w-4' />
<Button onClick={() => remove(index)} size="sm" type="button" variant="outline">
<Trash2Icon className="h-4 w-4" />
</Button>
)}
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4'>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<FormField
control={form.control}
name={`items.${index}.id_article`}
@ -439,7 +439,7 @@ export const CreateCustomerInvoiceEditForm = ({
<FormItem>
<FormLabel>Código Artículo</FormLabel>
<FormControl>
<Input placeholder='Código' {...field} />
<Input placeholder="Código" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -450,10 +450,10 @@ export const CreateCustomerInvoiceEditForm = ({
control={form.control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem className='md:col-span-2'>
<FormItem className="md:col-span-2">
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea placeholder='Descripción del producto/servicio' {...field} />
<Textarea placeholder="Descripción del producto/servicio" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -462,10 +462,10 @@ export const CreateCustomerInvoiceEditForm = ({
<TextAreaField
control={form.control}
name={`items.${index}.description`}
label={t("form_fields.items.description.label")}
placeholder={t("form_fields.items.description.placeholder")}
description={t("form_fields.items.description.description")}
label={t("form_fields.items.description.label")}
name={`items.${index}.description`}
placeholder={t("form_fields.items.description.placeholder")}
/>
<FormField
@ -476,9 +476,9 @@ export const CreateCustomerInvoiceEditForm = ({
<FormLabel>Cantidad</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
@ -497,9 +497,9 @@ export const CreateCustomerInvoiceEditForm = ({
<FormLabel>Precio Unitario</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
@ -518,10 +518,10 @@ export const CreateCustomerInvoiceEditForm = ({
<FormLabel>Descuento (%)</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
max='100'
max="100"
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
@ -533,8 +533,8 @@ export const CreateCustomerInvoiceEditForm = ({
/>
</div>
<div className='mt-4 p-3 bg-muted rounded-lg'>
<div className='text-sm text-muted-foreground'>
<div className="mt-4 p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">
Total del item:{" "}
{formatCurrency(
watchedItems[index]?.total_price?.amount || 0,
@ -556,20 +556,20 @@ export const CreateCustomerInvoiceEditForm = ({
<CardTitle>Impuestos y Totales</CardTitle>
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name='tax.amount'
name="tax.amount"
render={({ field }) => (
<FormItem>
<FormLabel>Tasa de Impuesto (%)</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
max='100'
max="100"
min="0"
step="0.01"
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
@ -584,33 +584,33 @@ export const CreateCustomerInvoiceEditForm = ({
<Separator />
{/* Resumen de totales */}
<div className='space-y-3'>
<div className='flex justify-between text-sm'>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span>Subtotal:</span>
<span>
{formatCurrency(form.watch("subtotal_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className="flex justify-between text-sm">
<span>Descuento:</span>
<span>
-{formatCurrency(form.watch("discount_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className="flex justify-between text-sm">
<span>Base imponible:</span>
<span>
{formatCurrency(form.watch("before_tax_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className="flex justify-between text-sm">
<span>Impuestos ({(form.watch("tax.amount") / 100).toFixed(2)}%):</span>
<span>
{formatCurrency(form.watch("tax_price.amount"), 2, form.watch("currency"))}
</span>
</div>
<Separator />
<div className='flex justify-between text-lg font-semibold'>
<div className="flex justify-between text-lg font-semibold">
<span>Total:</span>
<span>
{formatCurrency(form.watch("total_price.amount"), 2, form.watch("currency"))}
@ -619,11 +619,11 @@ export const CreateCustomerInvoiceEditForm = ({
</div>
</CardContent>
</Card>
<div className='flex justify-end space-x-4'>
<Button type='button' variant='outline' disabled={isPending} onClick={handleCancel}>
<div className="flex justify-end space-x-4">
<Button disabled={isPending} onClick={handleCancel} type="button" variant="outline">
Cancelar
</Button>
<Button type='submit' disabled={isPending}>
<Button disabled={isPending} type="submit">
Guardar Factura
</Button>
</div>
@ -633,130 +633,130 @@ export const CreateCustomerInvoiceEditForm = ({
);
return (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-muted/50'>
<form onSubmit={handleSubmit} className='space-y-6'>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-muted/50">
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Información básica */}
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
<div className='space-y-2'>
<Label htmlFor='id'>ID de Factura</Label>
<Input id='id' value={formData.id} disabled className='bg-muted' />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID de Factura</Label>
<Input className="bg-muted" disabled id="id" value={formData.id} />
</div>
<div className='space-y-2'>
<Label htmlFor='invoice_status'>Estado</Label>
<div className="space-y-2">
<Label htmlFor="invoice_status">Estado</Label>
<Select
value={formData.invoice_status}
onValueChange={(value) => handleInputChange("invoice_status", value)}
value={formData.invoice_status}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='draft'>Borrador</SelectItem>
<SelectItem value='sent'>Enviada</SelectItem>
<SelectItem value='paid'>Pagada</SelectItem>
<SelectItem value='cancelled'>Cancelada</SelectItem>
<SelectItem value="draft">Borrador</SelectItem>
<SelectItem value="sent">Enviada</SelectItem>
<SelectItem value="paid">Pagada</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='language_code'>Idioma</Label>
<div className="space-y-2">
<Label htmlFor="language_code">Idioma</Label>
<Select
value={formData.language_code}
onValueChange={(value) => handleInputChange("language_code", value)}
value={formData.language_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='ES'>Español</SelectItem>
<SelectItem value='EN'>English</SelectItem>
<SelectItem value='FR'>Français</SelectItem>
<SelectItem value='DE'>Deutsch</SelectItem>
<SelectItem value="ES">Español</SelectItem>
<SelectItem value="EN">English</SelectItem>
<SelectItem value="FR">Français</SelectItem>
<SelectItem value="DE">Deutsch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Numeración */}
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
<div className='space-y-2'>
<Label htmlFor='invoice_series'>Serie</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="invoice_series">Serie</Label>
<Input
id='invoice_series'
value={formData.invoice_series}
id="invoice_series"
onChange={(e) => handleInputChange("invoice_series", e.target.value)}
placeholder='A'
placeholder="A"
value={formData.invoice_series}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='invoice_number'>Número</Label>
<div className="space-y-2">
<Label htmlFor="invoice_number">Número</Label>
<Input
id='invoice_number'
value={formData.invoice_number}
id="invoice_number"
onChange={(e) => handleInputChange("invoice_number", e.target.value)}
placeholder='1'
placeholder="1"
value={formData.invoice_number}
/>
</div>
<div className='space-y-2 hidden'>
<Label htmlFor='currency'>Moneda</Label>
<div className="space-y-2 hidden">
<Label htmlFor="currency">Moneda</Label>
<Select
value={formData.currency}
onValueChange={(value) => handleInputChange("currency", value)}
value={formData.currency}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='EUR'>EUR ()</SelectItem>
<SelectItem value='USD'>USD ($)</SelectItem>
<SelectItem value='GBP'>GBP (£)</SelectItem>
<SelectItem value='JPY'>JPY (¥)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="GBP">GBP (£)</SelectItem>
<SelectItem value="JPY">JPY (¥)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Fechas */}
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div className='space-y-2'>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Fecha de Emisión</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' className='w-full justify-start text-left font-normal'>
<CalendarIcon className='mr-2 h-4 w-4' />
<Button className="w-full justify-start text-left font-normal" variant="outline">
<CalendarIcon className="mr-2 h-4 w-4" />
{format(invoiceDate, "PPP", { locale: es })}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode='single'
selected={invoiceDate}
onSelect={(date) => handleDateChange("invoice_date", date)}
initialFocus
mode="single"
onSelect={(date) => handleDateChange("invoice_date", date)}
selected={invoiceDate}
/>
</PopoverContent>
</Popover>
</div>
<div className='space-y-2'>
<div className="space-y-2">
<Label>Fecha de Operación</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' className='w-full justify-start text-left font-normal'>
<CalendarIcon className='mr-2 h-4 w-4' />
<Button className="w-full justify-start text-left font-normal" variant="outline">
<CalendarIcon className="mr-2 h-4 w-4" />
{format(operationDate, "PPP", { locale: es })}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode='single'
selected={operationDate}
onSelect={(date) => handleDateChange("operation_date", date)}
initialFocus
mode="single"
onSelect={(date) => handleDateChange("operation_date", date)}
selected={operationDate}
/>
</PopoverContent>
</Popover>
@ -764,46 +764,46 @@ export const CreateCustomerInvoiceEditForm = ({
</div>
{/* Importes */}
<div className='space-y-4'>
<h3 className='text-lg font-semibold'>Importes</h3>
<div className="space-y-4">
<h3 className="text-lg font-semibold">Importes</h3>
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Subtotal</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">Subtotal</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
<div className='space-y-2'>
<Label htmlFor='subtotal_amount'>Importe</Label>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="subtotal_amount">Importe</Label>
<Input
id='subtotal_amount'
type='number'
step='0.01'
value={formData.subtotal.amount / Math.pow(10, formData.subtotal.scale)}
id="subtotal_amount"
onChange={(e) =>
handleNestedChange(
"subtotal",
"amount",
Number.parseFloat(e.target.value) * Math.pow(10, formData.subtotal.scale)
Number.parseFloat(e.target.value) * 10 ** formData.subtotal.scale
)
}
step="0.01"
type="number"
value={formData.subtotal.amount / 10 ** formData.subtotal.scale}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='subtotal_currency'>Moneda</Label>
<div className="space-y-2">
<Label htmlFor="subtotal_currency">Moneda</Label>
<Select
value={formData.subtotal.currency_code}
onValueChange={(value) =>
handleNestedChange("subtotal", "currency_code", value)
}
value={formData.subtotal.currency_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='EUR'>EUR</SelectItem>
<SelectItem value='USD'>USD</SelectItem>
<SelectItem value='GBP'>GBP</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
@ -811,39 +811,39 @@ export const CreateCustomerInvoiceEditForm = ({
</Card>
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Total</CardTitle>
<CardHeader className="pb-3">
<CardTitle className="text-base">Total</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
<div className='space-y-2'>
<Label htmlFor='total_amount'>Importe</Label>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="total_amount">Importe</Label>
<Input
id='total_amount'
type='number'
step='0.01'
value={formData.total.amount / Math.pow(10, formData.total.scale)}
id="total_amount"
onChange={(e) =>
handleNestedChange(
"total",
"amount",
Number.parseFloat(e.target.value) * Math.pow(10, formData.total.scale)
Number.parseFloat(e.target.value) * 10 ** formData.total.scale
)
}
step="0.01"
type="number"
value={formData.total.amount / 10 ** formData.total.scale}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='total_currency'>Moneda</Label>
<div className="space-y-2">
<Label htmlFor="total_currency">Moneda</Label>
<Select
value={formData.total.currency_code}
onValueChange={(value) => handleNestedChange("total", "currency_code", value)}
value={formData.total.currency_code}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='EUR'>EUR</SelectItem>
<SelectItem value='USD'>USD</SelectItem>
<SelectItem value='GBP'>GBP</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
@ -853,18 +853,18 @@ export const CreateCustomerInvoiceEditForm = ({
</div>
{/* Botones de acción */}
<div className='flex justify-end gap-3 pt-6 border-t'>
<div className="flex justify-end gap-3 pt-6 border-t">
<Button
type='button'
variant='outline'
className="flex items-center gap-2"
onClick={handleCancel}
className='flex items-center gap-2'
type="button"
variant="outline"
>
<X className='h-4 w-4' />
<X className="h-4 w-4" />
Cancelar
</Button>
<Button type='submit' className='flex items-center gap-2'>
<Save className='h-4 w-4' />
<Button className="flex items-center gap-2" type="submit">
<Save className="h-4 w-4" />
Guardar Cambios
</Button>
</div>

View File

@ -6,9 +6,9 @@ import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { invoiceResumeDtoToFormAdapter } from "../../adapters/invoice-resume-dto.adapter";
import { useInvoicesQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { invoiceResumeDtoToFormAdapter } from "../../schemas/invoice-resume-dto.adapter";
import { InvoicesListGrid } from "./invoices-list-grid";

View File

@ -1,9 +1,14 @@
import { FieldErrors, useFormContext } from "react-hook-form";
import { FormDebug } from "@erp/core/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type FieldErrors, useFormContext } from "react-hook-form";
import { FormDebug } from '@erp/core/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
import { InvoiceBasicInfoFields, InvoiceItems, InvoiceRecipient, InvoiceTotals } from '../../components';
import { InvoiceFormData } from "../../schemas";
import type { InvoiceFormData } from "../../schemas";
import {
InvoiceBasicInfoFields,
InvoiceItems,
InvoiceRecipient,
InvoiceTotals,
} from "../../shared/ui/components";
interface InvoiceUpdateFormProps {
formId: string;
@ -21,11 +26,14 @@ export const InvoiceUpdateForm = ({
const form = useFormContext<InvoiceFormData>();
return (
<form noValidate id={formId} onSubmit={
(event: React.FormEvent<HTMLFormElement>) => {
<form
id={formId}
noValidate
onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
event.stopPropagation();
form.handleSubmit(onSubmit, onError)(event)
}}>
form.handleSubmit(onSubmit, onError)(event);
}}
>
<FormDebug />
<section className={cn("space-y-6 p-6", className)}>
@ -34,15 +42,13 @@ export const InvoiceUpdateForm = ({
<InvoiceBasicInfoFields className="flex flex-col lg:col-span-2" />
</div>
<div className='w-full'>
<div className="w-full">
<InvoiceItems />
</div>
<div className="w-full grid grid-cols-1 lg:grid-cols-2">
<InvoiceTotals className='lg:col-start-2' />
</div>
<div className="w-full">
<InvoiceTotals className="lg:col-start-2" />
</div>
<div className="w-full"></div>
</section>
</form>
);

View File

@ -1,17 +1,15 @@
import { SpainTaxCatalogProvider } from '@erp/core';
import {
useUrlParamId
} from "@erp/core/hooks";
import { SpainTaxCatalogProvider } from "@erp/core";
import { useUrlParamId } from "@erp/core/hooks";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { useMemo } from 'react';
import {
CustomerInvoiceEditorSkeleton
} from "../../components";
import { InvoiceProvider } from '../../context';
import { useMemo } from "react";
import { InvoiceProvider } from "../../context";
import { useInvoiceQuery } from "../../hooks";
import { useTranslation } from "../../i18n";
import { InvoiceUpdateComp } from './invoice-update-comp';
import { CustomerInvoiceEditorSkeleton } from "../../shared/ui/components";
import { InvoiceUpdateComp } from "./invoice-update-comp";
export const InvoiceUpdatePage = () => {
const invoice_id = useUrlParamId();
@ -29,8 +27,8 @@ export const InvoiceUpdatePage = () => {
return (
<AppContent>
<ErrorAlert
title={t("pages.update.loadErrorTitle")}
message={(error as Error)?.message || "Error al cargar la factura"}
title={t("pages.update.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
@ -40,15 +38,14 @@ export const InvoiceUpdatePage = () => {
// Monta el contexto aquí, así todo lo que esté dentro puede usar hooks
return (
<InvoiceProvider
invoice_id={invoice_id!}
taxCatalog={taxCatalog}
company_id={invoiceData.company_id}
status={invoiceData.status}
language_code={invoiceData.language_code}
currency_code={invoiceData.currency_code}
invoice_id={invoice_id!}
language_code={invoiceData.language_code}
status={invoiceData.status}
taxCatalog={taxCatalog}
>
<InvoiceUpdateComp invoice={invoiceData} />
</InvoiceProvider>
);
};

View File

@ -0,0 +1 @@
export * from "./proforma-summary-dto.adapter";

View File

@ -1,12 +1,15 @@
import { MoneyDTOHelper, PercentageDTOHelper, formatCurrency } from "@erp/core";
import type { ProformaSummaryPage } from "./proforma.api.schema";
import type { ProformaSummaryData, ProformaSummaryPageData } from "./proforma-resume.form.schema";
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
import type {
ProformaSummaryData,
ProformaSummaryPageData,
} from "../schema/proforma-summary.web.schema";
/**
* Convierte el DTO completo de API a datos numéricos para el formulario.
*/
export const ProformaResumeDtoAdapter = {
export const ProformaSummaryDtoAdapter = {
fromDto(pageDto: ProformaSummaryPage, context?: unknown): ProformaSummaryPageData {
return {
...pageDto,

View File

@ -0,0 +1,67 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { InvoiceFormData } from "../schemas";
export const CUSTOMER_INVOICES_LIST_KEY = ["customer-invoices"] as const;
type UpdateCustomerInvoiceContext = {};
type UpdateCustomerInvoicePayload = {
id: string;
data: Partial<InvoiceFormData>;
};
export function useDeleteProforma() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<{ id: string }, Error>({
mutationKey: ["customer-invoice:delete", id],
mutationFn: async (payload) => {
const { id: proformaId } = payload;
if (!proformaId) {
throw new Error("proformaId is required");
}
await dataSource.deleteOne("proformas", proformaId);
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_SCOPE] });
const snapshots = getAllCustomerListQueryKeys(queryClient).map((key) => ({
key,
page: queryClient.getQueryData<CustomersPage>(key),
}));
for (const { key, page } of snapshots) {
if (!page) continue;
queryClient.setQueryData<CustomersPage>(key, {
...page,
items: page.items.filter((c) => c.id !== id),
totalItems: Math.max(0, page.totalItems - 1),
});
}
return { snapshots };
},
onError: (_e, _v, ctx) => {
if (!ctx) return;
for (const snap of ctx.snapshots as Array<{ key: QueryKey; page?: CustomersPage }>) {
if (snap.page) queryClient.setQueryData(snap.key, snap.page);
}
},
onSuccess: ({ id }) => {
// Limpia cache de detalle
queryClient.removeQueries({ queryKey: buildCustomerQueryKey(id) });
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [CUSTOMERS_LIST_SCOPE] });
},
});
}

View File

@ -1,7 +1,7 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { Proforma } from "../proforma.api.schema";
import type { Proforma } from "../schema/proforma.api.schema";
export const PROFORMA_QUERY_KEY = (id: string): QueryKey => ["proforma", id] as const;

View File

@ -2,7 +2,7 @@ import type { CriteriaDTO } from "@erp/core";
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import type { ProformaSummaryPage } from "../proforma.api.schema";
import type { ProformaSummaryPage } from "../schema/proforma.api.schema";
export const PROFORMAS_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"proforma",

View File

@ -1,2 +1 @@
export * from "./hooks";
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./use-proformas-list";

View File

@ -0,0 +1,46 @@
// src/modules/proformas/hooks/use-proformas-list.ts
import type { CriteriaDTO } from "@erp/core";
import { useDebounce } from "@repo/rdx-ui/components";
import { useMemo, useState } from "react";
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
import { useProformasQuery } from "../../../hooks";
export const useProformasList = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo<CriteriaDTO>(
() => ({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}),
[pageSize, pageIndex, debouncedQ]
);
const query = useProformasQuery({ criteria });
const data = useMemo(
() => (query.data ? ProformaSummaryDtoAdapter.fromDto(query.data) : undefined),
[query.data]
);
const setSearchValue = (value: string) => setSearch(value.trim().replace(/\s+/g, " "));
return {
...query,
data,
pageIndex,
pageSize,
search,
setPageIndex,
setPageSize,
setSearchValue,
};
};

View File

@ -1,74 +1,25 @@
import type { CriteriaDTO } from "@erp/core";
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useProformasQuery } from "../../hooks";
import { ProformaResumeDtoAdapter } from "../../proforma-resume-dto.adapter";
import { ProformasGrid } from "./proformas-grid";
import { useProformasList } from "./hooks";
import { ProformasGrid } from "./ui";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const list = useProformasList();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() =>
({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}) as CriteriaDTO,
[pageSize, pageIndex, debouncedQ]
);
console.log(criteria);
const { data, isLoading, isError, error } = useProformasQuery({
criteria,
});
const proformaPageData = useMemo(() => {
if (!data) return undefined;
return ProformaResumeDtoAdapter.fromDto(data);
}, [data]);
const handlePageChange = (newPageIndex: number) => {
setPageIndex(newPageIndex);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPageIndex(0);
};
const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
};
console.log();
if (isError || !proformaPageData) {
if (list.isError || !list.data) {
return (
<AppContent>
<ErrorAlert
message={(error as Error)?.message || "Error al cargar el listado"}
message={(list.error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
@ -82,36 +33,28 @@ export const ProformaListPage = () => {
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<div className="flex items-center space-x-2">
<Button
aria-label={t("pages.proformas.create.title")}
className="cursor-pointer"
onClick={() => navigate("/proformas/create")}
variant={"default"}
>
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.proformas.create.title")}
</Button>
</div>
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<ProformasGrid
data={proformaPageData}
loading={isLoading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
</div>
</div>
<ProformasGrid
data={list.data}
loading={list.isLoading}
onPageChange={list.setPageIndex}
onPageSizeChange={list.setPageSize}
onSearchChange={list.setSearchValue}
pageIndex={list.pageIndex}
pageSize={list.pageSize}
searchValue={list.search}
/>
</AppContent>
</>
);

View File

@ -0,0 +1,114 @@
import type { CriteriaDTO } from "@erp/core";
import { PageHeader } from "@erp/core/components";
import { ErrorAlert } from "@erp/customers/components";
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useProformasQuery } from "../../hooks";
import { ProformaSummaryDtoAdapter } from "../../../adapters/proforma-summary-dto.adapter";
import { ProformasGrid } from "./proformas-grid";
export const ProformaListPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() =>
({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
order: "desc",
orderBy: "invoice_date",
}) as CriteriaDTO,
[pageSize, pageIndex, debouncedQ]
);
const { data, isLoading, isError, error } = useProformasQuery({
criteria,
});
const proformaPageData = useMemo(() => {
if (!data) return undefined;
return ProformaSummaryDtoAdapter.fromDto(data);
}, [data]);
const handlePageChange = (newPageIndex: number) => {
setPageIndex(newPageIndex);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPageIndex(0);
};
const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
};
if (isError || !proformaPageData) {
return (
<AppContent>
<ErrorAlert
message={(error as Error)?.message || "Error al cargar el listado"}
title={t("pages.proformas.list.loadErrorTitle")}
/>
<BackHistoryButton />
</AppContent>
);
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<div className="flex items-center space-x-2">
<Button
aria-label={t("pages.proformas.create.title")}
className="cursor-pointer"
onClick={() => navigate("/proformas/create")}
variant={"default"}
>
<PlusIcon aria-hidden className="mr-2 h-4 w-4" />
{t("pages.proformas.create.title")}
</Button>
</div>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex flex-col w-full h-full py-3">
<div className={"flex-1"}>
<ProformasGrid
data={proformaPageData}
loading={isLoading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSearchChange={handleSearchChange}
pageIndex={pageIndex}
pageSize={pageSize}
searchValue={search}
/>
</div>
</div>
</AppContent>
</>
);
};

View File

@ -15,7 +15,7 @@ import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
import type { ProformaSummaryPageData } from "../../proforma-resume.form.schema";
import type { ProformaSummaryPageData } from "../../schema/proforma-summary.web.schema";
import { useProformasGridColumns } from "./use-proformas-grid-columns";

View File

@ -0,0 +1 @@
export * from "./proformas-grid";

View File

@ -0,0 +1,91 @@
import { SimpleSearchInput } from "@erp/core/components";
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { FileDownIcon, FilterIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type { ProformaSummaryPageData } from "../../../schema/proforma-summary.web.schema";
import { useProformasGridColumns } from "../use-proformas-grid-columns";
interface ProformasGridProps {
data: ProformaSummaryPageData;
loading?: boolean;
pageIndex: number;
pageSize: number;
searchValue: string;
onSearchChange: (v: string) => void;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
onRowClick?: (id: string) => void;
onExportClick?: () => void;
}
export const ProformasGrid = ({
data,
loading,
pageIndex,
pageSize,
searchValue,
onSearchChange,
onPageChange,
onPageSizeChange,
onRowClick,
onExportClick,
}: ProformasGridProps) => {
const { t } = useTranslation();
const { items, total_items } = data;
const columns = useProformasGridColumns();
if (loading)
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<SimpleSearchInput onSearchChange={onSearchChange} value={searchValue} />
<Select defaultValue="all">
<SelectTrigger className="w-full sm:w-48">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.all")}</SelectItem>
<SelectItem value="draft">{t("filters.draft")}</SelectItem>
<SelectItem value="sent">{t("filters.sent")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={onExportClick} variant="outline">
<FileDownIcon aria-hidden className="mr-2 size-4" />
{t("actions.export")}
</Button>
</div>
<DataTable
columns={columns}
data={items}
enablePagination
manualPagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(_, row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
/>
</div>
);
};

View File

@ -27,9 +27,9 @@ import {
} from "lucide-react";
import * as React from "react";
import { CustomerInvoiceStatusBadge } from "../../../components";
import { useTranslation } from "../../../i18n";
import type { InvoiceSummaryFormData } from "../../../schemas";
import { CustomerInvoiceStatusBadge } from "../../../shared/ui/components";
type GridActionHandlers = {
onEdit?: (invoice: InvoiceSummaryFormData) => void;

View File

@ -0,0 +1,2 @@
export * from "./proforma.api.schema";
export * from "./proforma-summary.web.schema";

View File

@ -1,5 +1,6 @@
export * from "../adapters/invoice-dto.adapter";
export * from "../adapters/invoice-resume-dto.adapter";
export * from "./invoice.form.schema";
export * from "./invoice-dto.adapter";
export * from "./invoice-resume.form.schema";
export * from "./invoice-resume-dto.adapter";
export * from "./invoices.api.schema";

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -1,7 +1,8 @@
import { Button } from "@repo/shadcn-ui/components";
import { PlusCircleIcon } from "lucide-react";
import { JSX, forwardRef } from "react";
import { useTranslation } from "../../i18n";
import { type JSX, forwardRef } from "react";
import { useTranslation } from "../../../../i18n";
export interface AppendEmptyRowButtonProps extends React.ComponentProps<typeof Button> {
label?: string;
@ -14,7 +15,7 @@ export const AppendEmptyRowButton = forwardRef<HTMLButtonElement, AppendEmptyRow
const _label = label || t("common.append_empty_row");
return (
<Button type='button' variant='outline' ref={ref} {...props}>
<Button ref={ref} type="button" variant="outline" {...props}>
<PlusCircleIcon className={_label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{_label && <>{_label}</>}
</Button>

View File

@ -0,0 +1,35 @@
// components/CustomerSkeleton.tsx
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../i18n";
export const CustomerInvoiceEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppContent>
<div className="flex items-center justify-between">
<div aria-hidden="true" className="space-y-2">
<div className="h-7 w-64 rounded-md bg-muted animate-pulse" />
<div className="h-5 w-96 rounded-md bg-muted animate-pulse" />
</div>
<div className="flex items-center gap-2">
<BackHistoryButton />
<Button aria-busy disabled>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div aria-hidden="true" className="mt-6 grid gap-4">
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-28 w-full rounded-md bg-muted animate-pulse" />
</div>
<span className="sr-only">
{t("pages.update.loading", "Cargando factura de cliente...")}
</span>
</AppContent>
</>
);
};

View File

@ -7,8 +7,9 @@ import {
Separator,
} from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../i18n";
import { formatCurrency } from "../pages/create/utils";
import { useTranslation } from "../../../i18n";
import { formatCurrency } from "../../../pages/create/utils";
export const CustomerInvoicePricesCard = () => {
const { t } = useTranslation();
@ -32,52 +33,52 @@ export const CustomerInvoicePricesCard = () => {
<CardDescription>Configuración de impuestos y resumen de totales</CardDescription>
</CardHeader>
<CardContent className='flex flex-row items-end gap-2 p-4'>
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-1 font-semibold text-right text-muted-foreground'>
<CardDescription className='text-sm'>
<CardContent className="flex flex-row items-end gap-2 p-4">
<div className="grid flex-1 h-16 grid-cols-1 auto-rows-max">
<div className="grid gap-1 font-semibold text-right text-muted-foreground">
<CardDescription className="text-sm">
{t("form_fields.subtotal_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
<CardTitle className="flex items-baseline justify-end text-2xl tabular-nums">
{formatCurrency(watch("subtotal_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.discount.label")}</CardDescription>
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
<div className="grid gap-1 font-medium text-muted-foreground">
<CardDescription className="text-sm">{t("form_fields.discount.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
<div className="grid gap-1 font-semibold text-muted-foreground">
<CardDescription className="text-sm text-right">
{t("form_fields.discount_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end text-2xl tabular-nums'>
<CardTitle className="flex items-baseline justify-end text-2xl tabular-nums">
{"-"} {formatCurrency(watch("discount_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max'>
<div className='grid gap-1 font-medium text-muted-foreground'>
<CardDescription className='text-sm'>{t("form_fields.tax.label")}</CardDescription>
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-2 gap-6 auto-rows-max">
<div className="grid gap-1 font-medium text-muted-foreground">
<CardDescription className="text-sm">{t("form_fields.tax.label")}</CardDescription>
</div>
<div className='grid gap-1 font-semibold text-muted-foreground'>
<CardDescription className='text-sm text-right'>
<div className="grid gap-1 font-semibold text-muted-foreground">
<CardDescription className="text-sm text-right">
{t("form_fields.tax_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-2xl tabular-nums'>
<CardTitle className="flex items-baseline justify-end gap-1 text-2xl tabular-nums">
{formatCurrency(watch("tax_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>
</div>{" "}
<Separator orientation='vertical' className='w-px h-16 mx-2' />
<div className='grid flex-1 h-16 grid-cols-1 auto-rows-max'>
<div className='grid gap-0'>
<CardDescription className='text-sm font-semibold text-right text-foreground'>
<Separator className="w-px h-16 mx-2" orientation="vertical" />
<div className="grid flex-1 h-16 grid-cols-1 auto-rows-max">
<div className="grid gap-0">
<CardDescription className="text-sm font-semibold text-right text-foreground">
{t("form_fields.total_price.label")}
</CardDescription>
<CardTitle className='flex items-baseline justify-end gap-1 text-3xl tabular-nums'>
<CardTitle className="flex items-baseline justify-end gap-1 text-3xl tabular-nums">
{formatCurrency(watch("total_price.amount"), 2, watch("currency"))}
</CardTitle>
</div>

View File

@ -1,7 +1,8 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "../i18n";
import { useTranslation } from "../../../i18n";
export type CustomerInvoiceStatus = "draft" | "sent" | "approved" | "rejected" | "issued";
@ -28,8 +29,7 @@ const statusColorConfig: Record<CustomerInvoiceStatus, { badge: string; dot: str
dot: "bg-emerald-500",
},
rejected: {
badge:
"bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
badge: "bg-red-500/10 dark:bg-red-500/20 hover:bg-red-500/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
issued: {
@ -50,7 +50,7 @@ export const CustomerInvoiceStatusBadge = forwardRef<
if (!config) {
return (
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
<Badge className={cn(commonClassName, className)} ref={ref} {...props}>
{status}
</Badge>
);

View File

@ -1,9 +1,9 @@
import { SpainTaxCatalogProvider } from '@erp/core';
import { SpainTaxCatalogProvider } from "@erp/core";
import { MultiSelect } from "@repo/rdx-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useCallback, useMemo } from 'react';
import { useTranslation } from "../i18n";
import { useCallback, useMemo } from "react";
import { useTranslation } from "../../../i18n";
interface CustomerInvoiceTaxesMultiSelect {
value?: string[];
@ -24,37 +24,39 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
* Filtra para mantener solo un elemento por grupo.
* Si hay duplicados dentro del mismo grupo, se queda con el último.
*/
const filterSelectedByGroup = useCallback((selectedValues: string[]) => {
const groupMap = new Map<string | undefined, string>();
const filterSelectedByGroup = useCallback(
(selectedValues: string[]) => {
const groupMap = new Map<string | undefined, string>();
selectedValues.forEach((code) => {
const item = taxCatalog.findByCode(code).getOrUndefined();
const group = item?.group ?? "ungrouped";
groupMap.set(group, code); // Sobrescribe el anterior del mismo grupo
});
selectedValues.forEach((code) => {
const item = taxCatalog.findByCode(code).getOrUndefined();
const group = item?.group ?? "ungrouped";
groupMap.set(group, code); // Sobrescribe el anterior del mismo grupo
});
return Array.from(groupMap.values());
}, [taxCatalog]);
return Array.from(groupMap.values());
},
[taxCatalog]
);
return (
<div className={cn("w-full", "max-w-md")}>
<MultiSelect
id={inputId}
options={catalogLookup}
onValueChange={onChange}
defaultValue={value}
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
variant='secondary'
animation={0}
maxCount={3}
autoFilter={true}
filterSelected={filterSelectedByGroup}
className={cn(
"flex w-full -mt-0.5 px-1 py-0.5 rounded-md border min-h-8 h-auto items-center justify-between bg-background hover:bg-inherit [&_svg]:pointer-events-auto",
"hover:border-ring hover:ring-ring/50 hover:ring-[2px]",
className
)}
defaultValue={value}
filterSelected={filterSelectedByGroup}
id={inputId}
maxCount={3}
onValueChange={onChange}
options={catalogLookup}
placeholder={t("components.customer_invoice_taxes_multi_select.placeholder")}
variant="secondary"
{...otherProps}
/>
</div>

View File

@ -1,12 +1,10 @@
import {
DatePickerInputField,
TextField
} from "@repo/rdx-ui/components";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { ComponentProps } from 'react';
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
import { useTranslation } from "../../../../i18n";
import type { InvoiceFormData } from "../../../../schemas";
export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
@ -14,62 +12,64 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
return (
<FieldSet {...props}>
<FieldLegend className='hidden text-foreground' variant='label'>
<FieldLegend className="hidden text-foreground" variant="label">
{t("form_groups.basic_info.title")}
</FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldDescription className="hidden">
{t("form_groups.basic_info.description")}
</FieldDescription>
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
<DatePickerInputField
className='min-w-44 flex-1 sm:max-w-44'
className="min-w-44 flex-1 sm:max-w-44"
control={control}
name='invoice_date'
numberOfMonths={2}
required
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
numberOfMonths={2}
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<DatePickerInputField
className='min-w-44 flex-1 sm:max-w-44'
className="min-w-44 flex-1 sm:max-w-44"
control={control}
numberOfMonths={2}
name='operation_date'
label={t("form_fields.operation_date.label")}
placeholder={t("form_fields.operation_date.placeholder")}
description={t("form_fields.operation_date.description")}
label={t("form_fields.operation_date.label")}
name="operation_date"
numberOfMonths={2}
placeholder={t("form_fields.operation_date.placeholder")}
/>
<TextField
className='min-w-16 flex-1 sm:max-w-16'
className="min-w-16 flex-1 sm:max-w-16"
control={control}
name='series'
label={t("form_fields.series.label")}
placeholder={t("form_fields.series.placeholder")}
description={t("form_fields.series.description")}
label={t("form_fields.series.label")}
name="series"
placeholder={t("form_fields.series.placeholder")}
/>
<TextField
className='min-w-32 flex-1 sm:max-w-44'
maxLength={256}
className="min-w-32 flex-1 sm:max-w-44"
control={control}
name='reference'
label={t("form_fields.reference.label")}
placeholder={t("form_fields.reference.placeholder")}
description={t("form_fields.reference.description")}
label={t("form_fields.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.reference.placeholder")}
/>
<TextField
className='min-w-32 flex-1 xs:max-w-full'
maxLength={256}
className="min-w-32 flex-1 xs:max-w-full"
control={control}
name='description'
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.description.placeholder")}
/>
</FieldGroup>
</FieldSet>
);
};
};

View File

@ -1,25 +1,23 @@
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { ComponentProps } from 'react';
import { useTranslation } from '../../i18n';
import { ItemsEditor } from "./items";
export const InvoiceItems = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
return (
<FieldSet {...props}>
<FieldLegend className='hidden text-foreground' variant='label'>
{t('form_groups.items.title')}
<FieldLegend className="hidden text-foreground" variant="label">
{t("form_groups.items.title")}
</FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.items.description")}</FieldDescription>
<FieldDescription className="hidden">{t("form_groups.items.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1'>
<FieldGroup className="grid grid-cols-1">
<ItemsEditor />
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,10 +1,11 @@
import { TextAreaField } from "@repo/rdx-ui/components";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import { StickyNoteIcon } from "lucide-react";
import { ComponentProps } from 'react';
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
import { useTranslation } from "../../../../i18n";
import type { InvoiceFormData } from "../../../../schemas";
export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
@ -13,19 +14,20 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
return (
<FieldSet {...props}>
<FieldLegend>
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_info.title")}
<StickyNoteIcon className="size-6 text-muted-foreground" />
{t("form_groups.basic_info.title")}
</FieldLegend>
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 h-full min-h-0'>
<FieldGroup className="grid grid-cols-1 gap-x-6 h-full min-h-0">
<TextAreaField
maxLength={1024}
className='lg:col-span-full h-full'
className="lg:col-span-full h-full"
control={control}
name='notes'
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
label={t("form_fields.notes.label")}
maxLength={1024}
name="notes"
placeholder={t("form_fields.notes.placeholder")}
/>
</FieldGroup>
</FieldSet>

View File

@ -0,0 +1,78 @@
import { formatCurrency } from "@erp/core";
import {
Badge,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
} from "@repo/shadcn-ui/components";
import { ReceiptIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from "../../../../context";
import { useTranslation } from "../../../../i18n";
import type { InvoiceFormData } from "../../../../schemas";
export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<InvoiceFormData>();
const { currency_code, language_code } = useInvoiceContext();
const taxes = useWatch({
control,
name: "taxes",
defaultValue: [],
});
const displayTaxes = taxes || [];
return (
<FieldGroup>
<FieldSet {...props}>
<FieldLegend className="flex items-center gap-2 text-foreground">
<ReceiptIcon className="size-5" /> {t("form_groups.tax_resume.title")}
</FieldLegend>
<FieldDescription>{t("form_groups.tax_resume.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1">
<div className="space-y-3">
{displayTaxes.map((tax, index) => (
<div
className="border rounded-lg p-3 space-y-2 text-base "
key={`${tax.tax_code}-${index}`}
>
<div className="flex items-center justify-between mb-2 ">
<Badge className="text-sm font-semibold" variant="secondary">
{tax.tax_label}
</Badge>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-current">Base para el impuesto:</span>
<span className="text-base text-current tabular-nums">
{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}
</span>
</div>
<div className="flex justify-between">
<span className="text-current font-semibold">Importe de impuesto:</span>
<span className="text-base text-current font-semibold tabular-nums">
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
</div>
</div>
))}
{displayTaxes.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
<ReceiptIcon className="size-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No hay impuestos aplicados</p>
</div>
)}
</div>
</FieldGroup>
</FieldSet>
</FieldGroup>
);
};

View File

@ -8,11 +8,13 @@ import {
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ReceiptIcon } from "lucide-react";
import { ComponentProps } from "react";
import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from "../../context";
import { useTranslation } from "../../i18n";
import { InvoiceFormData } from "../../schemas";
import { useInvoiceContext } from "../../../../context";
import { useTranslation } from "../../../../i18n";
import type { InvoiceFormData } from "../../../../schemas";
import { PercentageInputField } from "./items/percentage-input-field";
export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
@ -34,55 +36,55 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
return (
<FieldSet {...props}>
<FieldLegend className='hidden'>
<ReceiptIcon className='size-6 text-muted-foreground' />
<FieldLegend className="hidden">
<ReceiptIcon className="size-6 text-muted-foreground" />
{t("form_groups.totals.title")}
</FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.totals.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 border rounded-lg bg-muted/10 p-4 gap-4'>
<div className='space-y-1.5'>
<FieldDescription className="hidden">{t("form_groups.totals.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 border rounded-lg bg-muted/10 p-4 gap-4">
<div className="space-y-1.5">
{/* Sección: Subtotal y Descuentos */}
<div className='flex justify-between text-sm'>
<span className='text-muted-foreground'>Subtotal sin descuentos</span>
<span className='font-medium tabular-nums text-muted-foreground'>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal sin descuentos</span>
<span className="font-medium tabular-nums text-muted-foreground">
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className='flex items-center gap-3'>
<span className='text-muted-foreground'>Descuento en líneas</span>
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Descuento en líneas</span>
</div>
<span className='font-medium text-destructive tabular-nums'>
<span className="font-medium text-destructive tabular-nums">
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
</span>
</div>
<div className='flex justify-between text-sm'>
<div className='flex items-center gap-3'>
<span className='text-muted-foreground'>Descuento global</span>
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Descuento global</span>
<PercentageInputField
control={control}
name={"discount_percentage"}
readOnly={readOnly}
inputId={"discount-percentage"}
showSuffix={true}
className={cn(
"w-20 text-right tabular-nums bg-background",
"border-input border text-sm shadow-xs"
)}
control={control}
inputId={"discount-percentage"}
name={"discount_percentage"}
readOnly={readOnly}
showSuffix={true}
/>
</div>
<span className='font-medium text-destructive tabular-nums'>
<span className="font-medium text-destructive tabular-nums">
-{formatCurrency(discount_amount, 2, currency_code, language_code)}
</span>
</div>
{/* Sección: Base Imponible */}
<div className='flex justify-between text-sm'>
<span className='text-foreground'>Base imponible</span>
<span className='font-medium tabular-nums'>
<div className="flex justify-between text-sm">
<span className="text-foreground">Base imponible</span>
<span className="font-medium tabular-nums">
{formatCurrency(taxable_amount, 2, currency_code, language_code)}
</span>
</div>
@ -91,8 +93,8 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
<Separator />
{/* Sección: Impuestos */}
<div className='space-y-1.5'>
<h3 className='text-xs font-semibold text-muted-foreground uppercase tracking-wide'>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Impuestos y retenciones
</h3>
@ -110,7 +112,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
if (taxesInGroup?.length === 0) return null;
return (
<div key={`tax-group-${group}`} className='space-y-1.5 leading-3'>
<div className="space-y-1.5 leading-3" key={`tax-group-${group}`}>
{taxesInGroup?.map((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
@ -118,11 +120,11 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
);
return (
<div
className="flex items-center justify-between text-sm"
key={`${group}:${item.tax_code}`}
className='flex items-center justify-between text-sm'
>
<span className='text-muted-foreground text-sm'>{tax?.name}</span>
<span className='font-medium tabular-nums text-sm text-muted-foreground'>
<span className="text-muted-foreground text-sm">{tax?.name}</span>
<span className="font-medium tabular-nums text-sm text-muted-foreground">
{formatCurrency(item.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
@ -132,9 +134,9 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
);
})}
<div className='flex justify-between text-sm mt-3'>
<span className='text-foreground'>Total de impuestos</span>
<span className='font-medium tabular-nums'>
<div className="flex justify-between text-sm mt-3">
<span className="text-foreground">Total de impuestos</span>
<span className="font-medium tabular-nums">
{formatCurrency(taxes_amount, 2, currency_code, language_code)}
</span>
</div>
@ -142,9 +144,9 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
<Separator />
<div className='flex justify-between text-sm '>
<span className='font-bold text-foreground'>Total de la factura</span>
<span className='font-bold tabular-nums'>
<div className="flex justify-between text-sm ">
<span className="font-bold text-foreground">Total de la factura</span>
<span className="font-bold tabular-nums">
{formatCurrency(total_amount, 2, currency_code, language_code)}
</span>
</div>

View File

@ -2,91 +2,92 @@ import { Badge, Button, Input, Label } from "@repo/shadcn-ui/components";
import { Trash2 } from "lucide-react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { InvoiceFormData } from "../../../schemas";
import { CustomerInvoiceTaxesMultiSelect } from '../../customer-invoice-taxes-multi-select';
import { CustomItemViewProps } from "./types";
import { useTranslation } from "../../../../../i18n";
import type { InvoiceFormData } from "../../../../../schemas";
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
export interface BlocksViewProps extends CustomItemViewProps { }
import type { CustomItemViewProps } from "./types";
export interface BlocksViewProps extends CustomItemViewProps {}
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
const { t } = useTranslation();
const { control } = useFormContext<InvoiceFormData>();
return (
<div className='space-y-4'>
<div className="space-y-4">
{items.map((item: any, index: number) => (
<div key={`item-${String(index)}`} className='border rounded-lg p-4 space-y-4'>
<div className='flex items-center justify-between'>
<Badge variant='outline' className='text-xs'>
<div className="border rounded-lg p-4 space-y-4" key={`item-${String(index)}`}>
<div className="flex items-center justify-between">
<Badge className="text-xs" variant="outline">
Línea {item.position}
</Badge>
{items.length > 1 && (
<Button
variant='ghost'
size='sm'
className="text-destructive hover:text-destructive"
onClick={() => removeItem(index)}
className='text-destructive hover:text-destructive'
size="sm"
variant="ghost"
>
<Trash2 className='h-4 w-4' />
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
<div className='col-span-full space-y-2'>
<Label className='text-sm font-medium'>Descripción</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="col-span-full space-y-2">
<Label className="text-sm font-medium">Descripción</Label>
<Input
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
placeholder='Descripción del producto o servicio...'
placeholder="Descripción del producto o servicio..."
value={item.description}
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>Cantidad</Label>
<div className="space-y-2">
<Label className="text-sm font-medium">Cantidad</Label>
<Input
type='number'
step='0.01'
value={item.quantity}
onChange={(e) =>
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
}
step="0.01"
type="number"
value={item.quantity}
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>Precio Unitario</Label>
<div className="space-y-2">
<Label className="text-sm font-medium">Precio Unitario</Label>
<Input
type='number'
step='0.0001'
value={item.unit_amount}
onChange={(e) =>
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0)
}
step="0.0001"
type="number"
value={item.unit_amount}
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>% Descuento</Label>
<div className="space-y-2">
<Label className="text-sm font-medium">% Descuento</Label>
<Input
type='number'
step='0.0001'
value={item.discount_percentage}
onChange={(e) =>
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0)
}
step="0.0001"
type="number"
value={item.discount_percentage}
/>
</div>
<div className='space-y-2 col-start-1'>
<div className="space-y-2 col-start-1">
<CustomerInvoiceTaxesMultiSelect
control={control}
name={`items.${index}.tax_codes`}
required
label={t("form_fields.item.tax_codes.label")}
placeholder={t("form_fields.item.tax_codes.placeholder")}
description={t("form_fields.item.tax_codes.description")}
label={t("form_fields.item.tax_codes.label")}
name={`items.${index}.tax_codes`}
placeholder={t("form_fields.item.tax_codes.placeholder")}
required
/>
{/*
@ -107,27 +108,27 @@ export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) =
</div>
{/* Calculated amounts */}
<div className='bg-muted/30 rounded-lg p-3'>
<div className='grid grid-cols-2 md:grid-cols-5 gap-4 text-sm'>
<div className="bg-muted/30 rounded-lg p-3">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
<div>
<Label className='text-xs text-muted-foreground'>SUBTOTAL</Label>
<p className='font-medium'>{formatCurrency(item.subtotal_amount)}</p>
<Label className="text-xs text-muted-foreground">SUBTOTAL</Label>
<p className="font-medium">{formatCurrency(item.subtotal_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>DESCUENTO</Label>
<p className='font-medium'>{formatCurrency(item.discount_amount)}</p>
<Label className="text-xs text-muted-foreground">DESCUENTO</Label>
<p className="font-medium">{formatCurrency(item.discount_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>BASE IMPONIBLE</Label>
<p className='font-medium'>{formatCurrency(item.taxable_amount)}</p>
<Label className="text-xs text-muted-foreground">BASE IMPONIBLE</Label>
<p className="font-medium">{formatCurrency(item.taxable_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>IMPUESTOS</Label>
<p className='font-medium'>{formatCurrency(item.taxes_amount)}</p>
<Label className="text-xs text-muted-foreground">IMPUESTOS</Label>
<p className="font-medium">{formatCurrency(item.taxes_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>TOTAL</Label>
<p className='font-semibold text-primary'>{formatCurrency(item.total_amount)}</p>
<Label className="text-xs text-muted-foreground">TOTAL</Label>
<p className="font-semibold text-primary">{formatCurrency(item.total_amount)}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,128 @@
import { formatCurrency } from "@erp/core";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@repo/shadcn-ui/components";
import type { PropsWithChildren } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useInvoiceContext } from "../../../../../context";
import { useTranslation } from "../../../../../i18n";
type HoverCardTotalsSummaryProps = PropsWithChildren & {
rowIndex: number;
};
/**
* Muestra un desglose financiero del total de línea.
* Lee directamente los importes del formulario vía react-hook-form.
*/
// Aparcado por ahora
export const HoverCardTotalsSummary = ({ children, rowIndex }: HoverCardTotalsSummaryProps) => (
<>{children}</>
);
const HoverCardTotalsSummary2 = ({ children, rowIndex }: HoverCardTotalsSummaryProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
const { currency_code, language_code } = useInvoiceContext();
// Observar los valores actuales del formulario
const [subtotal, discountPercentage, discountAmount, taxableBase, total] = useWatch({
control,
name: [
`items.${rowIndex}.subtotal_amount`,
`items.${rowIndex}.discount_percentage`,
`items.${rowIndex}.discount_amount`,
`items.${rowIndex}.taxable_base`,
`items.${rowIndex}.total_amount`,
],
});
const SummaryBlock = () => (
<div className="space-y-2">
<h4 className="text-sm font-semibold mb-3">
{t("components.hover_card_totals_summary.label")}
</h4>
{/* Subtotal */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.subtotal_amount")}:
</span>
<span className="font-mono tabular-nums">
{formatCurrency(subtotal, 4, currency_code, language_code)}
</span>
</div>
{/* Descuento (si aplica) */}
{discountPercentage && Number(discountPercentage.value) > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.discount_percentage")} (
{discountPercentage && discountPercentage.value
? (Number(discountPercentage.value) / 10 ** Number(discountPercentage.scale)) * 100
: 0}
%):
</span>
<span className="font-mono tabular-nums text-destructive">
-{formatCurrency(discountAmount, 4, currency_code, language_code)}
</span>
</div>
)}
{/* Base imponible */}
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-muted-foreground">
{t("components.hover_card_totals_summary.fields.taxable_amount")}:
</span>
<span className="font-mono tabular-nums font-medium">
{formatCurrency(taxableBase, 4, currency_code, language_code)}
</span>
</div>
{/* Total final */}
<div className="flex justify-between text-sm border-t pt-2 font-semibold">
<span>{t("components.hover_card_totals_summary.fields.total_amount")}:</span>
<span className="font-mono tabular-nums">
{formatCurrency(total, 4, currency_code, language_code)}
</span>
</div>
</div>
);
return (
<>
{/* Variante móvil (Dialog) */}
<div className="md:hidden">
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("components.hover_card_totals_summary.label")}</DialogTitle>
</DialogHeader>
<SummaryBlock />
</DialogContent>
</Dialog>
</div>
{/* Variante escritorio (HoverCard) */}
<div className="hidden md:block">
<HoverCard>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent align="end" className="w-64">
<SummaryBlock />
</HoverCardContent>
</HoverCard>
</div>
</>
);
};

View File

@ -0,0 +1,58 @@
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form";
import type { InvoiceFormData, InvoiceItemFormData } from "../../../../../schemas";
export function ItemRowEditor({
row,
index,
onClose,
}: {
row: InvoiceItemFormData;
index: number;
onClose: () => void;
}) {
// Editor simple reutilizando el mismo RHF
const { register } = useFormContext<InvoiceFormData>();
return (
<div className="grid gap-3">
<h3 className="text-base font-semibold">Edit line #{index + 1}</h3>
<div>
<Label htmlFor={`desc-${index}`}>Description</Label>
<Textarea id={`desc-${index}`} rows={4} {...register(`items.${index}.description`)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label htmlFor={`qty-${index}`}>Qty</Label>
<Input
id={`qty-${index}`}
step="1"
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div>
<Label htmlFor={`unit-${index}`}>Unit</Label>
<Input
id={`unit-${index}`}
step="0.01"
type="number"
{...register(`items.${index}.unit_amount`, { valueAsNumber: true })}
/>
</div>
<div>
<Label htmlFor={`disc-${index}`}>Discount %</Label>
<Input
id={`disc-${index}`}
step="0.01"
type="number"
{...register(`items.${index}.discount_percentage`, { valueAsNumber: true })}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button onClick={onClose}>OK</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,253 @@
import {
Button,
Checkbox,
TableCell,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { type Control, Controller, type FieldValues } from "react-hook-form";
import { useInvoiceContext } from "../../../../../context";
import { useTranslation } from "../../../../../i18n";
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
import { AmountInputField } from "./amount-input-field";
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { PercentageInputField } from "./percentage-input-field";
import { QuantityInputField } from "./quantity-input-field";
export type ItemRowProps<TFieldValues extends FieldValues = FieldValues> = {
control: Control<TFieldValues>;
rowIndex: number;
isSelected: boolean;
isFirst: boolean;
isLast: boolean;
readOnly: boolean;
onToggleSelect: () => void;
onDuplicate: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
};
export const ItemRow = <TFieldValues extends FieldValues = FieldValues>({
control,
rowIndex,
isSelected,
isFirst,
isLast,
readOnly,
onToggleSelect,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: ItemRowProps<TFieldValues>) => {
const { t } = useTranslation();
const { currency_code, language_code } = useInvoiceContext();
return (
<TableRow data-row-index={rowIndex}>
{/* selección */}
<TableCell className="align-top" data-col-index={1}>
<div className="h-5">
<Checkbox
aria-label={`Seleccionar fila ${rowIndex + 1}`}
checked={isSelected}
className="block size-5 leading-none align-middle"
data-cell-focus
disabled={readOnly}
onCheckedChange={onToggleSelect}
/>
</div>
</TableCell>
{/* # */}
<TableCell className="text-left pt-[6px]" data-col-index={2}>
<span className="block translate-y-[-1px] text-muted-foreground tabular-nums text-xs">
{rowIndex + 1}
</span>
</TableCell>
{/* description */}
<TableCell data-col-index={3}>
<Controller
control={control}
name={`items.${rowIndex}.description`}
render={({ field }) => (
<textarea
{...field}
aria-label={t("form_fields.item.description.label")}
className={cn(
"w-full bg-transparent p-0 pt-1.5 resize-none border-0 shadow-none h-8",
"hover:bg-background hover:border-ring hover:ring-ring/50 hover:ring-[2px] focus-within:resize-y"
)}
data-cell-focus
onFocus={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
readOnly={readOnly}
rows={1}
spellCheck
/>
)}
/>
</TableCell>
{/* qty */}
<TableCell className="text-right" data-col-index={4}>
<QuantityInputField
className="font-medium"
control={control}
data-cell-focus
data-col-index={4}
data-row-index={rowIndex}
emptyMode="blank"
inputId={`quantity-${rowIndex}`}
name={`items.${rowIndex}.quantity`}
readOnly={readOnly}
/>
</TableCell>
{/* unit */}
<TableCell className="text-right" data-col-index={5}>
<AmountInputField
className="font-medium"
control={control}
currencyCode={currency_code}
data-cell-focus
data-col-index={5}
data-row-index={rowIndex}
inputId={`unit-amount-${rowIndex}`}
languageCode={language_code}
name={`items.${rowIndex}.unit_amount`}
readOnly={readOnly}
scale={4}
/>
</TableCell>
{/* discount */}
<TableCell className="text-right" data-col-index={6}>
<PercentageInputField
className="font-medium"
control={control}
data-cell-focus
data-col-index={6}
data-row-index={rowIndex}
inputId={`discount-percentage-${rowIndex}`}
name={`items.${rowIndex}.discount_percentage`}
readOnly={readOnly}
showSuffix
/>
</TableCell>
{/* taxes */}
<TableCell data-col-index={7}>
<Controller
control={control}
data-cell-focus
name={`items.${rowIndex}.tax_codes`}
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
data-col-index={7}
data-row-index={rowIndex}
onChange={field.onChange}
value={field.value}
/>
)}
/>
</TableCell>
{/* total (solo lectura) */}
<TableCell className="text-right tabular-nums pt-[6px] leading-5" data-col-index={8}>
<HoverCardTotalsSummary rowIndex={rowIndex}>
<AmountInputField
className="font-semibold"
control={control}
currencyCode={currency_code}
inputId={`total-amount-${rowIndex}`}
languageCode={language_code}
name={`items.${rowIndex}.total_amount`}
readOnly
/>
</HoverCardTotalsSummary>
</TableCell>
{/* actions */}
<TableCell className="pt-[4px]" data-col-index={9}>
<div className="flex justify-end gap-0">
{onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Duplicar fila"
className="size-8 self-start -translate-y-[1px]"
data-cell-focus
disabled={readOnly}
onClick={onDuplicate}
onMouseDown={(e) => e.preventDefault()}
size="icon"
variant="ghost"
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar</TooltipContent>
</Tooltip>
)}
{onMoveUp && (
<Button
aria-label="Mover arriba"
className="size-8 self-start -translate-y-[1px]"
data-cell-focus
disabled={readOnly || isFirst}
onClick={onMoveUp}
onMouseDown={(e) => e.preventDefault()}
size="icon"
variant="ghost"
>
<ArrowUpIcon className="size-4" />
</Button>
)}
{onMoveDown && (
<Button
aria-label="Mover abajo"
className="size-8 self-start -translate-y-[1px]"
data-cell-focus
disabled={readOnly || isLast}
onClick={onMoveDown}
onMouseDown={(e) => e.preventDefault()}
size="icon"
variant="ghost"
>
<ArrowDownIcon className="size-4" />
</Button>
)}
{onRemove && (
<Button
aria-label="Eliminar fila"
className="size-8 self-start -translate-y-[1px]"
data-cell-focus
disabled={readOnly}
onClick={onRemove}
onMouseDown={(e) => e.preventDefault()}
size="icon"
variant="ghost"
>
<Trash2Icon className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
};

View File

@ -0,0 +1,165 @@
import { type CheckedState, useRowSelection } from "@repo/rdx-ui/hooks";
import {
Checkbox,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
import { useCallback } from "react";
import { useFormContext } from "react-hook-form";
import { useInvoiceContext } from "../../../../../context";
import { useInvoiceAutoRecalc, useItemsTableNavigation } from "../../../../../hooks";
import { useTranslation } from "../../../../../i18n";
import {
type InvoiceFormData,
type InvoiceItemFormData,
defaultCustomerInvoiceItemFormData,
} from "../../../../../schemas";
import { ItemRow } from "./item-row";
import { ItemsEditorToolbar } from "./items-editor-toolbar";
interface ItemsEditorProps {
onChange?: (items: InvoiceItemFormData[]) => void;
readOnly?: boolean;
}
const createEmptyItem = () => defaultCustomerInvoiceItemFormData;
export const ItemsEditor = ({ readOnly = false }: ItemsEditorProps) => {
const { t } = useTranslation();
const context = useInvoiceContext();
const form = useFormContext<InvoiceFormData>();
const { control } = form;
// Navegación y operaciones sobre las filas
const tableNav = useItemsTableNavigation(form, {
name: "items",
createEmpty: createEmptyItem,
firstEditableField: "description",
});
const {
fieldArray: { fields },
} = tableNav;
const { selectedRows, selectedIndexes, selectAllState, toggleRow, setSelectAll, clearSelection } =
useRowSelection(fields.length);
useInvoiceAutoRecalc(form, context);
const handleAddSelection = useCallback(() => {
if (readOnly) return;
tableNav.addEmpty(true);
}, [readOnly, tableNav]);
const handleDuplicateSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// duplicar en orden ascendente no rompe índices
selectedIndexes.forEach((i) => tableNav.duplicate(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveUpSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de menor a mayor para mantener índices válidos
selectedIndexes.forEach((i) => tableNav.moveUp(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleMoveDownSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// mover de mayor a menor evita desplazar objetivos
[...selectedIndexes].reverse().forEach((i) => tableNav.moveDown(i));
}, [readOnly, selectedIndexes, tableNav]);
const handleRemoveSelection = useCallback(() => {
if (readOnly || selectedIndexes.length === 0) return;
// borrar de mayor a menor para no invalidar índices siguientes
[...selectedIndexes].reverse().forEach((i) => tableNav.remove(i));
clearSelection();
}, [readOnly, selectedIndexes, tableNav, clearSelection]);
return (
<div className="space-y-0">
{/* Toolbar selección múltiple */}
<ItemsEditorToolbar
onAdd={handleAddSelection}
onDuplicate={handleDuplicateSelection}
onMoveDown={handleMoveDownSelection}
onMoveUp={handleMoveUpSelection}
onRemove={handleRemoveSelection}
readOnly={readOnly}
selectedIndexes={selectedIndexes}
/>
<div className="bg-background">
<Table className="w-full border-collapse text-sm">
<TableHeader className="text-sm bg-muted backdrop-blur supports-[backdrop-filter]:bg-muted/60 ">
<TableRow>
<TableHead className="w-[1%] h-5">
<Checkbox
aria-label={t("common.select_all")}
checked={selectAllState}
disabled={readOnly}
onCheckedChange={(checked: CheckedState) => setSelectAll(checked)}
/>
</TableHead>
<TableHead aria-hidden="true" className="w-[1%]">
#
</TableHead>
<TableHead className="w-[40%]">{t("form_fields.item.description.label")}</TableHead>
<TableHead className="w-[4%] text-right">
{t("form_fields.item.quantity.label")}
</TableHead>
<TableHead className="w-[10%] text-right">
{t("form_fields.item.unit_amount.label")}
</TableHead>
<TableHead className="w-[4%] text-right">
{t("form_fields.item.discount_percentage.label")}
</TableHead>
<TableHead className="w-[16%] text-right">
{t("form_fields.item.tax_codes.label")}
</TableHead>
<TableHead className="w-[8%] text-right">
{t("form_fields.item.total_amount.label")}
</TableHead>
<TableHead aria-hidden="true" className="w-[1%]" />
</TableRow>
</TableHeader>
<TableBody className="text-sm">
{fields.map((f, rowIndex: number) => (
<ItemRow
control={control}
isFirst={rowIndex === 0}
isLast={rowIndex === fields.length - 1}
isSelected={selectedRows.has(rowIndex)}
key={f.id}
onDuplicate={() => tableNav.duplicate(rowIndex)}
onMoveDown={() => tableNav.moveDown(rowIndex)}
onMoveUp={() => tableNav.moveUp(rowIndex)}
onRemove={() => tableNav.remove(rowIndex)}
onToggleSelect={() => toggleRow(rowIndex)}
readOnly={readOnly}
rowIndex={rowIndex}
/>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="p-0 m-0" colSpan={9}>
<ItemsEditorToolbar
onAdd={() => tableNav.addEmpty(true)}
readOnly={readOnly}
selectedIndexes={selectedIndexes}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
);
};

View File

@ -0,0 +1,127 @@
import {
Button,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { CopyPlusIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "../../../../../i18n";
export const ItemsEditorToolbar = ({
readOnly,
selectedIndexes,
onAdd,
onDuplicate,
onMoveUp,
onMoveDown,
onRemove,
}: {
readOnly: boolean;
selectedIndexes: number[];
onAdd?: () => void;
onDuplicate?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onRemove?: () => void;
}) => {
const { t } = useTranslation();
// memoiza valores derivados
const hasSel = selectedIndexes.length > 0;
const selectedCount = useMemo(() => selectedIndexes.length, [selectedIndexes]);
return (
<nav className="flex items-center justify-between h-12 py-1 px-2 text-muted-foreground bg-muted border-b">
<div className="flex items-center gap-2">
{onAdd && (
<Tooltip>
<TooltipTrigger asChild>
<Button disabled={readOnly} onClick={onAdd} size="sm" type="button" variant="outline">
<PlusIcon className="size-4 mr-1" />
{t("common.append_empty_row")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
)}
{onDuplicate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
disabled={!hasSel || readOnly}
onClick={onDuplicate}
onMouseDown={(e) => e.preventDefault()}
size="sm"
type="button"
variant="outline"
>
<CopyPlusIcon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
)}
{/*<Separator orientation="vertical" className="mx-2" />
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveUp}
disabled={!hasSel || readOnly}
>
{t("common.move_up")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onMouseDown={(e) => e.preventDefault()}
onClick={onMoveDown}
disabled={!hasSel || readOnly}
>
{t("common.move_down")}
</Button>
<Separator orientation="vertical" className="mx-2" />
*/}
{onRemove && (
<>
<Separator className="mx-2" orientation="vertical" />
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("common.remove_selected_rows")}
disabled={!hasSel || readOnly}
onClick={onRemove}
onMouseDown={(e) => e.preventDefault()}
size="sm"
type="button"
variant="outline"
>
<Trash2Icon className="size-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
</>
)}
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-normal">{t("common.rows_selected", { count: selectedCount })}</p>
</div>
</nav>
);
};

View File

@ -1,10 +1,12 @@
import { DataTable, useWithRowSelection } from "@repo/rdx-ui/components";
import { useMemo } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useInvoiceContext } from "../../../context";
import { useInvoiceAutoRecalc } from "../../../hooks";
import { useTranslation } from "../../../i18n";
import { defaultCustomerInvoiceItemFormData, InvoiceFormData } from "../../../schemas";
import { useInvoiceContext } from "../../../../../context";
import { useInvoiceAutoRecalc } from "../../../../../hooks";
import { useTranslation } from "../../../../../i18n";
import { type InvoiceFormData, defaultCustomerInvoiceItemFormData } from "../../../../../schemas";
import { ItemRowEditor } from "./item-row-editor";
import { useItemsColumns } from "./use-items-columns";
@ -27,10 +29,13 @@ export const ItemsEditor = () => {
const columns = useMemo(() => baseColumns, [baseColumns]);
return (
<div className='space-y-0'>
<div className="space-y-0">
<DataTable
columns={columns as any}
data={fields}
EditorComponent={ItemRowEditor}
enablePagination={false}
enableRowSelection
meta={{
tableOps: {
onAdd: () => append({ ...createEmptyItem() }),
@ -72,11 +77,8 @@ export const ItemsEditor = () => {
moveSelectedDown: (indexes) => [...indexes].reverse().forEach((i) => move(i, i + 1)),
},
}}
enableRowSelection
enablePagination={false}
pageSize={999}
readOnly={false}
EditorComponent={ItemRowEditor}
/>
</div>
);

View File

@ -17,10 +17,12 @@ import {
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Plus, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { InvoiceItemFormData } from "../../../schemas";
import { useTranslation } from "../../../../../i18n";
import type { InvoiceItemFormData } from "../../../../../schemas";
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { CustomItemViewProps } from "./types";
import type { CustomItemViewProps } from "./types";
export interface TableViewProps extends CustomItemViewProps {}
@ -90,63 +92,62 @@ export const TableView = ({ items, actions }: TableViewProps) => {
};
return (
<div className='space-y-4'>
<div className='rounded-lg border border-border'>
<Table className='min-w-full'>
<TableHeader className='sticky top-0 z-20 bg-background shadow-sm'>
<TableRow className='bg-muted/30 text-xs text-muted-foreground'>
<TableHead className='w-10 text-center'>#</TableHead>
<div className="space-y-4">
<div className="rounded-lg border border-border">
<Table className="min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background shadow-sm">
<TableRow className="bg-muted/30 text-xs text-muted-foreground">
<TableHead className="w-10 text-center">#</TableHead>
<TableHead>{t("form_fields.item.description.label")}</TableHead>
<TableHead className='text-right w-24'>
<TableHead className="text-right w-24">
{t("form_fields.item.quantity.label")}
</TableHead>
<TableHead className='text-right w-32'>
<TableHead className="text-right w-32">
{t("form_fields.item.unit_amount.label")}
</TableHead>
<TableHead className='text-right w-24'>
<TableHead className="text-right w-24">
{t("form_fields.item.discount_percentage.label")}
</TableHead>
<TableHead className='text-right w-32'>
<TableHead className="text-right w-32">
{t("form_fields.item.tax_codes.label")}
</TableHead>
<TableHead className='text-right w-32'>
<TableHead className="text-right w-32">
{t("form_fields.item.total_amount.label")}
</TableHead>
<TableHead className='w-44 text-center'>{t("common.actions")}</TableHead>
<TableHead className="w-44 text-center">{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((item, i) => (
<TableRow key={`item-${i}`} className='text-sm hover:bg-muted/40'>
<TableRow className="text-sm hover:bg-muted/40" key={`item-${i}`}>
{/* ÍNDICE */}
<TableCell className='text-center text-muted-foreground font-mono align-text-top'>
<TableCell className="text-center text-muted-foreground font-mono align-text-top">
{i + 1}
</TableCell>
{/* DESCRIPCIÓN */}
<TableCell className='align-top'>
<TableCell className="align-top">
<Textarea
value={item.description}
onChange={(e) => updateItem(i, { description: e.target.value })}
placeholder='Descripción del producto o servicio…'
className='min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
px-2 py-0
leading-5 overflow-y-auto'
aria-label={`Descripción línea ${i + 1}`}
autoComplete="off"
className="min-h-[2.5rem] max-h-[10rem] resize-y bg-transparent border-none shadow-none focus:bg-background
px-2 py-0
leading-5 overflow-y-auto"
onChange={(e) => updateItem(i, { description: e.target.value })}
placeholder="Descripción del producto o servicio…"
spellCheck={true}
autoComplete='off'
value={item.description}
/>
</TableCell>
{/* CANTIDAD */}
<TableCell className='text-right'>
<TableCell className="text-right">
<Input
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
aria-label={`Cantidad línea ${i + 1}`}
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
inputMode="decimal"
onChange={(e) =>
updateItem(i, {
quantity: {
@ -158,17 +159,17 @@ export const TableView = ({ items, actions }: TableViewProps) => {
},
})
}
aria-label={`Cantidad línea ${i + 1}`}
type="number"
value={Number(item.quantity.value) / 10 ** Number(item.quantity.scale)}
/>
</TableCell>
{/* PRECIO UNITARIO */}
<TableCell className='text-right'>
<TableCell className="text-right">
<Input
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
aria-label={`Precio unitario línea ${i + 1}`}
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
inputMode="decimal"
onChange={(e) =>
updateItem(i, {
unit_amount: {
@ -180,20 +181,17 @@ export const TableView = ({ items, actions }: TableViewProps) => {
},
})
}
aria-label={`Precio unitario línea ${i + 1}`}
type="number"
value={Number(item.unit_amount.value) / 10 ** Number(item.unit_amount.scale)}
/>
</TableCell>
{/* DESCUENTO */}
<TableCell className='text-right'>
<TableCell className="text-right">
<Input
type='number'
inputMode='decimal'
className='text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1'
value={
Number(item.discount_percentage.value) /
10 ** Number(item.discount_percentage.scale)
}
aria-label={`Descuento línea ${i + 1}`}
className="text-right border-0 bg-transparent focus-visible:ring-0 px-2 py-1"
inputMode="decimal"
onChange={(e) =>
updateItem(i, {
discount_percentage: {
@ -205,35 +203,39 @@ export const TableView = ({ items, actions }: TableViewProps) => {
},
})
}
aria-label={`Descuento línea ${i + 1}`}
type="number"
value={
Number(item.discount_percentage.value) /
10 ** Number(item.discount_percentage.scale)
}
/>
</TableCell>
<TableCell className='text-right'></TableCell>
<TableCell className="text-right" />
{/* TOTAL */}
<TableCell className='text-right font-mono'>
<TableCell className="text-right font-mono">
<HoverCardTotalsSummary item={item}>
<span className='cursor-help hover:text-primary transition-colors'>
<span className="cursor-help hover:text-primary transition-colors">
{format(item.total_amount)}
</span>
</HoverCardTotalsSummary>
</TableCell>
{/* ACCIONES */}
<TableCell className='text-center'>
<div className='flex items-center justify-center gap-1'>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => moveItem(i, "up")}
className="h-7 w-7"
disabled={i === 0}
className='h-7 w-7'
onClick={() => moveItem(i, "up")}
size="icon"
variant="ghost"
>
<ChevronUpIcon className='size-3.5' />
<ChevronUpIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Mover arriba</TooltipContent>
@ -244,13 +246,13 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => moveItem(i, "down")}
className="h-7 w-7"
disabled={i === lines.length - 1}
className='h-7 w-7'
onClick={() => moveItem(i, "down")}
size="icon"
variant="ghost"
>
<ChevronDownIcon className='size-3.5' />
<ChevronDownIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Mover abajo</TooltipContent>
@ -261,12 +263,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className="h-7 w-7"
onClick={() => duplicateItem(i)}
className='h-7 w-7'
size="icon"
variant="ghost"
>
<CopyIcon className='size-3.5' />
<CopyIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicar línea</TooltipContent>
@ -277,12 +279,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => removeItem(i)}
className='h-7 w-7 text-destructive hover:text-destructive'
size="icon"
variant="ghost"
>
<TrashIcon className='size-3.5' />
<TrashIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Eliminar línea</TooltipContent>
@ -297,12 +299,12 @@ export const TableView = ({ items, actions }: TableViewProps) => {
</div>
<Button
aria-label="Agregar nueva línea"
className="w-full border-dashed bg-transparent"
onClick={addNewItem}
variant='outline'
className='w-full border-dashed bg-transparent'
aria-label='Agregar nueva línea'
variant="outline"
>
<Plus className='h-4 w-4 mr-2' />
<Plus className="h-4 w-4 mr-2" />
Agregar línea
</Button>
</div>

View File

@ -4,8 +4,10 @@ import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useInvoiceContext } from "../../../context";
import { useInvoiceContext } from "../../../../../context";
import { CustomerInvoiceTaxesMultiSelect } from "../../customer-invoice-taxes-multi-select";
import { AmountInputField } from "./amount-input-field";
import { HoverCardTotalsSummary } from "./hover-card-total-summary";
import { ItemDataTableRowActions } from "./items-data-table-row-actions";
@ -38,7 +40,7 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
{
id: "position",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"#"} className='text-center' />
<DataTableColumnHeader className="text-center" column={column} title={"#"} />
),
cell: ({ row }) => row.index + 1,
enableSorting: false,
@ -48,9 +50,9 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "description",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("form_fields.item.description.label")}
className='text-left'
/>
),
cell: ({ row }) => (
@ -61,23 +63,23 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
<InputGroup>
<InputGroupTextarea
{...field}
id={`desc-${row.original.id}`} // ← estable
rows={1}
aria-label={t("form_fields.item.description.label")}
spellCheck
readOnly={readOnly}
// auto-grow simple
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
aria-label={t("form_fields.item.description.label")} // ← estable
className={cn(
"min-w-48 max-w-184 w-full resize-none bg-transparent border-dashed transition",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y"
)}
data-cell-focus
id={`desc-${row.original.id}`}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
// auto-grow simple
readOnly={readOnly}
rows={1}
spellCheck
/>
{/*<InputGroupAddon align="block-end">
<InputGroupText>Line 1, Column 1</InputGroupText>
@ -104,22 +106,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "quantity",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.quantity.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<QuantityInputField
className="font-base"
control={control}
data-cell-focus
data-col-index={4}
data-row-index={row.index}
emptyMode="blank"
inputId={`qty-${row.original.id}`}
name={`items.${row.index}.quantity`}
readOnly={readOnly}
inputId={`qty-${row.original.id}`}
emptyMode='blank'
data-row-index={row.index}
data-col-index={4}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
@ -131,24 +133,24 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "unit_amount",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.unit_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
className="font-base"
control={control}
currencyCode={currency_code}
data-cell-focus
data-col-index={5}
data-row-index={row.index}
inputId={`unit-${row.original.id}`}
languageCode={language_code}
name={`items.${row.index}.unit_amount`}
readOnly={readOnly}
inputId={`unit-${row.original.id}`}
scale={4}
currencyCode={currency_code}
languageCode={language_code}
data-row-index={row.index}
data-col-index={5}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
@ -160,22 +162,22 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "discount_percentage",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.discount_percentage.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<PercentageInputField
className="font-base"
control={control}
data-cell-focus
data-col-index={6}
data-row-index={row.index}
inputId={`disc-${row.original.id}`}
name={`items.${row.index}.discount_percentage`}
readOnly={readOnly}
inputId={`disc-${row.original.id}`}
scale={4}
data-row-index={row.index}
data-col-index={6}
data-cell-focus
className='font-base'
/>
),
enableSorting: false,
@ -186,19 +188,19 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "discount_amount",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.discount_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
currencyCode={currency_code}
inputId={`discount_amount-${row.original.id}`}
languageCode={language_code}
name={`items.${row.index}.discount_amount`}
readOnly
inputId={`discount_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
/>
),
enableHiding: true,
@ -211,19 +213,19 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "taxable_amount",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.taxable_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
currencyCode={currency_code}
inputId={`taxable_amount-${row.original.id}`}
languageCode={language_code}
name={`items.${row.index}.taxable_amount`}
readOnly
inputId={`taxable_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
/>
),
enableHiding: true,
@ -244,10 +246,10 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
render={({ field }) => (
<CustomerInvoiceTaxesMultiSelect
{...field}
inputId={`tax-${row.original.id}`}
data-row-index={row.index}
data-col-index={7}
data-cell-focus
data-col-index={7}
data-row-index={row.index}
inputId={`tax-${row.original.id}`}
/>
)}
/>
@ -261,19 +263,19 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "taxes_amount",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.taxes_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<AmountInputField
control={control}
currencyCode={currency_code}
inputId={`taxes_amount-${row.original.id}`}
languageCode={language_code}
name={`items.${row.index}.taxes_amount`}
readOnly
inputId={`taxes_amount-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
/>
),
enableSorting: false,
@ -285,21 +287,21 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
accessorKey: "total_amount",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("form_fields.item.total_amount.label")}
className='text-right'
/>
),
cell: ({ row }) => (
<HoverCardTotalsSummary rowIndex={row.index}>
<AmountInputField
className="font-semibold"
control={control}
currencyCode={currency_code}
inputId={`total-${row.original.id}`}
languageCode={language_code}
name={`items.${row.index}.total_amount`}
readOnly
inputId={`total-${row.original.id}`}
currencyCode={currency_code}
languageCode={language_code}
className='font-semibold'
/>
</HoverCardTotalsSummary>
),

View File

@ -1,8 +1,9 @@
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { ComponentProps } from 'react';
import { useTranslation } from "../../../i18n";
import { useTranslation } from "../../../../../i18n";
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
@ -13,17 +14,19 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
return (
<FieldSet {...props}>
<FieldLegend className='hidden text-foreground' variant='label'>
{t('form_groups.recipient.title')}
<FieldLegend className="hidden text-foreground" variant="label">
{t("form_groups.recipient.title")}
</FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
<FieldDescription className="hidden">
{t("form_groups.recipient.description")}
</FieldDescription>
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
<RecipientModalSelectorField
control={control}
name='customer_id'
label={t('form_groups.customer.title')}
initialRecipient={recipient}
label={t("form_groups.customer.title")}
name="customer_id"
/>
</FieldGroup>
</FieldSet>

View File

@ -2,8 +2,6 @@ export * from "./customer-invoice-editor-skeleton";
export * from "./customer-invoice-prices-card";
export * from "./customer-invoice-status-badge";
export * from "./customer-invoice-taxes-multi-select";
export * from "./customer-invoices-layout";
export * from "./editor";
export * from "./editor/invoice-tax-summary";
export * from "./editor/invoice-totals";

Some files were not shown because too many files have changed in this diff Show More