Facturas de cliente
This commit is contained in:
parent
5bacdcc2fc
commit
8dd8e3e5f4
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/auth",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/core",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/customer-invoices",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./get-issue-invoice.controller";
|
||||
export * from "./list-issue-invoices.controller";
|
||||
export * from "./report-issue-invoice.controller";
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
2
modules/customer-invoices/src/web/adapters/index.ts
Normal file
2
modules/customer-invoices/src/web/adapters/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./invoice-dto.adapter";
|
||||
export * from "./invoice-resume-dto.adapter";
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const InvoicesLayout = ({ children }: PropsWithChildren) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(() =>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-summary-dto.adapter";
|
||||
@ -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,
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "./hooks";
|
||||
export * from "./pages";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./use-proformas-list";
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proformas-grid";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./proforma.api.schema";
|
||||
export * from "./proforma-summary.web.schema";
|
||||
@ -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";
|
||||
|
||||
1
modules/customer-invoices/src/web/shared/index.ts
Normal file
1
modules/customer-invoices/src/web/shared/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -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>
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
@ -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>
|
||||
),
|
||||
@ -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>
|
||||
@ -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
Loading…
Reference in New Issue
Block a user